hono-forge docs

Batteries-included Hono framework built on TC39 decorators โ€” controller routing, DI, guards, validation, SSE/WebSocket, OpenAPI, scheduling, and more. Every export is documented below, grouped by what it does.

Install & scaffold

Add the package to an existing project, or let the CLI generate a runnable starter with an example controller, OpenAPI docs, health checks, and graceful shutdown already wired.

terminal
bun add hono-forge hono zod
# or
npm install hono-forge hono zod
tsconfig.json
{
  "compilerOptions": {
    "target": "ESNext"
  }
}
// TC39 Stage 3 decorators โ€” no experimentalDecorators,
// no reflect-metadata. Bun >= 1.2 required (1.3 recommended).
terminal
bunx hono-forge init my-api
cd my-api
bun install
bun run dev
# serves /hello, /health, /openapi.json and docs at /docs

Quick start

A controller groups HTTP methods and injects services through the constructor. Handlers receive the Hono Context directly โ€” route params, query, body and headers are read via typed context helpers. A plain return value is auto-serialized to JSON; a returned Response passes through untouched.

src/index.ts
import { Hono } from 'hono'
import type { Context } from 'hono'
import {
  Controller, Get, Post,
  Injectable, Body, Param,
  HonoRouteBuilder,
} from 'hono-forge'
import { z } from 'zod'

const CreateUserSchema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
})

@Injectable()
class UserService {
  getAll() { return [{ id: 1, name: 'Alice' }] }
  create(data: { name: string; email: string }) { return { id: 2, ...data } }
}

@Controller('/users')
@Injectable([UserService])
class UserController {
  constructor(private userService: UserService) {}

  @Get()
  list() { return this.userService.getAll() }

  @Post()
  async create(c: Context) {
    const body = await Body(c, CreateUserSchema)
    return this.userService.create(body)
  }

  @Get('/:id')
  getOne(c: Context) { return { id: Param(c, 'id') } }
}

const app = new Hono()
app.route('/', HonoRouteBuilder.build(UserController))
export default app

Modules & createApp

@Module groups controllers and providers โ€” imports are traversed recursively and deduplicated, and providers resolve eagerly so missing DI tokens fail at boot. createApp goes further: one call wires health probes, Prometheus metrics, OpenAPI + Scalar docs, CORS, route printing, and graceful shutdown.

src/app.module.ts
import { Module } from 'hono-forge'
import { Hono } from 'hono'
import { HonoRouteBuilder } from 'hono-forge'

@Module({ controllers: [UserController], providers: [UserService] })
class UserModule {}

@Module({ imports: [UserModule], controllers: [OrderController] })
class AppModule {}

const app = new Hono()
app.route('/', HonoRouteBuilder.buildModule(AppModule))
export default app
src/index.ts
import { createApp } from 'hono-forge'

const app = await createApp(AppModule, {
  health: { probes: true, checks: { db: () => db.ping() } }, // /health + /health/live + /health/ready
  metrics: true,                                            // GET /metrics (Prometheus)
  openapi: { info: { title: 'My API', version: '1.0.0' } }, // GET /docs + /openapi.json
  cors: true,                                               // global CORS
  printRoutes: true,                                        // route table at startup
  shutdown: { timeoutMs: 10_000 },                          // graceful SIGINT/SIGTERM
})

export default app

HonoRouteBuilder.configure

One global configuration point for cross-cutting behaviour โ€” guards, rate limiting, cache backends, logging, error format, and platform upgraders. Call once at startup before building routes.

src/bootstrap.ts
HonoRouteBuilder.configure({
  guardExecutor,      // auth decisions for guard decorators
  rateLimiterFactory, // swap @RateLimit backend (e.g. redisRateLimiter)
  cacheAdapter,       // swap @Cache/@Idempotent store (get/set/delete/deletePattern)
  webSocketUpgrader,  // upgradeWebSocket from hono/bun, cf-workers, ...
  requestLogger,      // (entry) => per-request log record
  onError,            // (err, c) => Response | void โ€” global error hook
  errorFormat,        // 'envelope' (default) | 'problem' (RFC 9457)
  exposeStack,        // false (default) | true | 'development'
  onRequestStart,     // ({ method, path, traceId, ip, userAgent }) => span hook
  trailingSlash,      // 'ignore' (default) | 'strip' | 'add'
})

Using the CLI

The CLI reads your decorators without starting the server โ€” every command takes an entry file that exports an @Module (or @Controller) class. TypeScript entries run under Bun; if bunx/npx resolves the bin to a Node shim, the CLI detects it and re-execs through Bun automatically.

terminal
bunx hono-forge <command> <entry-file> [options]

# the entry file must export an @Module or @Controller class
bunx hono-forge routes ./src/app.module.ts

# TypeScript entries need Bun at runtime. If bunx/npx resolves the
# bin to a Node shim (Windows), the CLI detects it and transparently
# re-execs through Bun when it's on PATH โ€” otherwise it prints the
# exact fallback command:
bun node_modules/hono-forge/dist/cli.js routes ./src/app.module.ts

# several exports in one file? pick one:
bunx hono-forge routes ./src/index.ts --export AppModule

# per-command flags + examples:
bunx hono-forge help doctor
bunx hono-forge doctor --help

Where it fits in your workflow โ€” inspection while you develop, a gate in CI, and codegen for specs, test flows, and typed clients. Each command's flags are listed in the cli commands reference below.

terminal
# during development โ€” inspect without booting the server
bunx hono-forge routes ./src/app.module.ts          # route table
bunx hono-forge graph  ./src/app.module.ts          # DI graph
bunx hono-forge graph  ./src/app.module.ts --format mermaid

# in CI โ€” fails the pipeline on wiring issues
bunx hono-forge doctor ./src/app.module.ts --json

# codegen โ€” commit or .gitignore the outputs, your call
bunx hono-forge openapi ./src/app.module.ts --out openapi.json
bunx hono-forge flows   ./src/app.module.ts --out ./flows
bunx hono-forge client  ./src/app.module.ts --out src/routes.gen.ts
bunx hono-forge schema  ./src/db/schema.ts --out db.schema.yaml
#                     โ†‘ also: schema.prisma | postgres://โ€ฆ | --from file

Tip: wire the commands you run often into package.json scripts.

package.json
{
  "scripts": {
    "routes": "hono-forge routes ./src/app.module.ts",
    "doctor": "hono-forge doctor ./src/app.module.ts",
    "gen:openapi": "hono-forge openapi ./src/app.module.ts --out openapi.json",
    "gen:client": "hono-forge client ./src/app.module.ts --out src/routes.gen.ts"
  }
}
// then: bun run doctor โ€” same flags work, e.g. bun run routes --json

routing

Map classes and methods to HTTP routes. Every method decorator accepts a path and optional { platform, isPrivate } options.

@Controller(path?, opts?)

Registers a class as a controller. Options: platform and version โ€” { version: 'v2' } prefixes all routes with /v2.

example.ts
@Controller('/users', { version: 'v2' })
@Injectable([UserService])
class UserController {
  constructor(private svc: UserService) {}
  // routes mount at /v2/users/...
}

@Get(path?)

Maps method to HTTP GET.

example.ts
@Get('/:id')
@Public()
getOne(c: Context) {
  return this.svc.findById(Param(c, 'id'));
}

@Post(path?)

Maps method to HTTP POST.

example.ts
@Post()
async create(c: Context) {
  const body = await Body(c, CreateSchema);
  return this.svc.create(body);
}

@Put(path?)

Maps method to HTTP PUT.

example.ts
@Put('/:id')
async replace(c: Context) {
  return this.svc.replace(
    Param(c, 'id'), await Body(c, Schema)
  );
}

@Delete(path?)

Maps method to HTTP DELETE.

example.ts
@Delete('/:id')
@RequireAuth()
remove(c: Context) {
  return this.svc.delete(Param(c, 'id'));
}

@Patch(path?)

Maps method to HTTP PATCH.

example.ts
@Patch('/:id')
async update(c: Context) {
  const body = await Body(c, UpdateSchema);
  return this.svc.update(Param(c, 'id'), body);
}

@Head(path?)

Registered as GET; Hono handles HEAD requests via fallback.

example.ts
@Head('/ping')
@Public()
ping() { return null; }

@Options(path?)

Maps method to HTTP OPTIONS.

example.ts
@Options('/cors')
@Public()
cors() { return { allow: 'GET,POST' }; }

@All(path?)

Matches all HTTP methods on the given path.

example.ts
@All('/any')
@Public()
any(c: Context) { return { method: c.req.method }; }

@Status(code)

Sets the default HTTP status for the response โ€” e.g. 201 for POST, without calling c.json(data, 201). A returned Response still wins.

example.ts
@Post()
@Status(201)
create(c: Context) {
  return this.svc.create(Body(c, Schema)); // โ†’ 201
}

@NoContent()

Returns 204 with an empty body regardless of the handler return value.

example.ts
@Delete('/:id')
@NoContent()
remove(c: Context) {
  this.svc.delete(Param(c, 'id'));
  // โ†’ 204, empty body
}

@Header(name, value)

Sets a static response header. Repeatable โ€” multiple @Header decorators merge. Bypassed by a raw new Response() return.

example.ts
@Get('/feed')
@Header('Cache-Control', 'public, max-age=60')
@Header('X-Source', 'hono-forge')
feed() { return this.repo.feed(); }

@CacheControl(opts)

Sets the Cache-Control header โ€” typed options object (maxAge, public/private, noStore, staleWhileRevalidate, immutable) or a raw directive string.

