hestjs README

The project's README from github.com/aqz236/hestjs. As one markdown file: README.md.

HestJS

把 Hono 组织起来,而不是替掉它。

HestJS 只做一件事:让你用模块声明「谁依赖谁、谁在什么时候初始化」, 然后把依赖注入进去。路由不是它的事——路由就是 Hono 的路由。

三条硬规矩

1. Hono 实例是唯一真相。 createApp() 返回的 app.hono 就是 Hono 实例本身——没有包装、没有代理。 路由、中间件、c.req.raw、c.var、c.header() 全部照旧。

2. 零反射依赖注入。 不依赖 reflect-metadata,不依赖 emitDecoratorMetadata。 依赖写在 @Inject() 里,一眼可见,换任何打包器都不会在编译期静默失效。

3. 装饰器只写元数据,不接管执行。 @Module() / @Injectable() / @Inject() 只往表里写字, 而那些表就直接挂在类上,随时能读出来。

为什么不用装饰器定义路由

装饰器路由看起来更像 NestJS,但代价是丢掉 Hono 的类型推导:

const dynamic = new Hono();
dynamic.on('GET', '/users/:id', handler);
hc<typeof dynamic>('...').users[':id']   // ❌ 'client' is of type 'unknown'

链式注册才有类型,动态注册没有——这是 Hono 的设计。 而 hc(端到端 RPC 客户端)是 Hono 最值钱的能力。

所以 HestJS 把路由交回 Hono:

const app = createApp(AppModule, {
  routes: (hono, resolve) => {
    const users = resolve(UserController);
    return hono
      .get('/users', (c) => users.list(c))
      .get('/users/:id', (c) => users.detail(c));
  },
});

export type AppType = typeof app.hono;   // hc<AppType> 类型完整

代价只有一个:没有 @Get('/:id')。换来完整类型、零路径重复、零黑盒。

快速开始

import type { Context, Env } from 'hono';
import { Inject, Module } from '@hestjs/core';

class GreetingService {
  greet(name: string): string {
    return `hello ${name}`;
  }
}

class GreetingController {
  constructor(@Inject(GreetingService) private readonly greeting: GreetingService) {}

  say(c: Context<Env, '/greet/:name'>): Response {
    return c.json({ message: this.greeting.greet(c.req.param('name')) });
  }
}

@Module({ providers: [GreetingService, GreetingController] })
export class AppModule {}

export { GreetingController };
import { logger } from 'hono/logger';
import { createApp } from '@hestjs/core';
import { AppModule, GreetingController } from './app.module';

const app = createApp(AppModule, {
  // middleware 在路由之前执行
  middleware: [logger()],

  routes: (hono, resolve) => {
    const greeting = resolve(GreetingController);
    return hono
      .get('/health', (c) => c.text('ok'))
      .get('/greet/:name', (c) => greeting.say(c));
  },
});

app.hono.notFound((c) => c.json({ message: 'not found' }, 404));

await app.start();

export type AppType = typeof app.hono;
export default { port: Number(process.env.PORT ?? 3000), fetch: app.hono.fetch };
bun install
bun run --filter @hestjs/example dev

完整可运行版本见 apps/example。生成新应用:

bun run new my-app              # 纯 API
bun run new my-app --with-web   # API + Vite 前端,hc<AppType> 端到端类型
bun install
bun run --filter @hestjs/my-app dev

模块作用域是真的

imports 会变成指向对方容器的 alias,exports 是唯一能借出去的东西。

@Module({ providers: [UserRepository], exports: [UserRepository] })
class DataModule {}

@Module({ imports: [DataModule], providers: [UserService] })
class UsersModule {}

把 exports 删掉,createApp() 会在启动前抛 ProviderNotFoundError—— 不是运行时某个请求才炸。

以下问题全部在启动前报错,不会漏到运行期:

情况 错误
同一个模块里 provider 写了两遍 DuplicateProviderError
自己提供的 token 与 import 撞名 AmbiguousProviderError
exports 了不存在的东西 UnresolvedExportError
模块 import 成环 ModuleCycleError(带完整链路)
构造参数少标 @Inject() MissingInjectError
overrides 替换了不存在的 token UnknownOverrideError

动态模块

需要把配置传进模块时用 dynamicModule():

const DB_URL = token<string>('dbUrl');

class DatabaseModule {
  static forRoot(options: { url: string }): DynamicModule {
    return dynamicModule(DatabaseModule, {
      providers: [{ provide: DB_URL, useValue: options.url }, ConnectionPool],
      exports: [ConnectionPool],
    });
  }
}

@Module({ imports: [DatabaseModule.forRoot({ url: 'postgres://main' })] })
class AppModule {}

模块图按引用去重,不按类——所以同一个类可以配置出多个并存实例 (主库 + 只读副本),各有独立的容器与单例。忘了调 forRoot() 直接写类名时, 报错会点名可用的工厂方法。

生命周期

模块是纯声明,钩子挂在有资源的东西上:

class Postgres implements OnStart, OnStop {
  async onStart() { await this.pool.connect() }
  async onStop() { await this.pool.end() }
}

app.start() 先构造全部单例(构造错误在这一步集中暴露), 再按依赖顺序执行 onStart();app.stop() 逆序执行 onStop()。

包

包 说明 必装
@hestjs/core 模块、依赖注入、生命周期 是
@hestjs/validation Standard Schema 请求校验 否
@hestjs/openapi OpenAPI 3.1 + Scalar UI 否
@hestjs/cqrs 三总线,纯 TS 不碰 web 否
@hestjs/schedule 声明式定时任务 否
@hestjs/queue 后台任务,可替换 driver 否
@hestjs/testing 启动应用、替换 provider、发请求 否
create-hest-app 最小生成器 否

