How Middleware Works
Courier uses a Koa-style onion model for middleware. Each middleware wraps around the next one (and ultimately the endpoint handler), giving you full control over both the request and response phases of every call.
Request ──▶ middleware A (before) ──▶ middleware B (before) ──▶ endpoint handler
Response ◀── middleware A (after) ◀── middleware B (after) ◀── responseThe first middleware in the array is the outermost layer: it runs first on the way in and last on the way out.
Core Types
A middleware is a name, a phase and a handler. Every handler receives a MiddlewareContext:
type MiddlewareContext = {
/** Source name (e.g., "api") */
source: string
/** Group name (e.g., "users") */
group?: string
/** Endpoint method name (e.g., "getAll") */
method: string
/** Request variables (body/params) — can be modified by middleware */
variables?: unknown
/** Request headers — can be modified by middleware */
headers: Headers
/** "http" for requests, "sse" or "ws" for streams and channels */
transport?: "http" | "sse" | "ws"
/** The incoming request, before `allowedHeaders` narrowed it, when a proxy runs the call */
request?: Request
/** Whether the endpoint reads or writes */
kind?: "query" | "mutation"
/** Whether the call carries the user's session: its answer is that user's alone */
carriesSession?: boolean
/** What tells one user's calls from the next in a browser or an app, which send the session's cookies themselves */
sessionPartition?: string
/** Fires when the caller stops waiting: the call's own signal, or the incoming request's in a proxy */
signal?: AbortSignal
/** Set on a request the session makes rather than an endpoint's call: "csrf-token" for the double-submit token a write asks for */
purpose?: "csrf-token"
/** The agent the call comes from, when an MCP server made it: its tool, access, principal and client */
agent?: AgentCall
}
type MiddlewareResponse = {
status: number
data: unknown
headers: Headers
}
type NextFn = () => Promise<MiddlewareResponse>
/** Runs before the call: can change the context, never sees the response */
type BeforeHandler = (ctx: MiddlewareContext) => void | Promise<void>
/** Runs after the call: receives the response and returns it, changed or not */
type AfterHandler = (
ctx: MiddlewareContext,
response: MiddlewareResponse,
) => MiddlewareResponse | Promise<MiddlewareResponse>
/** Wraps the call: decides whether, and how often, to call `next()` */
type WrapHandler = (ctx: MiddlewareContext, next: NextFn) => Promise<MiddlewareResponse>defineMiddleware
defineMiddleware(name, phase, handler) builds a middleware. The name is what Studio shows for it in each request's middleware timeline; the phase says what the handler receives:
import { defineMiddleware } from "@noverax/courier/middleware"
/* "before": change the request, then let it go on */
const auth = defineMiddleware("auth", "before", (ctx) => {
ctx.headers.set("Authorization", "Bearer my-token")
})
/* "after": read or change the response on its way back */
const stamp = defineMiddleware("stamp", "after", (_ctx, response) => {
response.headers.set("x-received-at", new Date().toISOString())
return response
})
/* "wrap": both, around a call to next() */
const timing = defineMiddleware("timing", "wrap", async (ctx, next) => {
const start = performance.now()
const response = await next()
const ms = Math.round(performance.now() - start)
console.log(`${ctx.source}:${ctx.group}:${ctx.method} took ${ms}ms`)
return response
})Use before or after when a middleware only needs one side of the call — they cannot forget to call next() — and wrap for timing, retries, short-circuiting and error handling.
composeMiddleware
composeMiddleware combines an array of middleware into one wrap handler, (ctx, next) => Promise<MiddlewareResponse>; defineMiddleware(name, "wrap", …) makes it a middleware of its own. Middleware execute in array order — the first element is the outermost layer:
import { composeMiddleware, defineMiddleware } from "@noverax/courier/middleware"
const auth = defineMiddleware("auth", "before", (ctx) => {
ctx.headers.set("Authorization", "Bearer my-token")
})
const logger = defineMiddleware("logger", "wrap", async (ctx, next) => {
console.log(`→ ${ctx.method}`)
const res = await next()
console.log(`← ${res.status}`)
return res
})
// auth runs first (outermost), logger runs second (innermost)
const combined = composeMiddleware([auth, logger])Adding Middleware to defineApi
Pass a middleware array in your defineApi configuration. The middleware wraps executeRequest and applies to every endpoint in that source:
import { courier, defineApi, defineGroup } from "@noverax/courier/define"
import {
authMiddleware,
logMiddleware,
retryMiddleware,
} from "@noverax/courier/middleware"
const { endpoint } = courier()
const api = defineApi({
baseURL: "https://api.example.com",
middleware: [
logMiddleware(),
retryMiddleware({ limit: 3, delay: 300 }),
authMiddleware({ getToken: () => getAccessToken() }),
],
groups: {
users: defineGroup({
prefix: "users",
endpoints: {
getAll: endpoint.get("").response<User[]>(),
create: endpoint.post("").variables<CreateUserInput>().response<User>(),
},
}),
},
})Execution order matters
Middleware execute in array order. In the example above, logMiddleware is outermost (logs first, receives the final response last), authMiddleware is innermost (runs right before the endpoint handler). Place cross-cutting concerns like logging and retry at the beginning, and request-specific concerns like auth at the end.
Key Capabilities
Middleware can do any of the following:
| Capability | How |
|---|---|
| Modify request headers | A before handler sets values on ctx.headers |
| Modify variables | A before handler reassigns ctx.variables |
| Transform response | An after handler returns a changed MiddlewareResponse |
| Short-circuit | A wrap handler returns a MiddlewareResponse without calling next() |
| Retry | A wrap handler calls next() more than once |
| Measure timing | A wrap handler records performance.now() before and after next() |
| Handle errors | A wrap handler puts next() in try/catch |
Next Steps
- Built-in Middleware -- ready-to-use middleware for retry, auth, logging, rate limiting, and caching
- Custom Middleware -- how to write your own middleware from scratch