Middleware
Compose cross-cutting concerns — auth checks, rate limiting, logging — and augment ctx for downstream handlers.
Last updated:
Middleware wraps a procedure builder so a piece of logic runs before the
handler on every function built from it. It is the seam for cross-cutting
concerns (authentication, permission checks, rate limiting, logging) and for
augmenting ctx so downstream handlers see fields the middleware added.
A middleware is a function of ({ ctx, next }) that calls next exactly once,
optionally passing a widened context:
const middleware = ({ ctx, next }) => next({ ctx: { ...ctx, requestId: crypto.randomUUID() } });Calling next({ ctx }) invokes the rest of the chain (the next middleware, or
the handler) with that context. Whatever you spread onto ctx is visible to
everything downstream, including the handler's ctx.
Attaching middleware
Middleware attaches to a builder with .use(...). Functions built from the
returned builder all run through it:
import { initLunora } from "lunorash/server";
import type { DataModel } from "./_generated/dataModel";
const c = initLunora.dataModel<DataModel>().create();
const authed = c.mutation.use(({ ctx, next }) => {
if (!ctx.auth.userId) throw new Error("must be signed in");
// Narrow ctx for downstream handlers: `user` is now guaranteed.
return next({ ctx: { ...ctx, userId: ctx.auth.userId } });
});
export const send = authed.input({ text: v.string() }).mutation(async ({ ctx, args }) => {
// ctx.userId is present and non-null thanks to the middleware.
await ctx.db.insert("messages", { text: args.text, userId: ctx.userId });
});Augmenting ctx
The convention for plugin middleware is to attach helpers under ctx.api.<key>
so two plugins never collide. A middleware widens ctx by spreading it and
returning it through next:
const withMetrics = ({ ctx, next }) => next({ ctx: { api: { ...ctx.api, metrics: makeMetricsApi(ctx) } } });Each middleware sees the context the previous one produced, so widenings
accumulate in order down the chain. The type flows too: a downstream handler's
ctx type reflects every field added upstream.
Composing concerns
Stack .use(...) calls to layer concerns; they run outermost-first:
const guarded = c.query.use(logging).use(requireAuth).use(rateLimit);Rate limiting with @lunora/ratelimit
@lunora/ratelimit ships procedure middleware so you can enforce a limit
before the handler runs. Compose it like any other middleware, keying the limit
on ctx.auth.userId (or ctx.ip for anonymous traffic):
import { rateLimit, RateLimiter } from "@lunora/ratelimit";
// A limiter holds the named limit configs; `rateLimit(limiter, name, options?)`
// returns the middleware. Key the limit on the signed-in user (or IP).
const limiter = new RateLimiter({
config: {
send: { kind: "token bucket", period: 1_000, rate: 10, capacity: 20 },
},
});
const limited = c.mutation.use(rateLimit(limiter, "send", { key: (ctx) => ctx.auth.userId ?? ctx.ip ?? "anon" }));Installing many at once
A plugin packages a schema extension plus middleware. To attach several
plugins' middleware in a single .use(...), compose them with
composePluginMiddleware([...]). It's equivalent to chaining each
.use(plugin.middleware), but as one value, and the chain's context carries
every plugin's additions:
import { composePluginMiddleware } from "lunorash/server";
const c2 = c.query.use(composePluginMiddleware([ratelimit, audit]));