Skip to content

MiddlewareMiddleware Overview

Middleware Overview

Koa-style onion model middleware for intercepting, transforming, and controlling every request in Courier.

3 min readboth modes

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)   ◀──  response

The 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:

types
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:

lib/middleware.ts
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:

lib/middleware.ts
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:

lib/sources.ts
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:

CapabilityHow
Modify request headersA before handler sets values on ctx.headers
Modify variablesA before handler reassigns ctx.variables
Transform responseAn after handler returns a changed MiddlewareResponse
Short-circuitA wrap handler returns a MiddlewareResponse without calling next()
RetryA wrap handler calls next() more than once
Measure timingA wrap handler records performance.now() before and after next()
Handle errorsA wrap handler puts next() in try/catch

Next Steps