example.ts
@Get('/feed')
@CacheControl({ public: true, maxAge: 60, staleWhileRevalidate: 30 })
feed() { return this.repo.feed(); }

@Get('/private') @CacheControl('private, no-store')
mine() { /* ... */ }

@ETag(opts?)

Emits an ETag computed from the JSON body (SHA-256, truncated). Weak W/"โ€ฆ" by default; { weak: false } for strong. Matching If-None-Match short-circuits to 304.

example.ts
@Get('/article/:id') @ETag()
article(c: Context) {
  return this.repo.find(Param(c, 'id'));
}
// If-None-Match: W/"abcโ€ฆ" โ†’ 304 Not Modified

@Redirect(location, status?)

Always redirects โ€” the handler is never invoked. Status defaults to 302 (301/302/303/307/308).

example.ts
@Get('/old-users')
@Redirect('/users', 301)
oldUsers() { /* never runs */ }

modules & bootstrap

Compose controllers and providers into modules, then build the app in one call โ€” or bootstrap everything (health, docs, metrics, shutdown) with createApp.

@Module({ controllers, providers, imports })

Groups controllers and providers into a composable unit. imports are traversed recursively and deduplicated. Modules organize wiring โ€” they do not create isolated DI scopes.

example.ts
@Module({
  controllers: [UserController],
  providers: [UserService, UserRepo],
  imports: [SharedModule],
})
class UserModule {}

HonoRouteBuilder.buildModule(module)

Builds routes from a @Module class. providers are resolved eagerly so missing DI tokens fail at boot, not at request time.

example.ts
const app = new Hono();
app.route('/', HonoRouteBuilder.buildModule(AppModule));
// equivalent to building each controller in the module

HonoRouteBuilder.build(ctrl, platform?, opts?)

Builds a single controller into a Hono sub-app. Filter by platform, or { excludePrivate: true } to skip @Private routes.

example.ts
app.route('/', HonoRouteBuilder.build(UserController));

// platform filter
app.route('/', HonoRouteBuilder.build(Ctrl, 'web'));

// exclude internal routes on the public instance
HonoRouteBuilder.build(Ctrl, undefined, { excludePrivate: true });

createApp(module, opts?)

One-call async bootstrap: buildModule + optional /health probes + /docs OpenAPI + /metrics + CORS + printRoutes + gracefulShutdown, then awaits container.boot() so onInit() hooks run before the app is returned. Pass boot: false to skip (tests managing lifecycle manually), strictDi: true to forbid bare `new` on @Injectable classes. Every option optional.

example.ts
const app = await createApp(AppModule, {
  health: { probes: true, checks: { db: () => db.ping() } },
  metrics: true,
  openapi: { info: { title: 'My API', version: '1.0.0' } },
  cors: true,
  printRoutes: true,
  shutdown: { timeoutMs: 10_000 },
  // boot: false,    // skip container.boot()
  // strictDi: true, // strict DI โ€” bare new() on @Injectable throws
});
export default app;

HonoRouteBuilder.describe(target)

Returns route metadata (method, path, guards, middlewares, cache, rateLimit, sse, websocket, isPublic, isPrivate) for a controller or module โ€” without instantiating anything.

example.ts
const routes = HonoRouteBuilder.describe(AppModule);
// [{ method: 'GET', path: '/users/:id', guards: [...] }]

HonoRouteBuilder.printRoutes(target)

Prints a route table of every route โ€” method, path, handler, guards, flags. Pure metadata read; great during dev startup.

example.ts
HonoRouteBuilder.printRoutes(AppModule);
// โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
// โ”‚ method โ”‚ path         โ”‚ handler โ”‚ guards โ”‚
// โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

HonoRouteBuilder.moduleControllers(module)

Returns every controller class reachable from a @Module (imports traversed, deduplicated). Useful for OpenAPI / client codegen.

example.ts
const ctrls = HonoRouteBuilder.moduleControllers(AppModule);
OpenAPIGenerator.generate(ctrls, { info: {...} });

discoverControllers(glob)

Bun-only runtime filesystem scan โ€” imports matching files and returns every @Controller class found.

example.ts
// Bun runtime
const controllers = await discoverControllers('./src/controllers/**/*.ts');
for (const ctrl of controllers) {
  app.route('/', HonoRouteBuilder.build(ctrl));
}

fromModules(modules)

Bundler-friendly discovery โ€” pass the result of import.meta.glob({ eager: true }) and get every @Controller class back. Other exports are ignored.

example.ts
const modules = import.meta.glob('./controllers/**/*.ts', { eager: true });
const controllers = fromModules(modules);
for (const ctrl of controllers) {
  app.route('/', HonoRouteBuilder.build(ctrl));
}

dependency injection

A small DI container: classes declare deps via @Injectable tokens, scopes control lifetime, and lifecycle hooks run at boot and shutdown.

@Injectable(tokens?)

Registers a class in the DI container. Pass explicit dependency tokens โ€” array index maps to constructor parameter position. Symbol/string tokens work for external objects.

example.ts
@Injectable()           // no deps
class EmailService { send(to: string) {} }

@Injectable([EmailService])  // with deps
class UserService {
  constructor(private email: EmailService) {}
}

// Symbol token
const DB = Symbol('db');
container.registerInstance(DB, drizzle(client));

@Injectable([DB])
class UserRepo {
  constructor(private db: AppDb) {}
}

@Singleton()

Returns the same instance on every resolve().

example.ts
@Injectable()
@Singleton()
class Database {
  readonly conn = createConnection();
}
// container.resolve(Database) always returns the same instance

@RequestScoped()

Fresh instance per request, destroyed automatically when the request ends. Shared within the same request. Injecting one into a singleton wires a lazy proxy resolved per request.

example.ts
@Injectable()
@RequestScoped()
class RequestContext implements OnDestroy {
  readonly requestId = crypto.randomUUID();
  onDestroy() { console.log('request done', this.requestId); }
}

@Stateless()

No-op marker for @Singleton classes that hold no mutable per-request state โ€” documents intent and can be enforced by tooling.

example.ts
@Injectable()
@Singleton()
@Stateless()
class UserRepo {
  findAll() { return db.select().from(users); }
}

OnInit / OnDestroy

Interfaces for lifecycle hooks โ€” onInit runs when the provider is resolved/booted, onDestroy on container.shutdown() in reverse registration order.

example.ts
@Injectable()
@Singleton()
class RedisClient implements OnInit, OnDestroy {
  private client!: Redis;
  async onInit() {
    this.client = new Redis(process.env.REDIS_URL!);
    await this.client.ping();
  }
  async onDestroy() {
    await this.client.quit();
  }
}

@OnBootstrap()

Method-level startup hook โ€” runs once after buildModule() mounts every route and boots singletons. Sequential and awaited; @RequestScoped classes are rejected (doctor flags it too).

example.ts
@Injectable()
@Singleton()
class SeedService {
  @OnBootstrap()
  async seed() {
    // routes are already mounted here
    if (await db.users.count() === 0) await db.users.insert(admin);
  }
}

container.registerInstance(token, value)

Registers a pre-built external object (Drizzle, Prisma, Redis, etc.) under a symbol token. Recommended name for external deps.

example.ts
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';

export const DB = Symbol('db');
const client = postgres(process.env.DATABASE_URL!);
container.registerInstance(DB, drizzle(client));

@Injectable([DB])
class UserRepo {
  constructor(private db: AppDb) {}
}

container.registerSingleton / registerFactory

Manual registration โ€” bind a class token to an instance, or a factory invoked per resolve.

example.ts
container.registerSingleton(Database, new Database(config));
container.registerFactory(Redis, () => new Redis(process.env.REDIS_URL));

container.boot()

Calls onInit() on all registered singletons that implement OnInit. createApp() already awaits it โ€” call yourself only with manual buildModule()/build() wiring.

example.ts
await container.boot();
Bun.serve({ fetch: app.fetch, port: 3000 });

container.shutdown()

Calls onDestroy() on all singletons in reverse registration order. Call in SIGTERM/SIGINT handler.

example.ts
process.on('SIGTERM', async () => {
  await container.shutdown(); // closes DB, Redis, etc.
  process.exit(0);
});

strict DI โ€” @Injectable(deps, { strict }) / strictDi

Opt-in guard against leaving the container: bare `new` on a strict @Injectable class throws 'DI bypass'. Enable per class via @Injectable(deps, { strict: true }) or globally via createApp(AppModule, { strictDi: true }) / container.strictDi = true. container.resolve(), constructor injection, and registerFactory() internals still construct normally.

example.ts
@Injectable([EmailService], { strict: true })
class UserService {
  constructor(private email: EmailService) {}
}

new UserService(mail);          // throws DI bypass
container.resolve(UserService); // OK

// global:
const app = await createApp(AppModule, { strictDi: true });

container.construct(cls, ...args)

The official escape hatch under strict DI โ€” creates an unmanaged instance with caller-provided args (no injection, no singleton caching, no lifecycle hooks). For tests and scripts.

example.ts
// deliberate unmanaged instance โ€” deps passed manually
const svc = container.construct(UserService, fakeEmail);

container.runInScope(fn)

Runs fn in a new request scope. onDestroy is called on all scoped instances in the finally block.

example.ts
const result = await container.runInScope(async () => {
  const svc = container.resolve(RequestScopedService);
  return svc.doWork();
});

container.describe() / printGraph()

Introspects the dependency graph without resolving โ€” scope, deps, registration kind, missing deps. printGraph renders a human-readable table.

