Skip to main content

Execution handlers

Execution handlers are Koa-style interceptors around command middleware and run(), component middleware and run(), or an event's run().

const metricsHandler: CommandExecutionHandler = async (execution, next) => {
metrics.increment("commands.started", {
command: execution.interaction.commandName,
});

const outcome = await next();

metrics.timing("commands.duration", outcome.durationMs);
return outcome;
};

Code before next() runs in declaration order. Code after next() runs in reverse order. An execution handler can also skip next() to short-circuit the execution or return a transformed outcome.

Execution flow

The default handler is an ordinary execution handler. With the configuration [default, metrics, audit], execution enters the chain from top to bottom and unwinds from bottom to top:

Dispatch errors occur before the chain and continue to use dispatchDiagnostics. Events have no command/component middleware, so their terminal calls run() directly.

Outcomes

next() returns an ExecutionOutcome:

type ExecutionOutcome<T, E = unknown> =
| {
kind: "completed";
exit: ExecutionExit<T, E>;
startedAt: number;
endedAt: number;
durationMs: number;
incidentId?: string;
}
| {
kind: "cancelled";
startedAt: number;
endedAt: number;
durationMs: number;
};

kind: "cancelled" means command or component middleware deliberately stopped before run(). Completed executions contain the existing ExecutionExit:

type ExecutionExit<T, E = unknown> =
| { status: "success"; value: T }
| { status: "failure"; failure: E }
| { status: "defect"; defect: unknown };
  • success means run() returned normally.
  • failure means middleware or run() returned an expected failure.
  • defect means middleware or run() threw.

Configuring a chain

Providing executionHandlers replaces Arcscord's default execution handler. Add the exported default handler explicitly when you want to augment rather than replace the built-in behavior:

import {
ArcClient,
defaultCommandExecutionHandler,
type CommandExecutionHandler,
} from "arcscord";

const metricsHandler: CommandExecutionHandler = async (execution, next) => {
const outcome = await next();

await recordCommandExecution({
command: execution.interaction.commandName,
outcome,
});

return outcome;
};

const client = new ArcClient(token, {
intents: ["Guilds"],
managers: {
command: {
executionHandlers: [
defaultCommandExecutionHandler,
metricsHandler,
],
},
},
});

With this order, the default handler receives the final outcome after inner handlers have transformed it. The equivalent exports for the other managers are defaultComponentExecutionHandler and defaultEventExecutionHandler.

The three default execution handlers own Arcscord's built-in logging and error reply behavior directly. They do not delegate to the deprecated defaultResultHandler().

An empty array is valid: middleware and run() still execute, but Arcscord does not apply any result logging or error reply.

Short-circuiting and transforming

Every execution context provides complete(exit) and cancel() helpers. They create a correctly timed outcome and make short-circuiting straightforward:

const maintenanceHandler: CommandExecutionHandler = async (execution, next) => {
if (maintenance.enabled) {
return execution.complete(executionFailure({
code: "MAINTENANCE",
}));
}

return next();
};

An interceptor can transform an outcome after next():

const recoveryHandler: CommandExecutionHandler = async (execution, next) => {
const outcome = await next();

if (outcome.kind === "completed" && outcome.exit.status === "failure") {
const recovered = await recover(outcome.exit.failure);
if (recovered) {
return execution.complete(executionSuccess("recovered"));
}
}

return outcome;
};

Each next() function may only be called once. Arcscord contains and logs an uncaught execution-handler error instead of leaking an unhandled rejection into Discord.js.

Manager-specific context

Command and component execution handlers receive the resolved handler, interaction, Arcscord context, locale, defer state, and timing controls.

Event execution handlers additionally receive the typed EventContext and the Discord.js event arguments:

const auditEvents: EventExecutionHandler = async (execution, next) => {
if (execution.eventName === "messageCreate") {
const [message] = execution.args;
audit.debug("message received", { messageId: message.id });
}

return next();
};

Resolution, context creation, pre-reply defer, and dispatchDiagnostics remain outside the execution-handler chain.

Migrating from resultHandler

resultHandler, its payload types, and manager.defaultResultHandler() remain available for compatibility but are deprecated. Existing configurations keep their previous behavior.

// Deprecated, but still supported
command: {
resultHandler: async (infos, manager) => {
await recordCommandUsage(infos.interaction.commandName);
return manager.defaultResultHandler(infos);
},
}

Migrate by moving pre-execution work before next(), post-execution work after it, and explicitly including the appropriate default execution handler. resultHandler and executionHandlers cannot be configured together.

The normal Arcscord path already uses the execution-handler implementation. Removing the legacy result-handler API in Arcscord v2 will therefore not change the default runtime behavior.

The default command and component execution handlers log successes, log failures and defects, and send the configured internal-error reply. The default event execution handler logs failures and defects without replying to Discord.