npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@guangnao/webapi

v1.6.4

Published

TypeScript 后端框架:装饰器路由 + DI + ORM + 缓存 + 实时通信 + 鉴权,一个包全带

Readme

@guangnao/webapi

TypeScript 后端框架。控制器、依赖注入、过滤器管道、ORM、鉴权、实时通信,一个包全带。

如果你写过 ASP.NET Core Web API,这个框架的形状你已经认识了 —— ControllerBase、[HttpGet]、[FromBody]、四段过滤器管道、构造函数注入、app.UseXxx(),都是同一套。下面每一节都按「你在 .NET 里怎么写 → 这里怎么写」对着列。

npm i @guangnao/webapi

Node ≥ 20.12,ESM only(package.json 里要有 "type": "module")。


一分钟上手

Program.cs 对应 main.ts:

import 'reflect-metadata';                  // 必须排在框架之前
import { WebApplication, NodeHttpAdapter, ControllerBase, Controller, Get, Query } from '@guangnao/webapi';

@Controller('/api/hello')                   // [Route("api/hello")]
class HelloController extends ControllerBase {
  @Get('/')                                 // [HttpGet]
  hello(@Query('name') name = 'world') {    // [FromQuery]
    return this.ok({ message: `hello, ${name}` });   // return Ok(...)
  }
}

const app = WebApplication.create()
  .useResponseFormat()                      // 统一响应信封,见下文
  .build();

app.useAdapter(new NodeHttpAdapter());      // 传输层:内置 Node http
app.useController(HelloController);         // 或 await app.scanModules('./src/modules') 自动发现
await app.listen(3000);

useController 逐个注册;scanModules(dir) 按目录扫描,等价于 .NET 的自动控制器发现——控制器、服务、实体、WebSocket 处理器、定时任务都能被认出来。


推荐目录结构

按 feature 分目录,一个功能的控制器 / 服务 / 实体放在一起——不是 .NET 那种 Controllers/ Services/ Models/ 按类型横切。scanModules() 递归扫描整棵树,照下面这样摆就能直接跑:

my-api/
├── .env                          # 配置(dotenv 读进 process.env)
├── package.json                  # 必须有 "type": "module"
├── tsconfig.json
├── public/                       # 静态资源(可选,useStaticFiles 的 root)
│   ├── index.html
│   └── assets/
└── src/
    ├── main.ts                   # ≈ Program.cs:装配 + 启动
    └── modules/                  # ← scanModules 扫这里
        ├── users/
        │   ├── users.controller.ts
        │   ├── users.service.ts
        │   └── users.entity.ts
        ├── posts/
        │   ├── posts.controller.ts
        │   ├── posts.service.ts
        │   └── posts.entity.ts
        ├── realtime/
        │   ├── events.controller.ts   # @SSE()
        │   └── echo.ws.ts             # @WebSocketHandler()
        └── shared/
            ├── error.filter.ts        # @GlobalFilter()
            └── jobs.ts                # @Scheduler() + @Cron()

scanModules 的四条规则

  1. 不需要文件名约定。.controller.ts / .service.ts 这些后缀纯粹是给人看的,扫描器只看类上的装饰器来分类:@Controller → 控制器,@Service/@Injectable → 服务,@Entity → 实体,@WebSocketHandler → WS 处理器,@Scheduler → 定时任务,@GlobalFilter(或实现了四个过滤器接口中任一钩子)→ 过滤器。叫 foo.ts 一样能被认出来。
  2. 扫 .ts 还是 .js 由入口决定。入口以 .ts 结尾(tsx src/main.ts)就扫 **/*.ts;入口是 .js(编译后 node dist/main.js)就扫 **/*.js。这一条是自动的,但意味着编译部署时 src/modules 的产物必须跟着进 dist,否则线上一个模块都扫不到。*.spec.ts *.test.ts *.d.ts 与 node_modules / dist 会被跳过。
  3. 类必须 export。扫描器读的是模块的导出表,@Service() 打在一个没导出的类上,它就不会被登记——而控制器注入它时才报错,错在别处、现象在别处。
  4. 路径相对 process.cwd(),不是相对 main.ts。写 './src/modules' 就得从项目根启动。想不受启动目录影响,用 new URL('./modules', import.meta.url).pathname(demo 就是这么写的)。

