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.

Scaffold one

kick g middleware <name> writes a typed middleware function, and -m drops it inside a module folder rather than the shared directory.

pnpm exec kick g middleware request-timer

For the short-circuiting kind, see Guards below. Full flag list: Generators.

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. Contributors marked beforeValidation — opt-in, for authentication
  2. Validation middleware (from route decorator { body, query, params })
  3. File-upload middleware (from @FileUpload)
  4. Class-level @Middleware() handlers (in declaration order)
  5. Method-level @Middleware() handlers (in declaration order)
  6. 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.
  7. 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. Step 0 is the same pipeline, moved ahead for the contributors that ask for it — which is also what lets a guard in step 3-4 read the authenticated user.

Guards ​

A guard is middleware whose job is to stop the request — auth, IP allow-lists, feature flags. Same (ctx, next) signature; the difference is that it answers instead of calling next().

pnpm exec kick g guard admin

The scaffold answers through ctx.problem.* rather than ctx.res:

ts
export async function adminGuard(ctx: RequestContext, next: () => void): Promise<void> {
  const header = ctx.headers.authorization
  if (!header) {
    // `ctx.res` is the ENGINE-NATIVE response — `ctx.res.status(401).json(...)`
    // only works on Express. A FastifyReply has no `.json()`, h3's event has no
    // `.status()`. The `ctx.*` helpers work on every runtime.
    ctx.problem.unauthorized({ detail: 'Missing or invalid authorization header' })
    return
  }
  next()
}

Mount it with @Middleware() at class or method level:

ts
@Controller()
@Middleware(adminGuard)
export class AdminController { ... }

-m, --module <module> puts it in a module folder instead of the shared directory.

Reach for a contributor instead when the job is to produce a value rather than to reject — see below.

Exempting routes with flags ​

A guard that has to let some routes through should read a route flag, not a list of pathnames. ctx.route is available to any @Middleware(), because it runs inside the matched route:

ts
export async function adminGuard(ctx: RequestContext, next: () => void): Promise<void> {
  if (ctx.route?.flags.has('auth.public')) return next()
  if (!ctx.headers.authorization) {
    ctx.problem.unauthorized({ detail: 'Missing or invalid authorization header' })
    return
  }
  next()
}

ctx.route also carries method, path, controller and handlerName. The built-in csrfGuard() and rateLimitGuard() take an exemptWhen option that accepts the same flag tests — a name, '!name', a single-polarity list, or a predicate.

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)
Read ctx.route.flagsYes — unless mounted globallyYes, plus skipWhen / onlyWhen
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 middlewares option. These run on every request before any route is matched.

Renamed from middleware

The option is middlewares (plural). The singular middleware still works as a deprecated alias — existing apps keep running — but new code should use middlewares. If you set both, middlewares wins.

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({ middlewares })(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,
  middlewares: [requestId(), express.json({ limit: '1mb' }), helmet(), cors(), morgan('dev')],
})

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

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

Global middleware entries can be path-scoped:

ts
middlewares: [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.url, // not `originalUrl` — Express-only
      timestamp: new Date().toISOString(),
    })
  },
})

Custom error handler ​

ts
bootstrap({
  modules,
  onError: (err, req, res) => {
    const status = err.status ?? 500
    // `req.url`, not `req.originalUrl` — the latter is Express-only.
    logger.error({ err, method: req.method, url: req.url })
    res.status(status).json({
      error: err.message,
      code: err.code ?? 'INTERNAL_ERROR',
    })
  },
})

Both are connect-shaped — onNotFound receives (req, res, next) and onError receives (err, req, res, next) — but they are not Express- semantic on every engine. What arrives differs:

reqresnext
Expressnative Requestnative Responsereal next
Fastifyrequest.rawreply driverno-op
h3event.node.reqresponse driverno-op

Two consequences to write for:

  • next(err) does nothing on Fastify and h3. It is an inert function there, so delegating to the default handler silently drops the error. Send a response yourself instead.
  • Express-only request members are undefined elsewhere.req.originalUrl, req.path, and req.ip are absent on the raw node request — use req.url, or ctx.ip where a RequestContext is in scope.

res is a RuntimeResponse on every engine, so status, json, send, setHeader, render, writeHead, and end are all available. 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,
  middlewares: [
    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 })
}

Released under the MIT License. Built with TypeScript — runs on Express, Fastify, or h3.