example.ts
container.describe([
  ...HonoRouteBuilder.moduleProviders(AppModule),
  ...HonoRouteBuilder.moduleControllers(AppModule),
]);

container.printGraph([...]);
//   TodoService   [singleton] โ†’ TodoRepo
//   โš  missing: 'Mailer' is not in providers

DI error classes

DependencyResolutionError โ€” thrown by resolve() when a token isn't in providers/registrations (note: a concrete class decorated with @Injectable() self-registers and never throws this). CircularDependencyError โ€” thrown when the resolution stack revisits a token mid-construction (A โ†’ B โ†’ A).

example.ts
import {
  DependencyResolutionError,
  CircularDependencyError,
} from 'hono-forge';

try {
  container.resolve(UnregisteredToken);
} catch (e) {
  if (e instanceof DependencyResolutionError) { /* โ€ฆ */ }
}

metadata introspection

Read decorator metadata for plugin/tooling authors โ€” getClassMetadata(class, key) and getMethodMetadata(target, method, key) read values stored under METADATA_KEYS.* symbols; hasDecorator(target, method, key) is a boolean check. Lower-level getClassMeta/getMethodMeta read from a class's Symbol.metadata record directly.

example.ts
import {
  getClassMetadata,
  getMethodMetadata,
  hasDecorator,
  METADATA_KEYS,
} from 'hono-forge';

const guards = getMethodMetadata<GuardMetadata[]>(
  instance, 'findAll', METADATA_KEYS.GUARDS,
);
const isController =
  getClassMetadata(ctrl, METADATA_KEYS.CONTROLLER) !== undefined;
const cached = hasDecorator(instance, 'getUser', METADATA_KEYS.CACHE);

context helpers

Typed functions for request data โ€” called inside the handler with c as the first argument. No magic parameter injection; the Hono Context stays the source of truth.

Body(c, schema?)

Reads + validates request body. With @Validate set, returns the pre-validated value (transforms/defaults applied); pass a schema for ad-hoc validation.

example.ts
@Post()
@Validate({ body: CreateSchema })
async create(c: Context) {
  const body = await Body<z.infer<typeof CreateSchema>>(c);
  return this.svc.create(body);
}

Param(c, name)

Reads a route path parameter by name. Omit name to get all params as raw strings.

example.ts
@Get('/:id')
getOne(c: Context) {
  const id = Param(c, 'id'); // string
  return this.svc.findById(id);
}

Params<T>(c)

All route params as an object โ€” the @Validate-parsed typed version when set, raw strings otherwise.

example.ts
@Get('/:id')
@Validate({ params: z.object({ id: z.string().uuid() }) })
getOne(c: Context) {
  const { id } = Params<{ id: string }>(c); // typed
  return this.svc.findById(id);
}

Query(c, schema?)

Reads query params. With @Validate set, returns the pre-validated object; pass a Zod schema for ad-hoc validation and coercion.

example.ts
const FilterSchema = z.object({
  search: z.string().optional(),
  limit: z.coerce.number().default(20),
});

@Get()
async list(c: Context) {
  const q = await Query(c, FilterSchema);
  return this.svc.getAll(q);
}

Headers(c, name?)

Reads a request header. Omit name to get all headers as an object.

example.ts
@Get()
check(c: Context) {
  const token = Headers(c, 'authorization'); // string | undefined
  const all   = Headers(c);                 // Record<string, string>
}

User<T>(c)

Returns the authenticated user object set on the context by the guard or @JwtAuth โ€” shorthand for c.get('user').

example.ts
@Get('/profile')
@RequireAuth()
profile(c: Context) {
  const user = User<{ id: string; email: string }>(c);
  return { email: user.email };
}

Req(c)

Returns the Hono HonoRequest object.

example.ts
@Get()
info(c: Context) {
  const req = Req(c);
  return { method: req.method, url: req.url };
}

Ctx(c)

Returns the full Hono Context c โ€” for redirect, set-cookie, etc. (Res() is a deprecated alias โ€” use Ctx.)

example.ts
@Get('/redirect')
redirect(c: Context) {
  return Ctx(c).redirect('/new-path', 301);
}

Ip(c)

Returns the real client IP โ€” CF-Connecting-IP โ†’ X-Real-IP โ†’ X-Forwarded-For โ†’ 'unknown'. Proxy headers are trusted unconditionally.

example.ts
@Get('/info')
info(c: Context) {
  return { ip: Ip(c) }; // '203.0.113.1'
}

Device(c)

Returns detected device type: 'mobile' | 'tablet' | 'desktop' | 'bot'.

example.ts
@Get('/page')
page(c: Context) {
  const device = Device(c);
  return device === 'mobile' ? mobileView() : desktopView();
}

UserAgent(c)

Returns the raw User-Agent header string.

example.ts
@Get('/ua')
ua(c: Context) {
  return { ua: UserAgent(c) };
}

Cookie(c, name)

Returns a single cookie value by name.

example.ts
@Get('/session')
@RequireAuth()
session(c: Context) {
  const token = Cookie(c, 'session'); // string | undefined
  return { valid: !!token };
}

Cookies(c)

Returns all cookies as Record<string, string>.

example.ts
@Get('/debug/cookies')
@Private()
debugCookies(c: Context) {
  return Cookies(c); // { session: '...', theme: 'dark' }
}

SetCookie(c, name, value, opts?)

Sets a cookie โ€” options match Hono setCookie: httpOnly, sameSite, maxAge, secure, path, domain.

example.ts
@Post('/login')
login(c: Context) {
  SetCookie(c, 'session', token, {
    httpOnly: true, sameSite: 'Strict', maxAge: 86400,
  });
  return { ok: true };
}

DeleteCookie(c, name, opts?)

Deletes a cookie by name.

example.ts
@Post('/logout')
logout(c: Context) {
  DeleteCookie(c, 'session');
  return { ok: true };
}

UploadedFile(c, field)

Returns a single File from multipart form data by field name. Returns File | null.

example.ts
@Post('/avatar')
async upload(c: Context) {
  const file = await UploadedFile(c, 'avatar');
  if (!file) return c.json({ error: 'no file' }, 400);
  return { name: file.name, size: file.size };
}

UploadedFiles(c, field?)

Returns all File objects for a field. Omit field to collect every file in the form.

example.ts
@Post('/gallery')
async gallery(c: Context) {
  const files = await UploadedFiles(c, 'photos');
  return files.map(f => ({ name: f.name, size: f.size }));
}

FormBody(c)

Returns the raw FormData object.

example.ts
@Post('/submit')
async submit(c: Context) {
  const form = await FormBody(c);
  const name = form.get('name') as string;
  return { name };
}

ValidatedBody / ValidatedQuery / ValidatedParams

Schema-first helpers โ€” return type infers as z.infer<typeof schema>, no manual generics. Cache-first: if @Validate already parsed the request, the cached value is returned; otherwise the helper parses through your schema itself (works on undecorated routes). Without a schema they degrade to the raw Body/Query/Params helpers.

example.ts
const data = await ValidatedBody(c, CreateSchema);    // z.infer<typeof CreateSchema>
const q    = await ValidatedQuery(c, FilterSchema);   // typed query object
const { id } = await ValidatedParams(c, IdSchema);    // typed route params

// no schema โ†’ raw (cache if @Validate ran)
const raw = await ValidatedBody(c);

ValidatedParam(c, name, schema)

Reads a route param and validates it through a Zod schema. Invalid input throws ZodError โ†’ 400 VALIDATION_ERROR.

example.ts
@Get('/:id')
async getOne(c: Context) {
  const id = await ValidatedParam(c, 'id', z.string().uuid());
  return this.svc.findById(id);
}

getContext()

Returns the active Hono Context from anywhere in the request call chain โ€” services, repos โ€” without prop drilling. undefined outside a request.

example.ts
import { getContext } from 'hono-forge';

@Injectable()
class AuditService {
  who() {
    const c = getContext();
    return c?.get('user'); // no need to pass c around
  }
}

getRequestContext()

Returns the full request scope context ({ traceId, hono, memoCache, diScope }) โ€” the lower-level API behind getContext(). runInRequestContext(ctx, fn) runs code inside a context; createRequestContext(traceId, hono?) builds one โ€” HonoRouteBuilder calls these once per request, so you only need them when invoking handlers outside the route builder.

example.ts
const ctx = getRequestContext();
if (ctx) {
  ctx.memoCache.set('flags', flags); // request-scoped cache
}

// run a handler outside the route builder (e.g. WS callback):
runInRequestContext(
  createRequestContext(getTraceId() ?? crypto.randomUUID(), c),
  () => handler(c),
);

extractIp / detectDevice / extractUserAgent / extractRequestContext

The raw extractor utilities behind Ip/Device/UserAgent โ€” usable outside handlers (middleware, loggers). extractIp honors CF-Connecting-IP โ†’ X-Real-IP โ†’ X-Forwarded-For; detectDevice classifies a UA string into 'mobile' | 'tablet' | 'desktop' | 'bot'; extractRequestContext(c) returns all three in one call ({ ip, device, userAgent }).

example.ts
const log = requestLogger((entry) => sink.write(entry));

// inside custom middleware:
const { ip, device, userAgent } = extractRequestContext(c);
// or individually:
const ip  = extractIp(c);
const dev = detectDevice(c.req.header('user-agent') ?? '');

validation & serialization

One Zod schema drives request validation AND the generated OpenAPI spec โ€” docs can't drift from validation.

@Validate({ body?, query?, params? })