package.json

{
  "name": "my-api",
  "type": "module",
  "scripts": {
    "dev": "tsx watch src/main.ts",
    "start": "tsx src/main.ts",
    "build": "tsc",
    "serve": "node dist/main.js"
  },
  "dependencies": {
    "@guangnao/webapi": "^1.6.0",
    "dotenv": "^17.0.0"
  },
  "devDependencies": {
    "tsx": "^4.19.0",
    "typescript": "^5.7.0",
    "@types/node": "^20.19.0"
  }
}

"type": "module" 不能少——这个包是 ESM only。

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "strictPropertyInitialization": false,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}

strictPropertyInitialization: false 是为了实体和 DTO——@Column() title!: string 由框架赋值,开着它每个字段都得写初始值。

emitDecoratorMetadata 开着没坏处,但别指望它:只有 tsc 会真的发射,tsx/esbuild 不会(见上文那一节)。参数类型一律显式写,两种构建方式下行为才一致。

.env

DB_HOST=localhost
DB_PORT=3306
DB_USER=root
DB_PASS=
DB_NAME=my_api

REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PWD=
REDIS_DB=0

JWT_SECRET=change-me

src/main.ts

import 'dotenv/config';           // 读 .env → process.env,必须排在其它 import 之前
import 'reflect-metadata';
import { WebApplication, NodeHttpAdapter } from '@guangnao/webapi';

const app = WebApplication.create()
  .useResponseFormat({ timestamp: true })
  .useJwt({ secret: process.env.JWT_SECRET! })
  .useOpenApi({ title: 'My API', version: '1.0.0' })
  .useStaticFiles({ root: './public', prefix: '/static', maxAge: 3600 })
  .useUpload({ maxFileSize: 5 * 1024 * 1024, maxFiles: 5 })
  .useCors()
  .useHealth('/health')            // 健康检查端点
  .build();

app.useAdapter(new NodeHttpAdapter());

await app.useDatabase({
  dialect: 'mysql',
  host: process.env.DB_HOST, port: Number(process.env.DB_PORT),
  username: process.env.DB_USER, password: process.env.DB_PASS, database: process.env.DB_NAME,
  autoSync: { alter: true },       // 开发期自动对齐表结构;生产请关掉
});
await app.useRedis({
  host: process.env.REDIS_HOST, port: Number(process.env.REDIS_PORT),
  ...(process.env.REDIS_PWD ? { password: process.env.REDIS_PWD } : {}),   // 空串别当密码传
});

await app.scanModules(new URL('./modules', import.meta.url).pathname);
await app.listen(3000);

// 优雅停机:释放数据库连接池与 Redis 连接
for (const sig of ['SIGINT', 'SIGTERM'] as const) {
  process.once(sig, () => void app.close().then(() => process.exit(0)));
}

useDatabase / useRedis 要 await(它们真的去连),其余 useXxx 是建造器上的链式配置。数据库或 Redis 连不上时 await 会抛——包在 try/catch 里就能降级启动(demo 就是这么处理的,没有外部依赖时仍然能起来看路由)。

编译部署

npm run build && npm run serve

TypeScript 编译到 ESM 有个众所周知的坑:产物里的相对 import 必须带 .js 后缀,所以源码里就得写 from './users.service.js'。嫌麻烦就直接用 tsx src/main.ts 跑生产——它不需要预编译,也没有这个问题。


总对照表

