Skip to content

Middleware

KickJS provides middleware at three levels: global (applied to all requests), class-level (applied to all routes in a controller), and method-level (applied to a single route handler). Adapters can also inject middleware at specific phases of the pipeline.

MiddlewareHandler Type

All KickJS middleware follows the same signature:

ts
type MiddlewareHandler<TCtx = any> = (ctx: TCtx, next: () => void) => void | Promise<void>

The generic TCtx defaults to any. For full type safety, pass RequestContext:

ts
import type { MiddlewareHandler } from '@forinda/kickjs'
import type { RequestContext } from '@forinda/kickjs'

const authMiddleware: MiddlewareHandler<RequestContext> = async (ctx, next) => {
  const token = ctx.headers['authorization'] // fully typed
  if (!token) return ctx.badRequest('Missing authorization header')

  ctx.set('user', { id: 'user-123' })
  next()
}

@Middleware Decorator

The @Middleware() decorator works on both classes and methods. It accepts one or more handler functions.

Class-level middleware

Runs on every route in the controller, before any method-level middleware:

ts
import { Controller, Get, Middleware } from '@forinda/kickjs'

@Controller()
@Middleware(authMiddleware, loggingMiddleware)
export class SecureController {
  @Get('/')
  async list(ctx: RequestContext) {
    const user = ctx.get('user')
    ctx.json({ user })
  }
}

Method-level middleware

Runs only on the decorated route, after class-level middleware:

ts
@Controller()
export class TodoController {
  @Post('/')
  @Middleware(rateLimitMiddleware)
  async create(ctx: RequestContext) {
    ctx.created({ id: '1' })
  }

  @Get('/') // no extra middleware
  async list(ctx: RequestContext) {
    ctx.json([])
  }
}

Execution order

For a given route, middleware executes in this order:

  1. Validation middleware (from route decorator { body, query, params })
  2. File-upload middleware (from @FileUpload)
  3. Class-level @Middleware() handlers (in declaration order)
  4. Method-level @Middleware() handlers (in declaration order)
  5. Context Contributor pipeline (context decorators) — runs after middleware, before the handler. Class + method + module + adapter + global contributors merge into one pipeline, topo-sorted by dependsOn.
  6. The route handler

Steps 1-4 use the @Middleware() mechanism described above. Step 5 is the typed defineContextDecorator() primitive — use it when the only job of a middleware is to compute a value and stash it on ctx.

Middleware vs context decorators

KickJS has two ways to "do something before the handler runs". Picking the right one matters for type safety, ordering, and reusability.

Concern@Middleware()defineContextDecorator()
Compute a typed value to stash on ctxPossible, untypedBuilt for this; types via ContextMeta
Short-circuit the responseYes (return ctx.notFound())Not the right tool
Mutate the response streamYes (compression, etc.)No
Run before route matchingYes (global middleware)No (per-route only)
Declare ordering against another middlewareManual array positiondependsOn: ['otherKey'] enforced at startup
Reusable across plugins/adaptersPass closures aroundBuilt-in contributors?() hook
Errors abort boot if misconfiguredNo (silent until requests hit)Yes (cycles/missing deps fail app.setup())

Rule of thumb: if the middleware's only job is to compute a value other code reads off ctx, use a context decorator. For everything else, use @Middleware(). See Context Decorators for the full guide.

Global Middleware

Global middleware is configured in bootstrap() via the middleware option. These run on every request before any route is matched.

Different signature from @Middleware

Global middleware uses the raw Express signature (req, res, next), not the KickJS MiddlewareHandler signature (ctx, next). This is because global middleware runs before routes are matched, outside the KickJS RequestContext pipeline.

LocationSignatureReceives
bootstrap({ middleware })(req, res, next)Express Request, Response, NextFunction
@Middleware() on class/method(ctx, next)KickJS RequestContext, next()
Adapter middleware()(req, res, next)Express Request, Response, NextFunction

Using the wrong signature causes runtime crashes. If you see Cannot read properties of undefined, check which signature you're using.

ts
import express from 'express'
import { bootstrap, requestId } from '@forinda/kickjs'
import { modules } from './modules'