Validates body/query/params before the handler runs โ€” invalid input returns 400 VALIDATION_ERROR with Zod issues. The same schemas are emitted into the OpenAPI spec, so no @ApiBody duplication.

example.ts
@Post()
@Validate({ body: CreateUserSchema })
async create(c: Context) {
  const body = await Body<z.infer<typeof CreateUserSchema>>(c);
  // already validated โ€” defaults/transforms applied
  return this.svc.create(body);
}

@Get('/:id')
@Validate({
  params: z.object({ id: z.string().uuid() }),
  query:  z.object({ verbose: z.coerce.boolean().default(false) }),
})
async getOne(c: Context) { /* ... */ }

@ValidateResult(schema)

Validates the return value against a Zod schema. Throws ZodError if the result doesn't match โ€” enforce response contracts at service boundaries.

example.ts
const UserSchema = z.object({ id: z.string(), email: z.string().email() });

@ValidateResult(UserSchema)
async getUser(id: string) {
  return this.repo.findById(id); // throws if shape is wrong
}

@Serialize(schema)

Runs the handler's return through a Zod schema before sending โ€” strip sensitive fields with .omit()/.pick() or coerce output shape. Validation errors become 400.

example.ts
const UserView = z.object({ id: z.string(), name: z.string() });

@Get('/me')
@Serialize(UserView)
getUser() {
  return { id: '1', name: 'Ada', passwordHash: '...' };
  // response contains only { id, name }
}

@Transform(fn)

Applies a transform function to the return value before sending.

example.ts
@Transform((user: User) => ({
  id: user.id,
  name: user.name,
  // strips sensitive fields like password
}))
async getUser(c: Context) {
  return this.repo.findById(Param(c, 'id'));
}

defineSchemas(select, insert, opts?)

Generates a consistent { select, insert, update } schema set from any two Zod schemas โ€” works with drizzle-zod, zod-prisma, or hand-written schemas. update defaults to insert.partial().

example.ts
import { createSelectSchema, createInsertSchema } from 'drizzle-zod';

export const UserSchemas = defineSchemas(
  createSelectSchema(users),  // full row
  createInsertSchema(users),  // insert fields
);
// .select โ†’ GET responses
// .insert โ†’ POST body
// .update โ†’ PATCH body (all optional)

// custom update rules:
defineSchemas(select, insert, {
  update: insert.omit({ email: true }).partial(),
});

auth & guards

Guard decorators attach named guards to a route; your guardExecutor (configured once) decides what each guard enforces. build() throws at startup if guarded routes have no executor.

guardExecutor config

The pluggable auth hook โ€” configure once via HonoRouteBuilder.configure. Throw HttpException for precise status codes; "Unauthorized" in message โ†’ 401, "Forbidden" โ†’ 403, return false โ†’ 403.

example.ts
HonoRouteBuilder.configure({
  guardExecutor: async (c, guards) => {
    for (const guard of guards) {
      if (guard.name === 'AuthGuard') {
        const token = c.req.header('authorization')?.split(' ')[1];
        if (!token) throw HttpException.unauthorized('No token');
        c.set('user', verifyJwt(token));
      }
      if (guard.name === 'RoleGuard') {
        const user = c.get('user') as { roles: string[] };
        const ok = guard.options?.roles?.some(r => user.roles.includes(r));
        if (!ok) throw HttpException.forbidden('Insufficient role');
      }
    }
    return true;
  },
});

@RequireAuth()

Attaches AuthGuard โ€” your guardExecutor validates the token and sets the user via c.set('user'). Read it back with User(c).

example.ts
@Get('/me')
@RequireAuth()
async me(c: Context) {
  const user = User<MyUser>(c);
  return { id: user.id, email: user.email };
}

@RequireRole(...roles)

Requires user to have ONE of the specified roles.

example.ts
@Delete('/users/:id')
@RequireRole('admin', 'moderator')
remove(c: Context) {
  return this.svc.delete(Param(c, 'id'));
}

@RequireAllRoles(...roles)

Requires user to have ALL specified roles.

example.ts
@Post('/promote')
@RequireAllRoles('admin', 'superuser')
promote(c: Context) { /* ... */ }

@RequirePermission(...perms)

Requires ALL listed permissions.

example.ts
@Post('/publish')
@RequirePermission('posts:write', 'posts:publish')
publish(c: Context) { /* ... */ }

@RequireAnyPermission(...perms)

Requires at least ONE of the listed permissions.

example.ts
@Get('/reports')
@RequireAnyPermission('reports:read', 'admin:all')
reports(c: Context) { /* ... */ }

@Public()

Marks route as public, bypassing all auth guards.

example.ts
@Get('/health')
@Public()
health() { return { status: 'ok' }; }

@Private()

Marks route as internal-only โ€” still registered, but excluded when build() is called with { excludePrivate: true }.

example.ts
@Get('/internal/metrics')
@Private()
metrics() { return process.memoryUsage(); }

// Exclude on public app:
HonoRouteBuilder.build(Ctrl, undefined, { excludePrivate: true });

@JwtAuth(opts)

Turnkey JWT auth โ€” verifies the Bearer token with hono/jwt (HS256 default; alg, cookie options available) and stores the payload for User(c). Reflected in OpenAPI as bearerAuth + 401/403 responses, and the flow generator detects it the same way โ€” protected routes get Authorization: Bearer {{token}} (or ?token={{token}} on generated WS nodes). Invalid/missing token โ†’ 401.

example.ts
@Controller('/api')
class MeController {
  @Get('/me')
  @JwtAuth({ secret: process.env.JWT_SECRET! })
  me(c: Context) {
    return User(c); // decoded JWT payload
  }
}

@UseGuards(...guards)

Attaches custom guard classes by constructor โ€” the executor receives them like built-in guards.

example.ts
@Get('/billing')
@UseGuards(SubscriptionGuard)
usage(c: Context) { /* ... */ }

middleware

Attach any Hono middleware at class level (every route) or method level (one route). Built-in decorators wrap Hono's own middleware.

execution order

The request pipeline runs class-level @Middleware first, then method-level, then rate limiting (@RateLimit/@Concurrency), guards (@JwtAuth/@RequireRole/โ€ฆ), and validation (@Validate). @Idempotent wraps the handler โ€” replay/dedupe happens around execution. @Throttle sits inside the method body (it throws when the handler is invoked too soon). On the way out, handler output passes @Serialize/@Transform, then interceptors, then the response.

example.ts
// request โ†’ class @Middleware โ†’ method @Middleware
//        โ†’ @RateLimit / @Concurrency โ†’ guards โ†’ @Validate
//        โ†’ @Idempotent (wraps) โ†’ handler
//        โ†’ @Serialize / @Transform โ†’ interceptors โ†’ response

// so a method-level middleware still runs BEFORE @JwtAuth โ€”
// put auth at class level, or verify inside the middleware itself

@Middleware(...fns)

Attaches Hono middleware at class level (all routes) or method level (one route). Multiple middleware run in order.

example.ts
const logMw = async (c: Context, next: Next) => {
  console.log(c.req.method, c.req.path);
  await next();
};

@Controller('/api')
@Middleware(logMw)          // class-level
class ApiController {
  @Get()
  @Middleware(tracingMw)    // method-level
  list() { /* ... */ }
}

@Use(...fns)

Alias for @Middleware โ€” familiar for NestJS/Express users.

example.ts
@Get()
@Use(authMw, auditMw)
list() { return this.svc.getAll(); }

MiddlewareClass

Interface for reusable stateful middleware โ€” implement use(c, next) and pass the class to @Use/@Middleware.

example.ts
class AuthMiddleware implements MiddlewareClass {
  async use(c: Context, next: Next) {
    if (!c.req.header('authorization'))
      return c.json({ error: 'Unauthorized' }, 401);
    await next();
  }
}

@Controller('/admin')
@Use(AuthMiddleware)
class AdminController { /* ... */ }

@Cors(opts?)

Applies CORS headers. Options match hono/cors โ€” origin, allowMethods, maxAge, etc. Class or method level.

example.ts
@Controller('/api')
@Cors({ origin: 'https://example.com' })
class ApiController { /* ... */ }

@Get('/public')
@Cors({ origin: '*' })
list() { /* ... */ }

@Compress(opts?)

Applies gzip/deflate response compression. Works at class or method level.

example.ts
@Controller('/api')
@Compress()
class ApiController { /* ... */ }

@SecureHeaders(opts?)

Adds security headers: CSP, HSTS, X-Frame-Options, X-Content-Type-Options, etc.

example.ts
@Controller('/api')
@SecureHeaders()
class ApiController { /* ... */ }

@PrettyJson(opts?)

Pretty-prints JSON responses when ?pretty query param is present.

example.ts
@Controller('/debug')
@PrettyJson()
class DebugController { /* ... */ }
// GET /debug?pretty โ†’ formatted JSON

interceptors & caching

Wrap handler execution โ€” transform the result, cache responses, measure metrics, or run inside a transaction.

@Cache(opts)

Caches the handler return value in the configured CacheAdapter โ€” in-memory by default, swappable for Redis/Postgres. Keyed by key (default: method name) + request path + query. ttl (ms) required; adapter picks a named cacheAdapters entry. Cached hits skip the handler entirely.

example.ts
@Get('/config')
@Cache({ ttl: 60_000, key: 'app-config' })
async getConfig() {
  return this.repo.getConfig(); // runs once per ttl
}

@CacheInvalidate(...patterns)

Deletes cache entries whose key starts with each pattern after the handler succeeds.