| ASP.NET Core | @guangnao/webapi | | --- | --- | | ControllerBase | ControllerBase | | [ApiController] [Route("api/x")] | @Controller('/api/x') | | [HttpGet("{id}")] | @Get('/:id') | | [FromBody] [FromQuery] [FromRoute] | @Body() @Query() @Param() | | [FromHeader] / Cookie | @Header() @Cookie() | | Ok() NotFound() BadRequest() | this.ok() this.notFound() this.badRequest() | | services.AddScoped<T>() | @Service({ scope: 'scoped' }) | | 构造函数注入 | 构造函数注入,但必须写 @Inject(T)(见下节) | | app.UseCors() app.UseAuthentication() | .useCors() .useJwt() | | 自定义中间件 | app.useMiddleware({ invoke(ctx, next) {} }) | | IAuthorizationFilter / IActionFilter / IExceptionFilter / IResultFilter | 同名四个接口,语义一致 | | [Authorize(Roles = "admin")] [AllowAnonymous] | @Authorize('admin') @AllowAnonymous() | | EF Core DbContext / DbSet<T> | app.useDatabase() / Repository<T> | | [Required] [EmailAddress] [MinLength] | @IsRequired() @IsEmail() @MinLength() | | Swashbuckle / Swagger UI | .useOpenApi() → /openapi.json + /docs | | BackgroundService / Quartz | @Scheduler() + @Cron() | | SignalR | @WebSocketHandler() / @SSE() | | appsettings.json + IOptions<T> | .env + process.env(框架不代管配置) |


⚠️ 唯一必须记住的差异:参数类型要显式写

.NET 从方法签名就知道 int id。这里不行,必须把类型作为装饰器参数传进去:

@Get('/items/:id')
detail(@Param('id', Number) id: number,          // ← 第二个参数不可省
       @Query('page', Number) page = 1,
       @Query('all', Boolean) all = false) { … }

@Post('/signup')
signup(@Body(SignupDto) dto: SignupDto) { … }    // ← 不传 DTO 类,校验不会跑

constructor(@Inject(UsersService) private readonly users: UsersService) { super(); }
//          ^^^^^^^^^^^^^^^^^^^^^ 不写的话解析不出依赖,请求直接 500

原因:TypeScript 的 design:paramtypes 元数据只有 tsc 会发射,esbuild / swc / tsx / Bun 一律不发(esbuild 明确表示不打算支持 emitDecoratorMetadata)。依赖反射的写法在这些工具下会静默失效,而且不报错:

| 写法 | tsc 构建 | esbuild / tsx 构建 | | --- | --- | --- | | @Query('page') page: number | 收到 7 | 收到 "7",page + 1 得 "71" | | @Query('on') on: boolean | 收到 false | 收到 "false",而 if (on) 为真 | | @Body() dto: SignupDto | 校验会跑 | 校验一条都不跑,非法数据原样 200 通过 | | constructor(private s: FooService) | 注入成功 | 解析不出依赖,请求 500 |

显式传类型是唯一在任何构建工具下都成立的写法。不传类型也是合法的——那就是拿字符串,行为可预期。


控制器与返回值

@Controller('/api/users')
export class UsersController extends ControllerBase {
  constructor(@Inject(UsersService) private readonly users: UsersService) { super(); }

  @Get('/')
  async list(@Query('page', Number) page = 1) {
    return this.ok(await this.users.list(page));                   // 200
  }

  @Get('/:id')
  async detail(@Param('id', Number) id: number) {
    const user = await this.users.byId(id);
    if (!user) return this.notFound('用户不存在');                  // 404
    return this.ok(user);
  }

  @Post('/')
  async create(@Body(CreateUserDto) dto: CreateUserDto) {
    return this.created(await this.users.create(dto));             // 201
  }

  @Delete('/:id')
  async remove(@Param('id', Number) id: number) {
    await this.users.remove(id);
    return this.noContent();                                       // 204
  }
}

| 方法 | .NET | 行为 | | --- | --- | --- | | this.ok(data) | Ok(data) | 200 | | this.created(data) | Created(...) | 201 | | this.noContent() | NoContent() | 204 | | this.status(code, data) | StatusCode(code, data) | 任意状态码 | | this.badRequest(msg, errors?) | BadRequest(...) | 400,抛异常(下同) | | this.unauthorized(msg) | Unauthorized() | 401 | | this.forbidden(msg) | Forbid() | 403 | | this.notFound(msg) | NotFound() | 404 | | this.redirect(url, 302) | Redirect(url) | 重定向 |

注意错误类方法是 throw(返回类型 never),所以 return this.notFound() 和 this.notFound() 效果相同,写 return 只是为了让读代码的人看清这里结束了。

上下文对象:this.context(≈ HttpContext)、this.request / this.response、this.user(≈ User / ClaimsPrincipal)、this.userId。

