HTTP
Contract
@modularityjs/http provides a decorator-based routing system with controller discovery, parameter extraction, guards, interceptors, middleware, and error filters.
Controllers
import {
Body,
Controller,
Get,
HttpControllersPool,
Params,
Post,
StatusCode,
} from '@modularityjs/http';
@Controller('/users')
class UserController {
@Get()
list() {
return [{ id: 1, name: 'Alice' }];
}
@Get('/:id')
get(@Params('id') id: string) {
return { id, name: 'Alice' };
}
@Post()
@StatusCode(201)
create(@Body() body: { name: string }) {
return { id: 2, name: body.name };
}
}Register controllers via the HttpControllersPool:
@Module({
name: 'users',
imports: [HttpModule],
providers: [UserController],
pools: [
{
pool: HttpControllersPool,
key: 'user-controller',
useClass: UserController,
},
],
})
class UsersModule {}Route Decorators
| Decorator | HTTP Method |
|---|---|
@Get() | GET |
@Post() | POST |
@Put() | PUT |
@Patch() | PATCH |
@Delete() | DELETE |
@Head() | HEAD |
@Options() | OPTIONS |
@All() | All methods |
All accept an optional path string parameter.
Parameter Decorators
| Decorator | Source |
|---|---|
@Body() | Request body |
@Query() | Query string |
@Params() | Route parameters |
@Headers() | Request headers |
@Cookies() | Cookies |
@Request() | Raw request object |
@Response() | Raw response object |
@Body(), @Query(), @Params(), @Headers(), and @Cookies() accept an optional name to extract a specific field (e.g., @Params('id')).
Response Decorators
@StatusCode(201) // Set HTTP status code
@SetHeader('X-Custom', 'value') // Set response header (method or class level)
@Html() // Shorthand for @SetHeader('content-type', 'text/html; charset=utf-8')@SetHeader and @Html() work at both class and method level. Applied to a class, the header is set on every route in that controller. A method-level decorator overrides the class-level one for that specific route.
@Html()
@Controller('/pages')
class PageController {
@Get('/home')
home() {
return '<h1>Home</h1>'; // content-type: text/html
}
@Get('/data')
@SetHeader('content-type', 'application/json')
data() {
return '{"ok":true}'; // method-level overrides class-level
}
}HtmlResponse
Return HtmlResponse from a controller method to send an HTML body with Content-Type: text/html; charset=utf-8. Useful for partial/fragment responses where the method doesn't carry @Html():
import { HtmlResponse } from '@modularityjs/http';
@Controller('/partials')
class PartialsController {
@Post('/item')
async item(@Body('text') text: string) {
const html = await this.templates.render('item', { text });
return new HtmlResponse(html);
}
}HtmlResponse overrides any @SetHeader('content-type', ...) on the method, mirroring the behaviour of FileResponse.
Guards
Guards control access to routes. They implement the Guard interface and run before the handler. If any guard returns false, the request is rejected with a 403 status.
interface Guard<TRequest = unknown> {
activate(request: TRequest): Promise<boolean> | boolean;
}import { Injectable } from '@modularityjs/di';
import { Guard, UseGuard, type HttpRequest } from '@modularityjs/http';
@Injectable()
class FeatureFlagGuard implements Guard<HttpRequest> {
activate(request: HttpRequest): boolean {
return request.headers['x-feature-beta'] === 'on';
}
}
@Controller('/beta')
@UseGuard(FeatureFlagGuard)
class BetaController {
@Get()
index() {
return { status: 'ok' };
}
@Get('/extras')
@UseGuard(ExtraGuard)
extras() {
return { status: 'restricted' };
}
}@UseGuard() can be applied at class level or method level. When both are present, class-level guards execute first, then method-level guards. If any guard returns false, the adapter responds with 403 Forbidden and the handler is never called.
Guards registered as DI providers are resolved from the container. Otherwise, they are instantiated directly.
For authentication and permission gating, use @RequireAuth() (http-auth) and @RequirePermission() (http-authz) instead of writing a guard — the adapter enforces them after middleware and guards run, and returns 401 / 403 automatically.
File Uploads
File upload support is split across two packages: @modularityjs/http-upload owns the driver-agnostic HttpUploadConfig (max file size, MIME allowlist, sanitizeFilename) and @modularityjs/http-fastify-upload is the Fastify driver that registers @fastify/multipart under the hood. Bridges like http-storage depend only on the abstract.
Setup
import { HttpModule } from '@modularityjs/http';
import { HttpFastifyModule } from '@modularityjs/http-fastify';
import { HttpFastifyUploadModule } from '@modularityjs/http-fastify-upload';
import { HttpUploadModule } from '@modularityjs/http-upload';
const modules = [
HttpModule.forRoot({ port: 3000 }),
HttpFastifyModule,
HttpUploadModule.forRoot({ maxFileSize: 5 * 1024 * 1024 }), // optional config
HttpFastifyUploadModule,
];Parameter Decorators
| Decorator | Description |
|---|---|
@UploadedFile() | Single file upload |
@UploadedFile('fieldName') | Single file from a specific form field |
@UploadedFiles() | Multiple file uploads (array) |
Files are provided as FileUpload objects:
interface FileUpload {
readonly filename: string;
readonly mimetype: string;
readonly size: number;
toBuffer(): Promise<Buffer>;
}Example
import {
Controller,
Post,
UploadedFile,
UploadedFiles,
} from '@modularityjs/http';
import type { FileUpload } from '@modularityjs/http';
@Controller('/uploads')
class UploadController {
@Post('/avatar')
async uploadAvatar(@UploadedFile() file: FileUpload) {
const buffer = await file.toBuffer();
return { filename: file.filename, size: file.size };
}
@Post('/documents')
async uploadDocuments(@UploadedFiles() files: FileUpload[]) {
return { count: files.length };
}
@Post('/profile')
async uploadProfile(@UploadedFile('photo') photo: FileUpload) {
return { filename: photo.filename };
}
}FileResponse
Return a FileResponse from a controller method to send binary data as a download:
import { Controller, FileResponse, Get } from '@modularityjs/http';
@Controller('/files')
class FileController {
@Get('/report')
download() {
const buffer = generateReport();
return new FileResponse(buffer, 'application/pdf', 'report.pdf');
}
@Get('/image')
image() {
const buffer = loadImage();
return new FileResponse(buffer, 'image/png');
}
}FileResponse takes three arguments:
| Parameter | Type | Description |
|---|---|---|
body | Buffer | The binary content |
contentType | string | MIME type (e.g., 'application/pdf', 'image/png') |
filename | string | Optional — sets Content-Disposition: attachment header |
When filename is provided, the browser treats the response as a file download. Without it, the response is sent inline.
Server-Sent Events
Return SseResponse from a controller method to stream events to the client using the Server-Sent Events protocol. The Fastify driver writes the text/event-stream headers and streams each event directly over the raw connection.
import { Controller, Get, SseResponse } from '@modularityjs/http';
import type { SseEvent } from '@modularityjs/http';
@Controller('/notifications')
class NotificationsController {
@Get('/stream')
stream(): SseResponse {
async function* generate(): AsyncGenerator<SseEvent> {
for (let i = 0; i < 10; i++) {
yield { data: { count: i }, event: 'tick', id: String(i) };
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
return new SseResponse(generate());
}
}SseResponse takes two arguments:
| Parameter | Type | Description |
|---|---|---|
stream | AsyncIterable<SseEvent> | Async generator or iterable that yields events |
heartbeatMs | number | null | Interval in ms to send keep-alive comments; null to disable (default: null) |
Each SseEvent is serialized as an SSE frame:
interface SseEvent {
data: unknown; // Required — serialized as JSON
id?: string; // Optional event ID (for Last-Event-ID reconnect)
event?: string; // Optional event type (client listens with addEventListener)
retry?: number; // Optional reconnect delay hint in ms
}When heartbeatMs is set, the driver periodically writes a bare SSE comment line (:\n\n) between events to keep the connection alive through proxies and load balancers that close idle connections.
Permission Enforcement
When HttpAuthzModule is loaded, @RequirePermission() decorators are enforced automatically. The adapter reads the required permissions from decorator metadata at route registration time and runs the authorization checks inside the request pipeline (after guards/auth, before the handler).
import { RequirePermission } from '@modularityjs/http-authz';
@Controller('/api/admin')
@RequirePermission('admin.access')
class AdminController {
@Get('/users')
listUsers() { ... }
@Post('/users')
@RequirePermission('users.create')
createUser() { ... }
}The enforcement flow:
- The adapter collects permissions from both class-level and method-level
@RequirePermission()decorators - If any permissions are required, the request must carry an authenticated identity — otherwise a
401 Authentication Requiredresponse is returned - Each required permission is checked via
AuthzService.can(identity, permission), delegated through the permission checker bound byHttpAuthzModule - If any permission check fails, a
403 Forbiddenresponse is returned with the denied permission in the response context
This requires HttpAuthzModule in the module list, which imports both AuthzModule and HttpModule and binds the permission checker in afterLoad().
Interceptors
Interceptors wrap handler execution as an onion: each one receives the request, the response, and a next() it must call to advance the chain. next() returns the value the next inner interceptor (or the handler at the innermost layer) produced. The interceptor returns the value that should be serialized as the response — usually await next() unchanged, sometimes transformed.
import { Injectable } from '@modularityjs/di';
import {
type HttpRequest,
type HttpResponse,
type Interceptor,
UseInterceptor,
} from '@modularityjs/http';
@Injectable()
class LoggingInterceptor implements Interceptor<HttpRequest, HttpResponse> {
async intercept(
request: HttpRequest,
_response: HttpResponse,
next: () => Promise<unknown>,
): Promise<unknown> {
const start = Date.now();
const result = await next();
console.log(`request done in ${Date.now() - start}ms`);
return result;
}
}
@Controller('/api')
@UseInterceptor(LoggingInterceptor)
class ApiController {
/* ... */
}Composition order: method-level interceptors are outermost (run first on the way in, last on the way out); class-level interceptors are closer to the handler.
Middleware
Middleware runs before guards/auth/handler — Express-style preprocessing. Call next() to advance; skip it to short-circuit (typically after writing a response on HttpResponse).
import { Injectable } from '@modularityjs/di';
import {
ApplyMiddleware,
type HttpRequest,
type HttpResponse,
type Middleware,
} from '@modularityjs/http';
@Injectable()
class RequestIdMiddleware implements Middleware<HttpRequest, HttpResponse> {
async execute(
_request: HttpRequest,
response: HttpResponse,
next: () => Promise<void>,
): Promise<void> {
response.setHeader('x-request-id', crypto.randomUUID());
await next();
}
}
@Controller('/api')
@ApplyMiddleware(RequestIdMiddleware)
class ApiController {
/* ... */
}Both interceptors and middleware use the abstract HttpRequest / HttpResponse types — no driver-specific casts needed for status, headers, cookies, send, or redirect. The Fastify reply implements HttpResponse; other drivers do the same.
Middleware vs. Interceptor vs. Plugin
Three tools that all "wrap something" — easy to confuse. The right one depends on what scope the wrap operates at:
| Middleware | Interceptor | Plugin | |
|---|---|---|---|
| Layer | HTTP request pipeline | HTTP request pipeline | DI container (any method on any class) |
What next() wraps | The rest of the request pipeline (guards → auth → interceptors → handler) | The HTTP handler invocation | A single method on a single class (or every subclass of an abstract base) |
| HTTP-aware? | Yes (request, response) | Yes (request, response) | No — sees method args + return only |
| Fires for non-HTTP code? | No | No | Yes — CLI commands, scheduled jobs, queue workers, anything the container instantiates |
| Can transform handler's return? | No (runs before handler exists) | Yes — await next() returns the handler's value; you return what gets serialized | Only if your target IS the handler method; usually targets a lower-level method |
| Can short-circuit the HTTP reply? | Yes (skip next(), write reply directly) | Yes (skip next(), return a value) | No (no response in scope) |
| Discovery model | Opt-in: @ApplyMiddleware(MyMw) on controller/method | Opt-in: @UseInterceptor(MyInt) on controller/method | Auto-applied at boot to every bound subclass matching @Plugin({ target }) |
| Typical use cases | Request ID, body parsing, IP rate-limit, CORS | Wrap response in envelope, cache HTTP response, log handler timing | Trace every DB query, cache every cache-miss-prone service call, lock acquisition AOP |
The mental shortcut
Each layer's next() lives at a different depth in the call stack:
middleware's next() ⟶ the rest of THE REQUEST PIPELINE
interceptor's next() ⟶ THE HANDLER (one specific method)
plugin's next() ⟶ THE WRAPPED METHOD (any one method on any class)Pipeline order for one HTTP request
HTTP request
→ onRequest hooks (session loader, auth resolver, …)
→ middleware ← @ApplyMiddleware
→ guards ← @UseGuard
→ @RequireAuth / @RequirePermission
→ interceptors ← @UseInterceptor (method-outer, class-inner onion)
→ handler ← @Get / @Post / …
→ service methods ← plugins fire here
→ repository methods ← plugins fire here
→ cache / lock / … ← plugins fire here
← interceptors (post-handler, reverse)
← (status/headers applied)
← (response sent)
→ onSend hooks (cookie writer, telemetry, …)Picking the right tool
| Goal | Use |
|---|---|
Set an X-Request-Id header on every response | Middleware |
Wrap every handler's JSON return value in { data, meta: { … } } | Interceptor |
| Log how long each handler took | Interceptor |
Cache the GET response for /products | Interceptor |
Trace every DatabaseConnection.query (HTTP, CLI, jobs, …) | Plugin |
Auto-acquire a lock around every method on @Locked classes | Plugin |
Add OpenTelemetry spans to every CacheService.get | Plugin (this is what *-telemetry packages do) |
| Reject requests with no session, returning 401 | Middleware (or @RequireAuth) |
Validate body, throw ValidationException on failure | @ValidatedBody(schema) parameter decorator |
The rule of thumb: if the wrap should fire for non-HTTP code too (CLI commands, scheduled jobs, queue consumers), it has to be a plugin. If it's specific to one handler's return value, it's an interceptor. Everything else that runs in the HTTP request pipeline is middleware.
Error Filters
Error filters catch exceptions thrown by handlers and return an ErrorResponse value the adapter applies to the reply (status, body, headers, cookies). Filters are pure — they don't mutate the response object directly, which keeps them driver-agnostic, unit-testable as functions, and impossible to leave half-finalized.
import { Injectable } from '@modularityjs/di';
import {
CatchError,
type ErrorFilter,
type ErrorResponse,
type HttpRequest,
UseErrorFilter,
} from '@modularityjs/http';
@CatchError({ error: ValidationError })
@Injectable()
class ValidationErrorFilter implements ErrorFilter<ValidationError> {
catch(error: ValidationError, _request: HttpRequest): ErrorResponse {
return {
status: 422,
body: { code: 'VALIDATION_ERROR', message: error.message },
};
}
}
@Controller('/api')
@UseErrorFilter(ValidationErrorFilter)
class ApiController {
/* ... */
}ErrorResponse shape:
interface ErrorResponse {
status: number;
body?: unknown;
headers?: Record<string, string | number | readonly string[]>;
cookies?: Array<{ name: string; value: string; options?: CookieOptions }>;
}Async filters return Promise<ErrorResponse> — the adapter awaits before finalizing. Method-level @UseErrorFilter filters run before class-level filters; the first one whose @CatchError({ error: X }) matches the thrown error wins.
The Fastify driver includes a built-in FrameworkExceptionFilter that maps framework exceptions to HTTP status codes:
| Exception | Status |
|---|---|
ValidationException | 422 |
NotFoundException | 404 |
AuthenticationException | 401 |
AuthorizationException | 403 |
ConflictException | 409 |
StateException | 409 |
RateLimitExceededException | 429 |
ServiceUnavailableException | 503 |
Drivers
Fastify (@modularityjs/http-fastify)
Fastify-based HTTP server.
import { HttpModule } from '@modularityjs/http';
import { HttpFastifyModule } from '@modularityjs/http-fastify';
const modules = [
HttpModule.forRoot({ host: '0.0.0.0', port: 3000 }),
HttpFastifyModule,
];Configuration
HttpModule.forRoot({
host: '0.0.0.0', // default
port: 3000, // default
listen: true, // auto-start on boot (default: true)
bodyLimit: 1_048_576, // max request body in bytes (default: 1 MiB)
connectionTimeoutMs: undefined, // socket connection timeout; unset = driver default
requestTimeoutMs: undefined, // per-request timeout; unset = driver default
keepAliveTimeoutMs: undefined, // HTTP keep-alive timeout; unset = driver default
});| Option | Default | Description |
|---|---|---|
host | '0.0.0.0' | Bind address |
port | 3000 | TCP port (0 = OS-assigned) |
listen | true | Auto-bind the listener on boot |
bodyLimit | 1048576 | Maximum accepted request body, in bytes (must be a positive integer) |
connectionTimeoutMs | undefined | Socket connection timeout in ms; non-negative integer when set |
requestTimeoutMs | undefined | Per-request timeout in ms; non-negative integer when set |
keepAliveTimeoutMs | undefined | HTTP keep-alive timeout in ms; non-negative integer when set |
Fastify-specific feature extensions
A handful of small packages add Fastify-specific request-handling features by registering Fastify plugins on the underlying server. They live alongside http-fastify and depend on it being the active HTTP driver.
| Package | Adds |
|---|---|
@modularityjs/http-fastify-upload | Multipart upload parsing via @fastify/multipart; provides @UploadedFile() / @UploadedFiles() decorators. Reads HttpUploadConfig from the abstract @modularityjs/http-upload. |
@modularityjs/http-fastify-formbody | Form-encoded body parsing via @fastify/formbody; lets controllers accept application/x-www-form-urlencoded payloads from HTML forms. |
@modularityjs/http-fastify-cors | Registers @fastify/cors with framework-managed config. |
@modularityjs/http-fastify-compression | Registers @fastify/compress (br/gzip/deflate) on responses. |
@modularityjs/http-fastify-security-headers | Registers @fastify/helmet (CSP, HSTS, X-Frame-Options, …). |
These are explicitly Fastify-driver extensions (the package name carries fastify to make that scope honest). Apps using a different HTTP driver in the future would not consume them. The abstract HttpServer does not expose getInstance(); only the concrete HttpService from @modularityjs/http-fastify does. Inside a known Fastify-extension package, cast through the concrete type — e.g. (httpServer as unknown as { getInstance(): FastifyInstance }).getInstance() — or inject HttpService directly. Bridges that target the abstract HttpServer contract should do neither.
The active driver binds the abstract HttpServer contract — inject it for programmatic access:
import { Inject, Injectable } from '@modularityjs/di';
import { HttpServer } from '@modularityjs/http';
@Injectable()
class MyService {
constructor(@Inject(HttpServer) private readonly http: HttpServer) {}
getAddress() {
return this.http.getAddress(); // { host, port }
}
}Typed Driver Aliases
The Fastify driver provides typed aliases for use in guards, interceptors, middleware, and error filters:
import type {
FastifyGuard,
FastifyInterceptor,
FastifyMiddleware,
FastifyErrorFilter,
} from '@modularityjs/http-fastify';