Middleware
Prerequisites
Install Vurb.ts before following this guide: npm install @vurb/core @modelcontextprotocol/sdk zod — or scaffold a project with vurb create.
f.middleware() — Context Derivation
The primary pattern. Create a middleware that derives data and injects it into context — like tRPC's .use():
The returned object merges into ctx via Object.assign. Downstream handlers see ctx.user and ctx.permissions — fully typed, no annotations.
TIP
f.middleware() returns a MiddlewareDefinition. Call .toMiddlewareFn() when passing it to a tool or group that expects a raw MiddlewareFn.
defineMiddleware() — Standalone Packages
Same as f.middleware() but without needing an initVurb() instance — for shared utility packages:
import { defineMiddleware } from '@vurb/core';
const addTenant = defineMiddleware(async (ctx: { orgId: string }) => {
const tenant = await db.getTenant(ctx.orgId);
return { tenant };
});Use defineMiddleware() when building reusable middleware libraries that don't know the application's context type.
Per-Tool .use() — Inline Chain
Apply middleware to a single tool with .use(). Stack multiple calls for layered derivations:
Architect's Check
Verify that auth middleware runs BEFORE business logic. If your AI agent stacked .use() calls in the wrong order — permissions check before authentication — the chain will fail silently. Order matters.
Raw MiddlewareFn
For before/after hooks that need to wrap next() directly:
import { type MiddlewareFn } from '@vurb/core';
const loggingMiddleware: MiddlewareFn<AppContext> = async (ctx, args, next) => {
console.log(`[${new Date().toISOString()}] Action called`);
const result = await next();
console.log(`[${new Date().toISOString()}] Action completed`);
return result;
};Call next() to continue to the next middleware or handler. Don't call it to block the request.
Execution Order
Middleware executes in declaration order, outermost first:
Each step can:
- Enrich: Return
next({ ...ctx, newProp })to add properties - Guard: Throw or return an error to halt execution
- Observe: Call
const result = await next()to run after the handler
Pre-Compilation
Middleware chains are compiled at registration time into a single nested function. No array iteration, no allocation per request:
// What the compiler produces (conceptual):
const chain = (ctx, args) =>
loggingMiddleware(ctx, args, () =>
authMiddleware(ctx, args, () =>
handler(ctx, args)
)
);At runtime, handler execution is a Map.get() lookup + one function call. O(1) dispatch.
Common Patterns
error()requireRole('admin', 'editor') — parametric guardawait next()withDatabase + withCurrentUserAuthentication Guard
const authMiddleware: MiddlewareFn<AppContext> = async (ctx, args, next) => {
if (!ctx.session?.userId) {
return error('Authentication required. Missing token.');
}
return next();
};Role Factory
function requireRole(...roles: string[]): MiddlewareFn<AppContext> {
return async (ctx, args, next) => {
if (!roles.includes(ctx.role)) {
return error(`Forbidden: requires one of [${roles.join(', ')}]`);
}
return next();
};
}Audit Logging
Capture the result after the handler completes:
const auditLog: MiddlewareFn<AppContext> = async (ctx, args, next) => {
const result = await next();
await ctx.db.auditLogs.create({
data: {
userId: ctx.session.userId,
action: args.action as string,
timestamp: new Date(),
},
});
return result;
};Stacking Derivations
const withDatabase = f.middleware(async (ctx) => {
const db = await getDbConnection(ctx.tenantId);
return { db };
});
const withCurrentUser = f.middleware(async (ctx) => {
const user = await ctx.db.users.findUnique({ where: { id: ctx.userId } });
return { user, isAdmin: user?.role === 'admin' };
});NOTE
resolveMiddleware(mw) accepts either MiddlewareFn or MiddlewareDefinition and returns a MiddlewareFn. Useful for accepting middleware from external packages.