@stitchem/http
v1.0.0
Published
HTTP layer for stitchem. Extends `@stitchem/core` with routers, middleware, guards, pipes, exception filters, validation, and response serialization — all wired through the DI container.
Readme
@stitchem/http
HTTP layer for stitchem. Extends @stitchem/core with routers, middleware, guards, pipes, exception filters, validation, and response serialization — all wired through the DI container.
Requirements
@stitchem/core>= 1.0.0- TypeScript >= 5.2 (TC39 decorators)
- Node.js >= 22 or any runtime with
AsyncLocalStorage
Install
pnpm add @stitchem/httpYou also need an adapter. The Hono adapter is currently the only supported one:
pnpm add @stitchem/honoQuick Start
import { module } from '@stitchem/core';
import { HttpServer, router, get } from '@stitchem/http';
import { hono } from '@stitchem/hono';
@router('/hello')
class HelloRouter {
@get('/')
index() {
return { message: 'Hello, world!' };
}
}
@module({ routers: [HelloRouter] })
class AppModule {}
const app = await HttpServer.create(AppModule, {
adapter: hono({ server: 'bun' }),
});
await app.listen(3000);Table of Contents
- HttpServer
- Routing
- Request Pipeline
- Middleware
- Guards
- Pipes
- Validation
- Exception Filters
- Exceptions
- Serialization
- HttpAdapter
- API Reference
HttpServer
HttpServer.create() is the entry point. It initializes the DI context, discovers routers, and returns a MergedHttpServer — a Proxy that exposes both HttpServer methods and the full native adapter API.
const app = await HttpServer.create(AppModule, {
adapter: hono({ server: 'bun' }),
logging: { level: LogLevel.INFO }, // optional
});
// HttpServer methods
await app.listen(3000);
app.get(UserService); // DI resolution shorthand
app.use(cors()); // native or stitchem middleware
app.useGlobal({ guards: [AuthGuard] });
// Native adapter methods pass through directly
app.use(cors());
app.get('/path', handler); // native Hono route (2+ args)HttpServerOptions
| Option | Description |
|--------|-------------|
| adapter | HTTP adapter instance (required) |
| logging? | false | { logger } | { level } — same as Context.create |
HttpServer Methods
| Method | Description |
|--------|-------------|
| HttpServer.create(module, options) | Creates the server and initializes the DI context |
| app.listen(port, hostname?) | Binds routes and starts listening |
| app.init() | Binds routes without starting (useful for testing) |
| app.close() | Stops the server and disposes the DI context |
| app.get(token) | Resolves a provider from the DI container |
| app.use(...handlers) | Registers global middleware (stitchem or native) |
| app.useGlobal({ filters?, pipes?, guards? }) | Registers global filters, pipes, and guards |
Routing
Routers are plain classes decorated with @router(). They receive full DI via static inject or @inject().
import { injectable } from '@stitchem/core';
import { router, get, post, del, HttpContext } from '@stitchem/http';
@router('/users')
class UserRouter {
static inject = [UserService] as const;
constructor(private users: UserService) {}
@get('/')
async list(ctx: HttpContext) {
return ctx.json(await this.users.findAll());
}
@get('/:id')
async get(ctx: HttpContext) {
const id = ctx.param('id');
return ctx.json(await this.users.findById(id));
}
@post('/')
async create(ctx: HttpContext) {
const body = await ctx.body<{ name: string }>();
const user = await this.users.create(body);
return ctx.status(201).json(user);
}
@del('/:id')
async delete(ctx: HttpContext) {
await this.users.delete(ctx.param('id'));
// returning nothing → 204 No Content
}
}Register routers in any module's routers array:
@module({ routers: [UserRouter], providers: [UserService] })
class UserModule {}HTTP Method Decorators
| Decorator | HTTP Method |
|-----------|-------------|
| @get(path?) | GET |
| @post(path?) | POST |
| @put(path?) | PUT |
| @patch(path?) | PATCH |
| @del(path?) | DELETE |
| @options(path?) | OPTIONS |
| @head(path?) | HEAD |
All decorators default path to '/'.
HttpContext
The portable request/response object passed to every route handler and middleware:
// Request
ctx.request // Web standard Request
ctx.param('id') // route parameter
ctx.param() // all route parameters
ctx.query('page') // query parameter (qs-parsed)
ctx.query() // all query parameters
ctx.header('authorization') // request header
await ctx.body<CreateUserDto>() // parsed request body
// Response state (chainable)
ctx.status(201)
ctx.header('X-Custom', 'value')
// Response builders
ctx.json(data) // JSON response
ctx.text('ok', 200) // plain text response
// Context store
ctx.set('user', user) // typed via HttpContextStore augmentation
ctx.get('user') // retrieve
ctx.get('correlation') // always available (built-in)
// Escape hatch
ctx.native<HonoContext>() // access native framework contextHttpContextStore
Typed per-request key–value store. Augment the interface to get type safety across middleware and handlers:
declare module '@stitchem/http' {
interface HttpContextStore {
user: User;
tenantId: string;
}
}
// Now typed everywhere:
ctx.set('user', user); // ✓
ctx.get('user'); // → Usercorrelation (UUID v7) is always available — automatically set by the built-in correlation middleware on every request.
Return Values
| Handler return | Response sent |
|----------------|---------------|
| Response | Returned directly — bypasses serialization |
| Any value | ctx.json(value) — or serialized via @serialize if applied |
| void / null / undefined | 204 No Content |
Request Pipeline
Every request flows through this pipeline (outer to inner):
Filters (catch errors from everything inside)
└── Middleware (global → class → method)
└── Pipes (validation → method → class → global)
└── Guards (method → class → global)
└── Handler- Middleware runs first in request direction, for cross-cutting concerns
- Pipes validate and transform request data before authorization
- Guards perform authorization after data is validated
- Filters wrap the entire pipeline and catch thrown exceptions
A UUID v7 correlation ID is automatically prepended as the first global middleware on every route — always accessible via ctx.get('correlation').
Middleware
Class Middleware
Implement the Middleware interface and decorate with @middleware():
import { middleware, Middleware, HttpContext } from '@stitchem/http';
@middleware()
class LoggerMiddleware implements Middleware {
use(ctx: HttpContext, next: () => Promise<void>) {
console.log(`${ctx.request.method} ${ctx.request.url}`);
return next();
}
}Apply to a router (all routes) or a specific route:
@router('/users')
@useMiddlewares(LoggerMiddleware) // all routes in this router
class UserRouter {
@post('/')
@useMiddlewares(RateLimitMiddleware) // this route only
create(ctx: HttpContext) { ... }
}Class middleware supports full DI:
@middleware()
class AuthMiddleware implements Middleware {
static inject = [AuthService] as const;
constructor(private auth: AuthService) {}
async use(ctx: HttpContext, next: () => Promise<void>) {
const token = ctx.header('authorization');
if (!token) throw new UnauthorizedException();
ctx.set('user', await this.auth.verify(token));
return next();
}
}Inline Middleware
Use defineMiddleware() for lightweight, DI-free middleware:
import { defineMiddleware } from '@stitchem/http';
const timingMiddleware = defineMiddleware(async (ctx, next) => {
const start = Date.now();
await next();
ctx.header('X-Response-Time', `${Date.now() - start}ms`);
});Apply inline middleware the same way as class middleware:
@useMiddlewares(timingMiddleware)
class UserRouter { ... }HttpModule consumer pattern
For fine-grained route matching, implement the HttpModule interface on a module class:
import { HttpModule, MiddlewareConsumer } from '@stitchem/http';
@module({ routers: [UserRouter, AdminRouter] })
class AppModule implements HttpModule {
middleware(consumer: MiddlewareConsumer) {
consumer
.apply(LoggerMiddleware)
.forRoutes(UserRouter, AdminRouter);
consumer
.apply(AuthMiddleware)
.forRoutes(AdminRouter)
.exclude('GET /admin/health');
}
}forRoutes() and exclude() accept router classes or 'METHOD /path' strings.
Global Middleware
Register middleware that runs on every route before app.listen():
app.use(cors()); // native framework middleware
app.use(i18n.detect()); // stitchem middlewareapp.use() accepts both. Stitchem middleware (created via defineMiddleware) is bridged through the adapter; native middleware is passed directly to the framework.
Guards
Guards control access to routes. They run after pipes, before the handler. Returning false throws ForbiddenException; throwing an exception sends it to the filter chain.
Class Guards
import { guard, Guard, HttpContext } from '@stitchem/http';
@guard()
class AuthGuard implements Guard {
static inject = [AuthService] as const;
constructor(private auth: AuthService) {}
async canActivate(ctx: HttpContext): Promise<boolean> {
const token = ctx.header('authorization');
if (!token) throw new UnauthorizedException();
ctx.set('user', await this.auth.verify(token));
return true;
}
}Apply with @useGuards():
@router('/admin')
@useGuards(AuthGuard) // all routes in this router
class AdminRouter {
@del('/users/:id')
@useGuards(AdminRoleGuard) // this route only
deleteUser(ctx: HttpContext) { ... }
}Inline Guards
Pass a GuardFunction directly to @useGuards() for simple cases:
@get('/me')
@useGuards((ctx: HttpContext) => ctx.get('user') !== undefined)
getProfile(ctx: HttpContext) { ... }Global Guards
app.useGlobal({ guards: [AuthGuard] });Guard resolution order per request: method → class → global. All guards must pass.
Pipes
Pipes run before guards to validate and transform request data. A pipe's transform() method can optionally return a ResponseTransform function that runs after the handler (applied in reverse pipe order).
Class Pipes
import { Pipe, HttpContext, ResponseTransform } from '@stitchem/http';
class TimingPipe implements Pipe {
transform(ctx: HttpContext): ResponseTransform {
const start = Date.now();
return (response: Response) => {
response.headers.set('X-Response-Time', `${Date.now() - start}ms`);
return response;
};
}
}Apply with @usePipes():
@router('/users')
@usePipes(new TimingPipe()) // all routes
class UserRouter {
@post('/')
@usePipes(new MyValidationPipe()) // this route only
create(ctx: HttpContext) { ... }
}Pipes are pre-constructed — they receive no DI. Pass instances or functions directly.
Validation
@validate
Use @validate() to attach Standard Schema validation to a route method. Validation runs first in the pipe chain. Errors from all sources are aggregated and thrown as a single ValidationException:
import { z } from 'zod';
@router('/users')
class UserRouter {
@post('/')
@validate({
body: z.object({ email: z.string().email(), name: z.string().min(1) }),
query: z.object({ notify: z.string().optional() }),
})
create(ctx: HttpContext) { ... }
@get('/')
@validate({ query: z.object({ page: z.coerce.number().default(1) }) })
list(ctx: HttpContext) { ... }
}Supported schemas: body, query, params. Any Standard Schema v1-compatible library works — Zod, Valibot, ArkType, Effect Schema, etc.
By default the parsed/coerced value replaces the original (transform: true). To disable:
@validate({ body: schema }, { transform: false })ValidationPipe
ValidationPipe is a standalone pipe for single-value validation:
import { ValidationPipe } from '@stitchem/http';
const emailPipe = new ValidationPipe(z.string().email());
@usePipes(emailPipe)
@get('/verify')
verify(ctx: HttpContext) { ... }Exception Filters
Exception filters catch errors thrown anywhere in the pipeline and convert them to HTTP responses.
Class Filters
Implement ExceptionFilter and decorate with @catches():
import { catches, ExceptionFilter, HttpContext, HttpException } from '@stitchem/http';
@catches(HttpException)
class HttpExceptionFilter implements ExceptionFilter {
catch(error: HttpException, ctx: HttpContext): Response {
return ctx.json({ error: error.message }, error.status);
}
}
@catches() // catches everything — useful for logging
class LoggingFilter implements ExceptionFilter {
catch(error: unknown, ctx: HttpContext): void {
console.error('[unhandled]', error);
// returning void → error falls through to next filter
}
}Apply with @useFilters():
@router('/users')
@useFilters(HttpExceptionFilter) // all routes
class UserRouter {
@post('/')
@useFilters(ValidationFilter) // this route only
create(ctx: HttpContext) { ... }
}Global Filters
app.useGlobal({ filters: [HttpExceptionFilter] });Filter Chain
Filter priority order: method → class → global. The first filter that returns a Response wins. If a filter returns void, the error falls through to the next filter. If no filter handles it, a 500 response is sent.
Exception filters are always resolved globally (ignoring the strict option).
Exceptions
Built-in exceptions extend HttpException:
throw new BadRequestException('Invalid email');
throw new NotFoundException();
throw new HttpException(418, "I'm a teapot");| Exception | Status | Default message |
|-----------|--------|-----------------|
| BadRequestException | 400 | 'Bad Request' |
| UnauthorizedException | 401 | 'Unauthorized' |
| ForbiddenException | 403 | 'Forbidden' |
| NotFoundException | 404 | 'Not Found' |
| MethodNotAllowedException | 405 | 'Method Not Allowed' |
| ConflictException | 409 | 'Conflict' |
| UnprocessableEntityException | 422 | 'Unprocessable Entity' |
| TooManyRequestsException | 429 | 'Too Many Requests' |
| InternalServerErrorException | 500 | 'Internal Server Error' |
| ServiceUnavailableException | 503 | 'Service Unavailable' |
| ValidationException | 400 | 'Validation failed' |
ValidationException extends BadRequestException and carries a structured errors object:
{
body: { email: ['Invalid email format'] },
query: { page: ['Required'] },
}Serialization
Use @dto(), @expose(), and @exclude() to declare response shapes, then attach them to route methods with @serialize().
import { dto, expose, exclude, serialize } from '@stitchem/http';
@dto()
class UserResponse {
@expose() id!: number;
@expose() name!: string;
@expose() email!: string;
// password is not exposed — omitted from output
password!: string;
}
@router('/users')
class UserRouter {
@get('/:id')
@serialize(UserResponse)
async getUser(ctx: HttpContext) {
return await this.userService.findById(ctx.param('id'));
// return value is filtered through UserResponse before ctx.json()
}
}Expose mode (one or more @expose() on the class): only explicitly exposed fields appear.
Exclude mode (no @expose(), only @exclude()): all own properties appear except excluded ones.
Computed getters work with both decorators:
@dto()
class UserResponse {
@expose() id!: number;
@expose() name!: string;
@expose() get fullName() { return `${this.firstName} ${this.lastName}`; }
}Response return values bypass serialization entirely.
HttpAdapter
HttpAdapter is the interface adapters implement to bridge a native framework into @stitchem/http:
export interface HttpAdapter<TApp = unknown> {
readonly name: string;
get(): TApp;
createContext(nativeContext: unknown): HttpContext;
registerRoute(method: HttpMethod, path: string, handler: RouteHandler): void;
useMiddleware(handler: MiddlewareHandler): void;
useErrorHandler(handler: ErrorHandler): void;
listen(port: number, hostname?: string): Promise<void>;
close(): Promise<void>;
}Adapters bridge stitchem's portable HttpContext to the native framework's request/response model.
API Reference
Decorators
| Decorator | Target | Description |
|-----------|--------|-------------|
| @router(path?) | Class | Marks a class as a router with a base path (default '/') |
| @get(path?) | Method | GET route |
| @post(path?) | Method | POST route |
| @put(path?) | Method | PUT route |
| @patch(path?) | Method | PATCH route |
| @del(path?) | Method | DELETE route |
| @options(path?) | Method | OPTIONS route |
| @head(path?) | Method | HEAD route |
| @middleware(options?) | Class | Marks a class as middleware. { strict? } controls DI scope |
| @useMiddlewares(...targets) | Class / Method | Applies middleware to a router or route |
| @guard(options?) | Class | Marks a class as a guard. { strict? } controls DI scope |
| @useGuards(...targets) | Class / Method | Applies guards to a router or route |
| @usePipes(...pipes) | Class / Method | Applies pipes to a router or route |
| @validate(schemas, options?) | Method | Attaches Standard Schema validation |
| @catches(...types) | Class | Marks a class as an exception filter |
| @useFilters(...filters) | Class / Method | Applies exception filters to a router or route |
| @dto() | Class | Marks a class as a DTO for serialization |
| @expose() | Field / Getter | Includes this field in serialized output |
| @exclude() | Field / Getter | Excludes this field from serialized output |
| @serialize(dtoClass) | Method | Applies DTO serialization to a route's return value |
Interfaces
| Interface | Description |
|-----------|-------------|
| Middleware | use(ctx, next): void \| Promise<void> |
| Guard | canActivate(ctx): boolean \| Promise<boolean> |
| Pipe | transform(ctx): void \| ResponseTransform \| Promise<...> |
| ExceptionFilter | catch(error, ctx): Response \| void \| Promise<...> |
| HttpModule | middleware(consumer): void — module-level middleware routing |
| HttpAdapter | Adapter contract for bridging native frameworks |
| HttpContext | Portable request/response context |
| HttpContextStore | Augmentable typed store for per-request data |
Functions
| Function | Description |
|----------|-------------|
| defineMiddleware(fn) | Brands a function as a stitchem middleware handler |
License
MIT
