Middleware
Prerequisites
Install Vurb.ts before following this guide: npm install @vurb/core @modelcontextprotocol/sdk zod — or scaffold a project with vurb create.
- Introduction
- f.middleware() — Context Derivation
- defineMiddleware() — Standalone Packages
- Per-Tool .use() — Inline Chain
- Raw MiddlewareFn
- Execution Order
- Pre-Compilation
- Common Patterns
Introduction
Every production application has cross-cutting concerns — authentication, authorization, auditing, rate limiting, tenant resolution. Without middleware, you'd duplicate these checks in every handler. This is especially painful when generating tools from @vurb/openapi-gen or @vurb/prisma-gen — dozens of auto-generated handlers that all need the same auth and audit layer:
// ❌ Without middleware — validation repeated in every tool
.handle(async (input, ctx) => {
const session = await checkAuth(ctx.token);
if (!session) throw new Error('Unauthorized');
if (!session.isAdmin) throw new Error('Forbidden');
await ctx.db.auditLogs.create({ data: { userId: session.id, action: 'stats' } });
// ...finally, the actual logic
})Vurb.ts's middleware system lets you extract these concerns into reusable, composable functions that run before (or after) the handler. The context is enriched at each step — fully typed, no casting. The untrusted request is validated and authorized before it ever touches your database.
f.middleware() — Context Derivation
The primary pattern. Create a middleware that derives data and injects it into context — like tRPC's .use():
import { initVurb } from '@vurb/core';
const f = initVurb<AppContext>();
const requireAuth = f.middleware(async (ctx) => {
const user = await db.getUser(ctx.token);
if (!user) throw new Error('Unauthorized');
return { user, permissions: user.permissions };
});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(). The middleware receives { ctx, next } and can enrich the context or halt execution:
export const adminStats = f.query('admin.stats')
.describe('Retrieve administrative system statistics')
.use(async ({ ctx, next }) => {
const session = await checkAuth(ctx.token);
if (!session.isAdmin) throw new Error('Unauthorized');
return next({ ...ctx, session });
})
.handle(async (input, ctx) => {
// ctx.session is fully typed here
return ctx.db.getStats(ctx.session.orgId);
});Stack multiple .use() calls for layered derivations:
export const sensitiveTool = f.query('admin.sensitive_data')
.describe('Access restricted data')
.use(async ({ ctx, next }) => {
const session = await checkAuth(ctx.token);
if (!session) throw new Error('Unauthorized');
return next({ ...ctx, session });
})
.use(async ({ ctx, next }) => {
if (!ctx.session.permissions.includes('read:sensitive')) {
throw new Error('Insufficient permissions');
}
return next({ ...ctx, canReadSensitive: true });
})
.handle(async (input, ctx) => {
// ctx.session AND ctx.canReadSensitive are both typed
return ctx.db.sensitiveData.findMany();
});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;
};The signature:
type MiddlewareFn<TContext> = (
ctx: TContext,
args: Record<string, unknown>,
next: () => Promise<unknown>,
) => Promise<unknown>;Call next() to continue to the next middleware or handler. Don't call it to block the request. The same signature works for tool and prompt middleware — share them freely.
Execution Order
Middleware executes in declaration order, outermost first:
Global → Per-Tool .use() → HandlerFor tools with multiple .use() calls:
.use(authMiddleware) → .use(permissionCheck) → .handle(handler)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. There's 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
Authentication 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
Compose multiple derivation middlewares into a shared base:
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' };
});
// Apply to tools:
export const dashboard = f.query('admin.dashboard')
.describe('Get the admin dashboard')
.use(async ({ ctx, next }) => {
const dbCtx = await withDatabase.toMiddlewareFn()(ctx, {}, async () => ({}));
return next({ ...ctx, ...dbCtx });
})
.handle(async (input, ctx) => {
return ctx.db.getDashboard(ctx.user.id);
});NOTE
resolveMiddleware(mw) accepts either MiddlewareFn or MiddlewareDefinition and returns a MiddlewareFn. Useful for accepting middleware from external packages that might use either form.