响应信封

.useResponseFormat() 会把所有返回值包成统一结构——.NET 里通常自己写 ApiResponse<T> 或者 Result 过滤器,这里是内置的:

{ "code": 200, "message": "success", "data": { "…" }, "timestamp": "2026-08-04T…" }

不需要就别调 useResponseFormat(),返回值原样出去。


依赖注入

import { Service, ServiceBase, Injectable, Inject } from '@guangnao/webapi';

@Service()                              // 默认 singleton
export class UsersService extends ServiceBase { … }

@Service({ scope: 'scoped' })           // services.AddScoped<T>()
export class RequestScopedThing { … }

@Service({ scope: 'transient' })        // services.AddTransient<T>()
export class Throwaway { … }

三种生命周期与 .NET 一一对应:singleton(默认)/ scoped(每请求)/ transient(每次解析)。

注入时必须显式写 token——原因同上一节,构造函数的参数类型也是靠 design:paramtypes 反射的:

@Controller('/api/users')
export class UsersController extends ControllerBase {
  constructor(
    @Inject(UsersService) private readonly users: UsersService,
    @InjectRepository(User) private readonly repo: Repository<User>,
    @Optional() @Inject(MetricsService) private readonly metrics?: MetricsService,
  ) { super(); }
}

漏写 @Inject 不会有编译错误,也不会有启动警告——直到请求打进来,解析不出依赖,500。

scanModules() 会把 @Service() 类自动登记,不需要一条条 AddScoped。


中间件与过滤器

中间件(≈ .NET 的 app.Use(async (ctx, next) => …))跑在路由之前:

app.useMiddleware({
  async invoke(ctx, next) {
    const started = Date.now();
    await next();
    console.log(`${ctx.method} ${ctx.path} ${Date.now() - started}ms`);
  },
});

过滤器跑在路由之后、围绕 action,四个接口跟 ASP.NET Core 的管道同名同义:

import { GlobalFilter, UseFilters } from '@guangnao/webapi';
import type { IExceptionFilter, IActionFilter, IAuthorizationFilter, IResultFilter } from '@guangnao/webapi';

@GlobalFilter()                                     // 全局注册,≈ options.Filters.Add<T>()
export class ErrorLogFilter implements IExceptionFilter {
  onException(ctx, error: Error) { console.error(ctx.path, error); }
}

@UseFilters(SomeFilter)                             // 挂在单个控制器/方法上
@Controller('/api/x')
class XController extends ControllerBase { … }

| 接口 | 钩子 | .NET 对应 | | --- | --- | --- | | IAuthorizationFilter | onAuthorization | 同名,最先跑 | | IActionFilter | onActionExecuting / onActionExecuted | 同名。onActionExecuting 返回 ActionResult 可短路 action(缓存命中就是这么做的) | | IExceptionFilter | onException | 同名 | | IResultFilter | onResultExecuting / onResultExecuted | 同名 |


ORM

EF Core 的 DbContext + DbSet<T> 在这里是 useDatabase() + Repository<T>(底层 Sequelize,支持 MySQL / PostgreSQL / SQLite)。

import { Entity, Column, Index, NotNull, Comment, BelongsTo, HasMany } from '@guangnao/webapi';

@Index({ name: 'idx_posts_user_created', fields: ['userId', 'createdAt'] })   // 复合索引
@Entity('posts')                                                              // [Table("posts")]
export class Post {
  @Column({ type: 'INTEGER', primaryKey: true, autoIncrement: true })
  id!: number;

  @Column({ type: 'STRING' }) @NotNull()
  title!: string;

  @Column({ type: 'TEXT', allowNull: true }) @Comment('正文,可为空')
  body?: string;

  @Column({ type: 'INTEGER', allowNull: false })
  userId!: number;

  @BelongsTo(() => User, 'userId', 'author')      // 第三个参数 = 查询 include 时的字段名
  author?: User;
}

仓储通过构造函数注入,≈ .NET 里注入 DbContext 或 IRepository<T>:

import { Service, ServiceBase, InjectRepository, Repository } from '@guangnao/webapi';

@Service()
export class PostsService extends ServiceBase {
  constructor(@InjectRepository(Post) private readonly repo: Repository<Post>) { super(); }