example.ts
@Put('/config')
@CacheInvalidate('app-config')
async updateConfig(c: Context) {
  return this.repo.setConfig(await Body(c, Schema));
}

cacheAdapter config

Swap the in-memory cache (InMemoryCacheAdapter, also exposed as defaultCacheAdapter) for a durable backend โ€” ship RedisCacheAdapter (ioredis-shaped client) or PostgresCacheAdapter (driver-free sql fn + one table, strongest for @Idempotent). Named cacheAdapters enable per-decorator picks: @Cache({ adapter: 'redis' }) vs @Idempotent({ adapter: 'pg' }).

example.ts
HonoRouteBuilder.configure({
  cacheAdapter: new RedisCacheAdapter(redis),
  cacheAdapters: {
    redis: new RedisCacheAdapter(redis),
    pg: new PostgresCacheAdapter(
      (sql, params) => pool.query(sql, params),
    ),
  },
});
// @Cache({ adapter: 'redis' })   @Idempotent({ adapter: 'pg' })

@Memoize(opts?)

Caches the return value in memory keyed by serialized arguments. scope: 'global' (default, shared across requests) or 'request' (isolated per request, via the request context). Optional ttl. Calling a request-scoped @Memoize method outside a request falls back to the global cache โ€” wrap the call in runWithMemoScope(fn) to give it a scope.

example.ts
// Global cache โ€” same result for all requests
@Memoize({ ttl: 60_000 })
async getFeatureFlags() {
  return this.db.getFlags();
}

// Request-scoped โ€” isolated per request
@Memoize({ scope: 'request' })
async getCurrentUser(id: string) {
  return this.db.findUser(id);
}

@TrackMetrics(opts)

Records method duration into the shared Prometheus registry as hono_forge_method_duration_seconds{method,status}. Options: name.

example.ts
@TrackMetrics({ name: 'user.create' })
async create(data: CreateDto) {
  return this.repo.insert(data);
}

@Audit(opts)

Logs an audit entry (action, userId, timestamp, method name) through the registered LOGGER before the method runs โ€” falls back to ConsoleLogger. Options: action.

example.ts
@Audit({ action: 'user.delete' })
async remove(id: string) {
  return this.repo.delete(id);
}

@Transaction(exec?)

Wraps the method in this.db.transaction() โ€” Drizzle/Knex/TypeORM compatible by default. Pass a custom TransactionExecutor for Prisma or Kysely. The tx propagates via AsyncLocalStorage: nested repos call useTransaction() to join it.

example.ts
// Drizzle / Knex โ€” default executor
@Transaction()
async transfer(from: string, to: string, amount: number) {
  await this.repo.debit(from, amount);
  await this.repo.credit(to, amount);
}

// Prisma โ€” custom executor
@Transaction((db: PrismaClient, run) => db.$transaction(run))
async onboard(data: NewUser) { /* ... */ }

// in any nested repo โ€” joins the active tx:
const tx = useTransaction<DrizzleDb>() ?? this.db;

rate limiting & resilience

Protect routes and services: per-route rate limits, request dedup, bulkheads, retries, timeouts, and circuit breakers.

@RateLimit(opts)

Rate-limits the route โ€” built-in in-memory fixed-window limiter works out of the box, keyed by client IP (or keyGenerator). Emits X-RateLimit-Limit/Remaining/Reset headers; exceeding max returns 429 + Retry-After. Options: max, windowMs, message, keyGenerator.

example.ts
@Post('/login')
@RateLimit({ max: 5, windowMs: 60_000, message: 'Too many attempts' })
async login(c: Context) {
  const body = await Body(c, LoginSchema);
  return this.authSvc.login(body);
}

redisRateLimiter(client)

Distributed @RateLimit via Redis (INCR + PEXPIRE fixed window) โ€” inMemoryRateLimiter is the default factory and is per-process, so multi-instance deploys need this. Client is injected (ioredis-compatible, or anything exposing incr/pexpire/pttl). Factories receive RateLimiterFactoryOptions: { max, windowMs, keyPrefix, keyGenerator?, message? }.

example.ts
import Redis from 'ioredis';
import { redisRateLimiter } from 'hono-forge';

HonoRouteBuilder.configure({
  rateLimiterFactory: redisRateLimiter(new Redis()),
});

// or roll your own factory:
rateLimiterFactory: ({ max, windowMs, keyPrefix }) => middleware

@Throttle(ms)

Prevents the method from being called more than once per ms window. Throws HttpException 429 (with retryAfterMs in meta + Retry-After header). Keyed on the instance โ€” on a singleton it applies globally across all users.

example.ts
@Throttle(10_000) // max once per 10s per instance
async generateReport() {
  return this.reportSvc.generate();
}

@Concurrency(n)

Per-route bulkhead โ€” caps simultaneous executions; extra requests get a fast 503 instead of queueing unbounded work. Slots release in finally โ€” exceptions never leak capacity.

example.ts
@Get('/export')
@Concurrency(4)
async exportAll() {
  // 5th concurrent caller โ†’ 503 until a slot frees
  return buildHugeCsv();
}

@Idempotent(opts?)

Replays the stored response when a client retries with the same Idempotency-Key header โ€” including concurrent duplicates (handler runs once). Same key + different request body โ†’ 409 Conflict (Stripe-style body-hash check, on by default โ€” hash: false to opt out). scope namespaces keys per tenant/user. Only successful responses are stored; ttl defaults to 24h. Uses the configured CacheAdapter โ€” default in-memory does NOT survive restarts; use adapter: 'name' with a named cacheAdapters entry (e.g. PostgresCacheAdapter) in production.

example.ts
@Post('/orders')
@Idempotent({
  scope: (c) => User(c)?.tenantId ?? 'anon', // per-tenant namespace
  ttl: 60_000,
})
async createOrder(c: Context) {
  // client sends: Idempotency-Key: order-req-123
  // retry same key + same body โ†’ same response, no double charge
  // retry same key + different body โ†’ 409 Conflict
  return this.svc.charge(await Body(c, OrderSchema));
}

@SingleFlight()

Coalesces concurrent identical requests (same method+path+query) โ€” the handler executes once and every caller gets a copy of that response. For client-driven retries on mutations, use @Idempotent instead.

example.ts
@Get('/expensive-report')
@SingleFlight()
async report() {
  // 100 concurrent callers โ†’ 1 execution
  return this.svc.heavyReport();
}

@Retry(opts)

Retries the method on failure. Options: attempts, delay, backoff (linear | exponential).

example.ts
@Retry({ attempts: 3, delay: 500, backoff: 'exponential' })
async fetchExternalData() {
  return await fetch('https://api.example.com/data');
}

@Timeout(ms)

Throws HttpException 504 GATEWAY_TIMEOUT if the method takes longer than ms milliseconds โ€” also documented in the generated OpenAPI spec.

example.ts
@Timeout(5000)
async slowOperation() {
  return await db.heavyQuery(); // โ†’ 504 if > 5s
}

@CircuitBreaker(opts)

Stops calling a failing method after failureThreshold consecutive errors โ€” fast-fails with CircuitOpenError (503) until resetAfterMs passes, then probes with a half-open call. State is per instance+method.

example.ts
@Injectable()
class PaymentGateway {
  @CircuitBreaker({ failureThreshold: 5, resetAfterMs: 30_000 })
  async charge(amount: number) {
    return this.http.post('/charge', { amount });
  }
}

error handling

Structured errors: HttpException factories, per-type exception filters through the DI container, an onError hook, and an opt-in RFC 9457 problem+json format.

HttpException

Structured HTTP error with status, code, message, and optional meta payload โ€” auto-handled by the route builder. Factories: badRequest 400, unauthorized 401, forbidden 403, notFound 404, conflict 409, unprocessable 422, tooManyRequests 429, internal 500, serviceUnavailable 503.

example.ts
// Throw from handler or service โ€” auto-formatted:
throw HttpException.notFound('User not found');
// โ†’ 404 { "status": "error", "error": { "code": "NOT_FOUND", ... } }

// With meta payload:
throw HttpException.unprocessable('Validation failed', {
  fields: ['email', 'name']
});

@Catch(...errorTypes)

Marks a class as an exception filter โ€” no args = catch-all. The class implements catch(err, c) and is resolved through the DI container, so it can inject services.

example.ts
@Catch(PrismaClientKnownError)
@Injectable()
class DbErrorFilter implements ExceptionFilter {
  catch(err: unknown, c: Context) {
    return c.json({ status: 'error', error: { code: 'DB_ERROR' } }, 500);
  }
}

@UseFilters(...filters)

Attaches exception filters at method or class level. Order: method filters โ†’ class filters โ†’ onError โ†’ default HttpException handling. A filter returning void falls through.

example.ts
@Controller('/users')
@UseFilters(DbErrorFilter)          // class-level
class UserController {
  @Post()
  @UseFilters(UploadErrorFilter)    // method-level runs first
  create(c: Context) { /* ... */ }
}

onError hook

Global hook called for every non-validation error (including HttpException). Return a Response to override; return void to fall through to default handling. ZodError always returns 400 and bypasses this hook.

example.ts
HonoRouteBuilder.configure({
  onError: async (err, c) => {
    if (err instanceof HttpException) {
      await db.insert(errorLogs).values({ code: err.code });
      // return nothing โ†’ default structured JSON still sent
    } else {
      return c.json({ error: { code: 'INTERNAL_SERVER_ERROR' } }, 500);
    }
  },
});

errorFormat: 'problem'

