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:
type MiddlewareHandler<TCtx = any> = (ctx: TCtx, next: () => void) => void | Promise<void>The generic TCtx defaults to any. For full type safety, pass RequestContext:
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:
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:
@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:
- Validation middleware (from route decorator
{ body, query, params }) - File-upload middleware (from
@FileUpload) - Class-level
@Middleware()handlers (in declaration order) - Method-level
@Middleware()handlers (in declaration order) - 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. - 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 ctx | Possible, untyped | Built for this; types via ContextMeta |
| Short-circuit the response | Yes (return ctx.notFound()) | Not the right tool |
| Mutate the response stream | Yes (compression, etc.) | No |
| Run before route matching | Yes (global middleware) | No (per-route only) |
| Declare ordering against another middleware | Manual array position | dependsOn: ['otherKey'] enforced at startup |
| Reusable across plugins/adapters | Pass closures around | Built-in contributors?() hook |
| Errors abort boot if misconfigured | No (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.
| Location | Signature | Receives |
|---|---|---|
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.
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:
// Default pipeline when middleware is not specified:
requestId()
express.json({ limit: '100kb' })Global middleware entries can be path-scoped:
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:
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:
| Step | Phase | Source |
|---|---|---|
| 1 | beforeMount | Adapter hooks (early routes like health, docs UI) |
| 2 | beforeGlobal | Adapter middleware |
| 3 | global | User-declared middleware array |
| 4 | afterGlobal | Adapter middleware |
| 5 | DI bootstrap | Module register() calls |
| 6 | beforeRoutes | Adapter middleware |
| 7 | routes | Module route mounting (per-route: validation → upload → @Middleware() → contributor pipeline → handler) |
| 8 | afterRoutes | Adapter middleware |
| 9 | error handlers | onNotFound + onError (or built-in defaults) |
AdapterMiddleware interface
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
bootstrap({
modules,
onNotFound: (req, res) => {
res.status(404).json({
error: 'Route not found',
path: req.originalUrl,
timestamp: new Date().toISOString(),
})
},
})Custom error handler
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:
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()
}
}@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
| State | Description |
|---|---|
| CLOSED | Normal operation. Requests pass through. Failures are counted. |
| OPEN | Failures exceeded the threshold. All requests are immediately rejected with CircuitOpenError. |
| HALF_OPEN | After 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
| Option | Type | Default | Description |
|---|---|---|---|
failureThreshold | number | (required) | Consecutive failures before the circuit opens. |
resetTimeout | number | (required) | Milliseconds to wait in OPEN state before transitioning to HALF_OPEN. |
halfOpenMax | number | 1 | Maximum probe requests allowed while HALF_OPEN. |
Usage
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
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:
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
- Reads the
traceparentheader (e.g.00-4bf92f3577b6a27ff0753a3a97bb3345-00f067aa0ba902b7-01). - If valid, extracts
traceId,parentSpanId,version, andflags. - If missing or invalid, generates a random 32-hex trace ID and 16-hex span ID.
- Stores
traceId,spanId,traceFlags, andtraceVersionin the request-scopedAsyncLocalStoragestore, making them available to the built-in logger and any downstream code. - Also exposes
req.traceIdandreq.spanIddirectly on the Express request object for convenience.
Options
| Option | Type | Default | Description |
|---|---|---|---|
propagateResponse | boolean | false | When true, sets a traceresponse header on the outgoing response containing the trace ID. Useful for debugging client-side requests. |
traceContext({ propagateResponse: true })
// Response will include: traceresponse: 4bf92f3577b6a27ff0753a3a97bb3345Accessing 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:
declare module '@forinda/kickjs' {
interface ContextMeta {
traceId: string
spanId: string
parentSpanId: string
traceFlags: number
traceVersion: string
}
}import { getRequestValue } from '@forinda/kickjs'
const traceId = getRequestValue('traceId') // string | undefined
const spanId = getRequestValue('spanId') // string | undefinedWithout 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:
@Get('/health')
async health(ctx: RequestContext) {
const traceId = (ctx.req as Request & { traceId?: string }).traceId
ctx.json({ status: 'ok', traceId })
}