Skip to content

Eden Treaty 是 Elysia 官方的客户端库,可以从 Elysia 应用程序类型直接生成类型安全的 API 客户端。由于 nestelia 使用 Elysia 作为 HTTP 层,Eden Treaty 开箱即用。

安装

bash
bun add @elysiajs/eden

为什么装饰器路由不能自动产生类型

nestelia 通过 Reflect.getMetadata 在运行时注册路由。TypeScript 将装饰器参数(例如 @Get('/users') 中的路径)视为 string,而非字面量类型 '/users'。这意味着 Elysia 实例的泛型参数无法仅通过装饰器路径获知路由信息。

解决方案是使用一个描述 API 接口的类型化 schema——可以通过 nestelia-gen CLI 自动生成,也可以手动编写(与控制器并列存放)。

自动生成 — nestelia-gen

nestelia-gen 使用 TypeScript 编译器对控制器进行静态分析,生成一个完整类型的 app.schema.ts 文件——包括从方法注解中提取的响应类型。无需启动应用,无运行时副作用。

bash
bunx nestelia-gen --tsconfig tsconfig.json src/app.schema.ts

为控制器方法添加返回类型注解,以便生成器能够识别:

typescript
@Controller("/users")
export class UsersController {
  @Get("/")
  getAll(): User[] { … }

  @Get("/:id")
  getOne(@Param(IdParams) p: Static<typeof IdParams>): User | null { … }

  @Post("/")
  create(@Body(CreateDto) body: Static<typeof CreateDto>): User { … }
}

生成的 app.schema.ts 如下所示:

typescript
// auto-generated by nestelia-gen — do not edit manually
import { Elysia, t } from "elysia";
import type { User } from "./users/user.entity";

export const appSchema = new Elysia()
  .get("/users", (): User[] => undefined as never)
  .get("/users/:id", (): User | null => undefined as never, {
    params: t.Object({ id: t.String() })
  })
  .post("/users", (): User => undefined as never, {
    body: t.Object({ name: t.String() })
  });

export type App = typeof appSchema;

body/params 的 TypeBox 表达式从控制器文件中逐字复制。响应类型来自方法注解。每次添加或修改路由时重新运行 nestelia-gen

json
// package.json
{
  "scripts": {
    "gen": "nestelia-gen --tsconfig tsconfig.json src/app.schema.ts",
    "build": "bun run gen && tsc"
  }
}

启动时自动生成 — gen 选项

除了将 nestelia-gen 作为单独脚本运行,还可以向 createElysiaApplication 传入 gen: true,这样每次应用启动时都会自动重新生成 schema:

typescript
const app = await createElysiaApplication(AppModule, { gen: true });

要自定义输出路径或 tsconfig,传入一个对象:

typescript
const app = await createElysiaApplication(AppModule, {
  gen: { output: "src/schema.ts", tsconfig: "tsconfig.app.json" },
});

这等同于在启动前运行 bunx nestelia-gen [args]——在开发中非常实用,不需要每次修改路由后手动重新运行 CLI。

然后配合 withSchema()treaty 使用该 schema:

typescript
// src/main.ts
import { createElysiaApplication } from "nestelia";
import { AppModule } from "./app.module";
import { appSchema } from "./app.schema"; // ← auto-generated

const app = await createElysiaApplication(AppModule);
const typedServer = app.withSchema(appSchema);
export type App = typeof typedServer;

await typedServer.listen(3000);
typescript
// src/client.ts
import { treaty } from "@elysiajs/eden";
import type { App } from "./main";

const client = treaty<App>("http://localhost:3000");

const { data } = await client.users.get();              // User[]
const { data: user } = await client.users.post({ name: "Alice" }); // User
const { data: found } = await client.users({ id: "1" }).get();     // User | null

手动 schema — 与控制器并列存放

对于倾向于使用显式 schema 而不需要构建步骤的团队,可在每个控制器旁边导出一个手动编写的 Elysia schema。该 schema 是客户端类型的唯一来源;控制器是业务逻辑的唯一来源。

typescript
// src/users/users.controller.ts
import { Elysia, t, type Static } from "elysia";
import { Controller, Get, Post, Delete, Body, Param } from "nestelia";
import type { User } from "./user.entity";
import { UsersService } from "./users.service";

const IdParams = t.Object({ id: t.String() });
const CreateDto = t.Object({ name: t.String() });

export const usersSchema = new Elysia({ prefix: "/users" })
  .get("/", (): User[] => [])
  .post("/", (): User => ({} as User), { body: CreateDto })
  .get("/:id", (): User | null => null, { params: IdParams })
  .delete("/:id", (): { success: boolean } => ({ success: true }), { params: IdParams });

@Controller("/users")
export class UsersController {
  constructor(private readonly users: UsersService) {}

  @Get("/")    getAll(): User[] { return this.users.findAll(); }
  @Post("/")   create(@Body(CreateDto) body: Static<typeof CreateDto>): User { return this.users.create(body); }
  @Get("/:id") getOne(@Param(IdParams) p: Static<typeof IdParams>): User | null { return this.users.findOne(p.id); }
  @Delete("/:id") remove(@Param(IdParams) p: Static<typeof IdParams>): { success: boolean } { return this.users.remove(p.id); }
}

main.ts 中组合来自所有模块的 schema:

typescript
// src/main.ts
import { createElysiaApplication } from "nestelia";
import { Elysia } from "elysia";
import { AppModule } from "./app.module";
import { usersSchema } from "./users/users.controller";
import { postsSchema } from "./posts/posts.controller";

const app = await createElysiaApplication(AppModule);
const typedServer = app.withSchema(new Elysia().use(usersSchema).use(postsSchema));
export type App = typeof typedServer;

await typedServer.listen(3000);

withSchema() 的工作原理

app.withSchema(schema) 执行一次运行时类型转换——this.httpServer as unknown as TSchema——并将其返回。运行时返回的是带有所有已编译控制器路由的实际 Nestelia 服务器;TypeScript 将其视为带有完整路由泛型的 schema 类型。零开销,零请求处理重复。

不使用 listen() 进行测试

将类型化服务器直接传递给 treaty——Elysia 的 handle() 方法会在进程内处理请求:

typescript
import { treaty } from "@elysiajs/eden";
import { createElysiaApplication } from "nestelia";
import { AppModule } from "./app.module";
import { appSchema } from "./app.schema";

const app = await createElysiaApplication(AppModule);
const client = treaty<App>(app.withSchema(appSchema)); // 无需 listen()

const { data } = await client.users.get(); // User[]

基于 MIT 许可证发布。