router: new 'afterware' yay
@@ -12,6 +12,7 @@ - [context menus](#context-menus)
- [interactive components](#interactive-components) - [adding autocomplete](#adding-autocomplete) - [using middleware](#using-middleware) +- [using afterware](#using-afterware) - [handling errors](#handling-errors) - [registering our commands and components](#registering-our-commands-and-components)@@ -260,6 +261,77 @@ async run(interaction, args, context) {
// context: { user: { id: string; moderator: boolean; ... } } await banUser(args.user.id, context.user.id); await interaction.reply(`banned ${args.user.username} (${args.user.id})`); + }, +}); +``` + +## using afterware + +where middleware runs _before_ execution to compile context, afterware runs +_after_ execution (in a safe `finally` block) regardless of whether your +interaction succeeded, threw an exception, or was halted. + +### strict typings + +you can specify the expected return type of your interaction handlers by passing +a generic type to `.after()`. this ensures: + +- the `result` parameter inside your afterware is strictly typed as + `Res | undefined`. +- TypeScript will enforce at compile-time that any commands built from this + builder _must_ return a type assignable to your declared generic. + +```ts +interface PaymentResult { + receiptId: string; + amount: number; + status: "completed" | "flagged"; +} + +const payBuilder = kitten + .builder() + .use((interaction, ctx) => { + return { + startTime: performance.now(), + requestId: `req_${Math.random().toString(36).substring(2, 9)}`, + }; + }) + .after<PaymentResult>(async (interaction, ctx, error, result) => { + const duration = performance.now() - ctx.startTime; + + console.log(`[req ${ctx.requestId}] processed in ${duration.toFixed(2)}ms`); + + if (error) { + console.error(`[req ${ctx.requestId}] payment failed:`, error); + return; + } + + // result is strictly typed as: PaymentResult | undefined + if (result) { + console.log( + `[req ${ctx.requestId}] payment successful! receiptId:${result.receiptId}, amount:$${result.amount / 100}`, + ); + } + }); + +const payCommand = payBuilder.command("pay", { + description: "process a payment.", + options: { + amount: option.integer.required("the billing amount in USD"), + }, + async run(interaction, { amount }, ctx) { + const receiptId = await processPayment(amount); + + await interaction.reply({ + content: `payment processed! receipt ID: ${receiptId}`, + ephemeral: true, + }); + + return { + receiptId, + amount, + status: "completed", + }; }, }); ```
@@ -0,0 +1,25 @@
+# [1.6.0] 2026-07-09 + +in this release i added strictly-typed post-execution flow handlers, which i've +dubbed "afterware". afterwares run after any command, context menu, or component +finishes executing, regardless of whether it completed cleanly, threw an exception, +or was halted by middleware. + +afterwares operate sequentially inside a safe `finally` block and receive: + +- the accumulated context passed down by your middlewares, +- any uncaught execution error (or `null` if the handler succeeded), and +- the return value of your interaction handler. + +to prevent type safety degradation, i've implemented strict compile-time type +inference for these handlers. when you register an afterware via `.after<Res>()` on +your builder: + +- the `result` argument inside your afterware is strictly typed as `Res | undefined`, and +- TypeScript will strictly verify that all commands, subcommands, and components + produced by that builder return a type assignable to `Res`. + +you can find more information and a complete example under the "using afterware" +section of the [Getting Started] guide. + +[Getting Started]: ../GET-STARTED.md
@@ -1,6 +1,6 @@
{ "name": "@purrkit/router", - "version": "1.5.0", + "version": "1.6.0", "license": "EUPL-1.2", "author": { "name": "apr",
@@ -31,7 +31,7 @@ GUILDS_ONLY: [INTERACTION_CONTEXTS.GUILD],
DMS_ONLY: [INTERACTION_CONTEXTS.BOT_DM, INTERACTION_CONTEXTS.PRIVATE_CHANNEL], }; -export type CommandResultType = Promise<unknown> | void; +export type CommandResultType<Res = unknown> = Promise<Res> | Res; export type IntegrationType = (typeof INTEGRATION_TYPES)[keyof typeof INTEGRATION_TYPES]; export type InteractionContextType = (typeof INTERACTION_CONTEXTS)[keyof typeof INTERACTION_CONTEXTS];@@ -46,6 +46,14 @@ err: unknown,
interaction: I, ctx: Ctx, ) => Promise<unknown> | unknown; + +// Parameterized with Res. result is Res | undefined because execution may fail/halt. +export type Afterware<I extends BaseInteraction = BaseInteraction, Ctx = any, Res = unknown> = ( + interaction: I, + ctx: Ctx, + error: unknown | null, + result: Res | undefined, +) => Promise<void> | void; export class Subcommand { constructor(@@ -59,7 +67,7 @@ },
) {} } -export class SubcommandGroup<Ctx = {}> { +export class SubcommandGroup<Ctx = {}, Res = unknown> { public subcommands: Record<string, Subcommand> = {}; constructor(@@ -67,7 +75,7 @@ public name: string,
public description: string, ) {} - subcommand<O extends Record<string, CommandOption<any, any>>>( + subcommand<O extends Record<string, CommandOption<any, any>>, R extends Res>( name: string, config: { description: string;@@ -81,7 +89,7 @@ run: (
interaction: ChatInputCommandInteraction, args: InferOptions<O>, ctx: Ctx, - ) => CommandResultType; + ) => CommandResultType<R>; }, ): this { this.subcommands[name] = new Subcommand(name, config);@@ -89,22 +97,23 @@ return this;
} } -export class SubcommandGroupBuilder<Ctx = {}> { +export class SubcommandGroupBuilder<Ctx = {}, Res = unknown> { public subcommands: Record<string, Subcommand> = {}; - public groups: Record<string, SubcommandGroup<Ctx>> = {}; + public groups: Record<string, SubcommandGroup<Ctx, Res>> = {}; constructor( public name: string, public description: string, public middlewares: Middleware<any, any, any>[] = [], public errorHandlers: ErrorHandler<any, any>[] = [], + public afterwares: Afterware<any, any, any>[] = [], public config?: { integrationTypes?: IntegrationType[]; contexts?: InteractionContextType[]; }, ) {} - subcommand<O extends Record<string, CommandOption<any, any>>>( + subcommand<O extends Record<string, CommandOption<any, any>>, R extends Res>( name: string, config: { description: string;@@ -118,7 +127,7 @@ run: (
interaction: ChatInputCommandInteraction, args: InferOptions<O>, ctx: Ctx, - ) => CommandResultType; + ) => CommandResultType<R>; }, ): this { this.subcommands[name] = new Subcommand(name, config);@@ -128,16 +137,16 @@
group( name: string, config: { description: string }, - setup: (group: SubcommandGroup<Ctx>) => void, + setup: (group: SubcommandGroup<Ctx, Res>) => void, ): this { - const group = new SubcommandGroup<Ctx>(name, config.description); + const group = new SubcommandGroup<Ctx, Res>(name, config.description); setup(group); this.groups[name] = group; return this; } } -export class Command<Ctx = any> { +export class Command<Ctx = any, Res = unknown> { constructor( public name: string, public config: {@@ -146,23 +155,30 @@ options?: Record<string, CommandOption<any, any>>;
integrationTypes?: IntegrationType[]; contexts?: InteractionContextType[]; onError?: ErrorHandler<ChatInputCommandInteraction, Ctx>; - run: (interaction: ChatInputCommandInteraction, args: any, ctx: Ctx) => CommandResultType; + run: ( + interaction: ChatInputCommandInteraction, + args: any, + ctx: Ctx, + ) => CommandResultType<Res>; }, public middlewares: Middleware<any, any, any>[] = [], public errorHandlers: ErrorHandler<any, any>[] = [], + public afterwares: Afterware<any, any, any>[] = [], ) {} } export class ContextMenuCommand< Ctx = any, I extends UserContextMenuCommandInteraction | MessageContextMenuCommandInteraction = any, + Res = unknown, > { constructor( public name: string, public type: "user" | "message", - public run: (interaction: I, ctx: Ctx) => Promise<unknown> | unknown, + public run: (interaction: I, ctx: Ctx) => CommandResultType<Res>, public middlewares: Middleware<any, any, any>[] = [], public errorHandlers: ErrorHandler<any, any>[] = [], + public afterwares: Afterware<any, any, any>[] = [], public config?: { integrationTypes?: IntegrationType[]; contexts?: InteractionContextType[];@@ -171,23 +187,40 @@ },
) {} } -export class CommandBuilder<Ctx = {}> { +export class CommandBuilder<Ctx = {}, Res = unknown> { constructor( private middlewares: Middleware<any, any, any>[] = [], private errorHandlers: ErrorHandler<any, any>[] = [], + private afterwares: Afterware<any, any, Res>[] = [], ) {} use<NewCtx extends Record<string, any>>( mw: (interaction: BaseInteraction, ctx: Ctx) => Promise<NewCtx | void> | NewCtx | void, - ): CommandBuilder<Ctx & NewCtx> { - return new CommandBuilder<Ctx & NewCtx>([...this.middlewares, mw], this.errorHandlers); + ): CommandBuilder<Ctx & NewCtx, Res> { + return new CommandBuilder<Ctx & NewCtx, Res>( + [...this.middlewares, mw], + this.errorHandlers, + this.afterwares, + ); } - onError(handler: ErrorHandler<BaseInteraction, Ctx>): CommandBuilder<Ctx> { - return new CommandBuilder<Ctx>(this.middlewares, [...this.errorHandlers, handler]); + onError(handler: ErrorHandler<BaseInteraction, Ctx>): CommandBuilder<Ctx, Res> { + return new CommandBuilder<Ctx, Res>( + this.middlewares, + [...this.errorHandlers, handler], + this.afterwares, + ); } - command<O extends Record<string, CommandOption<any, any>>>( + // Refines the Res generic type parameter of the CommandBuilder + after<NewRes>(handler: Afterware<BaseInteraction, Ctx, NewRes>): CommandBuilder<Ctx, NewRes> { + return new CommandBuilder<Ctx, NewRes>(this.middlewares, this.errorHandlers, [ + ...this.afterwares, + handler as any, + ]); + } + + command<O extends Record<string, CommandOption<any, any>>, R extends Res>( name: string, config: { description: string;@@ -199,9 +232,9 @@ run: (
interaction: ChatInputCommandInteraction, args: InferOptions<O>, ctx: Ctx, - ) => CommandResultType; + ) => CommandResultType<R>; }, - ): Command<Ctx>; + ): Command<Ctx, R>; command( name: string,@@ -210,17 +243,24 @@ description: string;
integrationTypes?: IntegrationType[]; contexts?: InteractionContextType[]; }, - ): SubcommandGroupBuilder<Ctx>; + ): SubcommandGroupBuilder<Ctx, Res>; command(name: string, config: any): any { if (config.run) { - return new Command<Ctx>(name, config, this.middlewares, this.errorHandlers); + return new Command<Ctx, Res>( + name, + config, + this.middlewares, + this.errorHandlers, + this.afterwares, + ); } - return new SubcommandGroupBuilder<Ctx>( + return new SubcommandGroupBuilder<Ctx, Res>( name, config.description, this.middlewares, this.errorHandlers, + this.afterwares, { integrationTypes: config.integrationTypes, contexts: config.contexts,@@ -228,33 +268,35 @@ },
); } - userContextMenu( + userContextMenu<R extends Res>( name: string, runOrConfig: - | ((interaction: UserContextMenuCommandInteraction, ctx: Ctx) => Promise<unknown> | unknown) + | ((interaction: UserContextMenuCommandInteraction, ctx: Ctx) => CommandResultType<R>) | { integrationTypes?: IntegrationType[]; contexts?: InteractionContextType[]; onError?: ErrorHandler<UserContextMenuCommandInteraction, Ctx>; - run: (interaction: UserContextMenuCommandInteraction, ctx: Ctx) => CommandResultType; + run: (interaction: UserContextMenuCommandInteraction, ctx: Ctx) => CommandResultType<R>; }, - ): ContextMenuCommand<Ctx, UserContextMenuCommandInteraction> { + ): ContextMenuCommand<Ctx, UserContextMenuCommandInteraction, R> { if (typeof runOrConfig === "function") { - return new ContextMenuCommand<Ctx, UserContextMenuCommandInteraction>( + return new ContextMenuCommand<Ctx, UserContextMenuCommandInteraction, R>( name, "user", runOrConfig, this.middlewares, this.errorHandlers, + this.afterwares, ); } - return new ContextMenuCommand<Ctx, UserContextMenuCommandInteraction>( + return new ContextMenuCommand<Ctx, UserContextMenuCommandInteraction, R>( name, "user", runOrConfig.run, this.middlewares, this.errorHandlers, + this.afterwares, { integrationTypes: runOrConfig.integrationTypes, contexts: runOrConfig.contexts,@@ -263,36 +305,38 @@ },
); } - messageContextMenu( + messageContextMenu<R extends Res>( name: string, runOrConfig: - | (( - interaction: MessageContextMenuCommandInteraction, - ctx: Ctx, - ) => Promise<unknown> | unknown) + | ((interaction: MessageContextMenuCommandInteraction, ctx: Ctx) => CommandResultType<R>) | { integrationTypes?: IntegrationType[]; contexts?: InteractionContextType[]; onError?: ErrorHandler<MessageContextMenuCommandInteraction, Ctx>; - run: (interaction: MessageContextMenuCommandInteraction, ctx: Ctx) => CommandResultType; + run: ( + interaction: MessageContextMenuCommandInteraction, + ctx: Ctx, + ) => CommandResultType<R>; }, - ): ContextMenuCommand<Ctx, MessageContextMenuCommandInteraction> { + ): ContextMenuCommand<Ctx, MessageContextMenuCommandInteraction, R> { if (typeof runOrConfig === "function") { - return new ContextMenuCommand<Ctx, MessageContextMenuCommandInteraction>( + return new ContextMenuCommand<Ctx, MessageContextMenuCommandInteraction, R>( name, "message", runOrConfig, this.middlewares, this.errorHandlers, + this.afterwares, ); } - return new ContextMenuCommand<Ctx, MessageContextMenuCommandInteraction>( + return new ContextMenuCommand<Ctx, MessageContextMenuCommandInteraction, R>( name, "message", runOrConfig.run, this.middlewares, this.errorHandlers, + this.afterwares, { integrationTypes: runOrConfig.integrationTypes, contexts: runOrConfig.contexts,@@ -301,23 +345,28 @@ },
); } - button<O extends Record<string, Option<any, any>>>( + button<O extends Record<string, Option<any, any>>, R extends Res>( name: string, config: { options?: O; onError?: ErrorHandler<ButtonInteraction, Ctx>; - run: (interaction: ButtonInteraction, args: InferOptions<O>, ctx: Ctx) => CommandResultType; + run: ( + interaction: ButtonInteraction, + args: InferOptions<O>, + ctx: Ctx, + ) => CommandResultType<R>; }, - ): KittenComponent<ButtonInteraction, O, Ctx> { - return new KittenComponent<ButtonInteraction, O, Ctx>( + ): KittenComponent<ButtonInteraction, O, Ctx, R> { + return new KittenComponent<ButtonInteraction, O, Ctx, R>( name, config, this.middlewares, this.errorHandlers, + this.afterwares, ); } - selectMenu<O extends Record<string, Option<any, any>>>( + selectMenu<O extends Record<string, Option<any, any>>, R extends Res>( name: string, config: { options?: O;@@ -326,18 +375,19 @@ run: (
interaction: AnySelectMenuInteraction, args: InferOptions<O>, ctx: Ctx, - ) => CommandResultType; + ) => CommandResultType<R>; }, - ): KittenComponent<AnySelectMenuInteraction, O, Ctx> { - return new KittenComponent<AnySelectMenuInteraction, O, Ctx>( + ): KittenComponent<AnySelectMenuInteraction, O, Ctx, R> { + return new KittenComponent<AnySelectMenuInteraction, O, Ctx, R>( name, config, this.middlewares, this.errorHandlers, + this.afterwares, ); } - modal<O extends Record<string, Option<any, any>>>( + modal<O extends Record<string, Option<any, any>>, R extends Res>( name: string, config: { options?: O;@@ -346,14 +396,15 @@ run: (
interaction: ModalSubmitInteraction, args: InferOptions<O>, ctx: Ctx, - ) => CommandResultType; + ) => CommandResultType<R>; }, - ): KittenComponent<ModalSubmitInteraction, O, Ctx> { - return new KittenComponent<ModalSubmitInteraction, O, Ctx>( + ): KittenComponent<ModalSubmitInteraction, O, Ctx, R> { + return new KittenComponent<ModalSubmitInteraction, O, Ctx, R>( name, config, this.middlewares, this.errorHandlers, + this.afterwares, ); } }
@@ -4,7 +4,7 @@ AnySelectMenuInteraction,
ModalSubmitInteraction, } from "discord.js"; import type { Option, InferOptions } from "./options"; -import type { Middleware, ErrorHandler } from "./commands"; +import type { Middleware, ErrorHandler, Afterware } from "./commands"; import { CustomIdTooLong } from "./errors"; export type KittenComponentInteraction =@@ -16,6 +16,7 @@ export class KittenComponent<
I extends KittenComponentInteraction = KittenComponentInteraction, O extends Record<string, Option<any, any>> = Record<string, Option<any, any>>, Ctx = any, + Res = unknown, // Added Res generic > { type = "component" as const;@@ -24,10 +25,11 @@ public name: string,
public config: { options?: Record<string, Option<any, any>>; onError?: ErrorHandler<I, Ctx>; - run: (interaction: I, args: InferOptions<O>, ctx: Ctx) => Promise<unknown> | unknown; + run: (interaction: I, args: InferOptions<O>, ctx: Ctx) => Promise<Res> | Res; // Typed run return }, public middlewares: Middleware<any, any, any>[] = [], public errorHandlers: ErrorHandler<any, any>[] = [], + public afterwares: Afterware<any, any, any>[] = [], ) {} /**
@@ -28,6 +28,7 @@ type InteractionContextType,
type CommandResultType, type Middleware, type ErrorHandler, + type Afterware, } from "./commands"; export interface KittenOptions {@@ -42,10 +43,11 @@ error: [message: string, data?: Record<string, any>];
} export class Kitten extends EventEmitter<KittenEvents> { - private commands = new Map<string, Command<any> | SubcommandGroupBuilder<any>>(); - private userContextMenus = new Map<string, ContextMenuCommand<any, any>>(); - private messageContextMenus = new Map<string, ContextMenuCommand<any, any>>(); - private components = new Map<string, KittenComponent<any, any, any>>(); + // Mapped to any/any to support multiple different return types in a single instance + private commands = new Map<string, Command<any, any> | SubcommandGroupBuilder<any, any>>(); + private userContextMenus = new Map<string, ContextMenuCommand<any, any, any>>(); + private messageContextMenus = new Map<string, ContextMenuCommand<any, any, any>>(); + private components = new Map<string, KittenComponent<any, any, any, any>>(); private globalOnError?: ErrorHandler<BaseInteraction, any>; constructor(@@ -157,8 +159,12 @@ return this.builder().modal(name, config);
} register(registry: { - commands?: (Command<any> | SubcommandGroupBuilder<any> | ContextMenuCommand<any, any>)[]; - components?: KittenComponent<any, any, any>[]; + commands?: ( + | Command<any, any> + | SubcommandGroupBuilder<any, any> + | ContextMenuCommand<any, any, any> + )[]; + components?: KittenComponent<any, any, any, any>[]; }) { registry.commands?.forEach((cmd) => { if (cmd instanceof ContextMenuCommand) {@@ -269,6 +275,7 @@ undefined,
{ localOnError: subcommand.config.onError, builderErrorHandlers: entry.errorHandlers, + afterwares: entry.afterwares, }, ); } else if (subName) {@@ -290,6 +297,7 @@ undefined,
{ localOnError: subcommand.config.onError, builderErrorHandlers: entry.errorHandlers, + afterwares: entry.afterwares, }, ); }@@ -304,6 +312,7 @@ undefined,
{ localOnError: entry.config.onError, builderErrorHandlers: entry.errorHandlers, + afterwares: entry.afterwares, }, ); }@@ -336,6 +345,7 @@ undefined,
{ localOnError: entry.config?.onError, builderErrorHandlers: entry.errorHandlers, + afterwares: entry.afterwares, }, ); }@@ -349,14 +359,21 @@ componentArgs?: Record<string, any>,
extra?: { localOnError?: ErrorHandler<any, any>; builderErrorHandlers?: ErrorHandler<any, any>[]; + afterwares?: Afterware<any, any, any>[]; }, ) { let context: Record<string, any> = {}; + let executionResult: unknown = undefined; + let executionError: unknown = null; + let didStartExecution = false; + try { this.emit( "debug", `Running ${middlewares.length} middleware(s) for interaction ${interaction.id}`, ); + + didStartExecution = true; for (let i = 0; i < middlewares.length; i++) { const mw = middlewares[i];@@ -369,7 +386,7 @@
if (interaction.isChatInputCommand()) { const args = this.parseSlashOptions(interaction, optionsSchema); this.emit("debug", `executing command handler for "${interaction.commandName}"`, { args }); - await run(interaction, args, context); + executionResult = await run(interaction, args, context); } else if ( interaction.isButton() || interaction.isAnySelectMenu() ||@@ -379,12 +396,13 @@ const customId = (interaction as any).customId;
this.emit("debug", `executing component handler for custom ID: "${customId}"`, { componentArgs, }); - await run(interaction, componentArgs ?? {}, context); + executionResult = await run(interaction, componentArgs ?? {}, context); } else { this.emit("debug", `executing generic fallback handler for interaction ${interaction.id}`); - await run(interaction, context); + executionResult = await run(interaction, context); } } catch (err) { + executionError = err; if (err instanceof HaltExecution) { this.emit("debug", `execution halted intentionally for interaction ${interaction.id}.`); if (@@ -473,6 +491,30 @@ })
.catch(() => null); } } + } finally { + if (didStartExecution && extra?.afterwares?.length) { + this.emit( + "debug", + `Running ${extra.afterwares.length} afterware(s) for interaction ${interaction.id}`, + ); + for (let i = 0; i < extra.afterwares.length; i++) { + const aw = extra.afterwares[i]; + if (!aw) continue; + try { + this.emit("debug", `executing afterware [${i + 1}/${extra.afterwares.length}]`); + await aw(interaction, context, executionError, executionResult); + } catch (awErr) { + this.emit( + "error", + `unhandled error executing afterware [${i + 1}/${extra.afterwares.length}]:`, + { + error: awErr instanceof Error ? awErr.stack : String(awErr), + interaction: interaction.id, + }, + ); + } + } + } } }@@ -549,6 +591,7 @@ args,
{ localOnError: component.config.onError, builderErrorHandlers: component.errorHandlers, + afterwares: component.afterwares, }, ); }