Middleware
Transform and control the notification pipeline
Middleware wraps the send pipeline. It runs before and after the render-and-send core, and it can transform the context, block a send entirely, or observe the result. If removing a piece of logic would change whether a notification goes out, it belongs in middleware. If it only observes or logs, it belongs in a hook.
const welcome = rpc
.email()
.input(schema)
.subject(fn)
.template(adapter)
.use(withRateLimit({ max: 10, window: '1m', store, key: ({ args }) => args.to }))
.use(withEventLogger({ sink }));
await mail.welcome.send({ to: 'ada@example.com', input: { name: 'Ada' } });Middleware vs hooks
Prop
Type
What middleware can do
Every middleware receives the same parameters and decides how to proceed:
Prop
Type
There are four things a middleware can do with these parameters:
Pass through
Call next() to continue the pipeline unchanged. The simplest middleware does nothing but forward:
const passthrough: Middleware = async ({ next }) => {
return next();
};Mutate context
Pass an object to next() to shallow-merge fields into the context. Downstream middleware and the render function see the updated context:
const withTenant: Middleware = async ({ next }) => {
const tenantId = await resolveTenant();
return next({ tenantId });
};Short-circuit
Don't call next(). Return a synthetic result instead. The render and transport never run:
const withDryRun = (): Middleware => {
return async () => ({
messageId: 'dry-run',
accepted: [],
rejected: [],
envelope: { from: '', to: [] },
timing: { renderMs: 0, sendMs: 0 },
});
};Fail
Throw to abort the pipeline. The error routes to onError hooks:
const withBlocklist: Middleware = async ({ args, next }) => {
if (blocked.has(args.to)) {
throw new NotifyRpcError({ message: 'Recipient blocked', code: 'BLOCKED' });
}
return next();
};Composition order
Middleware wraps in layers. Plugin middleware sits outermost, procedure-level middleware sits innermost, and the render-and-send core is at the center:
A request enters from the outside and moves inward. The response bubbles back out. Each layer can act on both directions — run logic before next() (on the way in) and after next() (on the way out).
Where you attach middleware
There are three levels, each with a different scope:
Root-level — runs on every procedure in the catalog:
const rpc = createNotify({ channels: { email } })
.use(withEventLogger({ sink }));Procedure-level — runs on a single procedure:
const passwordReset = rpc.email()
.input(schema)
.subject(fn)
.template(adapter)
.use(withRateLimit({ max: 3, window: '1h', key: ({ args }) => args.to }));Plugins — bundle middleware (and hooks) into reusable units that apply across catalogs. See Plugins.
Built-in middleware
withDryRun
Short-circuit sends with a synthetic result. Use in development, CI, or under a feature flag.
withRateLimit
Throttle sends per derived key with fixed or sliding window algorithms.
withIdempotency
Deduplicate identical sends within a time window.
withEventLogger
Emit one structured event per send to an event sink.
withTagInject
Inject tags into the rendered output before transport delivery.
withTracing
Integrate with distributed tracing systems.
Writing custom middleware
A middleware is an async function that receives MiddlewareParams and returns the result. Wrap next() to run logic on both sides of the pipeline:
import type { Middleware } from '@betternotify/core';
const withTiming = (): Middleware => {
return async ({ route, next }) => {
const start = performance.now();
const result = await next();
console.log(`${route}: ${(performance.now() - start).toFixed(1)}ms`);
return result;
};
};The next() call is what separates "before" from "after". Everything above it runs on the way in; everything below runs on the way out.