bootstrap({
  modules,
  middleware: [requestId(), express.json({ limit: '1mb' }), helmet(), cors(), morgan('dev')],
})

If you omit the middleware option, sensible defaults are applied:

ts
// Default pipeline when middleware is not specified:
requestId()
express.json({ limit: '100kb' })

Global middleware entries can be path-scoped:

ts
middleware: [express.json(), { path: '/api/v1/webhooks', handler: express.raw({ type: '*/*' }) }]

Adapter Middleware Phases

Adapters (database, rate limiting, CORS, Swagger, etc.) can inject middleware at four phases in the pipeline by returning a middleware() array from defineAdapter()'s build:

ts
import { defineAdapter, type AdapterMiddleware } from '@forinda/kickjs'

const RateLimitAdapter = defineAdapter({
  name: 'RateLimitAdapter',
  build: () => ({
    middleware(): AdapterMiddleware[] {
      return [
        { handler: rateLimit({ max: 200 }), phase: 'beforeRoutes' },
        { path: '/api/v1/auth', handler: rateLimit({ max: 10 }), phase: 'beforeRoutes' },
      ]
    },
  }),
})

Phase order

The full middleware pipeline executes in this order:

StepPhaseSource
1beforeMountAdapter hooks (early routes like health, docs UI)
2beforeGlobalAdapter middleware
3globalUser-declared middleware array
4afterGlobalAdapter middleware
5DI bootstrapModule register() calls
6beforeRoutesAdapter middleware
7routesModule route mounting (per-route: validation → upload → @Middleware()contributor pipeline → handler)
8afterRoutesAdapter middleware
9error handlersonNotFound + onError (or built-in defaults)

AdapterMiddleware interface

ts
interface AdapterMiddleware {
  handler: any // Express-compatible (req, res, next) handler
  phase?: MiddlewarePhase // 'beforeGlobal' | 'afterGlobal' | 'beforeRoutes' | 'afterRoutes'
  path?: string // Optional path scope
}

If phase is omitted, it defaults to 'afterGlobal'.

Custom Error Handlers

By default, KickJS returns { message: 'Not Found' } for unmatched routes and formats ZodError, HttpException, and unexpected errors. You can override both handlers:

Custom 404 handler

ts
bootstrap({
  modules,
  onNotFound: (req, res) => {
    res.status(404).json({
      error: 'Route not found',
      path: req.originalUrl,
      timestamp: new Date().toISOString(),
    })
  },
})

Custom error handler

ts
bootstrap({
  modules,
  onError: (err, req, res, next) => {
    const status = err.status ?? 500
    logger.error({ err, method: req.method, url: req.originalUrl })
    res.status(status).json({
      error: err.message,
      code: err.code ?? 'INTERNAL_ERROR',
    })
  },
})

Both use the standard Express signature: onNotFound receives (req, res, next) and onError receives (err, req, res, next). When omitted, the built-in handlers are used.

Writing Reusable Middleware

A factory function pattern works well for configurable middleware:

ts
export function requireRole(role: string): MiddlewareHandler {
  return (ctx: RequestContext, next) => {
    const user = ctx.get('user')
    if (user?.role !== role) {
      return ctx.json({ message: 'Forbidden' }, 403)
    }
    next()
  }
}
ts
@Controller()
@Middleware(authMiddleware, requireRole('admin'))
export class AdminController { ... }

Circuit Breaker

The CircuitBreaker class implements the circuit breaker pattern for external service calls. It protects your application from cascading failures when a downstream service is unhealthy by short-circuiting requests after a configurable failure threshold.

States

StateDescription
CLOSEDNormal operation. Requests pass through. Failures are counted.
OPENFailures exceeded the threshold. All requests are immediately rejected with CircuitOpenError.
HALF_OPENAfter resetTimeout elapses the circuit allows a limited number of probe requests. If they succeed the circuit closes; if any fail it re-opens.

Configuration options

OptionTypeDefaultDescription
failureThresholdnumber(required)Consecutive failures before the circuit opens.
resetTimeoutnumber(required)Milliseconds to wait in OPEN state before transitioning to HALF_OPEN.
halfOpenMaxnumber1Maximum probe requests allowed while HALF_OPEN.

