Skip to content

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

typescript
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:

typescript
type ExecutionContext = HttpExecutionContext | WsExecutionContext | MessageExecutionContext;

Each variant carries a type discriminant:

TransporttypeKey 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:

typescript
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:

typescript
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:

typescript
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:

typescript
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 undefined at run time and nothing fails at startup. This is true whether or not the class extends BaseInterceptor; extending it supplies this.logger and this.config, never the constructor arguments.
  • Decorated class whose dependency cannot be resolvedapp.start() fails loudly with DependencyResolutionError. Register the DEPENDENCY in a module's providers.

Applying Interceptors

Route-level

typescript
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:

typescript
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:

typescript
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.

typescript
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.

typescript
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:

typescript
@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:

typescript
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:

typescript
@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.

typescript
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:

typescript
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:

typescript
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.

typescript
@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]
       → Response

WebSocket:

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:

typescript
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():

typescript
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;
});

Released under the MPL-2.0 License.