Event middleware
Run code before your event handlers with event middleware. Covers stopping an event, choosing which events it runs for, the order, passing values along, cleaning up in after(), and where middleware files go.
Suppose three handlers respond to messageCreate, and none of them should answer a muted member. Each handler would repeat the same mute lookup. If you add a fourth handler and forget the lookup, it answers anyway.
An event middleware runs that check once, before every handler registered for the event. If the middleware throws, none of those handlers run.
import {
EventMiddleware,
RegisterEventMiddleware,
Silence
} from '@seedcord/gateway';
import { Events } from 'discord.js';
@RegisterEventMiddleware({
events: [Events.MessageCreate]
})
export class SkipMuted extends EventMiddleware<Events.MessageCreate> {
public async execute(): Promise<void> {
const [message] = this.event;
if (await isMuted(message.author.id)) {
throw new Silence('author is muted');
}
}
}@RegisterEventMiddleware takes one options object. Interactions have their own middleware, registered with @RegisterInteractionMiddleware, which filters on { kinds }.
Stopping the event
Any throw stops the event. What happens next depends on the class you throw. SkipMuted throws a Silence, which ends the event without reporting anything. seedcord logs its reason, 'author is muted', as a debug line on the errors log channel. To turn that line off, set errors.logSilences to false. If a Silence has no reason, seedcord doesn't log anything. Anything else is reported the same way as a handler's throw.
That stops one event dispatch. The next message runs every middleware again from the start.
Which events it runs for
You write the events twice, once in { events } and once in the generic on EventMiddleware. If the two differ, the decorator line fails to compile. SkipMuted lists one event, so this.event is that event's payload.
With several events, the payload could be any of them, so this.event is never. this.eventName returns the event that fired, which is enough for work that doesn't read the payload, like counting.
@RegisterEventMiddleware({
events: [Events.MessageCreate, Events.MessageDelete],
priority: 1
})
export class CountMessages extends EventMiddleware<
Events.MessageCreate | Events.MessageDelete
> {
public async execute(): Promise<void> {
const name = this.eventName; this.logger.debug(`${name} fired`);
}
}name is Events.MessageCreate | Events.MessageDelete. Without { events }, the middleware runs on every event your handlers registered. Leave the generic off too, since a generic without { events } fails to compile.
@RegisterEventMiddleware({ priority: 2 })
export class Trace extends EventMiddleware {
public async execute(): Promise<void> {
this.logger.debug(`${this.eventName} reached the chain`);
}
}Trace reads only this.eventName, which is all a catchall can read. An event middleware doesn't have match, because it runs the same code for every event it lists. When your check reads the payload of several events, write one middleware class per event.
Order
priority takes any finite number, including negative ones. The middleware with the lower number runs first. If you leave priority out, the middleware gets 0. That puts SkipMuted first at 0, then CountMessages at 1, then Trace at 2. All three run before seedcord builds the first handler, as the event dispatch order lays out.
If two middleware share a number, they run in the order seedcord loaded their files. That order can change when you add, rename, or move a middleware file, so give anything order-dependent its own number.
Passing a value along
A middleware often looks up something every handler needs, like the server's language. Without somewhere to put it, each handler would look it up again.
Each event dispatch gets one bag of values, which your code reads and writes through this.dispatch. The middleware and every handler for that event share it. Locale writes the language once. Each handler reads it back.
@RegisterEventMiddleware({ events: [Events.MessageCreate] })
export class Locale extends EventMiddleware<Events.MessageCreate> {
public async execute(): Promise<void> {
const [message] = this.event;
const guildLocale = message.guild?.preferredLocale;
this.dispatch.set('locale', guildLocale ?? 'en-US');
await Promise.resolve();
}
}You declare the key and read it back the same way you do on an interaction dispatch. An interaction's bag has one handler reading it. An event's bag is shared by every handler registered for that event. On an event, routeId reads event:<name>. The bag lasts until every handler for that event has finished and every middleware's after() has run. The next time the event fires, it gets a new, empty bag.
After the event
A middleware that opens something in execute(), like a timer or a lock, needs a place to close it once the handlers are done. That place is after(), an optional method seedcord calls when the event finishes.
A middleware gets its after() call as long as its own execute() started, even if that execute() threw. Say middleware A runs before middleware B.
- If both ran,
B.after()runs first, thenA.after(). - If
Athrew, onlyA.after()runs.Bnever started, so it doesn't get one.
@RegisterEventMiddleware<NonEmptyTuple<ValidNonInteractionKeys>>(options?: EventMiddlewareOptions<NonEmptyTuple<ValidNonInteractionKeys>> | undefined): (ctor: Constructor<EventMiddleware<ValidNonInteractionKeys>, any[]>) => voidRegisterEventMiddleware()
export class class TimingTiming extends class EventMiddleware<
in out EventName extends ValidNonInteractionKeys =
ValidNonInteractionKeys
>
EventMiddleware {
private Timing.startedAt: numberstartedAt = 0;
public async Timing.execute(): Promise<void>execute(): interface Promise<T>Promise<void> {
this.Timing.startedAt: numberstartedAt = var performance: Performanceperformance.Performance.now(): numbernow();
await var Promise: PromiseConstructorPromise.PromiseConstructor.resolve(): Promise<void> (+2 overloads)resolve();
}
public override async Timing.after(result: EventDispatchResult): Promise<void>after(
result: EventDispatchResultresult: type EventDispatchResult =
| {
readonly outcome: "refused" | "failed";
readonly caught: unknown;
readonly handlers: readonly [];
}
| {
readonly outcome: "handled";
readonly handlers: readonly HandlerResult[];
}
EventDispatchResult
): interface Promise<T>Promise<void> {
const const elapsed: numberelapsed = var Math: MathMath.Math.round(x: number): numberround(
var performance: Performanceperformance.Performance.now(): numbernow() - this.Timing.startedAt: numberstartedAt
);
this.BaseHandler<Event, TCore extends CoreBase>.logger: Loggerlogger.Logger.debug(msg: string, ...args: unknown[]): voiddebug(
`${this.EventMiddleware<ValidNonInteractionKeys>.eventName: ValidNonInteractionKeyseventName}: ${result: EventDispatchResultresult.handlers: readonly [] | readonly HandlerResult[]handlers.ReadonlyArray<T>.length: numberlength} in ${const elapsed: numberelapsed}ms`
);
await var Promise: PromiseConstructorPromise.PromiseConstructor.resolve(): Promise<void> (+2 overloads)resolve();
}
}The base class declares after(), so Timing marks its version override. Leaving that off is a compile error under TypeScript's noImplicitOverride.
after() receives an EventDispatchResult that says how the event ended. The Timing sample logs how many handlers ran, from result.handlers.
outcome | when | caught | handlers |
|---|---|---|---|
'handled' | every middleware finished without throwing | not present | one entry per handler that ran |
'refused' | a middleware threw a Silence, or a Notice with report false | the thrown value | empty |
'failed' | a middleware threw a reported Notice or any other error | the thrown value | empty |
outcome only covers the middleware. If a handler throws, outcome still reads 'handled'. That handler's own result is in its handlers entry, which carries the handler's class name as handler, plus its own outcome and caught with the same three values.
An after() that throws doesn't stop the other after() calls. If B.after() throws, seedcord logs the error and still calls A.after().
Where to put middleware
seedcord loads event middleware from the folder you set in bot.events.middlewares. If you leave the key out, seedcord skips the middleware scan. Leave it out when your bot doesn't use any event middleware.
export const seedcord = new Seedcord({
bot: {
...rest,
clientOptions: { intents: [GatewayIntentBits.Guilds] },
events: {
path: resolve(import.meta.dirname, './events'),
middlewares: resolve(
import.meta.dirname,
'./events/middlewares'
)
}
},
subscribers: { path: null }
});seedcord imports every file in that ./events/middlewares folder. If a class there doesn't have @RegisterEventMiddleware, it still loads but never runs, the same as an event handler without @RegisterEvent. The middleware-missing-register-decorator lint rule flags the missing decorator while you write the class.
Warning
Middleware runs only when a handler is about to run. seedcord checks for a handler left to run before it starts any middleware. A catchall middleware therefore skips two kinds of events:
- an event that no handler registered
- an event whose handlers were all registered
onceand have already run
Gateway only
@RegisterEventMiddleware and EventMiddleware ship on @seedcord/gateway. An http bot doesn't receive client events to run them on.
Message events come with rules that middleware can't work around.