Usage

ts
import { CircuitBreaker, CircuitOpenError } from '@forinda/kickjs'

const breaker = new CircuitBreaker('payment-api', {
  failureThreshold: 5,
  resetTimeout: 30_000, // 30 seconds
  halfOpenMax: 2,
})

// Wrap any async call
try {
  const res = await breaker.execute(() =>
    fetch('https://payment.example.com/charge', {
      method: 'POST',
      body: JSON.stringify({ amount: 1999 }),
    }),
  )
  const data = await res.json()
} catch (err) {
  if (err instanceof CircuitOpenError) {
    // The circuit is open — fail fast without hitting the remote service
    console.warn(err.message)
  }
}

Inspecting and resetting

ts
breaker.getState()
// => 'closed' | 'open' | 'half_open'

breaker.getStats()
// => { failures: 3, successes: 12, state: 'closed', lastFailure?: Date }

// Manually reset to CLOSED (e.g. after deploying a fix upstream)
breaker.reset()

Trace Context

The traceContext() middleware implements W3C Trace Context propagation. It parses an incoming traceparent header and, if none is present, generates a new trace ID so every request is always correlated.

Setup

traceContext() writes into the request's AsyncLocalStorage frame, so it has to run inside one. By default (contextStore: 'auto'), KickJS opens that frame automatically before any user middleware, so dropping traceContext() into your list just works:

ts
import express from 'express'
import { bootstrap, traceContext, requestLogger } from '@forinda/kickjs'

bootstrap({
  modules,
  middleware: [
    traceContext(), // extracts or generates traceId
    requestLogger(), // logger automatically includes traceId
    express.json(),
  ],
})

If you need to control the frame's position explicitly — e.g., to keep an outer wrapper above it — mount requestScopeMiddleware() yourself and place traceContext() after it. KickJS detects the explicit mount and skips its default placement.

If you opted out of auto-mount with contextStore: 'manual', traceContext() will silently no-op (there's no frame to write into) — make sure requestScopeMiddleware() (or your own ALS wrapper) runs before it.

How it works

  1. Reads the traceparent header (e.g. 00-4bf92f3577b6a27ff0753a3a97bb3345-00f067aa0ba902b7-01).
  2. If valid, extracts traceId, parentSpanId, version, and flags.
  3. If missing or invalid, generates a random 32-hex trace ID and 16-hex span ID.
  4. Stores traceId, spanId, traceFlags, and traceVersion in the request-scoped AsyncLocalStorage store, making them available to the built-in logger and any downstream code.
  5. Also exposes req.traceId and req.spanId directly on the Express request object for convenience.

Options

OptionTypeDefaultDescription
propagateResponsebooleanfalseWhen true, sets a traceresponse header on the outgoing response containing the trace ID. Useful for debugging client-side requests.
ts
traceContext({ propagateResponse: true })
// Response will include:  traceresponse: 4bf92f3577b6a27ff0753a3a97bb3345

Accessing the trace ID in application code

Inside a controller or service, read the trace values via the typed helper. getRequestValue is keyed off the augmentable ContextMeta registry — augment it once for the trace fields and every read returns the right type:

ts
declare module '@forinda/kickjs' {
  interface ContextMeta {
    traceId: string
    spanId: string
    parentSpanId: string
    traceFlags: number
    traceVersion: string
  }
}
ts
import { getRequestValue } from '@forinda/kickjs'

const traceId = getRequestValue('traceId') // string | undefined
const spanId = getRequestValue('spanId') // string | undefined

Without the augmentation, the return type falls back to unknown | undefined. Don't reach for a value-type generic (getRequestValue<string>('traceId')) — that generic slot is the key type, not the value type, and passing <string> widens the key and hides the typed lookup.

getRequestValue() returns undefined outside a request frame — null-tolerant by design so the same code can run in both request and background paths.

Or directly from the request object inside a handler:

ts
@Get('/health')
async health(ctx: RequestContext) {
  const traceId = (ctx.req as Request & { traceId?: string }).traceId
  ctx.json({ status: 'ok', traceId })
}