Skip to content

HTTP

Contract

@modularityjs/http provides a decorator-based routing system with controller discovery, parameter extraction, guards, interceptors, middleware, and error filters.

Controllers

typescript
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. The pool is what decides which controllers are routed — the @Controller() decorator marks a class and providers binds it in DI, but neither mounts anything on its own, so a class you never contribute serves nothing. That is what makes it possible to wire a shared feature module and route it in one deployment but not another:

typescript
@Module({
  name: 'users',
  imports: [HttpModule],
  providers: [UserController],
  pools: [
    {
      pool: HttpControllersPool,
      key: 'user-controller',
      useClass: UserController,
    },
  ],
})
class UsersModule {}

Route Decorators

DecoratorHTTP 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

DecoratorSource
@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

typescript
@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.

typescript
@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():

typescript
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.

API Versioning

@Version pins the API version of a controller (class-level) or a single route (method-level overrides the class). The version becomes a URL prefix built from HttpModuleConfig.versioning:

typescript
@Version('2')
@Controller('/users')
class UsersV2Controller {
  @Get('/') // served at /v2/users
  list() { ... }

  @Version('3')
  @Get('/next') // method override — served at /v3/users/next
  listNext() { ... }

  @Version(null)
  @Get('/health') // explicitly version-neutral — served at /users/health
  health() { ... }
}
typescript
HttpModule.forRoot({
  versioning: {
    prefix: 'v', // '/v2'; use '' for bare '/2' or 'api-v' for '/api-v2'
    defaultVersion: '1', // applied to undecorated routes; unset = they stay unprefixed
  },
});

@Version(null) keeps a route unprefixed even when defaultVersion is set — for health checks and other version-neutral endpoints. The resolved version and prefixed path appear in HttpServer.getRoutes() (RouteInfo.version), and the OpenAPI document uses the same prefixed paths — all three derive from one resolveRoutePath helper, so they can never disagree.

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.

typescript
interface Guard<TRequest = unknown> {
  activate(request: TRequest): Promise<boolean> | boolean;
}
typescript
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

typescript
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

DecoratorDescription
@UploadedFile()Single file upload
@UploadedFile('fieldName')Single file from a specific form field
@UploadedFiles()Multiple file uploads (array)

Files are provided as FileUpload objects:

typescript
interface FileUpload {
  readonly filename: string;
  readonly mimetype: string;
  readonly size: number;
  toBuffer(): Promise<Buffer>;
}

Example

typescript
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:

typescript
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:

ParameterTypeDescription
bodyBufferThe binary content
contentTypestringMIME type (e.g., 'application/pdf', 'image/png')
filenamestringOptional — 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.

typescript
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:

ParameterTypeDescription
sourceSseSourceAn AsyncIterable<SseEvent>, or a factory (connection: SseConnection) => AsyncIterable<SseEvent>
heartbeatMsnumber | null | undefinedundefined (default) → HttpModuleConfig.sseHeartbeatMs (default 15 000 ms); null → heartbeat off; a number → that interval in ms

The driver periodically writes a bare SSE comment line (:\n\n) between events at the heartbeat interval to keep the connection alive through proxies and load balancers that reap idle connections.

Each SseEvent is serialized as an SSE frame:

typescript
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
}

Per-connection sources: SseConnection

Pass a factory instead of a bare iterable to get the per-connection context:

typescript
interface SseConnection {
  readonly signal: AbortSignal; // fires on client disconnect AND graceful shutdown
  readonly lastEventId: string | undefined; // Last-Event-ID request header on reconnect
}
  • signal aborts on client disconnect and on graceful shutdown. Producers that await external sources (event bus, timers) must observe it to release their resources promptly — the write loop only notices a disconnect at the next yielded event.
  • lastEventId carries the Last-Event-ID request header when a client reconnects, so the source can resume from where the previous stream left off (set id on your events to enable this).

SseStream: bridging push sources

SseStream is a push-handle bridging callback sources (event bus, timers) into the AsyncIterable an SseResponse consumes. Single-consumer: create one per connection, push() events as they arrive, and close() it from the connection's abort signal:

typescript
import { SseResponse, SseStream } from '@modularityjs/http';

@Get('/orders/stream')
stream(): SseResponse {
  return new SseResponse(({ signal }) => {
    const stream = new SseStream();
    const off = this.bus.on(
      OrderShipped,
      (event) => void stream.push({ data: event }),
      { name: 'sse-order-shipped', local: true },
    );
    signal.addEventListener(
      'abort',
      () => {
        off();
        stream.close();
      },
      { once: true },
    );
    return stream;
  });
}

push() queues an event for delivery and returns false once the stream is closed; close() ends iteration after the buffered events flush (idempotent). Pass new SseStream({ maxBuffered: 100 }) to cap undelivered events — the oldest is dropped beyond the cap (unbounded when unset).

Limits and shutdown

  • Connection cap: set HttpModuleConfig.sseMaxConnections to cap concurrently open SSE connections — further SSE requests are rejected with a regular 503 through the exception filter (headers are not yet sent at that point, so clients see a clean error response, not a broken stream). Unlimited when unset.
  • Drain-aware shutdown: the driver tracks open SSE streams and, on stop(), aborts every producer (the connection signal fires) and ends every response immediately — a never-ending stream can't pin graceful shutdown for the full drainTimeoutMs. Browsers' EventSource clients reconnect automatically.

