Skip to content

Interceptors

Interceptors wrap route handlers in an onion-style pipeline — before and after the handler — without touching business logic. Use them for logging, caching, response transformation, timing, and more.

import type { Interceptor, ExecutionContext } from 'veloce-ts';
class LoggingInterceptor implements Interceptor {
async intercept(
ctx: ExecutionContext,
next: () => Promise<Response>
): Promise<Response> {
const start = Date.now();
const response = await next();
console.log(`${ctx.request.method} ${ctx.request.url}${Date.now() - start}ms`);
return response;
}
}

The next() call runs the remaining interceptors and then the route handler. Everything before next() is “before” logic; everything after is “after” logic.

type ExecutionContext = {
request: Request; // Web standard Request
controller: unknown; // controller instance (may be undefined for functional routes)
handler: string; // method name on the controller
};
const app = new Veloce();
app.useInterceptor(new LoggingInterceptor());
app.useInterceptor(new MetricsInterceptor());
await app.compile();

Global interceptors run for every route, before class/method interceptors.

import { UseInterceptor } from 'veloce-ts';
@Controller('/users')
@UseInterceptor(LoggingInterceptor) // applies to all methods in the class
class UserController {
@Get('/')
@UseInterceptor(CacheInterceptor) // stacks on top of class-level
async getUsers() {
return users;
}
}
Global interceptors (registration order)
→ Class interceptors (decorator order)
→ Method interceptors (decorator order)
→ Route handler
← Method interceptors (reverse)
← Class interceptors (reverse)
← Global interceptors (reverse)
class TimingInterceptor implements Interceptor {
async intercept(ctx: ExecutionContext, next: () => Promise<Response>) {
const start = performance.now();
const response = await next();
const ms = (performance.now() - start).toFixed(2);
// Clone to add header (Response is immutable)
const modified = new Response(response.body, response);
modified.headers.set('X-Response-Time', `${ms}ms`);
return modified;
}
}
const cache = new Map<string, Response>();
class CacheInterceptor implements Interceptor {
async intercept(ctx: ExecutionContext, next: () => Promise<Response>) {
const key = ctx.request.url;
if (cache.has(key)) {
return cache.get(key)!.clone();
}
const response = await next();
if (response.ok) {
cache.set(key, response.clone());
}
return response;
}
}
class AuditInterceptor implements Interceptor {
async intercept(ctx: ExecutionContext, next: () => Promise<Response>) {
const response = await next();
if (response.status === 401 || response.status === 403) {
console.warn(`Unauthorized access attempt: ${ctx.request.url}`);
}
return response;
}
}
class WrapResponseInterceptor implements Interceptor {
async intercept(ctx: ExecutionContext, next: () => Promise<Response>) {
const response = await next();
if (!response.headers.get('content-type')?.includes('json')) {
return response;
}
const data = await response.json();
return Response.json({ data, timestamp: Date.now() }, response);
}
}
  • Keep interceptors stateless — inject state via constructor or closure.
  • Always call next() unless you intend to short-circuit the pipeline.
  • Return a cloned Response when adding headers — Response is immutable.
  • Register frequently-needed interceptors globally; feature-specific ones with @UseInterceptor.