Controllers
A controller is where your HTTP routes live: a class that injects what it needs through its constructor
and exposes one handler method per route. There's no route-config list to keep in sync — each route is
declared by the type of its handler's action parameter (Get<"/:id">), which the compiler reads
straight from your source.
import type { Controller, Get } from "@heximon/http";
import { type User, UsersRepository } from "./users.repository";
export class UsersController implements Controller<"/users"> {
constructor(private readonly users: UsersRepository) {}
// GET /users — the route is the method's parameter type, not a separate config.
public async list(_action: Get<"/">): Promise<User[]> {
return this.users.list();
}
}
Controller is a marker, not behavior: its type argument carries the wiring — a "/prefix" string in
inline mode, a contract in contract mode — and it contributes nothing at runtime.
That's why implements is the natural clause: with a type-only import the marker vanishes from your
runtime module graph, and your single extends slot stays free for a base class of your own.
extends Controller<"/users"> works exactly the same (the compiler reads the type argument from either
clause) — use it when you want your own class hierarchy on top.
Constructor parameters are the dependency declaration: no tokens, no decorators. The DI graph resolves each by class identity, so listing the controller under a module is the whole registration step.
Declare routes by parameter type
In inline mode the controller carries a "/prefix" string, and each handler declares its own route
through its action parameter type — the verb shorthands Get / Post / Put / Patch / Delete /
Head / Options (<Path, Schemas?>), or the general Route<Method, Path, Schemas?>.
import type { Controller, Get, Post } from "@heximon/http";
import { CreatedUser, CreateUser } from "./user.schema";
import { type User, UsersRepository } from "./users.repository";
export class UsersController implements Controller<"/users"> {
constructor(private readonly users: UsersRepository) {}
// GET /users
public async list(_action: Get<"/">): Promise<User[]> {
return this.users.list();
}
// GET /users/:id — pathParams.id is typed `string` from the path literal
public async get(action: Get<"/:id">): Promise<User> {
return this.users.getById(action.request.pathParams.id);
}
// POST /users — body validated against `CreateUser` before the handler runs
public async create(
action: Post<"/", { body: CreateUser; responses: { 200: CreatedUser } }>,
): Promise<User> {
const body = await action.request.readValidatedBody();
return this.users.create(body);
}
}
The Schemas slot of a verb type carries { body, query, pathParams, headers } (each a DTO class
reference, or typeof aSchema for a plain runtime constant), { responses: { 200: X, 404: Y } },
{ scopes: ["read"] }, { cors: true } (or a CorsOptions object), and { cache: { maxAge: 60 } } (a
CacheOptions object). A path literal like "/:id" types action.request.pathParams as { id: string },
so the handler reads the id with no schema and no cast.
{ cors: true } opts a route into CORS handling — the compiler emits an extra OPTIONS row answered by
h3's handleCors before any middleware runs, plus an implicit HEAD row for every GET (plain GET
routes get that HEAD row regardless).
{ cache: { maxAge, … } } declares the same CacheOptions (maxAge/staleMaxAge/swr/vary/tags) a
contract route's Route(...).cache({...}) carries; the h3 layer emits the matching headers and
@heximon/cache's CacheMiddleware caches the response.
The handler method name is the route. Rename the method and you've renamed the route — there's no separate route list to drift from. That's the point of declaring the route on the parameter: the type and the runtime route can't disagree.
Receive the HttpAction
Every handler receives a single HttpAction<Descriptor> object, generic over the route's descriptor so
everything on it is typed per route. The request side is input-only, the response side is output-only, and
the terminal builders produce a response typed against the route's declared responses.
| Member | What it is |
|---|---|
action.request | An HttpRequest — input only: typed pathParams, validated accessors, and the full native Request surface |
action.response | An OutgoingResponse — output config only, no body: status / statusText / headers, setCookie / deleteCookie |
action.json(data) | Terminal builder → 200, returns a TypedResponse |
action.respond(status, data) | Terminal builder → a chosen status from the route's responses |
action.redirect(location, status?) | Redirect (default 302) |
action.noContent(status?) | Empty body (default 204) |
action.html(markup, init?) | HTML response |
action.scopes | The route's resolved authorization scopes (the module ∪ controller ∪ route union baked by the compiler) |
The split is enforced: the body never goes on action.response, which holds only status, headers, and
cookies — return a body through a terminal builder instead. A raw HttpResponse is intentionally not
assignable into a typed-response slot, so a "not assignable to TypedResponse" error means you built the
response by hand instead of through the action.
Read the request
Beyond the native Request members (delegated to h3), HttpRequest adds typed path
parameters and validated accessors. Each accessor throws RequestValidationError on failure — no
try/catch needed; it renders as a 400 application/problem+json through the error envelope.
// Typed path params from the route literal — no schema needed for the string shape.
const id = action.request.pathParams.id;
// Validated accessors — the schema is optional when the route declares one, required otherwise.
const body = await action.request.readValidatedBody();
const query = await action.request.getValidatedQuery();
const params = await action.request.getValidatedPathParams();
const headers = await action.request.getValidatedHeaders();
The request-scoped h3 helpers (readBody, getQuery, getRouterParams, parseCookies, getRequestIP,
and more) are also available as instance methods, so the native and validated surfaces sit on one object.
Add middleware
A Middleware is a singleton onion layer — one instance per worker, so per-request state lives in the
ambient Context, never an instance field (a singleton would leak one request's data into the next). Its
handle(action, next) either calls next() to continue the chain (and finally the handler) or returns a
Response to short-circuit.
Declaring implements Middleware makes the compiler recognize the class when a controller or module names
it, and makes TypeScript require the handle method — an empty middleware can't slip through.
(extends Middleware works the same; use it for your own base class.)
import type { HttpAction, Middleware, Next } from "@heximon/http";
export class RequestLoggerMiddleware implements Middleware {
public async handle(action: HttpAction, next: Next): Promise<Response> {
const startedAt = Date.now();
const { method } = action.request;
const { pathname } = new URL(action.request.url);
const response = await next(); // continue the chain; return a Response to short-circuit
console.log(`${method} ${pathname} -> ${response.status} (${Date.now() - startedAt}ms)`);
return response;
}
}
To apply a middleware to one controller, declare the controller with a config type argument — a
{ prefix, middlewares, errorFilters, scopes } object literal — and list it under middlewares:
import type { Controller, Get } from "@heximon/http";
import { AuthMiddleware } from "../common/auth.middleware";
import { RequestLoggerMiddleware } from "../common/request-logger.middleware";
import { type User, UsersRepository } from "./users.repository";
export class UsersController implements Controller<{
prefix: "/users";
middlewares: [AuthMiddleware, RequestLoggerMiddleware];
}> {
constructor(private readonly users: UsersRepository) {}
// Routes are still declared by the handler's parameter type — only the prefix/middlewares moved up.
public async list(_action: Get<"/">): Promise<User[]> {
return this.users.list();
}
}
To apply a middleware app-wide instead, declare it module-wide under http: { middlewares: [...] }, where it
propagates down the import graph as the broadest layer of every inherited route.
Middleware runs broad → narrow: inherited module layers first, then the controller's own innermost.
Because each middleware sees action.scopes, a module-wide guard can self-skip a route tagged public —
the opt-out is data on the route, not a separate registration.
Apply a middleware to one route
A middleware can guard a single route — the innermost onion layer, composed
module → controller → route. It is declared in the type layer the compiler reads, so it stays
server-side (middleware are classes / DI tokens and never reach the FE-safe contract). Inline routes carry a
middlewares tuple on the action type:
import type { Controller, Get } from "@heximon/http";
import { AdminGuard } from "../common/admin-guard.middleware";
import { AuthMiddleware } from "../common/auth.middleware";
export class ItemsController implements Controller<{ prefix: "/items"; middlewares: [AuthMiddleware] }> {
// AuthMiddleware (controller) runs, THEN AdminGuard (route) — outer to inner.
public async stats(_action: Get<"/stats", { middlewares: [AdminGuard] }>) { /* … */ }
// A sibling route with no `middlewares` runs only the controller + module layers.
public async list(_action: Get<"/">) { /* … */ }
}
A contract-mode handler carries the same per-route layer as an optional third argument on
Action<Api, "key", { middlewares: [...] }> — see Bind a contract.
The middlewares: [...] reference is the declaration — the compiler resolves each entry, reads its
constructor signature, and DI-constructs it; a route-level middleware needs no providers entry of its
own, only a provider for its constructor dependencies in the module's injectable scope.
Handle errors with filters
ErrorFilter lives in @heximon/http, alongside Middleware, because a filter's catch receives
the route's HttpAction. It attaches through a controller's or module's http options. A handler just
throws the domain error; the filter maps it to a response, so the happy path stays free of error-shaping
code.
import { type ErrorFilter, type HttpAction, HttpResponse } from "@heximon/http";
import { UserNotFoundError } from "./user-errors";
export class UserNotFoundErrorFilter implements ErrorFilter<UserNotFoundError> {
public catch(error: UserNotFoundError, action: HttpAction): Response {
const { pathname } = new URL(action.request.url);
return HttpResponse.json(
{
type: "https://example.com/problems/user-not-found",
status: 404,
title: "User Not Found",
detail: error.message,
instance: pathname,
userId: error.userId,
},
{ status: 404, headers: { "content-type": "application/problem+json" } },
);
}
}
The ErrorFilter<UserNotFoundError> type argument declares the error this filter catches (and its
subclasses); when a handler throws, the first filter that matches by instanceof wins. Filters run
narrow → broad (the controller's own first, then inherited reversed). A thrown RequestValidationError
maps to a 400 application/problem+json automatically; a custom ErrorFilter attached via
http: { errorFilters } still dispatches first.
Bind a contract
Inline mode is enough for a private API. The moment a second consumer shows up — a frontend, another
service, a generated OpenAPI spec — bind the controller to a shared Contract instead, by naming it as
the type argument. The contract then owns the prefix and the handler names, and each handler is typed per
route with Action<Api, "key">:
import type { Action, Controller } from "@heximon/http";
import { ProductsApi } from "./product.api";
import { ProductsRepository } from "./products.repository";
export class ProductsController implements Controller<ProductsApi> {
constructor(private readonly products: ProductsRepository) {}
public async find(action: Action<ProductsApi, "find">) {
const { sku } = await action.request.getValidatedPathParams();
return this.products.getBySku(sku);
}
}
The implements Controller<ProductsApi> clause both binds the class to the contract and makes TypeScript
check that every contract route has a matching handler — one declaration of method, path, and schemas
means the served API and its spec can never drift.
See Contracts for authoring the ProductsApi value, the config-object form
for controller-level middlewares (Controller<{ contract; middlewares }>), and the exhaustiveness/prefix
rules contract mode adds.
Wire the module
Controllers are discovered under the http namespace; their constructor dependencies — repositories, and
any middleware or error-filter classes you reference — go in providers. Both arrays are static so the
compiler reads them from source.
import { Module } from "@heximon/runtime";
import { RequestLoggerMiddleware } from "../common/request-logger.middleware";
import { UserNotFoundErrorFilter } from "./user-error.filter";
import { UsersController } from "./users.controller";
import { UsersRepository } from "./users.repository";
export class UsersModule extends Module({
providers: [UsersRepository, RequestLoggerMiddleware, UserNotFoundErrorFilter],
http: { controllers: [UsersController] },
}) {}
The controller's options say where a middleware or filter applies; listing it in providers makes it
part of the construction graph so the runtime resolves it by identity. Forget the provider and it's a build
error — the compiler reports a controller-named middleware with no provider in scope instead of letting the
request fail.
How a request reaches your handler
You never write a server entry. The heximon() Vite plugin compiles your controllers into plain JavaScript
wiring and a fetch boot, and pnpm dev serves it. From there a request flows through three things you
can observe but never hand-write:
- A route table — one
{ method, path, handler }row per route, already baking in itsContextframe, route descriptor, CORS/cache policy, middleware chain, controller resolution, and error filters. Inline and contract routes merge into this one table at boot, which is why both modes get identical semantics. - A
fetchboot entry — builds the DI graph directly (there is no runtime container) and exportsfetch(request). This is what a host loads. - The request dispatcher — on a hit it runs the middleware onion, invokes your handler on its resolved
controller, and converts the return to a
Response; a thrown error threads through the filters most-specific-first.
Each app gets its own route table, so two apps in one process stay fully isolated. In production another transport usually owns the live server while HTTP supplies the routes — WebSockets and SSE mount their upgrade routes onto the same server, and Durable Objects ride the same boot. See Hosts for choosing a host.
Test in-process
@heximon/http/testing drives a real Request through the generated fetch — the same route chain
a live host serves, with a per-request Context, no network. Overriding a provider at createApp swaps a
dependency (a recording mailer, an in-memory store) without touching the controller.
import { createTestClient } from "@heximon/http/testing";
import { createTestApp } from "@heximon/testing";
const app = await createTestApp({ root });
const client = createTestClient(app);
const response = await client.request(new Request("http://localhost/users"));
expect(response.status).toBe(200);
See also
- Contracts — author a shared
Contractwith theRoutebuilder for contract-mode controllers, then bind a server and a client to the same value. - Validation & DTOs — validate bodies, queries, params, and headers against any
Standard Schema at the boundary, with the built-in
400 problem+jsonon failure. - REST client — call the same contract from the frontend with a fully typed client.
- OpenAPI — generate an OpenAPI 3.1 document and serve Scalar / Swagger UI from a contract.
- the ladder's L01 — Minimal — an inline
controller plus a module-level middleware that reads
action.scopesto skip apublicroute. - the ladder's L02 — HTTP & validation
— inline routes with a validated body, controller-scoped middleware, and error filters that map domain
errors to
404/409problem+json. - OpenAPI + MCP — a
contract-mode controller bound to a
Contractthat also drives the generated OpenAPI document.
Modules & DI
Group a feature with Module(...), wire providers with constructor injection, share them via imports and exports, and let the editor catch a broken DI graph before you build.
Validation & DTOs
Request bodies, query, path params, and headers are validated automatically at dispatch — the handler receives typed data without calling an accessor. Declare the shape once with SchemaObject and any Standard Schema validator.