Skip to content
GitHub

Interceptors

MeoCord 4.1 · The request pipeline · page 27 of 41 · since 4.1.0

Wrap a handler to act before and after it, for timing, logging, caching or reporting its errors.

You'll learn

  • Write an interceptor that runs code around a handler
  • Skip the handler, or turn its error into another
  • Read the handler's params before and after validation

An interceptor runs around a handler once its guards have let the call through. It receives the call's ExecutionContext and a next whose handle() runs the rest of the call and resolves to what the handler returns. What it does before next.handle() runs before the handler; what it does after, runs after.

When to use it

Use an interceptor for work that wraps the call: measuring how long it takes, logging what ran, caching a result, reporting errors to a tracker, or adding a span around the handler's work.

To decide whether the call runs at all, use a guard. To answer an error, use an exception filter: an interceptor can see the error, but a filter decides what the user is told. To record every call, the denied ones included, use an observer, since an interceptor never sees a call a guard refused.

Example

interceptors/timing.interceptor.ts
import { type ExecutionContext, Logger } from 'meocord/common'
import { Interceptor } from 'meocord/decorator'
import { type CallHandler, type InterceptorInterface } from 'meocord/interface'

@Interceptor()
export class TimingInterceptor implements InterceptorInterface {
  private readonly logger = new Logger(TimingInterceptor.name)

  async intercept(context: ExecutionContext, next: CallHandler): Promise<unknown> {
    const started = performance.now()
    try {
      return await next.handle()
    } finally {
      this.logger.log(`${context.getHandlerName()} took ${Math.round(performance.now() - started)} ms`)
    }
  }
}

Applied with @UseInterceptor(TimingInterceptor), it logs how long each call of the handler took, whether it returned or threw.

How it works

Interceptors run after the guards and the fetch of a message's entities. They wrap validation, pipes, the cooldown count and the handler, so an interceptor sees invalid input or a cooldown as the handler's error. The exception is a message command's cooldown check before its params are fetched, which runs before the interceptors.

intercept decides what happens to the call:

  • It calls next.handle() once to run the rest of the call and gets the handler's result.
  • It returns without calling it to skip the handler, answering from a cache, say. Observers still see the outcome 'ran'. In development, one that leaves the interaction unanswered this way is named in a warning, as a handler is.
  • It catches the error next.handle() throws and throws another, which the filters then receive.

Call next.handle() at most once: each call runs the handler again. Return or await what it returns. One left without a rejection handler, as next.handle().then(log) leaves it, still has the call end when the handler does and fail with what it throws, so the filters and the fallback answer the error.

One instance of an interceptor serves every call, so it can inject services and hold a cache; keep per-call state in local variables. For the same reason, it can't inject ExecutionContext, which belongs to one call. The bot refuses to start if one does, and the context is intercept's first argument instead. The reporting interceptor injects its reporter:

interceptors/reporting.interceptor.ts
import { type ExecutionContext } from 'meocord/common'
import { Interceptor } from 'meocord/decorator'
import { type CallHandler, type InterceptorInterface } from 'meocord/interface'
import { ErrorReporter } from '@src/services/error-reporter.service'

// One instance serves every call, so it can inject services; per-call state stays in locals
@Interceptor({ types: ['interaction'] })
export class ReportingInterceptor implements InterceptorInterface {
  constructor(private readonly reporter: ErrorReporter) {}

  async intercept(context: ExecutionContext, next: CallHandler): Promise<unknown> {
    try {
      return await next.handle()
    } catch (error) {
      this.reporter.report(error, `${context.getController()?.name}.${context.getHandlerName()}`)
      // Thrown on, so the filters and the fallback still answer the user
      throw error
    }
  }
}

Options for one use go through { provide, params }, read with context.getParams().

Where interceptors apply

@UseInterceptor goes on a handler, or on a controller for every handler it declares or inherits, and @MeoCord({ interceptors }) wraps every handler in the bot. The global ones are outermost, then the controller's, then the method's; within one list, the first is outermost:

controllers/slash/lookup.slash.controller.ts
// The first listed is outermost: timing covers the reporting too
@Controller()
@UseInterceptor(TimingInterceptor, ReportingInterceptor)
export class LookupSlashController {
  @Command('lookup', CommandType.SLASH)
  async lookup(interaction: ChatInputCommandInteraction, { id }: { id: string }) {
    if (!/^\d+$/.test(id)) throw new Error(`"${id}" is not an id`)
    await respond(interaction).send({ content: `Looking up ${id}…` })
  }
}