Opt-in RFC 9457 application/problem+json error shape instead of the default envelope. Validation failures become a 400 problem with Zod issues under errors; headers set before the throw (e.g. Retry-After) are preserved. problemResponse(c, err, exposeStack) renders the same shape โ€” reuse it inside a custom onError; returns null for non-HttpException/non-Zod errors.

example.ts
HonoRouteBuilder.configure({ errorFormat: 'problem' });

// โ†’ 404 application/problem+json
{
  "type": "about:blank",
  "title": "Not Found",
  "status": 404,
  "detail": "User 42 not found",
  "instance": "/users/42",
  "code": "USER_NOT_FOUND"
}

exposeStack option

Controls stack trace exposure in HttpException responses. Values: false (default, production-safe) | true | "development" (NODE_ENV !== 'production').

example.ts
HonoRouteBuilder.configure({
  exposeStack: 'development',
});

real-time: sse, websocket & channels

Server-Sent Events, WebSockets, and a shared pub/sub channel registry โ€” in-memory by default, pluggable to Redis for multi-instance deployments.

@Sse(path?, opts?)

Registers a GET endpoint for SSE. Handler receives (c: Context, stream: SSEStreamingApi). Pass { keepAliveMs } to send :keepalive comments on an interval โ€” keeps proxies from closing idle streams.

example.ts
@Sse('/feed', { keepAliveMs: 15_000 })
@Public()
async feed(c: Context, stream: SSEStreamingApi) {
  await stream.writeSSE({ event: 'connected', data: 'ok' });
  while (!stream.closed) {
    await stream.sleep(30_000);
    await stream.writeSSE({ event: 'ping', data: '' });
  }
}

@WebSocket(path?)

Registers a WebSocket upgrade endpoint โ€” configure a platform upgrader once (e.g. hono/bun). Handler receives (c: Context) and returns { onOpen, onMessage, onClose }.

example.ts
HonoRouteBuilder.configure({
  webSocketUpgrader: upgradeWebSocket, // from 'hono/bun'
});

@WebSocket('/:room')
@Public()
chat(c: Context) {
  const room = Param(c, 'room');
  return {
    onOpen: (_e, ws) => console.log('connected to', room),
    onMessage: (e, ws) => ws.send(`Echo: ${e.data}`),
    onClose: () => console.log('disconnected'),
  };
}

@OnWsMessage(event)

Routes incoming WS frames to a sibling method by event name โ€” JSON envelope { "event", "data" } dispatches to (data, ws, rawEvent). Non-envelope frames and unmatched events fall through to onMessage.

example.ts
@WebSocket('/room') @Public()
room() { return { onMessage: (e, ws) => ws.send('unhandled') }; }

@OnWsMessage('join')
onJoin(data: { user: string }, ws) { /* ... */ }

@OnWsMessage('message')
onChat(data: { text: string }, ws) { ws.send(data.text); }

@ChannelRoute(channel)

Bridges a pub/sub channel to an @Sse or @WebSocket route โ€” subscribes a client on connect, unsubscribes on disconnect, pushes every published event to the stream/socket. Static string or fn of Context.

example.ts
@Sse('/user/:userId')
@ChannelRoute((c) => `user:${Param(c, 'userId')}`)
async userFeed(c: Context, stream: SSEStreamingApi) {
  // subscription + hold-open handled automatically
}

await channels.publish('user:42', 'order.created', data);

@ChannelAuth(authorize)

Authorizes the resolved channel before the SSE/WS upgrade completes โ€” runs as middleware, receives (c, channel), returns false โ†’ 403. Pair with token verification at upgrade (e.g. hono/jwt verify of ?token= or Authorization) for the standard authenticated-channel recipe. Flow generation marks these routes too โ€” generated WS nodes get ?token={{token}} on the connection URL.

example.ts
import { verify } from 'hono/jwt';

// 1. authenticate at upgrade โ€” method middleware
const wsAuth = async (c: Context, next: Next) => {
  const token = c.req.query('token');
  if (!token) return c.json({ error: 'Unauthorized' }, 401);
  c.set('user', await verify(token, JWT_SECRET));
  await next();
};

// 2. authorize the resolved channel
@WebSocket('/outlet/:outletId/kds/:station')
@Middleware(wsAuth)
@ChannelRoute((c) =>
  `outlet:${Param(c, 'outletId')}:kds:${Param(c, 'station')}`)
@ChannelAuth((c, channel) =>
  User(c)?.outletIds?.includes(Param(c, 'outletId')) ?? false)
kds(c: Context) { /* ... */ }

channels.subscribe / unsubscribe / publish

The shared channel registry API โ€” add/remove clients and broadcast event+data payloads to all subscribers of a channel.

example.ts
const client = new SseChannelClient(userId, stream);
await channels.subscribe(`user:${userId}`, client);
stream.onAbort(() =>
  channels.unsubscribe(`user:${userId}`, userId)
);

// Push from anywhere โ€” service, cron, webhook handler
await channels.publish('user:42', 'order.created', { orderId: 123 });

SseChannelClient / WsChannelClient

Client adapters wrapping an SSE stream or WebSocket so the channel registry can push to them. WsChannelClient takes the ws instance + an id.

example.ts
// WebSocket + channels manually:
onOpen: (_e, ws) =>
  channels.subscribe(`room:${room}`, new WsChannelClient(ws.id, ws)),
onClose: (_e, ws) =>
  channels.unsubscribe(`room:${room}`, ws.id),

channels.use(adapter)

Swaps the channel adapter at startup โ€” default is InMemoryChannelAdapter. Use RedisChannelAdapter for multi-instance broadcasting.

example.ts
import { RedisChannelAdapter } from 'hono-forge';
import Redis from 'ioredis';

channels.use(new RedisChannelAdapter(
  new Redis(process.env.REDIS_URL), // pub
  new Redis(process.env.REDIS_URL), // sub
));

events & scheduling

In-process pub/sub between services, plus scheduled jobs โ€” interval timers and a zero-dependency 5-field cron parser.

@OnEvent(event)

Subscribes a service method to an in-process event. Repeatable; supports wildcards like "user.*". Listeners resolve through the DI container per emit โ€” keep them singleton/transient, not @RequestScoped.

example.ts
@Injectable()
class MailService {
  @OnEvent('user.created')
  async welcome(p: { id: string }) {
    await this.send(p.id);
  }

  @OnEvent('user.*') // wildcard
  auditUserEvents(payload: unknown) { /* ... */ }
}

events.emit(event, data)

Emits an event to all matching @OnEvent listeners. Each listener is isolated โ€” a throwing one is logged via LOGGER, others still run; emit never throws.

example.ts
@Post()
async create(c: Context) {
  const user = await this.svc.create(await Body(c));
  await events.emit('user.created', { id: user.id });
  return user;
}

startEventBus(...targets)

Scans classes or @Module trees for @OnEvent methods and subscribes them. Returns a teardown function โ€” wire it into gracefulShutdown.

example.ts
const stop = startEventBus(AppModule);

gracefulShutdown({
  onShutdown: async () => { stop(); stopJobs(); },
});

@Interval(ms, opts?)

Runs a service method every ms milliseconds once startScheduler() activates it. Overlapping ticks are skipped; { immediate: true } runs once at start. Timers are unref'd.

example.ts
@Injectable()
@Singleton()
class Janitor {
  @Interval(60_000)
  sweep() { /* cleanup expired tokens */ }

  @Interval(30_000, { immediate: true })
  warmCache() { /* ... */ }
}

@Cron(expr, opts?)

Schedules a method on a standard 5-field cron expression (min hour dom mon dow, local time). Supports *, */n, ranges, lists โ€” Vixie OR semantics when dom+dow are both restricted. Invalid expressions are logged and skipped.

example.ts
@Cron('0 2 * * *')         // daily 02:00
nightlyBackup() { /* ... */ }

@Cron('*/15 9-17 * * 1-5')   // every 15 min, work hours, weekdays
pollFeed() { /* ... */ }

startScheduler(...targets)

Activates all @Interval and @Cron methods on classes or @Module trees. Job errors are logged via LOGGER; the loop never crashes. Returns a teardown.

example.ts
const stopJobs = startScheduler(AppModule);

gracefulShutdown({
  onShutdown: async () => stopJobs(),
});

parseCron / nextCronTime

The exported cron primitives โ€” parse an expression once, compute the next fire time.

example.ts
const parsed = parseCron('0 2 * * *');
const next = nextCronTime(parsed, new Date());

observability & config

Trace IDs on every request, pluggable logging and metrics, and Zod-validated typed config bound to the DI container.

getTraceId()

Returns the active trace/correlation ID โ€” from the X-Request-ID header or a generated UUID, echoed back on the response. Callable anywhere in the call chain.

example.ts
import { getTraceId } from 'hono-forge';

@Injectable()
class AuditService {
  log(action: string) {
    console.log({ traceId: getTraceId(), action });
  }
}

runWithTraceId(id, fn)

Runs fn within a trace context โ€” for code outside the route builder (cron, workers).

example.ts
await runWithTraceId('cron-job-001', async () => {
  // getTraceId() returns 'cron-job-001' inside here
  await myService.runDailyReport();
});

onRequestStart hook

Called before middleware and guards on every request. Receives { method, path, traceId, ip, userAgent } โ€” use it to start an OpenTelemetry span.

example.ts
HonoRouteBuilder.configure({
  onRequestStart: ({ method, path, traceId }) => {
    const span = tracer.startSpan(`${method} ${path}`, {
      attributes: { traceId },
    });
  },
});

requestLogger hook

