⚠️ EXPERIMENTAL: This library is in early development and highly experimental. APIs may change without notice. Not recommended for production use.
Honest is a decorator-driven application toolkit for Hono that runs on Deno, Node.js, Bun and Cloudflare Workers. It provides controllers, modules, explicit dependency injection, middleware decorators, and route argument resolvers in a small API surface built around standard decorators.
Honest is the same idea as oakest — a decorator-driven, NestJS-like toolkit — but built on Hono instead of Oak. See Differences from oakest at the end if you know oakest already.
Compatibility: requires Hono ^5.0.0-rc.1 and, on Node.js, version 22.12 or later. This is a pre-release line that follows the Hono 5 release candidates; for Hono 4, use Honest 0.3.x. Honest's own deno.json declares this as a version range, not an exact pin, so Deno can resolve it to the same Hono install your own project already uses wherever possible — keep your project on a single resolved Hono version (on Node.js and Bun, install Hono from JSR as shown in Installation). Mixing two different Hono versions in one project can produce confusing Context is not assignable to Context-style TypeScript errors, since Hono's classes use private fields that make two separately-resolved copies of the same version nominally incompatible.
- Controllers and Modules: Organize Hono routes in a clear application structure.
- Explicit Dependency Injection: Declare dependencies with
inject(...)instead of emitted type metadata. - Standard Decorators: Build on the current TC39 decorator model instead of legacy experimental decorators.
- Middleware Decorators: Attach reusable request guards and flow control directly to route methods.
- Route Argument Resolvers: Map params, body, query, headers, request, response, context, or custom values directly on route decorators.
- Schema Validation: Validate the body, query, params, or headers against any Standard Schema-compatible library (Zod, Valibot, ArkType) with no added dependency.
- Built-in Error Handling: A ready-made
errorHandler()turns thrown errors into clean, consistent responses. - Request Scope: Opt in to a fresh, per-request instance for values that shouldn't be long-lived singletons.
- Guards:
@UseGuard(GuardClass)gates a route or a whole controller behind a DI-resolvedcanActivate()check. - OpenAPI Documentation:
@ApiTags/@ApiOperation/@ApiResponsedecorators plusbuildOpenApiDocument()generate a real OpenAPI 3.1 document — pair it with Scalar for an interactive API reference, no Swagger/Nest dependency required. - Lifecycle Hooks: Implement
OnModuleInit/OnModuleDestroyon a controller or provider to run setup/teardown around the rest of your app, plus a ready-madehealthCheck()route handler for readiness probes. - Config:
Config(schema)validates a config source (env vars by default) against the same Standard Schema interface as request validation, exposing a typed, validated.value. - Testing Helper:
createTestApp()(via@dx/honest/testing) skips the module-class boilerplate for tests, while still exercising the real DI/routing pipeline.
Honest is published on JSR. Supported runtimes — the CI runs the full test suite on each of them:
| Runtime | Install |
|---|---|
| Deno | deno add jsr:@dx/honest |
| Node.js (>= 22.12) | npx jsr add @dx/honest, then npm i hono@npm:@jsr/hono__hono |
| Bun | bunx jsr add @dx/honest, then bun add hono@npm:@jsr/hono__hono |
| Cloudflare Workers | as for Node.js |
While Hono 5 is a release candidate, name the versions explicitly, since the plain commands install the stable latest releases on Hono 4: deno add jsr:@dx/honest@0.4.0-rc.1, or npx jsr add @dx/honest@0.4.0-rc.1 and npm i hono@npm:@jsr/hono__hono@5.0.0-rc.1.
The examples import Hono as hono. On Deno, map it in your deno.json imports ("hono": "jsr:@hono/hono@^5.0.0-rc.1"). On Node.js and Bun, install Hono from JSR under the name hono as shown above rather than the npm hono package: Honest's npm build depends on the JSR copy, and mixing the two produces Context is not assignable to Context type errors.
Honest uses standard (TC39) decorators. Deno, Bun and Wrangler (Workers) run them directly. Node.js's built-in type stripping cannot (decorators are not erasable syntax), so run TypeScript through tsx (npx tsx main.ts) or compile with tsc (target ES2022 or later, without experimentalDecorators).
Define controllers to handle HTTP endpoints
// ./controllers/util-controller.ts
import { Controller, Get, headers, query } from '@dx/honest';
@Controller('util')
export class UtilController {
@Get('user-agent', [headers<string>('user-agent')])
bounceUserAgent(userAgent: string) {
return { status: 'ok', userAgent };
}
@Get('multiply', [query<string>('f1'), query<string>('f2')])
getRandomStuff(factor1: string, factor2: string) {
return { status: 'ok', result: Number(factor1) * Number(factor2) };
}
}Define modules
// ./app.module.ts
import { Module } from '@dx/honest';
import { UtilController } from './controllers/util-controller.ts';
@Module({
controllers: [UtilController],
routePrefix: 'api/v1',
modules: [], // optional submodules
})
export class AppModule {}Register an app module with Hono.
// ./main.ts
import { Hono } from 'hono';
import { assignModule } from '@dx/honest';
import { AppModule } from './app.module.ts';
const app = new Hono();
app.route('/', assignModule(AppModule));
Deno.serve(app.fetch);Start it — see Running for Node.js, Bun and Workers — and the following endpoints will be available:
/api/v1/util/user-agent/api/v1/util/multiply?f1=2&f2=4
assignModule() returns a plain Hono app, so start it the way Hono does on each runtime — Honest has no server adapter of its own:
// Deno: deno run --allow-net --allow-env main.ts
Deno.serve(app.fetch);
// Bun: bun run main.ts
export default app;
// Node.js: npx tsx main.ts (after npm i @hono/node-server)
import { serve } from '@hono/node-server';
serve({ fetch: app.fetch, port: 3000 });
// Cloudflare Workers: npx wrangler dev (with "main": "main.ts" in wrangler.jsonc)
export default app;For a graceful shutdown on Deno, Node.js and Bun, use process (see Lifecycle Hooks); Workers have no shutdown signal. nodejs_compat is not required on Workers — pass config bindings to Config() explicitly instead.
A module is a class annotated with a @Module() decorator. The @Module() decorator provides metadata that the application makes use of to organize the application structure.
Each application has at least one module, a root module, and each modules can have child modules.
The @Module() decorator takes those options:
| name | description |
|---|---|
controllers |
the set of controllers defined in this module which have to be instantiated |
providers |
the providers that will be instantiated by the injector |
modules |
the set of modules defined as child modules of this module |
routePrefix |
the prefix name to be set in route as the common URL for controllers. |
import { Module } from '@dx/honest';
import { AppController } from './app.controller.ts';
import { SampleModule } from './sample/sample.module.ts';
@Module({
modules: [SampleModule],
controllers: [AppController],
routePrefix: 'v1',
})
export class AppModule {}A controller is a class annotated with a @Controller() decorator. Controllers are responsible for handling incoming requests and returning responses to the client.
The @Controller() decorator takes an optional route path prefix.
import { Controller, Get } from '@dx/honest';
@Controller('sample')
export class UsersController {
@Get()
findAll(): string {
return 'OK';
}
}The @Get() HTTP request method decorator before the findAll() method tells the application to create a handler for a specific endpoint for HTTP requests.
For http methods, you can use @Get(), @Post(), @Put(), @Patch(), @Delete(), @All().
Routes respond with 200 by default. Use @HttpCode() to set a different success status — it applies to returned values (a returned Response keeps its own status, undefined still means 404), and 204/205/304 always send an empty body. buildOpenApiDocument() picks the status up for its default success response.
@Post([validatedBody(CreateUserSchema)])
@HttpCode(201)
create(body: CreateUser) {
return this.userService.create(body);
}
@Delete(':id', [param('id')])
@HttpCode(204)
remove(id: string) {
this.userService.remove(id);
}Handlers can map request-derived values directly on the HTTP method decorator.
import { Controller, Get, headers, param, query } from '@dx/honest';
@Controller('sample')
export class SampleController {
@Get(':id', [param<string>('id'), query<string | undefined>('dryRun'), headers<string>('user-agent')])
findOne(id: string, dryRun: string | undefined, userAgent: string) {
return { id, dryRun, userAgent };
}
}Available resolvers:
| name | result |
|---|---|
req() |
c.req (the Hono request) |
res() |
alias for ctx() (Hono has no separate response object) |
next() |
Hono next handler |
query(key?) |
URLSearchParams or a single query value |
param(key?) |
route params object or a single route param |
body(key?) |
parsed JSON body or a single body property |
headers(name?) |
all headers as an object or a single header value |
ip(options?) |
client IP from the runtime's connection (cf-connecting-ip on Workers; '' if unknown) |
ctx() |
the full Hono Context |
custom(handler, data?) |
custom async/sync value resolver, typed from the handler return value |
scoped(Provider) |
a request-scoped instance of Provider — see Request Scope |
validatedBody(schema) |
the JSON body, validated against a schema — see Validation |
validatedQuery(schema) |
the query string, validated against a schema — see Validation |
validatedParam(schema) |
the route params, validated against a schema — see Validation |
validatedHeaders(schema) |
the headers, validated against a schema — see Validation |
Notes on resolver arguments:
ip()accepts an optional{ trustProxy?: boolean }. It defaults tofalse(uses the raw connection address); pass{ trustProxy: true }to preferX-Forwarded-Forwhen running behind a reverse proxy/load balancer you control — don't enable it otherwise, since that header is client-spoofable.key?/name?on the lookup resolvers (query,param,body,headers) must be a string or omitted — passing anything else throws aTypeErrorimmediately at decoration time, rather than silently resolving to "no key" at request time.custom(handler, data?)is the exception:datais a free-form payload passed through tohandlerunchanged, so it accepts any value.- If the handler declares exactly one parameter and no resolver array, Honest still injects
ctxautomatically. - If the handler uses a resolver array and declares exactly one extra trailing parameter, that final parameter receives
ctxautomatically.
Providers are responsible for main business logic as services, repositories, factories, helpers, and so on. The main idea of a provider is that it can be injected as a dependency. Depending on the environment, different implementations of a service can be provided.
Swapping an implementation (e.g. by environment) means both classes have to implement the same explicit token — a provider self-binds to its own class as the token (see Dependency injection notes below), so inject(UserService) only ever resolves UserService itself, never a different class listed in its place:
// ./sample.service.ts
import { Injectable } from '@dx/honest';
import db from './db-service.ts';
export const USER_SERVICE = Symbol('UserService');
@Injectable({ implementing: USER_SERVICE })
export class UserService {
async getAllUsers() {
const { data: users } = await db.users.getAll();
return { status: 'ok', data: users };
}
}
@Injectable({ implementing: USER_SERVICE })
export class MockUserService {
getAllUsers() {
return {
status: 'ok',
data: [
{
name: 'John Doe',
},
{
name: 'Jane Doe',
},
],
};
}
}
// ./sample.controller.ts
import { Controller, Get, inject } from '@dx/honest';
import { USER_SERVICE } from './sample.service.ts';
import type { UserService } from './sample.service.ts';
@Controller('users')
export class UsersController {
constructor(private readonly userService = inject<UserService>(USER_SERVICE)) {}
@Get()
getAllUsers() {
return this.userService.getAllUsers();
}
}
// ./sample.module.ts
import { Module } from '@dx/honest';
import { UsersController } from './sample.controller.ts';
import { MockUserService, UserService } from './sample.service.ts';
@Module({
controllers: [UsersController],
providers: [
process.env.NODE_ENV === 'production' ? UserService : MockUserService,
],
})
export class SampleModule {}Dependency injection notes:
- Dependencies must be requested explicitly in constructor default values.
- Providers still need to be registered in your module's
providersarray. @Injectable({ implementing: TOKEN })can still be used to bind string or symbol tokens and resolve them withinject<T>(TOKEN).isSingleton: falseis no longer supported in this Needle-based mode.- Honest uses standard decorators — do not enable
experimentalDecoratorsindeno.jsonortsconfig.json. - Constructor
inject(...)always resolves against long-lived singletons, built once when the app starts. For a value that must be fresh per request instead, use thescoped()resolver — see Request Scope.
It's possible to register middleware that can be used in controllers by means of decorators.
For instance, to protect routes based on user roles, you can create a @RequiresRole middleware decorator.
// ./middleware.ts
import { registerMiddlewareMethodDecorator } from '@dx/honest';
import type { Context } from 'hono';
function checkUserRoles(c: Context, roles: string[]) {
// Logic to check the user role
return false;
}
export function RequiresRole(roles: string[]) {
return function (_value, context) {
const requiresRole = async (c: Context, next: () => Promise<unknown>) => {
// Logic to check the user session or JWT for the required role
if (checkUserRoles(c, roles)) {
await next();
} else {
// Short-circuit by returning a Response instead of calling next()
return c.json({ error: 'Unauthorized' }, 401);
}
};
registerMiddlewareMethodDecorator(context, requiresRole);
};
}Then you can use the @RequiresRole decorator in your controllers' methods.
// ./sample.controller.ts
import { Controller, Get } from '@dx/honest';
import { RequiresRole } from './middleware.ts';
@Controller('users')
export class SampleController {
@Get('/')
@RequiresRole(['admin'])
getAllUsers() {
// Logic to get all users
}
}Custom route inputs can be declared inline with custom(...). The resolver type is inferred from the handler return value.
import { Controller, custom, Get } from '@dx/honest';
@Controller('users')
export class UsersController {
@Get('me', [custom((c) => c.get('jwtData')?.sub)])
getCurrentUser(userId: string | undefined) {
return { userId };
}
}Resolvers can be asynchronous too:
import { Controller, custom, Get } from '@dx/honest';
@Controller('products')
export class ProductsController {
@Get('recent', [custom(async (c) => {
const sid = c.get('jwtData')?.sid;
return sid ? await retrieveSession(sid) : null;
})])
getRecentProducts(sessionData: { recentProducts: unknown[] } | null) {
return sessionData?.recentProducts ?? [];
}
}validatedBody(schema), validatedQuery(schema), validatedParam(schema), and validatedHeaders(schema) validate the JSON body, query string, route params, or headers against any schema implementing the Standard Schema interface — Zod (>=3.24), Valibot, and ArkType all work out of the box, and honest doesn't depend on any of them. On failure they throw ValidationError, which errorHandler() (see Error Handling) turns into a 400 response with the schema's issues (only message and path, no issue input). Malformed JSON in validatedBody() is reported the same way, not as a 500, with the fixed message Invalid JSON body — the parser error is not passed on, since it quotes the body. Note that a schema library's default message may itself contain the received value (Valibot: Invalid type: Expected string but received 424242); when validating secrets, set custom messages (v.string('…'), z.string({ message: '…' })). Repeated query keys (?ids=1&ids=2) are preserved as a string array by validatedQuery() instead of being collapsed to the last value.
import { Controller, Post, validatedBody } from '@dx/honest';
import { z } from 'zod';
const CreateUserSchema = z.object({ name: z.string(), email: z.string().email() });
@Controller('users')
export class UsersController {
@Post([validatedBody(CreateUserSchema)])
create(user: z.infer<typeof CreateUserSchema>) {
return { status: 'ok', user };
}
}Config(schema, source?) validates a config source — by default process.env — against a Standard Schema, the same interface Validation uses. Extend it, don't decorate with it — TypeScript can't propagate a decorator's return type to the decorated class's own type, so .value would need a cast at every call site; a normal extends is fully typed with none:
import { Config, Controller, Get, inject } from '@dx/honest';
import { z } from 'zod';
const AppConfigSchema = z.object({ PORT: z.coerce.number(), DATABASE_URL: z.string().url() });
export class AppConfig extends Config(AppConfigSchema) {}
@Controller('health')
export class HealthController {
constructor(private readonly config = inject(AppConfig)) {}
@Get()
get() {
return { port: this.config.value.PORT };
}
}Add AppConfig to a module's providers and it's ready wherever you inject() it. Validation runs synchronously in the constructor — the moment AppConfig is first injected (typically while assignModule() builds the module tree) — so a bad config fails the same way any other constructor error would: loudly, at startup, before serving traffic. It throws ValidationError (same shape as the request validators), but since this happens outside any request, it does not go through errorHandler(). Config() only supports schemas that validate synchronously (env vars never need async); a schema that returns a Promise throws a clear error immediately instead of silently misbehaving.
Controllers and providers are built once per assignModule() call, not per request — constructor inject(...) always resolves against those long-lived singletons. For values that genuinely need to be fresh per request (e.g. a unit-of-work or a per-request cache), use the scoped() resolver instead of constructor injection:
import { Controller, Get, inject, scoped } from '@dx/honest';
import { DbConnection } from './db-connection.ts'; // a regular, singleton provider
export class UnitOfWork {
// scoped() classes can still inject() the module's singletons — only
// UnitOfWork itself is fresh per request.
constructor(readonly db = inject(DbConnection)) {}
}
@Controller('orders')
export class OrdersController {
@Get(':id', [scoped(UnitOfWork)])
findOne(unitOfWork: UnitOfWork) {
return unitOfWork.db.query(/* ... */);
}
}scoped() is backed by a real needle-di child container, created once per request by assignModule()'s own middleware — repeated scoped() calls within the same request return the same instance, and a fresh one is used for the next request. It only works on controllers mounted via assignModule(); calling it on a controller test-mounted by hand throws a clear error. This is an opt-in mechanism scoped to the resolver argument list — it doesn't change how constructor inject(...) works anywhere else.
@UseGuard(GuardClass) gates a route — or, applied to the controller class itself, every route on it — behind GuardClass.canActivate(c). It's a small, purpose-built specialization of Custom Middleware Decorators: a Guard is resolved through the same request scope scoped() uses, so its constructor can inject() any of the module's singleton providers.
import { Controller, Get, inject, UseGuard } from '@dx/honest';
import type { Guard, HonestContext } from '@dx/honest';
import { AuthService } from './auth.service.ts';
class AuthGuard implements Guard {
constructor(private readonly auth = inject(AuthService)) {}
canActivate(c: HonestContext) {
return this.auth.isValid(c.req.header('authorization'));
}
}
@UseGuard(AuthGuard) // every route on this controller
@Controller('admin')
export class AdminController {
@UseGuard(SomeOtherGuard) // stacks with the class-level guard above
@Get('audit-log')
auditLog() {
// ...
}
}Returning (or resolving to) false from canActivate() denies the request with a plain HttpError(403, 'Forbidden') — handled the same way as any other HttpError, see Error Handling. Throw your own HttpError from canActivate() instead for a different status or message. Like scoped(), UseGuard() requires the controller to be mounted via assignModule() and throws a clear error otherwise.
Guards are additive across inheritance, never subtractive: a subclass that extends a guarded controller (or overrides a guarded method) inherits its guard(s) on top of any it applies itself — either one denying is enough to deny. There's no way to remove or replace an inherited guard on an override; give the override a different name instead if it genuinely shouldn't be guarded.
Need a plain function check instead of a DI-resolved class, or something that isn't really an authorization concern? Reach for Custom Middleware Decorators (registerMiddlewareMethodDecorator()) directly, or its class-level counterpart registerMiddlewareClassDecorator() — UseGuard() is built on exactly these two.
A controller or provider can implement OnModuleInit/OnModuleDestroy to run setup/teardown logic — e.g. opening or closing a database connection pool once, instead of on every request:
import type { OnModuleDestroy, OnModuleInit } from '@dx/honest';
import { Injectable } from '@dx/honest';
@Injectable()
export class DbConnection implements OnModuleInit, OnModuleDestroy {
async onModuleInit() {
await this.connect();
}
async onModuleDestroy() {
await this.disconnect();
}
// ...
}assignModule() stays synchronous (its existing (module) => Hono signature is unchanged), so hooks are run by two separate functions you call explicitly:
import { assignModule, destroyModule, initModule } from '@dx/honest';
import { AppModule } from './app.module.ts';
const app = new Hono();
const honestApp = assignModule(AppModule);
app.route('/', honestApp);
await initModule(honestApp); // await before serving traffic
Deno.serve(app.fetch);
// Deno, Node.js and Bun all provide `process`
process.on('SIGINT', async () => {
await destroyModule(honestApp); // not wired automatically — call it from your own shutdown handling
process.exit(0);
});initModule() runs onModuleInit() on every controller (always eagerly built) and every provider that implements OnModuleInit/OnModuleDestroy — a provider that implements neither stays exactly as lazily-constructed as it is today, so this changes nothing for code that doesn't use the hooks. Lifecycle-implementing providers run before controllers, so a provider like the DbConnection above has already finished its own onModuleInit() by the time a controller that injects it runs its; destroyModule() runs in the reverse order (controllers before providers). Both stop and propagate on the first error, rather than collecting multiple failures.
This only orders providers before controllers, not a full dependency graph between lifecycle-implementing providers themselves — if one lifecycle provider injects another, list the dependency first in providers (honest doesn't (yet) introspect the DI graph to order these automatically).
Health/readiness check. healthCheck(app) is a ready-made route handler reporting 200 once initModule(app) has completed and 503 before that or once destroyModule(app) has started — the shape orchestrators (Kubernetes, load balancers) expect from a health/readiness probe:
import { healthCheck } from '@dx/honest';
app.route('/', honestApp);
app.get('/health', healthCheck(honestApp));
await initModule(honestApp);Need a different response shape (uptime, version, dependency checks)? Call isModuleReady(app) directly inside your own handler instead of using healthCheck().
resolve(app, token) returns the module's singleton for token, so plain Hono middleware on the outer app can use a provider. The token is a provider class or an implementing token, the same tokens inject() accepts. app must be the exact instance assignModule() (or createTestApp()) returned:
import { assignModule, resolve } from '@dx/honest';
const honest = assignModule(AppModule);
const store = resolve<Store>(honest, STORE);
app.use(adminActivity(store));
app.route('/', honest);It returns the same instance controllers get injected, never a new or request-scoped one. It works before initModule() and runs no lifecycle hooks. An unknown token throws an error naming it.
Honest ships a ready-made app.onError() handler instead of a copy-paste recipe: errorHandler() maps ValidationError (thrown by the validation resolvers above) to a 400 with the schema's issues (message and path only), HttpError to its own status/details, and anything else to a logged, generic 500.
import { Hono } from 'hono';
import { assignModule, errorHandler, HttpError } from '@dx/honest';
import { AppModule } from './app.module.ts';
const app = new Hono();
app.route('/', assignModule(AppModule));
app.onError(errorHandler());
Deno.serve(app.fetch);Since Hono 5, onError() registers middleware: handlers receive (c, next) and read the error from c.error. errorHandler() always returns a response, so it ends that chain. Register your own error middleware (logging, reporting) before it and call next() there.
Throw HttpError(status, message, details?) from a handler, provider, or middleware to produce a specific response through the same handler:
import { Get, HttpError } from '@dx/honest';
@Get(':id')
findOne(id: string) {
const item = this.service.find(id);
if (!item) throw new HttpError(404, 'Not found', { id });
return item;
}HttpError's status is validated when constructed: it must be an integer between 100 and 599, and can't be a status that isn't allowed to carry a body (101, 204, 205, 304) — since HttpError always renders one.
Like @nestjs/swagger, but without a Swagger/Nest dependency: @ApiTags, @ApiOperation, and @ApiResponse attach descriptive OpenAPI metadata to a controller, and buildOpenApiDocument() turns the whole module tree into a real OpenAPI 3.1 document — read purely from the same decorator metadata the router already uses, without instantiating any controller or provider.
import { ApiOperation, ApiResponse, ApiTags, Controller, Get, param } from '@dx/honest';
@ApiTags('users')
@Controller('users')
export class UsersController {
@ApiOperation({ summary: 'Get a user by id' })
@ApiResponse({ status: 200, description: 'The user' })
@ApiResponse({ status: 404, description: 'Not found' })
@Get(':id', [param<string>('id')])
findOne(id: string) {
return this.service.find(id);
}
}// ./main.ts
import { buildOpenApiDocument } from '@dx/honest';
import { AppModule } from './app.module.ts';
const document = buildOpenApiDocument(AppModule, {
info: { title: 'My API', version: '1.0.0' },
});
app.get('/openapi.json', (c) => c.json(document));Request/response shapes backed by validatedBody/validatedQuery/validatedParam/validatedHeaders (see Validation) are only included if you pass a schemaToJsonSchema converter — honest has no built-in Standard Schema → JSON Schema conversion, since that would tie it to one schema library. Use the schema library's own converter, e.g. Zod's built-in z.toJSONSchema:
import { z } from 'zod';
const document = buildOpenApiDocument(AppModule, {
info: { title: 'My API', version: '1.0.0' },
schemaToJsonSchema: (schema) => z.toJSONSchema(schema as z.ZodType),
});Without a converter, everything else still works — tags, summaries, descriptions, responses, path/query/header parameters declared via plain param()/query()/headers(), and route exclusion via @ApiExcludeEndpoint(). Routes registered with @All() have no single OpenAPI method equivalent and are skipped.
Serve the document with Scalar for an interactive UI — like Production Hardening below, this is plain Hono middleware, no honest-specific wiring:
import { Scalar } from 'npm:@scalar/hono-api-reference@0.11.16';
app.get('/reference', Scalar({ url: '/openapi.json' }));That's a plain npm: specifier, not an entry in honest's own imports — Scalar is entirely optional and only pulled in if (and where) you actually import it. Add a bare "@scalar/hono-api-reference": "npm:@scalar/hono-api-reference@..." to your own deno.json if you'd rather use the short specifier.
createTestApp(options), imported from @dx/honest/testing (a separate export path, so it never ships as part of the main entry point), is sugar over @Module(options) + assignModule() — it skips declaring a named module class per test while still exercising the real DI/routing/request-scope pipeline:
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { createTestApp } from '@dx/honest/testing';
import { UsersController } from './users.controller.ts';
import { MockUserService } from './mock-user.service.ts';
test('GET /users returns the mocked users', async () => {
const app = createTestApp({ controllers: [UsersController], providers: [MockUserService] });
const response = await app.request('/users');
assert.equal(response.status, 200);
});node:test runs on every supported runtime (deno test, npx tsx --test, bun test); any other test runner works the same way.
A test double works as a provider the same way a real one does — nothing test-specific is required of it beyond being a normal class matching whatever token your controller/provider injects (see Providers for implementing tokens if you need to substitute an implementation without changing what's injected).
None of the following needs any honest-specific code — assignModule() returns a plain Hono instance, so Hono's own middleware (and the wider Hono ecosystem) applies directly, before you mount your module. Honest re-exports only its own API from @dx/honest; import Hono submodules like hono/cors or hono/secure-headers straight from hono, same as in a plain Hono app. On Node.js and Bun they resolve from the installed package. On Deno, each submodule needs its own entry in your deno.json imports — a single trailing-slash entry ("hono/": "jsr:@hono/hono@^5.0.0-rc.1/") does not resolve JSR subpaths, so list the ones you use explicitly:
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { secureHeaders } from 'hono/secure-headers';
import { assignModule, errorHandler } from '@dx/honest';
import { AppModule } from './app.module.ts';
const app = new Hono();
app.use(cors({ origin: 'https://example.com' }));
app.use(secureHeaders());
app.route('/', assignModule(AppModule));
app.onError(errorHandler());
Deno.serve(app.fetch);For rate limiting, use a Hono-compatible middleware package (e.g. hono-rate-limiter) the same way. None of this is bundled with honest itself — it's plain Hono middleware, applied to the app assignModule() gives you.
Honest mirrors oakest's decorator API almost 1:1, but Hono's request-handling model is architecturally different from Oak's, which shows up in a few places:
-
assignModule()returns a Hono app, not a middleware. Oak'sassignModule()returned aMiddlewareyou passed toapp.use(...). Hono routers and apps are the same kind of object, soassignModule()now returns a fullHonoinstance — mount it withapp.route('/', assignModule(AppModule))(or use it directly as your root app / fetch handler). -
res()is an alias forctx(). Hono has no separate mutable response object like Oak'scontext.response— everything is expressed by what the handler returns. Bothres()andctx()resolve to the same HonoContext; preferctx()in new code. -
Handler return values are mapped to a response automatically:
Return value Response undefined404 (via c.notFound()) — "nothing to send"nullJSON null, status 200 — a deliberate valuea Responseinstancereturned as-is (escape hatch for full control) stringtext/plainviac.text(...)Uint8Array/ArrayBufferraw body via c.body(...)anything else JSON via c.json(...)Only
undefinedmeans "no result" —nullis valid, serializable JSON and is sent as such. -
ip()reads the runtime's connection info. Hono has no built-in client IP like Oak'srequest.ip. Honest'sip()resolver reads it from what the server passes to Hono (Deno.serve,Bun.serve,@hono/node-server), usescf-connecting-ipon Cloudflare Workers, and resolves to''when no real connection is available (for example, when calling routes in tests viaapp.request()). -
Middleware short-circuits by returning a
Response, not by mutating-then-returning. Oak middleware denied a request by mutatingcontext.responseand returningvoid. Hono middleware denies a request byreturning aResponsedirectly instead of callingnext()— see Custom Middleware Decorators above. -
Errors fall back to Hono's default
onErrorbehavior unless you installerrorHandler(). See Error Handling above.
Since the decorator API itself carries over 1:1, most migration effort lands in code that talked to Oak's Context/Request/Response directly rather than through honest's decorators. Quick idiom lookup:
| Oak | Hono / honest |
|---|---|
:name* / :name+ route params |
:name{.*} / :name{.+} — see Route Arguments; honest throws at decoration time if you use the Oak form |
ctx.throw(status, message) |
throw new HttpError(status, message) — see Error Handling |
ctx.response.body = x |
return x; from the handler, or c.html(x)/c.json(x)/... via ctx() — see the return-value table above |
ctx.response.status = code |
return c.json(x, code) (status is the return's 2nd argument), or throw HttpError(code, ...) |
ctx.upgrade() |
Deno.upgradeWebSocket(c.req.raw) inside a handler that received c via ctx() |
ctx.sendEvents() / ServerSentEventTarget |
streamSSE(c, async (stream) => { ... }) from hono/streaming, returned from the handler |
send(ctx, path, { root }) (static files) |
serveStatic({ root }) from @hono/deno (Hono 5 moved runtime adapters to their own packages), mounted on the underlying Hono app (e.g. app.use('/static/*', serveStatic({ root: './public' }))) |
ctx.request.ip |
honest's ip() resolver, or c.req via ctx() — see ip() above |