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 class per module, shared by every route, WebSocket handler and queue subscription that names it in that module. The module is the one whose controllers declare the controller, gateway or consumer, and the instance resolves its dependencies from that module's DI scope. A counter, a limiter or a cache on this therefore counts across one module's handlers; a class named by handlers in two modules is two instances, with two counters. Passing an instance instead of a class opts out: the caller owns it, and two instances of the same class stay two. 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.

Construction is per class and module, not per route: a class covering three routes of one module is one instance. A class listed in ApplicationOptions.interceptors resolves through the owning module of each handler it wraps, so it is built once per module that has handlers — not once per route, and not once per application.

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 resolved — app.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, every WebSocket message handler and every @Subscribe queue subscriber in the application, and they wrap outermost — ahead of a gateway-level or class-level @UseInterceptors, which is the same order HTTP routes use. A global logging or metrics interceptor therefore covers all three transports; one that reads ctx.getRequest() must narrow with isHttpContext(ctx) first.

Scheduled handlers (@Cron, @Interval, @Timeout) are not wrapped — see the pipeline table below.

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. An undecorated CacheInterceptor would answer 500 on every GET it wraps, which is why it carries @Service().

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]        (a guard error is filtered here)
       → [Exception Filters on error
            → [Global Interceptors → [Controller Interceptors → [Route Interceptors
                 → params + validation → Handler]]]]
       → Response

The filter boundary wraps the interceptor chain, so an interceptor sees a handler error and can rethrow it for the filters to answer. It sits below the middleware chain because middleware sets headers after await next() — CORS and security headers are on error responses too.

WebSocket:

Message → [Guards] → [Global Interceptors → [Gateway Interceptors → [Handler Interceptors → Handler]]]

Queue:

Message → [Guards] → [Global Interceptors → [Controller Interceptors → Handler]]

All three transports read ApplicationOptions.interceptors, and it wraps outermost on each. Queue has no handler level, 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.