Custom Middleware
Write your own middleware for the send pipeline
A middleware is an async function that wraps the send pipeline. It receives a set of parameters and decides whether to continue, modify the context, short-circuit, or fail. Everything above next() runs on the way in; everything below runs on the way out.
Use createMiddleware to define one — it gives you full autocomplete on the parameters without needing to import and annotate the Middleware type:
import { createMiddleware } from '@betternotify/core/middlewares';
const withTiming = () =>
createMiddleware(async ({ route, next }) => {
const start = performance.now();
const result = await next();
console.log(`${route}: ${(performance.now() - start).toFixed(1)}ms`);
return result;
});What the pipeline gives you
Every middleware receives a params object from the pipeline. Destructure only what you need — next is the only one most middleware uses:
createMiddleware(async ({ next }) => next())
createMiddleware(async ({ route, input, next }) => { ... })Prop
Type
Patterns
Pass through
The simplest middleware — forward everything unchanged:
const noop = () => createMiddleware(async ({ next }) => next());Enrich context
Pass an object to next() to add fields to the context. Downstream middleware and the render function see the updated context:
const withTenant = () =>
createMiddleware(async ({ next }) => {
const tenantId = await resolveTenant();
return next({ tenantId });
});Wrap and observe
Call next() in the middle to run logic on both sides of the pipeline:
const withMetrics = () =>
createMiddleware(async ({ route, next }) => {
metrics.increment(`${route}.attempts`);
const result = await next();
metrics.increment(`${route}.success`);
return result;
});Short-circuit
Don't call next() to skip the rest of the pipeline. Return a synthetic result:
const withCachedResult = (cache: Map<string, unknown>) =>
createMiddleware(async ({ route, args, next }) => {
const key = `${route}:${JSON.stringify(args.to)}`;
const cached = cache.get(key);
if (cached) return cached;
const result = await next();
cache.set(key, result);
return result;
});Fail
Throw to abort the pipeline. The error routes to onError hooks:
import { NotifyRpcError } from '@betternotify/core';
import { createMiddleware } from '@betternotify/core/middlewares';
const withBlocklist = (blocked: Set<string>) =>
createMiddleware(async ({ args, next }) => {
if (blocked.has(String(args.to))) {
throw new NotifyRpcError({ message: 'Recipient blocked', code: 'BLOCKED' });
}
return next();
});Typed input
Pass the input type as a generic to createMiddleware to narrow input to a specific schema. This is useful when the middleware depends on the shape of the procedure's input:
type OrderInput = { orderId: string; email: string };
const withOrderDedup = () => {
const seen = new Set<string>();
return createMiddleware<OrderInput>(async ({ input, next }) => {
if (seen.has(input.orderId)) {
return { messageId: 'dedup', timing: { renderMs: 0, sendMs: 0 } };
}
seen.add(input.orderId);
return next();
});
};Attaching middleware
Middleware can be attached at three levels:
const welcome = rpc.email()
.input(schema)
.subject(fn)
.template(adapter)
.use(withTiming());
const rpc = createNotify({ channels: { email } })
.use(withTenant());
const observability = createPlugin({
name: 'observability',
middleware: [withTiming(), withMetrics()],
hooks: { onError: ({ error }) => errorTracker.capture(error) },
});See Middleware for composition order and how middleware differs from hooks.