Ampulla's H3 adapter turns your DI-managed classes into H3 routes. Import from ampulla/h3.
import { Controller, Get, Post, Extract, UseMiddleware, registerControllers } from "ampulla/h3";
import { param, query, json } from "ampulla/h3";import { H3 } from "h3";
import { Container } from "ampulla";
import { registerControllers } from "ampulla/h3";
import { AppModule } from "./app.module.js";
const app = new H3();
const container = await Container.create(AppModule);
registerControllers(app, container);
export default app;registerControllers iterates the container, finds every class decorated with @Controller, and registers its routes on the H3 app. Providers without @Controller metadata are silently ignored.
@Controller(prefix) marks a class as a controller and sets the route prefix. The prefix defaults to "" (empty string, routes at root).
Each route handler is a method decorated with one of the HTTP verb decorators:
import { Injectable } from "ampulla";
import { Controller, Get, Post, Delete } from "ampulla/h3";
import type { H3Event } from "h3";
@Controller("users")
@Injectable()
class UserController {
@Get()
list(event: H3Event) {
return { users: [] };
}
@Get(":id")
getOne(event: H3Event) {
return { id: event.context.params?.id };
}
@Post()
create(event: H3Event) {
return { created: true };
}
@Delete(":id")
remove(event: H3Event) {
return { deleted: true };
}
}Path joining rules:
@Controller("users")+@Get(":id")→/users/:id- Leading slashes on method paths are tolerated:
@Get("/search")and@Get("search")both work - An empty method path registers at the controller prefix:
@Controller("users")+@Get()→/users - Both empty → root:
@Controller()+@Get()→/
Why both @Controller and @Injectable?
They are intentionally separate because they do different things. @Injectable is a DI primitive: it tells the container what dependencies to inject into the constructor. @Controller is a routing primitive: it tells registerControllers what URL prefix this class owns. Neither implies the other. See Core Concepts for the full rationale.
Decorator order matters. Class decorators run bottom-to-top in the modern JavaScript decorator spec. @Controller must appear above @Injectable in source so that it runs after — it needs to scan the prototype for routes that the HTTP verb decorators have already registered.
@Controller("users") // runs second — scans prototype for routes
@Injectable() // runs first — stores DI metadata
class UserController { ... }Available verbs: @Get, @Post, @Put, @Patch, @Delete.
By default, route handlers receive the raw H3Event. @Extract lets you declare what the handler actually needs, and the adapter extracts it before calling the handler.
import { Get, Extract, param, query } from "ampulla/h3";
@Get(":id")
@Extract(param("id"))
async getUser(id: string | undefined) {
// id is already extracted — no H3Event needed
}Pass one or more extractors — each becomes a positional argument to the handler, in order:
// Single extractor — handler receives the value directly
@Extract(query("page"))
handler(page: string | undefined) {}
// Multiple extractors — splatted as positional arguments
@Extract(param("id"), query("page"), json<CreateUserDto>())
handler(id: string | undefined, page: string | undefined, body: CreateUserDto) {}To combine multiple values into a single object argument instead, use one function extractor that builds the object itself:
@Extract(e => ({ id: e.context.params?.id, page: new URL(e.req.url).searchParams.get("page") }))
handler(params: { id: string | undefined; page: string | null }) {}| Extractor | What it returns |
|---|---|
param(name) |
URL path parameter (:name) from event.context.params |
query(name) |
Single query-string value |
queries(name) |
All values for a repeated query param |
header(name) |
Request header value |
json<T>() |
Parsed JSON body |
text() |
Plain text body |
formData() |
FormData object |
bytes() |
Request body as Uint8Array |
ctxGet<T>(key) |
event.context[key] — middleware-set value |
ctx() |
The raw H3Event (useful alongside other extractors to access it directly) |
Extractors have two chainable methods:
// .pipe — transform the extracted value
@Extract(query("page").pipe(s => parseInt(s ?? "1", 10)))
list(page: number) {}
// .valid — validate with any Standard Schema–compatible library (Zod, Valibot, ArkType, etc.)
import { z } from "zod";
@Extract(json<unknown>().valid(z.object({ name: z.string() })))
create(body: { name: string }) {}When .valid() fails, it throws a ValidationError that becomes the .cause of an ExtractionError — add H3 error handling to convert it into a 400 response.
Attach H3 middleware to a controller class or a single route method.
import { UseMiddleware } from "ampulla/h3";
import type { H3Middleware } from "h3";
const authMiddleware: H3Middleware = async (event, next) => {
const token = event.req.headers.get("Authorization");
if (!token) return Response.json({ error: "Unauthorized" }, { status: 401 });
event.context.userId = verifyToken(token);
return next();
};
// Class-level: runs before every route in this controller
@UseMiddleware(authMiddleware)
@Controller("admin")
@Injectable()
class AdminController { ... }
// Method-level: runs only before this route
@UseMiddleware(rateLimitMiddleware)
@Get("resource")
getResource(event: H3Event) { ... }Multiple @UseMiddleware decorators stack top-down — the topmost runs first. Class-level middleware runs before method-level middleware.
For middleware that itself needs dependencies from the container, implement the MiddlewareClass interface and register it as a provider:
import type { MiddlewareClass } from "ampulla/h3";
import { Injectable } from "ampulla";
@Injectable(AUTH_SERVICE)
class AuthMiddleware implements MiddlewareClass {
constructor(private readonly auth: AuthService) {}
async use(event: H3Event, next: () => Promise<void>) {
const token = event.req.headers.get("Authorization");
const user = await this.auth.verify(token);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
event.context.user = user;
return next();
}
}
@Module({
providers: [AuthService, AuthMiddleware],
})
class AuthModule {}Then reference the class (or its injection token) in @UseMiddleware:
@UseMiddleware(AuthMiddleware)
@Controller("admin")
@Injectable()
class AdminController { ... }registerControllers detects that AuthMiddleware is a registered provider and resolves it from the container instead of using it as a plain function.
Sets a fixed response header on a route. A thin wrapper around @UseMiddleware:
import { Header, Get } from "ampulla/h3";
@Header("Cache-Control", "public, max-age=3600")
@Header("X-Content-Type-Options", "nosniff")
@Get(":id")
getOne(event: H3Event) { ... }Multiple @Header decorators stack like any other middleware — all headers are set before the handler runs.
import { H3 } from "h3";
import { Injectable, Module, injection, useValue } from "ampulla";
import { Controller, Get, Post, Delete, Extract, UseMiddleware, Header } from "ampulla/h3";
import { param, json, ctxGet } from "ampulla/h3";
import { Container, registerControllers } from "ampulla";
import { z } from "zod";
// --- tokens ---
const DB = injection<Database>("DB");
// --- services ---
@Injectable(DB)
class UserService {
constructor(private readonly db: Database) {}
findAll() { return this.db.findAll("users"); }
findOne(id: string) { return this.db.findOne("users", id); }
create(data: { name: string }) { return this.db.insert("users", data); }
delete(id: string) { return this.db.delete("users", id); }
}
// --- middleware ---
const logger: H3Middleware = async (event, next) => {
console.log(`${event.req.method} ${event.req.url}`);
return next();
};
// --- controller ---
@UseMiddleware(logger)
@Controller("users")
@Injectable(UserService)
class UserController {
constructor(private readonly users: UserService) {}
@Get()
list(event: H3Event) {
return this.users.findAll();
}
@Header("Cache-Control", "no-store")
@Get(":id")
@Extract(param("id"))
getOne(id: string | undefined) {
return this.users.findOne(id ?? "");
}
@Post()
@Extract(json<unknown>().valid(z.object({ name: z.string() })))
async create(body: { name: string }) {
return this.users.create(body);
}
@Delete(":id")
@Extract(param("id"))
remove(id: string | undefined) {
return this.users.delete(id ?? "");
}
}
// --- module ---
@Module({
providers: [
useValue(DB, new InMemoryDatabase()),
UserService,
UserController,
],
})
class AppModule {}
// --- bootstrap ---
const app = new H3();
const container = await Container.create(AppModule);
registerControllers(app, container);
export default app;| Error | When it's thrown |
|---|---|
ExtractionError |
A @Extract extractor threw. Inspect .cause for the underlying error. |
ValidationError |
A .valid() schema check failed. Surfaces as .cause on ExtractionError. |
InvalidHandlerError |
A route's handler name doesn't resolve to a function on the controller instance. |