Interceptors
Interceptors wrap handler execution, letting you run logic before and after the handler. Common use cases include logging, caching, timeouts, and response transformation. Unlike middleware (which only sees HTTP requests), interceptors work across all transports — HTTP controllers, WebSocket gateways, and Queue handlers — and can control when and whether the handler runs and modify the result it returns.
Interface
import type { Interceptor, ExecutionContext } from '@onebun/core';
interface Interceptor {
intercept(
context: ExecutionContext,
next: () => Promise<unknown>,
): Promise<unknown>;
}ExecutionContext is a discriminated union across all three transports:
type ExecutionContext = HttpExecutionContext | WsExecutionContext | MessageExecutionContext;Each variant carries a type discriminant:
| Transport | type | Key methods |
|---|---|---|
| HTTP | 'http' | getRequest(), getHandler(), getController() |
| WebSocket | 'ws' | getClient(), getSocket(), getData(), getHandler() |
| Queue | 'queue' | getMessage(), getMetadata(), getPattern(), getHandler() |
Use the type guard functions for safe narrowing:
import { isHttpContext, isWsContext, isQueueContext } from '@onebun/core';
if (isHttpContext(ctx)) {
// ctx is HttpExecutionContext — access ctx.getRequest(), etc.
}
if (isWsContext(ctx)) {
// ctx is WsExecutionContext — access ctx.getClient(), etc.
}
if (isQueueContext(ctx)) {
// ctx is MessageExecutionContext — access ctx.getMessage(), etc.
}The next() function calls the handler (or the next interceptor in the chain). Calling await next() executes the handler and returns its result. You can run code before the call, after the call, or skip the call entirely.
Creating Interceptors
Function-based
The simplest way — use the createInterceptor factory:
import { createInterceptor } from '@onebun/core';
const TimingInterceptor = createInterceptor(async (ctx, next) => {
const start = performance.now();
const result = await next();
const duration = Math.round(performance.now() - start);
if (isHttpContext(ctx)) {
const response = result as Response;
response.headers.set('X-Response-Time', `${duration}ms`);
}
return result;
});Class-based
Implement the Interceptor interface directly:
import type { Interceptor, ExecutionContext } from '@onebun/core';
class AddHeaderInterceptor implements Interceptor {
async intercept(
ctx: ExecutionContext,
next: () => Promise<unknown>,
): Promise<unknown> {
const result = await next();
if (isHttpContext(ctx)) {
const response = result as Response;
response.headers.set('X-Powered-By', 'OneBun');
}
return result;
}
}With DI
Extend BaseInterceptor to get constructor injection, this.logger, and this.config — just like controllers and services:
import type { ExecutionContext } from '@onebun/core';
import { BaseInterceptor, Service, isHttpContext } from '@onebun/core';
@Service()
class AuditInterceptor extends BaseInterceptor {
constructor(private auditService: AuditService) {
super();
}
async intercept(
ctx: ExecutionContext,
next: () => Promise<unknown>,
): Promise<unknown> {
const result = await next();
if (isHttpContext(ctx)) {
await this.auditService.log(ctx.getHandler(), (result as Response).status);
this.logger.info(`Audited ${ctx.getHandler()}`);
}
return result;
}
}Applying Interceptors
Route-level
import { Controller, Get, UseInterceptors } from '@onebun/core';
@Controller('/api')
class ApiController extends BaseController {
@UseInterceptors(TimingInterceptor)
@Get('/data')
getData() {
return { items: [1, 2, 3] };
}
}Controller-level
Applies to every route in the controller:
import { Controller, Get, UseInterceptors, LoggingInterceptor } from '@onebun/core';
@UseInterceptors(LoggingInterceptor)
@Controller('/api/users')
class UserController extends BaseController {
@Get('/')
list() { return []; }
@Get('/:id')
getById(@Param('id') id: string) { return { id }; }
}WebSocket gateway
Applies to every message handler in the gateway:
import { WebSocketGateway, SubscribeMessage, UseInterceptors, LoggingInterceptor } from '@onebun/core';
@UseInterceptors(LoggingInterceptor)
@WebSocketGateway({ path: '/ws' })
class ChatGateway extends BaseWebSocketGateway {
@SubscribeMessage('chat:send')
handleMessage(client: WsClientData, data: unknown) {
return { event: 'chat:received', data };
}
}Queue handler
Applies to queue subscribers, cron jobs, intervals, and timeouts:
import { Controller, Subscribe, UseInterceptors, LoggingInterceptor } from '@onebun/core';
@UseInterceptors(LoggingInterceptor)
@Controller('/orders')
class OrderController extends BaseController {
@Subscribe('order.created')
async handleOrderCreated(message: Message) {
await this.processOrder(message.data);
}
}Global
Pass interceptors in ApplicationOptions.interceptors. They wrap every handler in the application:
import { OneBunApplication, LoggingInterceptor } from '@onebun/core';
const app = new OneBunApplication(AppModule, {
interceptors: [LoggingInterceptor],
});Combined
Controller and route interceptors can be combined. Wrapping follows onion order — global outermost, route innermost:
@UseInterceptors(LoggingInterceptor) // wraps all routes in this controller
@Controller('/api')
class ApiController extends BaseController {
@Get('/fast')
fast() { return { ok: true }; }
@UseInterceptors(new TimeoutInterceptor(5000)) // additionally wraps this route only
@Get('/slow')
slow() { return { ok: true }; }
}Cross-Transport Usage
Interceptors receive an ExecutionContext that may represent any transport. Use the type guard functions for transport-specific logic while keeping a single interceptor class:
import {
BaseInterceptor,
isHttpContext,
isWsContext,
isQueueContext,
} from '@onebun/core';
import type { ExecutionContext } from '@onebun/core';
class MetricsInterceptor extends BaseInterceptor {
async intercept(
ctx: ExecutionContext,
next: () => Promise<unknown>,
): Promise<unknown> {
const start = performance.now();
let label: string;
if (isHttpContext(ctx)) {
const req = ctx.getRequest();
label = `HTTP ${req.method} ${new URL(req.url).pathname}`;
} else if (isWsContext(ctx)) {
const handler = ctx.getHandler();
label = `WS ${handler.pattern || handler.handler}`;
} else {
label = `Queue ${ctx.getPattern()}`;
}
try {
const result = await next();
const duration = Math.round(performance.now() - start);
this.logger.info(`${label} completed in ${duration}ms`);
return result;
} catch (error) {
const duration = Math.round(performance.now() - start);
this.logger.error(`${label} failed in ${duration}ms`);
throw error;
}
}
}This single interceptor can be applied to HTTP controllers, WebSocket gateways, and Queue handlers alike:
@UseInterceptors(MetricsInterceptor)
@Controller('/api/users')
class UserController extends BaseController { /* ... */ }
@UseInterceptors(MetricsInterceptor)
@WebSocketGateway({ path: '/ws' })
class ChatGateway extends BaseWebSocketGateway { /* ... */ }Built-in Interceptors
LoggingInterceptor
Logs incoming requests and messages with timing across all transports. Produces transport-aware labels:
- HTTP:
Incoming GET /api/users/Completed GET /api/users 200 12ms - WebSocket:
Incoming WS chat:send/Completed WS chat:send 3ms - Queue:
Incoming Queue order.created/Completed Queue order.created 5ms
On error, logs Failed <label> <duration>ms and re-throws.
import { LoggingInterceptor, UseInterceptors } from '@onebun/core';
@UseInterceptors(LoggingInterceptor)
@Controller('/api')
class ApiController extends BaseController { /* ... */ }TimeoutInterceptor
Aborts handler execution after the specified number of milliseconds. For HTTP, throws HttpException(408). For WebSocket and Queue transports, throws a generic Error. Pass as an instance because it takes a constructor argument:
import { TimeoutInterceptor, UseInterceptors } from '@onebun/core';
@UseInterceptors(new TimeoutInterceptor(5000))
@Get('/slow')
async slowRoute() { /* ... */ }Route timeout vs. interceptor timeout
The route-level timeout option (in @Get('/path', { timeout: 10 })) sets Bun's idle connection timeout in seconds. TimeoutInterceptor limits total handler processing time in milliseconds — they serve different purposes and can be used together.
CacheInterceptor
From @onebun/cache — caches successful (2xx) HTTP GET responses via CacheService. Non-GET requests and non-HTTP transports pass through without caching. Requires CacheModule to be imported so that CacheService is available for DI:
import { CacheInterceptor } from '@onebun/cache';
import { CacheModule } from '@onebun/cache';
@Module({
imports: [CacheModule],
controllers: [DataController],
})
class DataModule {}
@UseInterceptors(CacheInterceptor)
@Controller('/api/data')
class DataController extends BaseController {
@Get('/')
getData() { return { items: [1, 2, 3] }; }
}Execution Order
Each transport has its own pipeline. Interceptors sit between guards and the handler in all three:
HTTP:
Request → [Global Middleware] → [Module Middleware] → [Controller Middleware] → [Route Middleware]
→ [Controller Guards] → [Route Guards]
→ [Global Interceptors → [Controller Interceptors → [Route Interceptors → Handler]]]
→ [Exception Filters on error]
→ ResponseWebSocket:
Message → [Guards] → [Global Interceptors → [Gateway Interceptors → [Handler Interceptors → Handler]]]Queue:
Message → [Guards] → [Global Interceptors → [Controller Interceptors → [Handler Interceptors → Handler]]]Interceptors use onion wrapping: the first interceptor in the list (global) wraps outermost and sees the result last. The innermost interceptor (handler-level) runs closest to the handler.
Short-circuiting
An interceptor can return a result without calling next(), skipping the handler and all inner interceptors:
const MaintenanceInterceptor = createInterceptor(async (ctx, next) => {
if (isMaintenanceMode()) {
if (isHttpContext(ctx)) {
return new Response(
JSON.stringify({ error: 'Service temporarily unavailable' }),
{ status: 503, headers: { 'Content-Type': 'application/json' } },
);
}
return { error: 'Service temporarily unavailable' };
}
return await next();
});Response Transformation
An interceptor can modify the result returned by next():
const WrapResponseInterceptor = createInterceptor(async (ctx, next) => {
const result = await next();
if (isHttpContext(ctx) && result instanceof Response) {
const body = await result.json();
return new Response(
JSON.stringify({ success: true, data: body }),
{ status: result.status, headers: result.headers },
);
}
return result;
});