Middleware & hooks
Middleware
Section titled “Middleware”Each middleware receives the context and a next it may await. Everything before the await
runs on the way in, everything after runs on the way out.
app.use(async (ctx, next) => { const start = Bun.nanoseconds() const result = await next() ctx.set('server-timing', `total;dur=${(Bun.nanoseconds() - start) / 1e6}`) return result})There is no (req, res, next) and no Connect compatibility. A middleware either returns a
value or throws, exactly like a handler — which is what lets the two compose without special
cases.
Short-circuiting
Section titled “Short-circuiting”Not calling next() skips the handler. This is how guards and caches work:
app.use(async (ctx, next) => { if (!ctx.token) throw new Unauthorized() return next()})Path scoping
Section titled “Path scoping”app.use('/admin', requireAdmin)Prefixes respect segment boundaries: /admin matches /admin and /admin/users, but never
/administrators.
Order is registration order
Section titled “Order is registration order”Middleware runs in the order you register it, outermost first. On the way out, the order reverses — the first registered is the last to see the result:
app.use(securityHeaders()) // 1 in, 4 outapp.use(cors({ origin })) // 2 in, 3 outapp.use(requestLogger()) // 3 in, 2 outapp.use(rateLimit({ limit: 100 })) // 4 in, 1 outThe order that usually matters:
securityHeadersfirst, so even a404or a rejected preflight carries them.corsearly, because a preflight it answers should not travel further.requestLoggerbefore anything that can reject, so rejections are logged too.rateLimitbefore expensive work, but after logging — otherwise the requests you most want to see are the ones you never record.- Your own auth or tenant middleware last, closest to the handler.
Brick request() hooks run after all middleware and before the handler, so ctx.user is not
yet populated in a middleware. That is deliberate: a middleware that needed the user would be a
guard, and guards belong on the route (auth: true), where they are visible and documented.
Middleware wraps routing, not just the handler
Section titled “Middleware wraps routing, not just the handler”Middleware runs for every request, including ones that match no route. A CORS preflight, a 404 that still needs security headers, a request log that should include misses — none of those work if middleware only runs once a route has matched.
The order everything runs in
Section titled “The order everything runs in”Bun.serve fetch │ ├─ build Context ├─ onRequest hooks ← before middleware; returning a value ends it here │ ├─ middleware chain ← in registration order, wrapping everything below │ │ │ ├─ route match ← 404 from here is still inside your middleware │ ├─ brick request() hooks ← ctx.user and other typed per-request state appear │ ├─ beforeHandle hooks │ ├─ validate params, query, body │ ├─ handler │ └─ afterHandle hooks │ ├─ build the Response └─ onResponse hooks ← onError instead, if anything above threwTwo consequences worth internalising:
ctx.useris not available in middleware. Bricks contribute per-request state after the middleware chain has been entered. Guard routes withauth: true, not with a middleware.- A 404 runs your whole middleware chain. That is what makes CORS and security headers work on requests that match nothing.
Lifecycle hooks
Section titled “Lifecycle hooks”| Hook | When | Returning a value |
|---|---|---|
onRequest |
before middleware and routing | short-circuits the request |
beforeHandle |
after routing, params available | skips the handler |
afterHandle |
after the handler | replaces the result |
onResponse |
after the Response is built |
replaces the response |
onError |
on any thrown error | replaces the error body |
app.onRequest((ctx) => (maintenance ? 'Back shortly' : undefined))app.beforeHandle((ctx) => cache.get(ctx.path))app.afterHandle((_ctx, result) => ({ data: result }))app.onResponse((_ctx, response) => { metrics.record(response.status) })app.onError((error) => ({ ok: false, code: error.status }))Returning undefined from any hook means “carry on unchanged”, which is why a hook that only
observes needs no return at all.
Hooks or middleware?
Section titled “Hooks or middleware?”They overlap; the difference is what you can see and what you can wrap.
| Middleware | Hooks | |
|---|---|---|
| Wraps the whole request | ✓ — code before and after await next() |
✗ — one point each |
| Can be scoped to a path | ✓ app.use('/admin', …) |
✗ — always global |
| Sees the matched route | only after next() |
beforeHandle onward |
Can replace the Response object itself |
✓ | onResponse only |
| Runs when nothing matched | ✓ | onRequest, onResponse, onError |
Reach for middleware when you need something on both sides of the handler — timing, a transaction, a wrapper. Reach for a hook when you need exactly one point and want it to read as one line.
Writing one
Section titled “Writing one”A middleware is a function. There is nothing to extend and nothing to register with:
import type { Middleware } from '@theoven/core'
export function timing(header = 'server-timing'): Middleware { return async (ctx, next) => { const start = Bun.nanoseconds() try { return await next() } finally { // `finally`, so a thrown error is still timed — those are the slow ones. ctx.set(header, `total;dur=${(Bun.nanoseconds() - start) / 1e6}`) } }}
app.use(timing())Two things worth knowing:
- Return what
next()gave you, or your own value. Returning nothing means the response isundefined, which is a204and almost never what was meant. - Do not catch errors just to log them. Errors already become RFC 9457 responses and are
already logged; a middleware that swallows one turns a
500into a silent success. Usefinally, oronErrorif you want to change the body.
A middleware has nowhere to put per-request state. There is no ctx.state bag, deliberately:
an untyped bucket on the context is invisible to autocomplete, and anything in it is unknown at
the point you read it — which is the opposite of types are the
documentation.
When a middleware wants to hand something to the handler, what you want is a
brick with a request() hook. It contributes a typed property
under its own key:
const tenancy = { name: 'tenant', setup: () => ({ resolve }), request: async (ctx) => ({ tenant: await resolve(ctx.header('host')) }),} satisfies Brick<'tenant', TenantService, { tenant: Tenant }>
app.use(tenancy)// `ctx.tenant` now exists, typed — and only in apps that registered this brick.That is the same mechanism auth uses for ctx.user (D15). Use a plain middleware for behaviour
that wraps a request; use a brick when the request needs to carry something new.
Built-ins
Section titled “Built-ins”These ship in the box but stay configurable — the cases where behaviour genuinely varies between apps, unlike body parsing where it does not. Configured, not installed.
app.use(cors({ origin: ['https://app.example'], credentials: true }))| Option | Default | |
|---|---|---|
origin |
'*' |
a string, a list, '*', or (origin) => boolean |
methods |
the usual verbs | methods to allow |
allowedHeaders |
reflects the request’s | Access-Control-Allow-Headers |
exposedHeaders |
none | headers the browser may read from the response |
credentials |
false |
allow cookies and Authorization |
maxAge |
86400 |
seconds a preflight result may be cached |
Preflights are answered with 204, and a reflected origin always sets Vary: Origin so a
shared cache cannot serve one origin’s response to another.
Security headers
Section titled “Security headers”app.use(securityHeaders({ contentSecurityPolicy: "default-src 'self'" }))| Option | Default | |
|---|---|---|
contentSecurityPolicy |
off | a CSP string, or false |
hsts |
1 year | Strict-Transport-Security max-age in seconds, or false |
frameOptions |
DENY |
'DENY', 'SAMEORIGIN' or false |
referrerPolicy |
strict-origin-when-cross-origin |
any policy string, or false |
permissionsPolicy |
denies high-risk features | a policy string, or false |
CSP is off by default deliberately: a policy strict enough to be worth having breaks a page that was not built for it, and one loose enough never to break anything is decoration. Write it for your app.
Sets X-Content-Type-Options, X-Frame-Options, Referrer-Policy and Permissions-Policy.
HSTS is only sent over HTTPS — sending it on plain HTTP would pin localhost to https in your
browser for a year.
Rate limiting
Section titled “Rate limiting”app.use(rateLimit({ limit: 100, window: 60_000, key: (ctx) => ctx.token ?? ctx.ip }))| Option | Default | |
|---|---|---|
limit |
— | required; requests allowed per window |
window |
60000 |
window length in ms |
key |
client IP | (ctx) => string | undefined — key on a user or API key instead |
skip |
— | (ctx) => boolean, to exempt internal traffic |
Keying on ctx.token ?? ctx.ip is usually what you want: signed-in users get their own budget
rather than sharing one with everyone behind the same NAT.
Sets RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset; a rejection carries
Retry-After and a 429.
Request logging
Section titled “Request logging”app.use(requestLogger({ ignore: ['/health'], slowThreshold: 500 }))| Option | Default | |
|---|---|---|
ignore |
none | paths to skip — health checks otherwise dominate the log |
slowThreshold |
1000 |
log at warn above this many ms |
One structured line per request with the request id already bound. Slow requests log at warn,
failures at error.
Compression
Section titled “Compression”app.use(compression({ threshold: 1024 }))| Option | Default | |
|---|---|---|
threshold |
1024 |
smallest response worth compressing, in bytes |
types |
text, JSON, JS, XML, SVG | content types to compress; prefixes allowed |
Compresses text-shaped payloads above a threshold. Streaming responses — a returned Response,
ReadableStream or Blob — are passed through untouched: buffering them to compress would stop
server-sent events arriving and pull a large download entirely into memory.
Most Bun deployments sit behind a CDN that already compresses, in which case this is redundant. Measure before reaching for it.