Skip to content

Hooks & Plugins

Lifecycle hooks observe dispatch events without affecting the pipeline. Plugins package hooks and middleware into reusable units.

Lifecycle Hooks

Register hooks with router.on():

ts
const router = new WorkflowRouter(taskWorkflow);

router.on("pipeline:start", ({ command }) => {
	console.log(`→ ${command.type}`);
});

router.on("pipeline:end", (_ctx, result) => {
	console.log(`← ${result.ok ? "ok" : "error"}`);
});

router.on("transition", (from, to) => {
	console.log(`${from} → ${to}`);
});

router.on("error", (error, _ctx) => {
	console.log(`error: ${error.category}`);
});

router.on("event", (event) => {
	console.log(`event: ${event.type}`);
});

Hook Events

EventWhenParameters
dispatch:startBefore any validation(workflow, command)
dispatch:endAfter dispatch completes (always, even early returns)(workflow, command, result)
pipeline:startAfter context created, before handler(ctx)
pipeline:endAfter handler pipeline completes(ctx, result)
transitionAfter a state change(from, to, workflow)
errorOn domain, validation, dependency, or unexpected error(error, ctx)
eventFor each emitted event(event, workflow)

Hooks vs Middleware

MiddlewareHooks
RoleIn the pipeline — can modify, short-circuitObserver — reacts after the fact
ErrorsPropagate and affect dispatchCaught, never affect dispatch
ContextFull ContextReadonlyContext (pipeline hooks) or raw args (dispatch hooks)
Use forAuth, validation, wrappingTelemetry, logging, devtools

Pipeline hooks (pipeline:start, pipeline:end, error) receive a ReadonlyContext — it has command, workflow, deps, data, events, and context-key access (set/get/getOrNull), but no mutation methods. Dispatch hooks (dispatch:start, dispatch:end) receive raw workflow and command arguments without context.

Error Isolation

Hook errors never affect the dispatch result. By default they are logged to console.error. You can provide a custom handler:

ts
const errorRouter = new WorkflowRouter(taskWorkflow, undefined, {
	onHookError: (err) => myLogger.warn("Hook error:", err),
});

Execution Order

Hooks run in registration order. Multiple hooks on the same event all fire, even if one throws.

dispatch:end is guaranteed to fire whenever dispatch:start fires, including early-return errors (UNKNOWN_STATE, command validation, NO_HANDLER). pipeline:end is guaranteed to fire whenever pipeline:start fires, even if the handler throws an unexpected error.

Plugins

A plugin is a function that receives the router and configures it — registering hooks, middleware, or both.

Defining a Plugin

ts
// biome-ignore lint/complexity/noBannedTypes: {} means "no deps", matching the router default
const loggingPlugin = definePlugin<TaskRouterConfig, {}>((router) => {
	router.on("pipeline:start", ({ command }) => {
		console.log(`[${new Date().toISOString()}] → ${command.type}`);
	});
	router.on("pipeline:end", (_ctx, result) => {
		console.log(`[${new Date().toISOString()}] ← ${result.ok ? "ok" : "error"}`);
	});
});

Using a Plugin

Pass it to router.use():

ts
const pluginRouter = new WorkflowRouter(taskWorkflow);
pluginRouter.use(loggingPlugin);

How .use() Discriminates

router.use() accepts three things:

ArgumentWhat happens
WorkflowRouter instanceMerges handlers (composable routers)
definePlugin() resultCalls the plugin function with the router
Plain function (ctx, next) => ...Adds as global middleware

Plugins are branded with a symbol by definePlugin(), so the router can tell them apart from middleware at runtime.

Plugin + Middleware

Plugins can register both hooks and middleware:

ts
const authPlugin = definePlugin((router) => {
	// Middleware: runs in the dispatch pipeline
	router.use(async ({ deps }, next) => {
		if (!(deps as Record<string, unknown>).currentUser) throw new Error("Unauthorized");
		await next();
	});

	// Hook: observes after the fact
	router.on("pipeline:end", ({ command }, result) => {
		auditLog.record(command, result);
	});
});