htmx client

For htmx frontends, @modularityjs/assets-htmx vendors the pinned htmx SSE extension as the htmx-ext-sse.min.js static asset (SRI-verified) — the client half of SseResponse streaming. With @modularityjs/template-assets' assetUrl helper:

html
<script src="{{assetUrl 'htmx.min.js'}}"></script>
<script src="{{assetUrl 'htmx-ext-sse.min.js'}}"></script>

<div hx-ext="sse" sse-connect="/notifications/stream" sse-swap="tick">
  Waiting for updates…
</div>

sse-connect opens the stream and sse-swap swaps in the payload of each named event (the event field on SseEvent'tick' in the controller example above).

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).

typescript
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:

  1. The adapter collects permissions from both class-level and method-level @RequirePermission() decorators
  2. If any permissions are required, the request must carry an authenticated identity — otherwise a 401 Authentication Required response is returned
  3. Each required permission is checked via AuthzService.can(identity, permission), delegated through the permission checker bound by HttpAuthzModule
  4. If any permission check fails, a 403 Forbidden response 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.

typescript
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).

typescript
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:

MiddlewareInterceptorPlugin
LayerHTTP request pipelineHTTP request pipelineDI container (any method on any class)
What next() wrapsThe rest of the request pipeline (guards → auth → interceptors → handler)The HTTP handler invocationA 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?NoNoYes — 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 serializedOnly 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 modelOpt-in: @ApplyMiddleware(MyMw) on controller/methodOpt-in: @UseInterceptor(MyInt) on controller/methodAuto-applied at boot to every bound subclass matching @Plugin({ target })
Typical use casesRequest ID, body parsing, IP rate-limit, CORSWrap response in envelope, cache HTTP response, log handler timingTrace 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

GoalUse
Set an X-Request-Id header on every responseMiddleware
Wrap every handler's JSON return value in { data, meta: { … } }Interceptor
Log how long each handler tookInterceptor
Cache the GET response for /productsInterceptor
Trace every DatabaseConnection.query (HTTP, CLI, jobs, …)Plugin
Auto-acquire a lock around every method on @Locked classesPlugin
Add OpenTelemetry spans to every CacheService.getPlugin (this is what *-telemetry packages do)
Reject requests with no session, returning 401Middleware (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.

typescript
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:

typescript
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:

ExceptionStatus
ValidationException422
NotFoundException404
AuthenticationException401
AuthorizationException403
ConflictException409
StateException409
RateLimitExceededException429
ServiceUnavailableException503

Drivers

Fastify (@modularityjs/http-fastify)

Fastify-based HTTP server.

typescript
import { HttpModule } from '@modularityjs/http';
import { HttpFastifyModule } from '@modularityjs/http-fastify';

const modules = [
  HttpModule.forRoot({ host: '0.0.0.0', port: 3000 }),
  HttpFastifyModule,
];

Configuration

typescript
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
  trustProxy: undefined, // trust X-Forwarded-* behind a proxy; unset = off
});
OptionDefaultDescription
host'0.0.0.0'Bind address
port3000TCP port (0 = OS-assigned)
listentrueAuto-bind the listener on boot
bodyLimit1048576Maximum accepted request body, in bytes (must be a positive integer)
connectionTimeoutMsundefinedSocket connection timeout in ms; non-negative integer when set
requestTimeoutMsundefinedPer-request timeout in ms; non-negative integer when set
keepAliveTimeoutMsundefinedHTTP keep-alive timeout in ms; non-negative integer when set
trustProxyundefinedPassed to Fastify's trustProxy (boolean, hop count, CIDR/IP, or list)

Behind a load balancer? Set trustProxy so request.ip — and anything keyed on it, such as http-rate-limit and unauthenticated http-idempotency scoping — reflects the real client via X-Forwarded-For instead of the proxy's address. Leave it unset unless a trusted proxy really does set the forwarding header, or clients can spoof their IP.

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.

PackageAdds
@modularityjs/http-fastify-uploadMultipart upload parsing via @fastify/multipart; provides @UploadedFile() / @UploadedFiles() decorators. Reads HttpUploadConfig from the abstract @modularityjs/http-upload.
@modularityjs/http-fastify-formbodyForm-encoded body parsing via @fastify/formbody; lets controllers accept application/x-www-form-urlencoded payloads from HTML forms.
@modularityjs/http-fastify-corsRegisters @fastify/cors with framework-managed config.
@modularityjs/http-fastify-compressionRegisters @fastify/compress (br/gzip/deflate) on responses.
@modularityjs/http-fastify-security-headersRegisters @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:

typescript
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 Requests

Guards, interceptors, middleware, and error filters parameterise the abstract http interfaces with the driver's request and reply types:

typescript
import type { Guard, Interceptor } from '@modularityjs/http';
import type { FastifyReply, FastifyRequest } from '@modularityjs/http-fastify';

class MyGuard implements Guard<FastifyRequest> {
  /* … */
}