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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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
- Built-in Middleware -- reference for all shipped middleware
- Middleware Overview -- the onion model, the three phases and the core types