Global interceptors also wrap message, reaction and event handlers. One written for interactions declares @Interceptor({ types: ['interaction'] }), as the reporting interceptor above does. Autocomplete handlers run no interceptors, since they must answer within three seconds and have no reply to shape.

The call's params

context.getHandlerParams() reads the handler's params as they stand when the interceptor asks: raw before next.handle(), and validated and piped after it, as the handler received them. context.getArgs() follows the same stages. An audit interceptor can record both:

interceptors/audit.interceptor.ts
import { type ExecutionContext } from 'meocord/common'
import { Interceptor } from 'meocord/decorator'
import { type CallHandler, type InterceptorInterface } from 'meocord/interface'
import { AuditLog } from '@src/services/audit-log.service'

// Records what a call asked for and what its handler ran with
@Interceptor()
export class AuditInterceptor implements InterceptorInterface {
  constructor(private readonly audit: AuditLog) {}

  async intercept(context: ExecutionContext, next: CallHandler): Promise<unknown> {
    // Before next.handle(), the params as the call sent them
    const received = context.getHandlerParams()
    const result = await next.handle()
    // After it, validated and piped, as the handler received them; getArgs()[1] is the same value
    this.audit.record({ handler: context.getHandlerName(), received, ran: context.getHandlerParams() })
    return result
  }
}

On a button whose uid is validated and piped into an account:

controllers/button/redeem.button.controller.ts
@Command('redeem/{uid}', CommandType.BUTTON)
@Validate(z.object({ uid: z.string().regex(/^\d{9,10}$/) }), { pipes: { uid: RedeemAccountPipe } })
@UseInterceptor(AuditInterceptor)
@UseFilter(UnknownAccountFilter)
async redeem(interaction: ButtonInteraction, { uid }: { uid: Account }) {
  await respond(interaction).send({ content: `Redeemed for ${uid.name}.` })
}
controllers/button/redeem.button.controller.spec.ts
it('records the params as the call sent them and as the handler ran with them', async () => {
  const { module, audit } = setup()

  await module.invoke(
    RedeemButtonController,
    'redeem',
    createMockInteraction(ButtonInteraction, { customId: 'redeem/800000001' }),
  )

  expect(audit.entries).toEqual([
    { handler: 'redeem', received: { uid: '800000001' }, ran: { uid: { uid: '800000001', name: 'Ada' } } },
  ])
})

it('answers a uid with no account from the params as they were when the pipe threw', async () => {
  const { module } = setup()
  const interaction = createMockInteraction(ButtonInteraction, { customId: 'redeem/800000009' })

  await module.invoke(RedeemButtonController, 'redeem', interaction)

  // Answered privately, as an error embed
  expect(getResponse(interaction).calls.at(-1)?.payload).toMatchObject({
    embeds: [expect.objectContaining({ description: 'There is no account 800000009.' })],
  })
})

Testing

A testing module runs interceptors under invoke, and resolves their dependencies from its providers, so a stand-in replaces the real reporter:

controllers/slash/lookup.slash.controller.spec.ts
const report = vi.fn()
const module = MeoCordTestingModule.create({
  controllers: [LookupSlashController],
  providers: [{ provide: ErrorReporter, useValue: { report } }],
}).compile()

it('reports an error the handler throws, and lets it through', async () => {
  await expect(module.invoke(LookupSlashController, 'lookup', lookup('abc'))).rejects.toThrow('"abc" is not an id')
  expect(report).toHaveBeenCalledWith(expect.any(Error), 'LookupSlashController.lookup')
})

Generate an interceptor with npx meocord g i <name>.

Gotchas

  • An interceptor that injects needs @Interceptor(). Without it, the bot refuses to start, naming the decorator to add.
  • A controller method called directly runs no interceptors. Only the testing module and the bot's dispatch run them.
  • next.handle() called twice runs the handler twice, with its side effects. Keep its promise if you need the result in two places.
  • An interceptor doesn't see denied calls. Guards run before it; count refusals in an observer.
  • A handler raced against a timeout can throw after the call has ended. Nothing is left to report it then, so MeoCord warns, naming both: "Racing returned before Shop.buy finished, which then threw; nothing caught it, so the call could not report it:", then the error. Forwarding it into a promise that has already settled, as (error) => reject(error) does, discards it unseen: race with Promise.race, or handle the error where it arrives.

Next steps

  • Cooldowns: limit how often the calls an interceptor wraps can run.
  • Exception filters: decide what the user sees when the handler throws.
  • Observers: trace every call from outside, and pair it with an interceptor's span inside.