Middleware
Middleware uses the Koa-style onion model. Each middleware calls next() to pass control inward, then can run logic after the inner layers complete.
Three Levels
Global Middleware
Added with router.use(). Wraps every dispatch regardless of state.
ts
router.use(async ({ command }, next) => {
const start = Date.now();
await next();
console.log(`${command.type} took ${Date.now() - start}ms`);
});State-Scoped Middleware
Added with use() inside a .state() block. Only runs for handlers registered in that state.
ts
router.state("Draft", ({ on, use }) => {
use(async (_ctx, next) => {
console.log("entering Draft handler");
await next();
});
on("UpdateDraft", ({ command, update }) => {
update({ title: command.payload.title });
});
});State middleware does not run for wildcard handlers, even if the workflow is in that state.
Inline Middleware
Passed as extra arguments to on() before the handler. Runs only for that specific command.
ts
inlineRouter.state("Draft", ({ on }) => {
on(
"Submit",
async ({ data, error }, next) => {
if (!data.body) {
error({ code: "BodyRequired", data: {} });
}
await next();
},
({ data, command, transition }) => {
transition("Review", {
title: data.title,
// biome-ignore lint/style/noNonNullAssertion: guarded by conditional logic in middleware above
body: data.body!,
reviewerId: command.payload.reviewerId,
});
},
);
});Execution Order
The full onion executes in this order:
global-before
state-before
inline-before
handler
inline-after
state-after
global-afterVerified by test:
ts
const log: string[] = [];
execRouter.use(async (_ctx, next) => {
log.push("global-before");
await next();
log.push("global-after");
});
execRouter.state("Draft", ({ on, use }) => {
use(async (_ctx, next) => {
log.push("state-before");
await next();
log.push("state-after");
});
on(
"SetTitle",
async (_ctx, next) => {
log.push("inline-before");
await next();
log.push("inline-after");
},
({ command, update }) => {
log.push("handler");
update({ title: command.payload.title });
},
);
});
(async () => {
await execRouter.dispatch(workflow, { type: "SetTitle", payload: { title: "x" } });
// log: ["global-before", "state-before", "inline-before", "handler",
// "inline-after", "state-after", "global-after"]
})();Example: Auth Middleware
ts
authRouter.use(async ({ set }, next) => {
// In a real app, extract user from a token or session
set(UserKey, { id: "user-1", role: "admin" });
await next();
});
authRouter.state("Review", ({ on }) => {
on("Approve", ({ get, error, data, transition }) => {
const user = get(UserKey);
if (user.role !== "admin") {
error({ code: "Unauthorized", data: { required: "admin" } });
}
transition("Published", {
title: data.title,
body: data.body,
publishedAt: new Date(),
});
});
});Example: Logging Middleware
ts
loggingRouter.use(async ({ workflow, command }, next) => {
console.log(`[${workflow.state}] ${command.type}`, command.payload);
await next();
});See Context Keys for the full createKey / set / get API.