Interceptors
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
Before this
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
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:
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:
// 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:
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:
@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}.` })
}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:
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 withPromise.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.