  list()                     { return this.repo.findAll({ include: [{ model: User, as: 'author' }] }); }
  byId(id: number)           { return this.repo.findByPk(id); }
  page(offset = 0)           { return this.repo.findAndCountAll({ offset, limit: 20 }); }
  create(data: Partial<Post>) { return this.repo.create(data); }
  update(id: number, d: Partial<Post>) { return this.repo.update(d, { where: { id } }); }
  remove(id: number)         { return this.repo.destroy({ where: { id } }); }
}

连接(autoSync 相当于 EF 的 EnsureCreated / 自动迁移,生产环境请关掉):

await app.useDatabase({
  dialect: 'mysql', host: 'localhost', port: 3306,
  username: 'root', password: '…', database: 'app',
  autoSync: { alter: true },
});

数据库约束冲突会被翻译成合适的 HTTP 状态:唯一键冲突 → 409(消息里带上是哪个约束),外键不存在 → 400,模型校验失败 → 422。不会像裸 Sequelize 那样统统冒成 500。


校验

DataAnnotations 的对应物,装饰器打在 DTO 属性上:

import { IsRequired, IsEmail, MinLength, MaxLength, Min, Max, Range, Matches, IsIn, ValidNested } from '@guangnao/webapi';

export class SignupDto {
  @IsRequired() @IsEmail()          email!: string;
  @IsRequired() @MinLength(8)       password!: string;
  @Min(0) @Max(150)                 age?: number;
  @IsIn(['admin', 'user'])          role?: string;
}

校验不通过直接 400,响应体里逐字段列出原因——不需要像 .NET 那样手动检查 ModelState.IsValid。前提是 @Body(SignupDto) 把类传进去了(见上文的差异章节)。


鉴权

const app = WebApplication.create().useJwt({ secret: process.env.JWT_SECRET! }).build();
@Authorize()                 // 类级:整个控制器都要登录
@Controller('/api/vault')
class VaultController extends ControllerBase {
  @Get('/items') items() { return this.ok(this.user); }   // this.user = JWT payload

  @AllowAnonymous()          // 在受保护的类里开个口子
  @Get('/health') health() { return this.ok({ ok: true }); }
}

@Authorize('admin')          // 方法级:还要角色
@Get('/admin') adminOnly() { … }

优先级:方法 @AllowAnonymous > 方法 @Authorize > 类 @Authorize > 公开。

401 与 403 是分开的:没带 token / token 无效 → 401;已登录但角色不匹配 → 403。JWT 中间件验不过时按「未登录」处理而不是当场拒绝,所以公开路由不会因为带了个坏 token 就打不开。

另有 OAuth2(微信 / 支付宝 / GitHub / Google / 钉钉 / QQ / 微软)与 CAS 式单点登录(.useOAuth() / .useSso())。SSO 的 allowedServices 白名单必须配——不配会直接拒绝带 service= 的登录请求,因为放行任意回调地址等于把用户的登录票据寄给对方。


API 文档

const app = WebApplication.create()
  .useOpenApi({ title: 'My API', version: '1.0.0' })
  .build();

/openapi.json 拿规范,/docs 是 Swagger UI(内存直出,不需要配静态目录)。标注装饰器与 Swashbuckle 的注解一一对应:

@ApiTags('Users')                                    // [ApiExplorerSettings(GroupName=...)]
@Controller('/api/users')
class UsersController extends ControllerBase {
  @ApiOperation('取单个用户', '按主键查询。')          // <summary> XML 注释
  @ApiResponse(200, '用户详情')                        // [ProducesResponseType(200)]
  @ApiResponse(404, '用户不存在')
  @Get('/:id') detail(@Param('id', Number) id: number) { … }

  @ApiIgnore()                                       // [ApiExplorerSettings(IgnoreApi=true)]
  @Get('/_internal/dump') dump() { … }               // 路由照常能打,只是不进文档
}

class CreateUserDto {
  @ApiProperty({ description: '邮箱', example: '[email protected]' })
  email!: string;
}

缓存与限流

需要先 await app.useRedis({ host, port })。

