Middleware
Middleware are async functions that run before your route handlers. They receive the same ctx plus a next function. Call await next() to continue to the next middleware (or the route handler). Don't call it to stop the request early — for example, to reject an unauthorized request.
Every middleware has the signature: (ctx: HttpContext, next: NextFunction) => Promise<void>
Writing middleware
A simple request logger — runs after the handler returns so it can log the duration:
import type { HttpContext, NextFunction } from '@pearl-framework/pearl'
export async function LoggerMiddleware(ctx: HttpContext, next: NextFunction) {
const start = Date.now()
await next() // run the handler
const ms = Date.now() - start
console.log(`${ctx.request.method} ${ctx.request.url} ${ms}ms`)
}Short-circuiting (blocking requests)
Return early without calling next() to stop the request. The handler and any subsequent middleware won't run:
import type { HttpContext, NextFunction } from '@pearl-framework/pearl'
export async function RequireApiKey(ctx: HttpContext, next: NextFunction) {
const key = ctx.request.headers.get('x-api-key')
if (key !== process.env.API_KEY) {
// Stop here — don't call next()
ctx.response.status(401).json({ error: 'Invalid or missing API key' })
return
}
await next()
}CORS middleware
Handle CORS headers and preflight OPTIONS requests:
import type { HttpContext, NextFunction } from '@pearl-framework/pearl'
export async function CorsMiddleware(ctx: HttpContext, next: NextFunction) {
ctx.response.setHeader('Access-Control-Allow-Origin', '*')
ctx.response.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS')
ctx.response.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization')
// Preflight requests end here
if (ctx.request.method === 'OPTIONS') {
ctx.response.status(204).end()
return
}
await next()
}Error handler middleware
Register this first (before other middleware) so it wraps every handler. It catches any thrown error and returns a structured JSON response:
import type { HttpContext, NextFunction } from '@pearl-framework/pearl'
import { ValidationException } from '@pearl-framework/pearl'
export async function ErrorHandlerMiddleware(ctx: HttpContext, next: NextFunction) {
try {
await next()
} catch (err) {
// Validation errors from FormRequest get a 422
if (err instanceof ValidationException) {
ctx.response.status(422).json({ errors: err.errors })
return
}
// Everything else is a 500
console.error(err)
ctx.response.status(500).json({ error: 'Internal server error' })
}
}Built-in: rate limiting
Pearl ships a RateLimit middleware out of the box — fixed window, per-key, with X-RateLimit-* and Retry-After headers on 429 responses. The default store is in-memory; swap in a Redis-backed store for multi-process deployments.
import { RateLimit } from '@pearl-framework/pearl'
// Global — 100 requests per minute per IP
router.use(new RateLimit({ windowMs: 60_000, max: 100 }))
// Per-route — tight limit on the login endpoint
router.post('/auth/login', loginHandler, [
new RateLimit({
windowMs: 15 * 60_000,
max: 5,
message: 'Too many login attempts. Try again in 15 minutes.',
}),
])Customize the key (e.g. per-user instead of per-IP) by passing keyGenerator:
new RateLimit({
windowMs: 60_000,
max: 30,
keyGenerator: (ctx) => {
const user = ctx.get<{ id: number }>('auth.user')
return user ? `u:${user.id}` : `ip:${ctx.request.header('x-forwarded-for') ?? 'unknown'}`
},
})For multi-process deployments, implement RateLimitStore against Redis and pass it via the store option — the same middleware then rate-limits consistently across every instance.
Applying middleware
Use router.use() for global middleware (runs on every request) and pass an array as the third route argument for per-route middleware:
// Global middleware — runs on every request, in order
router.use(ErrorHandlerMiddleware) // register first — wraps everything
router.use(CorsMiddleware)
router.use(LoggerMiddleware)
// Per-route — only runs on this route
router.post('/posts', createPost, [Authenticate(auth)])
// Stack multiple per-route middleware
router.post('/admin/posts', createPost, [Authenticate(auth), RequireApiKey])