Middleware
Wrap route execution with shared application context.
Use .use() to implement guards, transactions, cleanup, timing, and caching alongside
framework middleware. Route middleware runs after request validation.
Add Middleware
next() executes the remaining middleware and handler. Return its result to
forward downstream output, or return another value to replace it. Returning
without calling next() short-circuits the chain. Call next() at most once.
import { route } from "@rest-rpc/express";
const getProfile = route
.get("/profile")
.use(async ({ next }) => {
const started = performance.now();
try {
return await next();
} finally {
console.log(performance.now() - started);
}
})
.use(({ context, next }) => {
if (!context.user) return { status: 401 };
return next();
})
.handler(({ context }) => ({ status: 200, body: { user: context.user } }));
Middleware runs in declaration order; work after await next() runs in reverse
order. Request schemas validate before middleware runs, regardless of setter order.
Shared Context
Middleware and the handler share one mutable context object. Declare its type by augmenting DefaultContext:
declare module "@rest-rpc/express" {
interface DefaultContext {
readonly requestId: string;
readonly user?: { id: string; name: string };
}
}
Reuse Middleware
Define reusable callbacks with route.middleware() on the imported route root,
then attach them with .use():
const transaction = route.middleware(({ next }) =>
db.transaction(() => next()),
);
const base = route.use(transaction);
const getTodos = base.get("/todos").handler(() => listTodos());
Adding middleware to one builder branch leaves sibling stacks unchanged. Use
inline callbacks for route-specific inputs; declare their schemas before .use()
to access validated request types.
Outputs and Errors
Middleware receives raw application output. Response validation and serialization
run after the chain completes. Middleware responses are unchecked and do not change
the client contract; declare .output() or .response() schemas to validate the
final value and expose responses to clients.
Downstream errors propagate through next(), allowing middleware to catch errors
or clean up in finally. Response delivery and stream consumption happen outside
this wrapping scope.
Contract-first
Attach .use() to individual implementations from implement(singleRoute) or
leaves from implement(contract):
const getTodo = implement(api.todos.get)
.use(logging)
.handler(async ({ params }) => ({
status: 200,
body: await findTodo(params.id),
}));