Skip to main content

Middleware

Middleware runs between the incoming request and the route handler. It is the right place for cross-cutting behavior that should not live inside every controller method.

Use middleware for:

  • Authentication and authorization gates.
  • Request IDs and correlation IDs.
  • Logging and request timing.
  • Response headers.
  • Compression.
  • Per-request context setup.
  • Short-circuiting requests before they reach a handler.

Carno uses the onion middleware pattern for class-based middleware.

The Onion Pattern

In an onion chain, each middleware can run code before and after the next layer.

Request -> Middleware A -> Middleware B -> Handler -> Middleware B -> Middleware A -> Response
before before execute after after

Calling await next() continues to the next middleware or handler. Code after await next() runs after the downstream layer has produced a response.

Function Middleware

The simplest middleware is a function.

import type { Context } from '@carno.js/core';

const requestIdMiddleware = (ctx: Context) => {
ctx.locals.requestId = crypto.randomUUID();
};

Function middleware can return a Response to stop the chain.

const authMiddleware = (ctx: Context) => {
const token = ctx.headers.get('authorization');

if (!token) {
return new Response('Unauthorized', { status: 401 });
}
};

If a function middleware returns void, Carno continues to the next layer.

Class-Based Middleware

Use a class when the middleware needs dependencies or before/after behavior.

import {
type CarnoClosure,
type CarnoMiddleware,
type Context,
Service,
} from '@carno.js/core';

@Service()
export class TimingMiddleware implements CarnoMiddleware {
async handle(ctx: Context, next: CarnoClosure): Promise<void> {
const start = Date.now();

await next();

const duration = Date.now() - start;
console.log(`${ctx.method} ${ctx.path} ${duration}ms`);
}
}

Class middleware must implement handle(ctx, next).

Returning Early

To block a request, return a Response and do not call next().

@Service()
export class AuthMiddleware implements CarnoMiddleware {
async handle(ctx: Context, next: CarnoClosure): Promise<Response | void> {
const token = ctx.headers.get('authorization');

if (!token) {
return new Response('Unauthorized', { status: 401 });
}

await next();
}
}

If a class middleware does not call next() and does not return a Response, the framework returns an empty 200 response. In most guard middleware, returning an explicit response is clearer.

Transforming Responses

Because next() resolves to the downstream Response, middleware can transform response headers or body.

@Service()
export class SecurityHeadersMiddleware implements CarnoMiddleware {
async handle(ctx: Context, next: CarnoClosure): Promise<Response> {
const response = await next();
const headers = new Headers(response.headers);

headers.set('X-Content-Type-Options', 'nosniff');
headers.set('X-Frame-Options', 'DENY');

return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}
}

Sharing State With ctx.locals

ctx.locals is the per-request scratchpad shared by middleware and handlers.

const currentUserMiddleware = (ctx: Context) => {
ctx.locals.user = { id: '42', role: 'admin' };
};
import { Controller, Ctx, Get, type Context } from '@carno.js/core';

@Controller('/me')
class MeController {
@Get()
me(@Ctx() ctx: Context) {
return ctx.locals.user;
}
}

You can also read values with @Locals().

import { Locals } from '@carno.js/core';

@Get('/dashboard')
dashboard(@Locals('user') user: any) {
return { user };
}

Applying Middleware

Global Middleware

Global middleware applies to every route.

import { Carno } from '@carno.js/core';

const app = new Carno({
globalMiddlewares: [requestIdMiddleware],
})
.services([TimingMiddleware])
.middlewares([TimingMiddleware])
.controllers([UsersController]);

app.listen(3000);

You can pass function middleware, middleware classes or pre-created middleware instances.

Controller Middleware

Use @Middleware() on a controller to apply middleware to all routes in that controller.

import { Controller, Middleware } from '@carno.js/core';

@Controller('/users')
@Middleware(AuthMiddleware)
export class UsersController {}

Route Middleware

Use @Middleware() on a route method when only one endpoint needs the middleware.

import { Get, Middleware } from '@carno.js/core';

class UsersController {
@Get('/admin')
@Middleware(AuthMiddleware)
adminOnly() {
return [];
}
}

Execution Order

Middleware runs in this order:

  1. globalMiddlewares from app configuration.
  2. Middlewares registered with .middlewares().
  3. Controller-level middleware.
  4. Route-level middleware.
  5. Route handler.

For class-based middleware, the after phase runs in reverse order.

Dependency Injection

Class middleware is resolved through the DI container, so constructor injection works.

@Service()
export class LoggerMiddleware implements CarnoMiddleware {
constructor(private logger: LoggerService) {}

async handle(ctx: Context, next: CarnoClosure): Promise<void> {
this.logger.info(`-> ${ctx.method} ${ctx.path}`);

await next();

this.logger.info(`<- ${ctx.method} ${ctx.path}`);
}
}

Register the middleware class as a service when it has dependencies.

const app = new Carno()
.services([LoggerService, LoggerMiddleware])
.middlewares([LoggerMiddleware]);

Built-In Compression Middleware

CompressionMiddleware compresses eligible responses with Brotli, gzip or deflate.

It checks:

  • Accept-Encoding from the request.
  • Existing Content-Encoding on the response.
  • Response Content-Type.
  • Response size threshold.
  • Whether compressed output is smaller than the original.
import { Carno, CompressionMiddleware } from '@carno.js/core';

const app = new Carno()
.middlewares([new CompressionMiddleware()])
.controllers([MyController]);

app.listen(3000);

Compression Configuration

new CompressionMiddleware({
threshold: 512,
encodings: ['br', 'gzip'],
gzipLevel: 6,
brotliQuality: 4,
compressibleTypes: [
'text/',
'application/json',
'application/javascript',
'application/xml',
'image/svg+xml',
],
});
OptionDefaultDescription
threshold1024Minimum response size in bytes
encodings['br', 'gzip']Preferred encoding order
gzipLevel6Gzip level from -1 to 9
brotliQuality4Brotli quality from 0 to 11
compressibleTypestext, JSON, JavaScript, XML, SVGContent-Type fragments eligible for compression

Practical Guidelines

  • Return Response for guard failures instead of mutating context only.
  • Use ctx.locals for request-scoped data that handlers need.
  • Keep middleware focused on cross-cutting concerns.
  • Prefer class middleware when dependencies or after-response behavior are needed.
  • Register class middleware as services when it uses constructor injection.
  • Be careful when consuming response.body; create a new Response if you transform it.

See Also