HTTP
Middleware
Wrap requests in middleware layers, choose their scope and priority, and see the exact order in which Knead runs middleware, guards, validation, the handler and exception filters.
On this page 8 sections
Middleware wraps the rest of the request pipeline. A middleware receives the request context and a
next() function, and returns a Response: usually the one next() resolves to, sometimes
decorated with extra headers, and sometimes one of its own that ends the request early.
#The request pipeline
Every request to a route runs through these steps, in this order:
- Host check. When
allowedHostsis in effect, a request whoseHostheader names another host is answered with421 Misdirected Requestbefore anything else runs (see the server guide). - Global middleware, highest
priorityfirst. Middleware with equal priority runs in registration order:APP_MIDDLEWAREproviders (in module order) beforeapp.useGlobalMiddleware()calls (in call order). - Module middleware:
@ModuleMiddleware()on the module that declares the controller. - Controller middleware: the
middlewareoption of@Controller(), then@UseMiddleware()on the class. - Route middleware: the
middlewareoption of the route decorator (@Get(),@Post(), ...), then@UseMiddleware()on the method. - Guards: global guards (
APP_GUARDproviders, thenapp.useGlobalGuards()), then the controller's guards (guardsoption, then@UseGuards()), then the route's (guardsoption, then@UseGuards()). See guards. - Validation of the route's
params,query,headersandbodyschemas, in that order. Every issue is collected, then a failure is one 400 response (see validation). authorizeguards of the route, which see the validated values.- The handler. Its return value becomes the response (see controllers).
- On the way back out, each middleware gets the response from its
next(), in reverse order.
When any step from 2 to 9 throws, the error goes to the route's
exception filters (route, then controller, then global) and finally
to the default problem response. The error never escapes as an exception: it becomes a
Response at the layer that threw, and the middleware outside that layer receives it from
next() like any other response. Last, when listen({ defaultCacheControl }) is set, the value
is added to a final response that has no Cache-Control header.
A request that matches no route runs only the global middleware, then gets a 404 (or a 405
with an Allow header when the path exists with other methods), and only global exception filters
see that error. WebSocket upgrades take a related path, described in
WebSockets.
#Writing middleware
A middleware is a class with a use(ctx, next) method (the Middleware interface), a plain
object with that method, or a function (ctx, next) => Response (the MiddlewareFn type). All
three may be async.
export class RequestTimer implements Middleware {
async use(_ctx: Ctx, next: Next): Promise<Response> {
const started = performance.now();
const res = await next();
const duration = (performance.now() - started).toFixed(1);
return withHeaders(res, { 'server-timing': `app;dur=${duration}` });
}
}export const requestId: MiddlewareFn = async (ctx, next) => {
const id = crypto.randomUUID();
ctx.state.requestId = id;
return withHeaders(await next(), { 'x-request-id': id });
};withHeaders(res, headers) adds headers to a response. It appends set-cookie values, overwrites
other headers by default (pass false as the third argument to keep values the response already
has), and copies the response when its headers are immutable, so always use the Response it
returns. Values you put in ctx.state are visible to every later middleware, guard and the
handler of the same request.
A middleware class is constructed through dependency injection, so it can inject() services.
When the class is also registered as a provider visible from the module, that provider is used;
otherwise Knead constructs one instance per module. Instances that Knead constructs this way are
built at listen(), receive onModuleInit there, and receive onModuleDestroy at close() (see
lifecycle). A failing onModuleInit makes listen() reject.
#Short-circuiting
A middleware that returns without calling next() ends the request: nothing inside it runs, not
even the handler's guards.
@Injectable()
export class MaintenanceSwitch {
enabled = false;
}
export class MaintenanceMode implements Middleware {
readonly #maintenance = inject(MaintenanceSwitch);
use(_ctx: Ctx, next: Next): Response | Promise<Response> {
if (this.#maintenance.enabled) {
return new Response('Down for maintenance', {
status: 503,
headers: { 'retry-after': '120', 'content-type': 'text/plain; charset=utf-8' },
});
}
return next();
}
}A middleware can also throw. A thrown HttpException becomes an
application/problem+json response through the exception filters, exactly as if the handler had
thrown it, so returning a Response is for answers you want to build yourself and throwing is for
errors:
export const requireJson: MiddlewareFn = (ctx, next) => {
const type = ctx.req.headers.get('content-type') ?? '';
if (ctx.req.body !== null && !type.startsWith('application/json')) {
throw new UnsupportedMediaTypeException('Send application/json');
}
return next();
};#The next() contract
next()returns a promise of the inner response. It does not reject when an inner layer or the handler throws: that error has already become a response (a 415, a 500, ...). Middleware cannot catch a handler's error withtry/catcharoundnext(); use an exception filter for that, and inspectres.statusif you only need to know about it.- Call
next()at most once. A second call rejects withnext() called multiple times in <ClassName>, which turns into a 500. - Return a
Response. Any other value is aTypeError(<ClassName> did not return a Response), answered with a 500.
In both error cases, outside development the 500 body carries no detail; the message is in the
server log under the response's correlationId.
#Scopes
Middleware can apply to a route, a controller, every controller of a module, or the whole application.
const noStore: MiddlewareFn = async (_ctx, next) =>
withHeaders(await next(), { 'cache-control': 'no-store' });
@Controller('/reports', { middleware: [RequestTimer] })
export class ReportsController {
@Get('/daily')
daily() {
return { visits: 1204 };
}
@Post('/export', { middleware: [requireJson] })
export() {
return { queued: true };
}
@Get('/live')
@UseMiddleware(noStore)
live() {
return { online: 18 };
}
}
@ModuleMiddleware(requestId)
@Module({ controllers: [ReportsController] })
export class ReportsModule {}Here GET /reports/live runs requestId (module), then RequestTimer (controller), then
noStore (route). Module middleware applies to the controllers listed in that module's own
controllers; it does not reach controllers of the modules it imports.
#Global middleware
Global middleware runs on every request, including requests that match no route. Register it
with an APP_MIDDLEWARE multi-provider, whose value is a GlobalMiddleware
({ middleware, priority? }), or imperatively before listen():
@Module({
imports: [ReportsModule],
providers: [
MaintenanceSwitch,
{
provide: APP_MIDDLEWARE,
multi: true,
useValue: { middleware: MaintenanceMode, priority: 500 } satisfies GlobalMiddleware,
},
],
})
export class AppModule {}A class given as middleware is resolved with the injector of the module that provides the
APP_MIDDLEWARE entry, so its inject() calls see that module's providers.
app.useGlobalMiddleware(middleware, priority = 0) does the same from your bootstrap code, using
the root module's injector, and throws an HttpConfigError once listen() has been called.
#Priorities
Higher priority runs first, which makes it the outermost layer: it sees the request before the
others and the response after them. The default is 0. Knead's own modules use:
| Module | Middleware | Priority |
|---|---|---|
SecurityHeadersModule | SecurityHeadersMiddleware | 1100 |
CorsModule | CorsMiddleware | 1000 |
RateLimitModule | RateLimitMiddleware | 900 |
So security headers wrap CORS, which wraps rate limiting: CORS preflight responses and 429
responses still get the security headers. Give your own middleware a priority below 900 (the
example uses 500) when its responses should get those headers and be rate limited, and above
1100 only when it must run before all of them.
#Testing middleware
TestingModuleBuilder.overrideMiddleware(Class) replaces a middleware class in a test module. See
testing.