Pluggable per-request logger โ€” receives { method, path, ip, device, userAgent, statusCode, durationMs, userId? } after each response.

example.ts
HonoRouteBuilder.configure({
  requestLogger: (entry) => {
    console.log(JSON.stringify(entry));
  },
});

registerLogger(logger) / LOGGER

Binds a pino/winston-compatible logger ({ info(data, msg?) }) to the LOGGER DI token โ€” used by @Audit, the scheduler, and the event bus. Injectable anywhere. getLogger() resolves the registered logger with a ConsoleLogger fallback when nothing is bound.

example.ts
import { registerLogger, LOGGER } from 'hono-forge';

registerLogger(pinoInstance);

@Injectable([LOGGER])
class AuditService {
  constructor(private log: Logger) {}
}

registerConfig(schema)

Parses process.env through a Zod schema once at boot โ€” fails fast listing every invalid field. Returns the typed config and binds it to the CONFIG token.

example.ts
const EnvSchema = z.object({
  PORT: z.coerce.number().default(3000),
  DATABASE_URL: z.string().url(),
  JWT_SECRET: z.string().min(16),
});
export const env = registerConfig(EnvSchema); // typed

CONFIG token / getConfig()

Access the registered config โ€” inject via @Injectable([CONFIG]) or call getConfig<T>() anywhere outside DI.

example.ts
@Injectable([CONFIG])
class DbService {
  constructor(private env: z.infer<typeof EnvSchema>) {}
}

// outside DI:
const cfg = getConfig<z.infer<typeof EnvSchema>>();

mountMetrics(app, opts?)

Mounts GET /metrics in Prometheus text format โ€” http_requests_total + http_request_duration_seconds histogram labeled by method/route/status. Mount BEFORE building routes; the metrics path is never self-counted.

example.ts
const app = new Hono();
mountMetrics(app); // before buildModule

// or: createApp(AppModule, { metrics: true })
// GET /metrics โ†’ scrape-ready exposition text

getMetricsRegistry()

Returns the shared MetricsRegistry โ€” read counters/histograms programmatically (custom exporters, tests, in-process dashboards) without scraping /metrics.

example.ts
const reg = getMetricsRegistry();
reg.counter('jobs_processed').inc();
// or snapshot for a test assertion
expect(reg.counter('http_requests_total').get()).toBeGreaterThan(0);

openapi documentation

Auto-generate an OpenAPI 3.1 spec from your decorators and serve Scalar docs โ€” auth, validation, and path params are reflected automatically. Schemas are converted with the bundled zodToJsonSchema โ€” also exported if you need a standalone Zod โ†’ JSON Schema pass.

OpenAPIGenerator.generate()

Generates a full OpenAPI 3.1 spec from your controller classes.

example.ts
const spec = OpenAPIGenerator.generate(
  [UserController, OrderController],
  {
    info: { title: 'My API', version: '2.0.0' },
    servers: [{ url: 'https://api.example.com' }],
  }
);

OpenAPIGenerator.mount()

Serves /openapi.json and Scalar UI at /docs. Accepts a generated spec OR a controller array โ€” generates and mounts in one call.

example.ts
const app = new Hono();
app.route('/', HonoRouteBuilder.build(UserController));

// One-call overload โ€” generates the spec itself:
OpenAPIGenerator.mount(app, [UserController], {
  info: { title: 'API', version: '1.0.0' },
});
// โ†’ GET /openapi.json  (raw spec)
// โ†’ GET /docs          (Scalar UI)

@ApiTags(...tags)

Groups controller endpoints under named tags in the OpenAPI spec.

example.ts
@Controller('/users')
@ApiTags('Users')
class UserController {
  @Get()
  @ApiTags('list')  // method-level tag
  list() { /* ... */ }
}

@ApiDoc(opts)

Documents an endpoint. Options: summary, description.

example.ts
@Get('/:id')
@ApiDoc({
  summary: 'Get user by ID',
  description: 'Returns a single user. Throws 404 if not found.',
})
getOne(c: Context) { /* ... */ }

@ApiResponse(status, msg)

Describes a possible HTTP response for this endpoint โ€” repeatable. Pass { schema } to attach a response body schema.

example.ts
@Post()
@ApiResponse(201, 'User created successfully')
@ApiResponse(409, 'Email already taken')
async create(c: Context) { /* ... */ }

@ApiBody(schema, opts?)

Documents the JSON request body in the spec. required is auto-inferred โ€” schemas with .optional()/.default() are optional.

example.ts
@Post()
@ApiBody(CreateUserSchema, { description: 'New user payload' })
async create(c: Context) {
  return this.svc.create(await Body(c, CreateUserSchema));
}

@ApiQuery(name, schema, opts?)

Documents a query parameter in the spec. required is auto-inferred from the schema.

example.ts
@Get()
@ApiQuery('search', z.string().optional())
@ApiQuery('limit', z.coerce.number().default(20))
async list(c: Context) { /* ... */ }

@ApiDeprecated(sunset?)

Marks the endpoint deprecated in the spec AND emits an RFC 9745 Deprecation response header. Pass a date to also emit a Sunset header.

example.ts
@Get('/v1/users')
@ApiDeprecated('2027-01-01')
@ApiDoc({ description: 'Use /v2/users instead.' })
listV1() { /* ... */ }

@ApiSecurity(name)

Applies a named security requirement to a route โ€” use for schemes the auto-detection doesn't cover (apiKey, basic, OAuth2).

example.ts
@Get('/status')
@ApiSecurity('apiKey')
status(c: Context) { /* ... */ }

@ApiSecurityScheme(name, spec)

Declares a security scheme emitted under components.securitySchemes โ€” Scalar/Redoc pick it up for the Authorize button.

example.ts
@Controller('/payments')
@ApiSecurityScheme('apiKey', {
  type: 'apiKey', in: 'header', name: 'X-Api-Key',
})
class PaymentsController { /* ... */ }

pagination

Standard { data, meta } pagination โ€” query schema, response wrapper, and spec-ready schemas.

paginate(data, total, opts)

Wraps a data array and total count into a standard { data, meta } response โ€” meta carries page, limit, total, totalPages, hasNext, hasPrev. Return type is PaginatedResult<T> ({ data: T[]; meta: PaginationMeta }) โ€” PaginationMeta is the exported meta type.

example.ts
const [data, total] = await repo.findAndCount({ limit, offset });
return paginate(data, total, { page, limit });
// { data: [...], meta: { page: 1, limit: 20, total: 95, totalPages: 5, ... } }

PaginationQuerySchema

Zod schema for standard page/limit query params โ€” page defaults to 1, limit defaults to 20 (max 100). Use with @Validate or Query(c, schema).

example.ts
@Get()
@Validate({ query: PaginationQuerySchema })
async list(c: Context) {
  const { page, limit } = Query<PaginationQuery>(c);
  const [data, total] = await this.repo.findAndCount({
    limit, offset: (page - 1) * limit,
  });
  return paginate(data, total, { page, limit });
}

paginatedSchema(itemSchema)

Wraps an item schema into a full paginated response schema โ€” for @ApiResponse or @ValidateResult.

example.ts
const UserListSchema = paginatedSchema(UserSchemas.select);

@Get()
@ApiResponse(200, { schema: UserListSchema })
@ValidateResult(UserListSchema)
async list(c: Context) { /* ... */ }

ops, testing & tooling

Health checks, graceful shutdown, test isolation, typed clients, and api-tester flows โ€” everything needed to ship and verify.

mountHealth(app, opts?)

Mounts GET /health returning { status, uptime, checks } โ€” any failing check โ†’ 503. With probes: true also mounts /health/live (process alive, no deps) and /health/ready (dependency checks) for k8s liveness/readiness.

example.ts
mountHealth(app, {
  probes: true,
  checks: { db: () => db.ping() },      // โ†’ /health/ready
  liveChecks: { deadlock: () => true }, // โ†’ /health/live (optional)
});

gracefulShutdown(opts?)

Hooks SIGINT/SIGTERM: onShutdown โ†’ drain in-flight HTTP requests โ†’ close channel subscribers (SSE/WS, WS 1001) โ†’ container.shutdown() โ†’ exit. Hard-exits after timeoutMs. Returns a detach function.

example.ts
gracefulShutdown({
  timeoutMs: 10_000,
  drainRequests: true, // needs mountDrain โ€” 503s new work while draining
  drainChannels: true, // default โ€” closes SSE/WS transports
  onShutdown: async () => {
    stopEventBus();
    stopScheduler();
  },
});

mountDrain(app)

Installs in-flight request tracking so gracefulShutdown can wait for running requests instead of cutting them off. Mount BEFORE building routes. createApp mounts it automatically when shutdown is enabled. getRequestDrain() returns the RequestDrain ({ active, isDraining, drain() }) for custom probes.

example.ts
const app = new Hono();
mountDrain(app); // before routes!
new HonoRouteBuilder(app)
  .buildControllers(controllers);
gracefulShutdown();

createTestingModule(meta)

Builds an isolated DI container for tests โ€” framework-agnostic (bun:test, vitest, node:test). overrideProvider().useValue/useClass/useFactory swaps dependencies; cleanup() restores the real container.

example.ts
const mod = createTestingModule({
  controllers: [UserController],
})
  .overrideProvider(UserService)
  .useValue({ findAll: () => [] })
  .compile();

mod.get(UserController);            // unit
await mod.createApp().fetch(        // or e2e
  new Request('http://t/users')
);
mod.cleanup(); // always call this

generateClientTypes(opts)

