Middleware Chains
In TriFrost, middleware is a first-class, typed, and deterministic execution model.
Unlike most frameworks, TriFrost does not use the onion model. There is no next()
, no wrapping, no โbefore/afterโ dance.
Instead, it uses a linear, top-down chain:
- Each middleware runs in order
- If it returns a response โ the chain stops
- Otherwise โ the next handler runs
- Middleware is automatically span-wrapped
This makes reasoning about execution clear, predictable, composable as well as observable.
Key Principles
- No
next()
: middleware is not a stack - Linear execution: top-to-bottom, no recursion
- Short-circuiting: middleware can stop the chain
- Typed
ctx.state
: middleware can safely extend context - Scoped chains: middleware can be attached at any level: app, group, or route
๐ What is Middleware?
Itโs just a function:
(ctx) => {
if (!ctx.headers.get('x-api-key')) {
return ctx.text('Forbidden', 403);
}
}
If it returns undefined
or the ctx
, the chain continues.
If it returns a response, sets a 400+ status, or calls ctx.abort()
, execution halts immediately.
๐งฌ Expanding State
If you want to pass data down the chain (like a user or session), use ctx.setState(...)
.
const requireUser = async <State extends Record<string, unknown>>(ctx: Context<State>) => {
const user = await getUserFromToken(ctx);
if (!user) return ctx.status(401);
return ctx.setState({ user });
};
This is required to propagate new types into
ctx.state
.
Now all downstream middleware and routes know ctx.state.user
is defined, and typed.
๐ Also see: Context & State Management | Middleware Basics
Manual Typing (Advanced)
If you must call ctx.setState(...)
somewhere in the middle and canโt return it (e.g., logging after setting), you can annotate the return manually:
async function m <S extends Record<string, unknown>> (
ctx: Context<S>
): Promise<Context<S & {foo: string}>> {
ctx.setState({foo: 'bar'});
...
return ctx;
};
But whenever possible
return ctx.setState(...)
is the cleanest and most type-safe way.
๐ Typing the Chain
Each middleware explicitly defines what it requires and what it adds:
const auth <State>(ctx: Context<State>) => {
const user = ...;
return ctx.setState({ user });
};
const onlyAdmins <State extends {user: User}>(ctx: Context<State>) => {
if (ctx.state.user.role !== 'admin') {
return ctx.status(403);
}
};
If you add onlyAdmins()
without auth()
before it in your chain, TypeScript will yell at you.
โ Type safety isn't optional โ itโs baked into the chain.
๐ Also see: Context & State Management | Middleware Basics
๐ Composing Chains
You can attach middleware:
- To the app
- To a router or group
- To a single route
They stack in order:
app
.use(auth) // adds ctx.state.user
.use(onlyAdmins) // requires ctx.state.user
.get('/admin', ctx => ctx.json(ctx.state.user));
app
.get('/home', ctx => ctx.text('hello world')) // no middleware here
.use(auth) // adds ctx.state.user
.use(onlyAdmins) // requires ctx.state.user
.get('/admin', ctx => ctx.json(ctx.state.user));
This is deterministic. Thereโs no โbefore/afterโ guesswork.
๐ Halting the Chain
Any middleware can short-circuit the chain by:
- Returning a response through eg:
ctx.text()
,ctx.json()
,ctx.status()
, etc. - Calling
ctx.abort()
if in dire need - Returning a status >= 400 using
return ctx.setStatus(...)
Once any of these happen, downstream handlers wonโt run.
const blockUnauthenticated = (ctx) => {
if (!ctx.state.user) return ctx.setStatus(401);
};
๐งฑ Example: Scoping
app
.use(cors()) // all routes
.group('/admin', admin => {
admin
.use(requireUser)
.use(onlyAdmins)
.get('/dashboard', ctx => ctx.text('welcome admin'));
});
cors()
applies to everything/admin/dashboard
runscors โ requireUser โ onlyAdmins โ handler
- Outside
/admin
, those checks are skipped
โ๏ธ Built-In Middleware
TriFrost includes a powerful set of built-ins, just .use()
them:
- โ Cors(): configure CORS headers
- โ Security(): set secure HTTP headers (HelmetJS-style)
- โ CacheControl(): apply cache policy
- โ
RateLimit(): per-IP/per-key limiters using
.limit(...)
- โ SessionCookieAuth(): signed session cookie auth
- โ BearerAuth()/ApiKeyAuth(): for APIs, services, ...
All of them follow the same model: deterministic, typed, halt-or-continue.
Mental Model
TriFrost middleware is not a sandwich.
There is no onion. No unwind. No next()
.
Itโs a pipeline:
[ A ] โ [ B ] โ [ C ] โ handler
โ๏ธ (halts if returns)
๐ Also see: Request & Response Lifecycle
Best Practices
- โ
Use
.setState()
to share state downstream - โ Use type constraints to enforce chain correctness
- โ Attach middleware at the level it applies: app/group/route
- โ Let middleware short-circuit deliberately (401, 429, etc.)
- โ Keep each piece focused: one job per middleware
- โ
Use
Context<Env, State>
to lock in types across files
TLDR
- No
next()
, no onion model - Top-down, linear execution
- Middleware halts chain if it returns a response
ctx.setState()
is how you extend context- TypeScript knows your state chain, and enforces it
- Middleware can run globally, per group, or per route
- Middleware can require a state shape and extend the state