Writing middleware
Writing Metro middleware
A Metro middleware is an async function that receives a Metro request, a next function, and a small context object. It can pass a changed request to next(), inspect or change the response that comes back, return its own response without calling next(), throw an error, or make a nested Metro call through context.fetch().
The contract
The first argument is a Metro request, which behaves like a standard Fetch Request and adds .with(). The second argument calls the next middleware in the chain, eventually reaching the real browser or Node fetch(). The third argument contains { client, options, trace, fetch }.
Return a response or a deliberately transformed value. A normal middleware should return a Response, but some middleware, such as getdata(), intentionally returns response.data. That is fine, but it should be visible in the middleware name and documentation because later middleware will then receive data instead of a response.
Do not mutate; derive
Use .with() instead of mutating the incoming request, response, URL, or form data. Metro objects wrap standard Fetch objects, and standard Fetch objects have body-stream rules that become much easier to reason about when middleware derives a fresh object instead of poking at the existing one.
Be careful with bodies
A Fetch body can usually be read only once. If a middleware wants to inspect text, JSON, or form data and still pass the request or response onward, use clone() first. This is why logging middleware should read req.clone().text() rather than req.text().
Metro also keeps the original body value on req.data and res.data when it can. Middleware such as json() uses that to serialize plain objects before they become streams and to expose parsed response data without consuming the original response body.
Call next() deliberately
Most middleware calls next(req) exactly once. Some middleware may skip next() to return a cached or mocked response. Retry middleware may call next() more than once, but only because retrying is its explicit job. Hidden extra calls surprise users and make tracing difficult.
Use context.fetch() for nested Metro calls
context.fetch() runs another request through the same client while preserving trace context. Use it when a middleware needs to do internal HTTP work, such as discovery, token exchange, or refreshing remote metadata. Passing trace context manually is still possible through context.trace.options(), but context.fetch() is the simple default.
Emit diagnostics instead of swallowing surprises
Convenient middleware is allowed to be forgiving, but silent failure makes view-source debugging much harder. If a middleware catches an error and continues, emit a trace diagnostic when context.trace is available. The user can then see that Metro continued by design instead of wondering where the bug disappeared.
Keep state explicit
A middleware factory may keep state in a closure, but shared state should be obvious from the options. Retry counters, backoff stores, caches, token stores, and trace stores should accept an injected store where practical. This keeps tests small and lets applications decide whether state belongs in memory, sessionStorage, localStorage, IndexedDB, or somewhere else.
Ordering matters
Middleware runs from left to right on the way out and unwinds from right to left on the way back. Put broad request controls such as timeout, retry, abort, and backoff near the start of the chain. Put body-format middleware such as json() near the API boundary. Put response-shaping middleware such as thrower() and getdata() after the middleware that creates or parses the response data.
Checklist
Before adding middleware to Metro, check that it has a narrow job, returns a clear value, uses .with() instead of mutation, clones bodies before reading them for inspection, calls next() deliberately, documents any state it keeps, accepts injected stores or clocks where useful, and emits trace diagnostics for recoverable surprises.