插件之间互不依赖,都只依赖 core。

@hestjs/validation

只认 Standard Schema, 底层是 Hono 官方的 @hono/standard-validator:

hono.post('/users',
  validate({ body: CreateUser, jsonSchema: { body: z.toJSONSchema(CreateUser) } }),
  (c) => users.create(c),
)

做成中间件而不是包装 handler——包装会破坏 Hono 的链式类型推导。

@hestjs/openapi

app.hono.route('/', openApiRoutes({ hono: app.hono, info: { title: 'HestJS API', version: '1.0.0' } }));
// GET /openapi.json   GET /docs

只写它真的知道的东西:路由表、validate() 登记的 schema、documented() 里的说明。 猜不出来就不写。

@hestjs/cqrs

三总线 + 装饰器,不依赖 web 层。handler 必须显式列出来——不扫目录、不建全局注册表。

@hestjs/testing

const app = await createTestApp(AppModule, {
  routes,
  overrides: [{ provide: Database, useValue: new FakeDatabase() }],
});

overrides 只能替换本来就注册过的 token,写错名字立刻抛错。

仓库结构

packages/
├── core/                     # 模块、依赖容器、生命周期
├── validation/               # Standard Schema 校验
├── openapi/                  # OpenAPI 3.1 + Scalar UI
├── cqrs/                     # 三总线,纯 TS
├── schedule/                 # 定时任务
├── queue/                    # 后台任务
├── testing/                  # 测试工具
├── create-hest-app/          # 最小生成器
├── typescript-config/        # 共享 tsconfig
└── eslint-config/            # 共享 ESLint 配置

apps/
├── example/                  # 完整示例
└── docs/                     # VitePress 文档站

所有包都是 private,不发布到 npm:通过 workspace:* 直接引用 TypeScript 源码, 没有构建步骤,改完即生效。

环境要求

⚠️ Bun 的转译器不解析包名形式的 extends。请用相对路径: "extends": "../../packages/typescript-config/base.json"。 漏掉 experimentalDecorators 会让装饰器元数据写不到表里,表现是静默失效。

不需要 emitDecoratorMetadata——Bun 支持它,但 HestJS 不用它, 因为 @Inject() 显式声明更可靠。

文档

https://aqz236.github.io/hestjs/

许可

MIT

hestjs (aqz236/hestjs)

Searching Database…

290 ·GitHub
这是什么?HestJS 是一个构建在 Hono、Bun 和 TSyringe 之上的 TypeScript 后端框架。它以 NestJS 风格的开发体验提供模块声明和依赖注入,同时把实际路由完全交给 Hono。
它是 Hono 的封装,还是取代 Hono?它是一个轻薄的组织层,而不是封装:`createApp()` 返回的 `app.hono` 就是未经修改的真正 Hono 实例,因此路由、中间件以及 Hono 类型安全的 `hc` RPC 客户端的工作方式与纯 Hono 完全一致。
怎么用它创建新应用?用 create-hest-app 生成器生成一个新应用,或者查看 apps/example 里完全可运行的版本。该仓库是一个 Turborepo monorepo,包之间直接通过 workspace: 引用,不需要构建步骤。
需要什么样的运行时和 TypeScript 配置?你需要 Bun 1.2 或更新版本作为唯一受支持的运行时和包管理器,TypeScript 5.8+,并在每个 tsconfig 中显式启用 experimentalDecorators。HestJS 依赖显式的 @Inject(),因此不需要 emitDecoratorMetadata。
为什么不能用 @Get('/:id') 这种写法来写路由?基于装饰器的路由会破坏 Hono 的链式类型推断,而它的 hc RPC 客户端正依赖这种推断,因此 HestJS 有意把路由交还给纯 Hono。代价是没有装饰器路由,换来的是完整的类型安全,也不存在隐藏的路由注册表。
没有 reflect-metadata 时依赖注入是怎么工作的?HestJS 完全不用 reflect-metadata 和 emitDecoratorMetadata;每个依赖都必须用 @Inject() 显式声明,因此漏写注解会在编译/启动时大声报错,而不是在换用另一个打包器时悄悄出问题。
如果模块配置错了会发生什么?模块装配在应用启动之前就会被校验:重复的 provider、与导入项的命名冲突、导出不存在的对象,或者模块循环导入,都会各自抛出特定的错误(DuplicateProviderError、AmbiguousProviderError、UnresolvedExportError、ModuleCycleError),而不是等到请求时才失败。
这个框架附带哪些可选包?除了必需的 @hestjs/core,还有这些可选包:通过 Standard Schema 做请求校验的 @hestjs/validation、带 Scalar UI 的 OpenAPI 3.1 文档 @hestjs/openapi、三总线 CQRS 层 @hestjs/cqrs、声明式定时任务 @hestjs/schedule、可换驱动的后台任务队列 @hestjs/queue,以及测试工具 @hestjs/testing。它们彼此互不依赖,只依赖 core。
同一个模块能配置成多个相互独立的实例吗?可以,dynamicModule() 允许你把一个模块类配置成多个并存的实例(例如一个主数据库连接加一个只读副本),每个实例都有自己的容器和单例,按引用而不是按类去重。
Creator
aqz236
Size
6 MB
Stars
290
Age
1y
Commits · 90 Days