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;
}
}An interceptor is instantiated when handlers are registered, never per request: one instance per registration site (a global interceptor gets its own instance for each route it wraps), reused by every request or message that reaches that handler. Never hold per-request state on this — keep it in locals inside intercept(). Guards are the opposite: passing the guard CLASS constructs the guard per invocation, so state on this is safe there.
With DI
Constructor injection works for any interceptor class that carries a class decorator; extend BaseInterceptor to additionally get this.logger and this.config:
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;
}
}The interceptor class must carry a CLASS decorator
DI is what @Service() buys here, not BaseInterceptor: the injector reads design:paramtypes, and TypeScript emits that metadata only when the class itself is decorated — a class decorator, or a decorator on a constructor parameter such as @Inject(TOKEN). A decorator on the intercept method does not count, because the metadata is emitted for the member it sits on.
Use @Service(). The constructor-parameter form works, but it makes DI depend on a token you might later remove, and the failure it leaves behind is the silent one described below.
- Undecorated class with constructor parameters — every parameter is
undefinedat run time and nothing fails at startup. This is true whether or not the class extendsBaseInterceptor; extending it suppliesthis.loggerandthis.config, never the constructor arguments. - Decorated class whose dependency cannot be resolved —
app.start()fails loudly withDependencyResolutionError. Register the DEPENDENCY in a module'sproviders.
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,
BaseWebSocketGateway,
OnMessage,
UseInterceptors,
LoggingInterceptor,
type WsClientData,
} from '@onebun/core';
@UseInterceptors(LoggingInterceptor)
@WebSocketGateway({ path: '/ws' })
class ChatGateway extends BaseWebSocketGateway {
@OnMessage('chat:send')
handleMessage(client: WsClientData, data: unknown) {
return { event: 'chat:received', data };
}
}Queue handler
A class-level @UseInterceptors wraps the queue subscribers (@Subscribe) declared on that class. Put queue interceptors on the class: the METHOD form does nothing on a @Subscribe handler, because the method decorator writes its metadata to the prototype while queue registration looks it up on the class — nothing errors, the interceptor simply never runs. If one subscriber needs different wrapping, give it its own consumer class.
Scheduled handlers — @Cron, @Interval, @Timeout — are not wrapped either: the scheduler calls the bound method directly. If such a job publishes to a pattern, the interceptors of the class holding a @Subscribe on that pattern still run, once per delivered message.
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 HTTP route in the application — and only those: the global list is merged into the chain at route registration, so WebSocket gateways and queue subscribers never see it. To wrap those, put @UseInterceptors on the gateway or consumer class.
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.
Use it directly — CacheInterceptor carries @Service(), so the resolver injects CacheService:
import { CacheInterceptor, CacheModule } from '@onebun/cache';
@UseInterceptors(CacheInterceptor)
@Controller('/api/data')
class DataController extends BaseController {
@Get('/')
getData() { return { items: [1, 2, 3] }; }
}
@Module({
imports: [CacheModule],
controllers: [DataController],
})
class DataModule {}The interceptor does not belong in providers — the resolver instantiates it; only its dependency has to be resolvable, which is what CacheModule provides.
Writing your own interceptor with constructor dependencies
Decorate it. TypeScript emits design:paramtypes only for a decorated class, and that is the metadata the resolver reads to find constructor dependencies. Without a decorator the class is built with zero arguments, the dependency lands undefined, and the failure arrives at request time as a 500 rather than at startup.
@Service() // ← load-bearing, not decoration
class AuditInterceptor extends BaseInterceptor {
constructor(private readonly audit: AuditService) {
super();
}
}This applies to guards, filters and middleware alike. CacheInterceptor shipped without it and answered 500 on every GET it wrapped.
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] → [Gateway Interceptors → [Handler Interceptors → Handler]]Queue:
Message → [Guards] → [Controller Interceptors → Handler]The last two rows have no global level on purpose: ApplicationOptions.interceptors is read at HTTP route registration only. Queue has no handler level either, because a method-level @UseInterceptors never reaches a @Subscribe 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;
});