@Cache('posts:by-user:$userId', 60)      // 命中直接返回,不进 action;$userId 依次从 params、query 取
@Get('/by-user') byUser(@Query('userId', Number) userId: number) { … }

@CacheEvict('posts:by-user:*')           // action 跑完按模式删键
@Throttle(20, 60)                        // 每分钟 20 次,计数在 Redis(多实例共享)
@Post('/') create(@Body(CreatePostDto) dto: CreatePostDto) { … }

@Cache 大致相当于 .NET 的 [ResponseCache] + IDistributedCache,但缓存的是 action 返回值而不是 HTTP 响应;@Throttle 相当于 AddRateLimiter。


实时通信

SignalR 的两个替代:

@Controller('/api/events')
class EventsController extends ControllerBase {
  @SSE()                                  // 服务端推送:async generator,yield 什么客户端收什么
  async *stream() {
    for (let i = 0; i < 10; i++) {
      yield { event: 'tick', data: { i } };
      await new Promise(r => setTimeout(r, 1000));
    }
  }
}

@WebSocketHandler('/ws/echo')             // 全双工
class EchoSocket {
  onConnection(ws: WebSocket) { ws.send('welcome'); }
  onMessage(ws: WebSocket, msg: string | Buffer) { ws.send(msg); }
  onClose() { }
}

要从别处往 SSE 频道推消息用 SseHub.getInstance().publish(channel, event, data)——相当于 SignalR 的 IHubContext<T>。


后台任务

BackgroundService / Quartz 的对应物:

import { Scheduler, Cron, Interval, Timeout } from '@guangnao/webapi';

@Scheduler()
class Jobs {
  @Cron('0 */5 * * * *')  everyFiveMinutes() { … }   // 6 字段,第一位是秒
  @Interval(30_000)       every30s() { … }
  @Timeout(5_000)         onceAfterStartup() { … }
}

文件与静态资源

const app = WebApplication.create()
  .useUpload({ maxFileSize: 5 * 1024 * 1024, maxTotalSize: 20 * 1024 * 1024, maxFiles: 5 })
  .useStaticFiles({ root: './public', prefix: '/static', maxAge: 3600 })   // app.UseStaticFiles()
  .build();
@Post('/upload')
async upload() {
  const { fields, files } = await this.context.parseMultipart();     // 整收:全进内存
  return this.ok({ count: files.length });
}

@Post('/big')
async big() {
  const upload = this.context.multipartFiles();                      // 流式:不进内存
  for await (const file of upload) {
    await pipeline(file.stream!, createWriteStream(`/data/${file.filename}`));
  }
  return this.ok({ fields: upload.fields });                         // 字段要迭代走完才齐
}

超限、类型不符会返回 413 / 415(而不是 500),且不会像某些实现那样悄悄把文件截断后返回 200。SPA 前端用 useStaticFiles({ spa: true, exclude: ['/api'] }),记得把 API 前缀排除掉。


GraphQL

const app = WebApplication.create().useGraphQL({ resolvers: [BookResolver] }).build();
@ObjectType()
class Book {
  @Field(() => ID)     id!: string;
  @Field(() => String) title!: string;
  @Field(() => Int)    year!: number;
}

@Resolver()
class BookResolver {
  @GqlQuery(() => [Book])                            // 注意:导出名是 GqlQuery
  books() { … }                                      // 避免与取查询参数的 @Query 撞名

  @Mutation(() => Book)
  addBook(@Arg('input', () => AddBookInput) input: AddBookInput) { … }
}

/graphql 是端点,/graphql-ui 是 GraphiQL。N+1 用 createDataLoader(batchFn),每请求建一个(放进 context 工厂里)。


配置

框架不代管配置——没有 appsettings.json / IOptions<T> 那一套。用 dotenv 读 .env 到 process.env 即可:

import 'dotenv/config';        // 要排在其它 import 之前

运行时依赖

sequelize、ioredis、graphql、ws、busboy、validator、glob、reflect-metadata 会随包一起装。用不到的特性不 import 就不会加载,但仍然占磁盘——这是全家桶包的代价。

uWebSockets.js(更快的传输层)不在依赖里,要用得自己装;不装就走内置的 NodeHttpAdapter。