Emits an AppRoutes TypeScript interface from your controllers โ€” each output is the handler's Awaited<ReturnType>. Handlers returning Response produce Response in the map โ€” use plain-value returns for typed clients. Imports come from importPath by default; controllerSpecifiers (a Map of controller โ†’ module specifier) emits grouped per-file imports โ€” the CLI fills this in automatically from each controller's recorded source file.

example.ts
const code = generateClientTypes({
  controllers: [UserController],
  importPath: './controllers',
  exportName: 'AppRoutes',
  // per-file imports instead of one barrel:
  controllerSpecifiers: new Map([
    [UserController, './modules/users/user.controller'],
  ]),
});
await Bun.write('src/routes.gen.ts', code);

createClient<AppRoutes>(baseUrl)

Typed fetch client keyed by "METHOD /path". Response types come from the generated interface. Throws ClientRequestError on non-2xx.

example.ts
import type { AppRoutes } from './routes.gen';

const api = createClient<AppRoutes>('http://localhost:3000');
const user = await api.request('GET /users/:id', {
  params: { id: '1' },
}); // typed response

writeFlowFiles(dir, module, opts?)

Emits api-tester .flow.yaml files โ€” one per controller, CRUD-chained (POST extracts id โ†’ {{id}} downstream). @Validate/@ApiBody schemas become sample bodies (z.file() fields โ†’ form-data multipart rows; schema-less mutations get a visible empty raw body); @Validate({query})/@ApiQuery become query params; @Validate({params}) seeds the editable {{param}} variables; guard- or @JwtAuth-protected routes add Bearer placeholders โ€” WebSocket nodes get ?token={{token}} on the URL instead (sockets can't carry headers; @ChannelAuth routes and declared @ApiQuery params land there too, and the wsQuery option appends anything else). @Status/@ApiResponse(2xx) drive status assertions and @Serialize/@ApiResponse schemas seed exists assertions on required response fields. Deterministic ids โ†’ re-imports upsert. In-memory variants for your own writer: generateFlowFiles/generateFlowFile (returns [{filename, flow}]) and stringifyFlowFile (returns the YAML string).

example.ts
await writeFlowFiles('./flows', AppModule, {
  baseUrl: 'http://localhost:3000',
});
// flows/todos.flow.yaml, flows/users.flow.yaml

// single-file variant โ€” groups per controller:
await writeFlowFile('./flows', AppModule);

db schema introspection

Programmatic API behind hono-forge schema โ€” extractDbSchema(mod) reflects a module's exported Drizzle tables into a DbSchemaFile, introspectPostgresSchema(connStr, { schema? }) reads a live database via information_schema (needs 'pg' in the project), parsePrismaSchema(text) parses schema.prisma, and normalizeDbSchemaDoc(doc) canonicalizes hand-written/external docs. Write with writeDbSchema(file, schema) or serialize via stringifyDbSchema.

example.ts
import {
  extractDbSchema, writeDbSchema,
  introspectPostgresSchema, parsePrismaSchema,
} from 'hono-forge';

const mod = await import('./src/db/schema.ts');
const schema = extractDbSchema(mod, { entryFile });
await writeDbSchema('./db.schema.yaml', schema);

// or live:
const live = await introspectPostgresSchema(
  'postgres://user:pass@localhost:5432/db',
);

cli commands

The hono-forge CLI introspects your @Module/@Controller entry file โ€” no server needed. TypeScript entries require Bun; Node needs a pre-built JS entry. --export <name> picks a specific export when the file exports several modules, and every command supports --help (or hono-forge help <command>).

hono-forge init <name>

Scaffolds a runnable starter project โ€” app.module.ts, an example controller/service, src/index.ts wiring health + OpenAPI docs + graceful shutdown, package.json, tsconfig.json (TC39 decorators configured), README.md, and .gitignore.

example.ts
bunx hono-forge init my-api
cd my-api
bun install
bun run dev

# serves /hello, /health, /openapi.json
# and interactive docs at /docs

hono-forge schema <entry|conn-string>

Reflects a database schema into a portable .schema.yaml โ€” columns, vendor+portable types, notNull, PK/composite PK flags, the full FK graph, and dialect. Four sources, auto-detected or forced with --from: drizzle (.ts/.js exporting pgTable/mysqlTable/sqliteTable), prisma (schema.prisma model blocks โ€” no prisma CLI needed), pg (postgres://โ€ฆ entry or --connect <url> โ€” live information_schema introspection, any ORM, needs 'pg' installed), file (.yaml/.json โ€” normalizes hand-written/external docs). Options: --from, --connect, --out <file> (default ./db.schema.yaml), --json. api-tester's Database view imports the output.

example.ts
bunx hono-forge schema ./src/db/schema.ts
# entry exports pgTable/mysqlTable/sqliteTable objects

bunx hono-forge schema ./prisma/schema.prisma
# parses model blocks โ€” @id, @@id, @map, @relation, enums

bunx hono-forge schema "postgres://user:pass@localhost:5432/db"
# live information_schema introspection โ€” any ORM or none

bunx hono-forge schema ./db.schema.yaml --from file
# normalize a hand-written/external doc to canonical form

# then in api-tester: Database โ†’ Import .schema.yaml
# (or "Introspect live DB" โ€” no CLI needed)

hono-forge flows <entry>

Generates api-tester .flow.yaml files โ€” one per controller, CRUD-chained. Auth detection covers guards AND @JwtAuth middleware โ€” REST/SSE nodes get Bearer {{token}} headers, auth-protected and @ChannelAuth WS nodes get ?token={{token}} on the URL (sockets can't carry headers), and @ApiQuery params declared on SSE/WS handlers land on the URL as seeded {{var}} placeholders. @Serialize/@ValidateResult contracts seed exists assertions for required top-level fields ($[0].* for arrays), scoped with forStatus to the success status. Options: --out <dir> (default ./flows), --base-url <url> (default http://localhost:3000), --single (one file with a visual group per controller), --auth '<METHOD> <path>' + --auth-body/--auth-extract (prepend a login step so guarded flows actually run โ€” {{vars}} in the body become editable variables), --ws-query 'k=v,...' (extra params on every WS node URL, {{vars}} allowed), --export <name>.

example.ts
bunx hono-forge flows ./src/app.module.ts --out ./flows
bunx hono-forge flows ./src/app.module.ts --single \
  --base-url http://localhost:3000

# authenticated APIs โ€” login step produces {{token}}:
bunx hono-forge flows ./src/app.module.ts \
  --auth 'POST /v1/auth/login' \
  --auth-body '{"username":"{{user}}","password":"{{pass}}"}' \
  --auth-extract 'token=data.accessToken'

# extra params on every WebSocket node URL:
bunx hono-forge flows ./src/app.module.ts \
  --ws-query 'outletId={{outletId}}&region=us-1'

# then in api-tester: Import flow โ†’ pick a .flow.yaml โ†’ Run

hono-forge openapi <entry>

Writes the OpenAPI 3.1 spec to a JSON file without booting the server. Options: --out <file> (default ./openapi.json), --title <text> (default: entry file name), --version <ver> (default 1.0.0), --export <name>.

example.ts
bunx hono-forge openapi ./src/app.module.ts --out openapi.json
bunx hono-forge openapi ./src/app.module.ts \
  --title "Todo API" --version 2.1.0

# feed the file to Swagger UI, api-tester "Import from API",
# or codegen

hono-forge routes <entry>

Prints the route table โ€” method / path / handler / guards / middleware / flags. Options: --json (machine-readable output), --export <name>.

example.ts
bunx hono-forge routes ./src/app.module.ts
bunx hono-forge routes ./src/app.module.ts --json

hono-forge graph <entry>

Prints the DI dependency graph โ€” provider scopes, constructor deps, and missing-dep warnings. Options: --format text|mermaid (mermaid is embeddable in docs/PRs), --export <name>.

example.ts
bunx hono-forge graph ./src/app.module.ts
bunx hono-forge graph ./src/app.module.ts --format mermaid

hono-forge doctor <entry>

Static wiring check โ€” exits 1 on issues so it works as a CI guard. Checks: missing DI deps, route collisions across controllers, invalid @Cron expressions, scheduled jobs on @RequestScoped classes, and @OnBootstrap on @RequestScoped classes. Options: --json (issue list for CI/IDE), --export <name>.

example.ts
bunx hono-forge doctor ./src/app.module.ts
bunx hono-forge doctor ./src/app.module.ts --json

# โœ– doctor found 2 issue(s):
#   โ€ข di: 'UserService' depends on 'Mailer' โ€” not in providers
#   โ€ข route: GET /users bound by both UserController.list
#     and AdminController.list

hono-forge client <entry>

Writes a typed client route map โ€” the generated file declares interface AppRoutes for createClient<AppRoutes>(baseUrl). Each controller is imported from its own source file โ€” @Controller records where the class is declared, so the output compiles even when controllers aren't re-exported from the entry module. Options: --out <file> (default ./routes.gen.ts), --name <ident> (interface name), --import-path <p> (opt out of per-file imports and treat one module as a barrel for every controller), --export <name>. Re-run as a codegen step whenever routes change.

example.ts
bunx hono-forge client ./src/app.module.ts --out src/routes.gen.ts

# then:
import { createClient } from 'hono-forge';
import type { AppRoutes } from './routes.gen';

const api = createClient<AppRoutes>('http://localhost:3000');

hono-forge help <command>

Shows per-command flags and examples โ€” also available as hono-forge <command> --help. Unknown commands get a 'did you mean' suggestion.

example.ts
hono-forge help
hono-forge help client
hono-forge client --help