Skip to content

MiddlewareCustom Middleware

Custom Middleware

Write your own middleware to intercept requests, modify headers, transform responses, and handle errors.

3 min readboth modes

Writing a Middleware

defineMiddleware(name, phase, handler) builds one. A before handler receives the MiddlewareContext, an after handler the context and the response, and a wrap handler the context and next(), and returns a Promise<MiddlewareResponse>. The name is what Studio shows for it:

lib/middleware/timing.ts
import { defineMiddleware } from "@noverax/courier/middleware"

export const timingMiddleware = defineMiddleware("timing", "wrap", async (ctx, next) => {
  const start = performance.now()
  const response = await next()
  const ms = Math.round(performance.now() - start)

  response.headers.set("X-Response-Time", `${ms}ms`)
  return response
})

Accessing Context

The ctx object gives you full visibility into the current request:

types
type MiddlewareContext = {
  source: string                       // Source name (e.g., "api")
  group?: string                       // Group name (e.g., "users")
  method: string                       // Endpoint method (e.g., "getAll")
  variables?: unknown                  // Request body/params — mutable
  headers: Headers                     // Request headers — mutable
  transport?: "http" | "sse" | "ws"    // What kind of call it is
  request?: Request                    // The incoming request, when a proxy runs the call
  kind?: "query" | "mutation"          // Whether the endpoint reads or writes
  carriesSession?: boolean             // Whether the call carries the user's session
  sessionPartition?: string            // Tells one user's calls from the next in a browser or an app
  signal?: AbortSignal                 // Fires when the caller stops waiting
  purpose?: "csrf-token"               // A request the session makes, not an endpoint's call
  agent?: AgentCall                    // The agent the call comes from, when an MCP server made it
}

A before handler can read or modify any of these; the call goes on with what it left:

lib/middleware/enrich.ts
import { defineMiddleware } from "@noverax/courier/middleware"

export const enrichMiddleware = defineMiddleware("enrich", "before", (ctx) => {
  // Add a custom header
  ctx.headers.set("X-Request-Source", ctx.source)

  // Inject a default variable
  if (ctx.variables && typeof ctx.variables === "object") {
    ctx.variables = { ...ctx.variables, _locale: "en" }
  }
})

Calling next()

In a wrap handler, next() calls the next middleware in the chain, or the endpoint handler if this is the innermost middleware. The handler must either call next() and return its result, or return its own MiddlewareResponse.

Pass-through

The simplest wrap handler just calls next() and returns the result:

ts
import { defineMiddleware } from "@noverax/courier/middleware"

const passthrough = defineMiddleware("passthrough", "wrap", (_ctx, next) => next())

Transform the response

An after handler receives the response and returns it, changed or not:

lib/middleware/unwrap.ts
import { defineMiddleware } from "@noverax/courier/middleware"

// Unwrap a { data, meta } API envelope
export const unwrapMiddleware = defineMiddleware("unwrap", "after", (_ctx, response) => {
  const body = response.data
  if (body && typeof body === "object" && "data" in body) {
    return { ...response, data: body.data }
  }
  return response
})

Short-circuit (skip the endpoint)

Return a MiddlewareResponse without calling next() to prevent the request from executing:

lib/middleware/maintenance.ts
import { defineMiddleware } from "@noverax/courier/middleware"

export const maintenanceMiddleware = defineMiddleware("maintenance", "wrap", async (_ctx, next) => {
  if (isMaintenanceMode()) {
    return {
      status: 503,
      data: { message: "Service temporarily unavailable" },
      headers: new Headers(),
    }
  }

  return next()
})

An answer whose status is not a success fails the call as the upstream's own error answer would: a caller's promise rejects with an HTTPError carrying that status and body, whether it called directly or through the proxy, and Studio shows the call as failed. A 2xx answer is the call's data — a cache hit, a stub.

Retry

Call next() multiple times to implement retry logic:

lib/middleware/retry-on-stale.ts
import { defineMiddleware } from "@noverax/courier/middleware"

export const retryOnStale = defineMiddleware("retry-on-stale", "wrap", async (ctx, next) => {
  const response = await next()

  // Retry once if the response indicates stale data
  if (response.headers.get("X-Data-Stale") === "true") {
    ctx.headers.set("Cache-Control", "no-cache")
    return next()
  }

  return response
})

Error Handling

Wrap next() in a try/catch to intercept errors from downstream middleware or the endpoint handler:

lib/middleware/error-reporter.ts
import { defineMiddleware } from "@noverax/courier/middleware"
import * as Sentry from "@sentry/node"

export const errorReporter = defineMiddleware("error-reporter", "wrap", async (ctx, next) => {
  try {
    return await next()
  } catch (error) {
    Sentry.captureException(error, {
      tags: {
        source: ctx.source,
        group: ctx.group,
        method: ctx.method,
      },
    })
    throw error // re-throw so the caller still receives the error
  }
})

Always re-throw or return

If you catch an error, either re-throw it or return a valid MiddlewareResponse. Swallowing errors silently will cause the caller to receive undefined instead of data, which leads to confusing bugs.

Middleware Factories

For configurable middleware, write a factory function that returns a Middleware:

lib/middleware/slow-call-warning.ts
import { defineMiddleware } from "@noverax/courier/middleware"
import type { Middleware } from "@noverax/courier/middleware"

export function slowCallWarning(thresholdMs: number): Middleware {
  return defineMiddleware("slow-call-warning", "wrap", async (ctx, next) => {
    const start = performance.now()
    try {
      return await next()
    } finally {
      const ms = Math.round(performance.now() - start)
      if (ms > thresholdMs) console.warn(`${ctx.group}.${ctx.method} took ${ms}ms`)
    }
  })
}

Use it like any other middleware:

lib/sources.ts
import { defineApi } from "@noverax/courier/define"
import { slowCallWarning } from "./middleware/slow-call-warning"

const api = defineApi({
  baseURL: "https://api.example.com",
  middleware: [slowCallWarning(1000)],
  groups: {},
})

A middleware cannot cancel the request it wraps; to limit how long a call may take, use the timeout option.

Composing Custom Middleware

Use composeMiddleware to bundle multiple middleware into a reusable unit. It returns a wrap handler, which defineMiddleware turns into one middleware:

lib/middleware/production.ts
import { composeMiddleware, defineMiddleware } from "@noverax/courier/middleware"
import { errorReporter } from "./error-reporter"
import { timingMiddleware } from "./timing"
import { unwrapMiddleware } from "./unwrap"

// Single middleware that applies all three
export const productionMiddleware = defineMiddleware(
  "production",
  "wrap",
  composeMiddleware([errorReporter, timingMiddleware, unwrapMiddleware]),
)

Then use the composed middleware in your source definition:

lib/sources.ts
import { defineApi } from "@noverax/courier/define"
import { productionMiddleware } from "./middleware/production"

const api = defineApi({
  baseURL: "https://api.example.com",
  middleware: [productionMiddleware],
  groups: {},
})

Conditional Middleware

Apply middleware only for specific sources, groups, or methods by checking ctx:

lib/middleware/admin-only.ts
import { defineMiddleware } from "@noverax/courier/middleware"

export const adminGuard = defineMiddleware("admin-guard", "wrap", async (ctx, next) => {
  // Only enforce on admin endpoints
  if (ctx.group === "admin") {
    const isAdmin = await checkAdminPermission()
    if (!isAdmin) {
      return {
        status: 403,
        data: { message: "Admin access required" },
        headers: new Headers(),
      }
    }
  }

  return next()
})

Next Steps