# yest **Repository Path**: yangyxu/yest ## Basic Information - **Project Name**: yest - **Description**: No description available - **Primary Language**: TypeScript - **License**: MIT - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-08-24 - **Last Updated**: 2026-10-21 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # yestjs Nest-style Node.js framework built on [Hono](https://hono.dev). One process can serve **HTTP/HTTPS**, **WebSocket**, **SSE**, **TCP** and **MQTT**, with decorators, DI, guards, pipes and graceful shutdown. > The npm package is **`yestjs`** because the name `yest` is already taken on the registry. Imports use `yestjs`. ```bash npm install yestjs reflect-metadata ``` Requires **Node.js 20+**. Enable `experimentalDecorators` and `emitDecoratorMetadata` in `tsconfig.json`. ## Quick start ```ts import "reflect-metadata"; import { Controller, Get, Module, Post, UploadedFile, YestFactory, type YestUploadedFile, } from "yestjs"; @Controller("/api") class AppController { @Get("/ping") ping() { return { pong: true }; } @Post("/upload") upload(@UploadedFile("file") file?: YestUploadedFile) { return { name: file?.originalname, size: file?.size }; } } @Module({ controllers: [AppController] }) class AppModule {} const app = await YestFactory.create(AppModule, { cors: true }); await app.listen({ port: 3000 }); ``` ```ts import { MqttModule } from "yestjs/mqtt"; ``` To keep the import path `yest` in an existing app: ```json { "dependencies": { "yest": "npm:yestjs@^1.0.0" } } ``` ## Modules A module can inject its own providers, including an `@Injectable()` that was not listed and was created for that module, and the providers those modules **export**. Imports are not transitive: re-export a token to pass it further. `@Global()` exports are visible everywhere after that module is imported. `app.get()` uses the root module's view, so a private provider from another module is not reachable from the root. Subclass decorators keep their own route and parameter metadata. `@Injectable("request")` is created per request. A singleton cannot take one in its constructor; mark that class `@Injectable("request")` as well, and resolve it from a guard, pipe, or another request-scoped provider. Resolving it during startup throws. `close()` then `listen()` builds a fresh router, so routes and middleware are not registered twice. A dynamic module class can be imported once. Importing it again throws. ## Concurrency and memory Classic mode and DDD mode share one admission gate and one memory governor. - A singleton async factory is created once, even when several callers resolve it together. Request-scoped providers stay per request. Concurrent commands each get their own unit of work. Cached providers are returned without opening another async context. - Access logs collect a field only when that field is enabled, and burst counters stay capped at 2,000 keys. - `maxInflight` (default **4096**, `0` disables the cap) limits handler executions across HTTP, WebSocket, SSE, TCP and MQTT. Further work gets **429**. - Memory is compared with the V8 heap limit. At **75%** the process trims in-memory buffers (including published outbox rows) and, if Node was started with `--expose-gc`, collects garbage at most once every 10 seconds. At **90%** new work gets **503** and `/readyz` is not ready. `/healthz` stays up and reports `memory.pressure`. Set `memory: false` to turn this off, or pass `highWaterRatio` / `criticalRatio`. - `MemoryOutbox` keeps at most 10,000 unpublished events and drops rows after they are published. ```ts await YestFactory.create(AppModule, { maxInflight: 1024, memory: { highWaterRatio: 0.7, criticalRatio: 0.85 }, }); ``` ## Unit tests `yestjs/testing` compiles a module without listening. `overrideProvider()` replaces an implementation for every module. The same API covers classic providers and DDD repositories. ```ts import { Test, createMock } from "yestjs/testing"; const app = await Test.createTestingModule({ imports: [ReportModule] }) .overrideProvider(Clock) .useValue(createMock({ now: () => "fake" })) .compile(); app.get(Report).text(); await app.close(); ``` `useClass()` and `useFactory()` are the other overrides. Pass `architecture: "ddd"` on the module metadata, or as `createTestingModule(metadata, { architecture: "ddd" })`. `listen()` is available when a test needs a port. Global and `@UsePipes()` pipes run on input parameters (`body`, `query`, `param`, and the rest). They do not run on `@Ctx()`, `@Req()`, `@Res()`, or the connected socket. ## Core middleware `app.useCore(part, middleware)` and `@CoreHook(part)` run in both classic and DDD mode. A part is a transport (`http`, `ws`, `sse`, `mqtt`, `tcp`), `session`, or `persistence`. Return `next()` to continue. Skipping `next()` stops that phase. HTTP `app.use()` and `@UseMiddleware()` stay as they are. Session middleware runs after guards. The built-in session hook copies `user.sub`, `user.userId`, or `user.id` from the request into `session.userId`. `runWithSession({ userId }, work)` opens a session for a job outside a request. `currentSession()` reads it. On insert and update, call `emitPersistence(action, entity)` from the ORM listener (TypeORM `beforeInsert` / `beforeUpdate`). The built-in persistence hook fills: | When | Properties | |---|---| | insert, when empty | `create_user_id`, `create_time`, `update_user_id`, `update_time` | | update | `update_user_id` (when an operator is in the session), `update_time` | Create fields stay as they were on update. Pass `audit` to use the property names already on your entities. `audit: false` turns the filler off; your own persistence middleware still runs. Give every new table these four columns so a later query can show who wrote the row. ```ts await YestFactory.create(AppModule, { audit: { createUserId: "createId", createTime: "createDate", updateUserId: "modifyId", updateTime: "modifyDate", }, }); ``` ```ts import { CoreHook, Injectable } from "yestjs"; import type { CoreHookContext } from "yestjs"; @CoreHook("session") @Injectable() class OperatorSession { use(ctx: CoreHookContext, next: () => Promise | void) { if (ctx.session && ctx.http?.hono?.get?.("user")?.sub) { ctx.session.userId = String(ctx.http.hono.get("user").sub); } return next(); } } ``` ## Tenancy Tenancy is off unless `tenant` is set. A single-tenant process keeps its current rows and queries. The organization tree stays inside a tenant; `tenantId` is the isolation boundary. `true` uses the entity property `tenantId` (column `tenant_id`). The session copies `user.tenantId` after guards. `runWithSession({ tenantId }, work)` opens a tenant for MQTT handlers and jobs. `runWithSession({ bypassTenant: true }, work)` skips isolation for a platform job. `emitPersistence` writes the session tenant on insert and update when the property is empty. A row that already belongs to another tenant is rejected with HTTP 403. `@TenantIgnore()` marks a shared table, such as a dictionary, and the stamper leaves it alone. `tenantScope(where)` adds the current tenant to a query. Call it from the repository. With tenancy off, the condition is returned unchanged. A condition that names a different tenant is narrowed to the session tenant. ```ts await YestFactory.create(AppModule, { tenant: { tenantId: "tenantId", userProperty: "tenantId" }, }); const rows = await repo.find({ where: tenantScope({ status: "open" }) }); ``` ## Architecture The default is the existing style: `@Module`, `@Controller`, and providers. Set `architecture: "ddd"` when a process should use domain-driven design instead. One process uses one architecture. A DDD application can still expose HTTP with controllers; those controllers are the interface layer and only dispatch commands or queries. ```ts import { Module, YestFactory } from "yestjs"; import { AggregateRoot, BoundedContext, CommandBus, CommandHandler, DomainEvent, DomainException, Repository, RepositoryOf, ValueObject, } from "yestjs/ddd"; class Sku extends ValueObject<{ value: string }> { private constructor(value: string) { super({ value }); } static create(raw: string) { const value = raw.trim(); if (!value) throw new DomainException("sku is required"); return new Sku(value); } get value() { return this.props.value; } } class OrderPlaced extends DomainEvent { constructor( readonly aggregateId: string, readonly sku: string, ) { super(); } } class Order extends AggregateRoot { private constructor(id: string, readonly sku: string) { super(id); } static place(id: string, sku: Sku) { const order = new Order(id, sku.value); order.record(new OrderPlaced(id, sku.value)); return order; } } abstract class OrderRepository extends Repository {} @RepositoryOf(Order) class InMemoryOrderRepository extends OrderRepository { private readonly items = new Map(); async findById(id: string) { return this.items.get(id) ?? null; } async save(order: Order) { this.items.set(order.id, order); this.stage(order); } } class PlaceOrder { constructor(readonly sku: string) {} } @CommandHandler(PlaceOrder) class PlaceOrderHandler { constructor(private readonly orders: OrderRepository) {} async execute(command: PlaceOrder) { const order = Order.place(crypto.randomUUID(), Sku.create(command.sku)); await this.orders.save(order); return { id: order.id }; } } @BoundedContext({ name: "ordering" }) @Module({ providers: [ PlaceOrderHandler, { provide: OrderRepository, useClass: InMemoryOrderRepository }, ], }) class OrderingModule {} const app = await YestFactory.create(OrderingModule, { architecture: "ddd" }); await app.get(CommandBus).execute(new PlaceOrder("sku-1")); ``` | Building block | Role | |---|---| | `@BoundedContext({ name })` | Names a module as one bounded context. DDD mode requires at least one | | `Entity` / `AggregateRoot` / `ValueObject` | Identity, recorded domain events, structural equality | | `Repository` + `@RepositoryOf` | `findById` / `save`. `save()` calls `this.stage(aggregate)` so events leave the aggregate | | `@CommandHandler` / `CommandBus` | One handler per command class. `execute()` runs inside a unit of work and then publishes staged events | | `@QueryHandler` / `QueryBus` | Reads. A query opens a read-only unit of work, including when a command dispatches it | | `@OnDomainEvent` | In-process handler. Several handlers may listen to one event | | `ApplicationService.run()` | Same unit of work as the command bus, without a command class | `DomainException` raised from an HTTP handler becomes **422**. Command, query, and event handlers, and `@RepositoryOf` classes, must be providers or controllers of a `@BoundedContext()` module. Queries open a read-only unit of work, including a query dispatched from a command; `stage()` throws if a query saves an aggregate. Nested `CommandBus.execute()` joins the outer unit of work and publishes once. Dispatch from `onModuleInit` is safe; the buses are bound before module init hooks run. Without an outbox, events stay on the aggregate until in-process handlers finish. If a handler throws, they are still there. To share one database transaction with the aggregate, register `TRANSACTION_MANAGER` and `OUTBOX` (`MemoryOutbox` is an in-memory implementation). The transaction runs the command and `outbox.append` together. Handlers run after it commits, one event at a time. An event that was handled is marked published; if a later event's handler throws, only that event stays pending for `replayOutbox(outbox, eventBus)`. `{ architecture: "classic" }` (the default) fails fast if a module uses `@BoundedContext`, `@CommandHandler`, `@QueryHandler`, `@OnDomainEvent`, or `@RepositoryOf`. ## HTTP / HTTPS ```ts await app.listen({ port: 443, https: { key, cert }, }); ``` Decorators: `@Controller` `@Get` `@Post` `@Put` `@Patch` `@Delete` `@Body` `@Param` `@Query` `@HeaderParam` `@Cookie` `@Ctx` `@UseGuards` `@UsePipes` `@UseFilters` `@Timeout`. - `/healthz` — liveness - `/readyz` — readiness (503 if MQTT is configured but not connected) - `/docs` — OpenAPI document + in-browser debugger (`/docs/openapi.json`) - Request id on `x-request-id` - Body size limit (`bodyLimit`) and multipart upload limits (`upload`) ### File upload ```ts @Post("/upload") upload( @UploadedFile("file") file: YestUploadedFile, @UploadedFiles("photos") photos: YestUploadedFile[], @Body() fields: Record, ) { return { file: file.originalname, photos: photos.length, fields }; } ``` `YestUploadedFile`: `fieldname`, `originalname`, `encoding`, `mimetype`, `buffer`, `size`. ### Validation Pass any schema with `.parse(data)` (Zod, Valibot, etc.): ```ts @Post("/users") create(@ValidatedBody(UserSchema) body: User) { return body; } ``` ## OpenAPI / Swagger Enable a self-hosted docs UI (search, schemas, **Try it**, curl, Bearer token). No extra npm dependency. ```ts const app = await YestFactory.create(AppModule, { swagger: { path: "/docs", title: "UGV Cloud API", version: "1.0.0", bearerAuth: true, }, }); ``` Or after `create()`: ```ts import { DocumentBuilder, SwaggerModule } from "yestjs"; const document = SwaggerModule.createDocument( app, new DocumentBuilder().setTitle("API").setVersion("1.0").addBearerAuth().build(), ); SwaggerModule.setup("/docs", app, document); ``` Decorators (optional; routes are documented from `@Get`/`@Body`/`@Param` even without them): `@ApiTags` `@ApiOperation` `@ApiParam` `@ApiQuery` `@ApiHeader` `@ApiBody` `@ApiResponse` `@ApiOkResponse` `@ApiBearerAuth` `@ApiPublic` `@ApiExcludeEndpoint` `@ApiProperty` - UI: `GET /docs` - Spec: `GET /docs/openapi.json` · `GET /docs/openapi.yaml` - Usage guide: `GET /docs/guide.md` - `ui: "scalar"` loads Scalar from CDN if you prefer that debugger Write the same three files from a script. The entry module returns an application that has already called `init()`: ```ts import { YestFactory, writeApiDocs } from "yestjs"; const app = await YestFactory.create(AppModule, { swagger: { title: "API", version: "1.0.0", bearerAuth: true }, gracefulShutdown: false, }); const files = await writeApiDocs(app, { outDir: "docs/api" }); console.log(files); await app.close(); ``` Or run the bundled command. With TypeScript source, start it through `tsx`: ```bash tsx node_modules/yestjs/dist/cli/docs.js ./src/docs-app.ts --out ./docs/api ``` `docs-app.ts` default-exports `async function createApp()`. The command writes `openapi.json`, `openapi.yaml`, and `guide.md`. ## SSE ```ts @Sse("/ticks") async *ticks(@Query("n") n?: string) { yield { event: "tick", data: { n: n ?? "1" } }; } ``` Query / param / cookie / header decorators work the same as HTTP. Disconnect aborts the stream. ## WebSocket ```ts @WebSocketGateway("/ws") @UseGuards(AuthGuard) class ChatGateway { @OnGatewayInit() afterInit(server: YestWebSocketServer) {} @SubscribeMessage("ping") ping(@MessageBody() body: unknown, @ConnectedSocket() client: YestWsClient) { return { ok: true, body }; } } ``` Client messages: `{ "event": "ping", "data": {}, "id": 1 }`. Multiple gateways on the same path share one connection and a merged event map. Class-level `@UseGuards` run on HTTP upgrade (401 if they fail). ## MQTT 5 ```ts MqttModule.forRoot({ url: "mqtts://127.0.0.1:8883", abortOnConnectError: false, outboundQueue: { max: 256, drop: "oldest" }, reconnect: { enabled: true }, // TLS / mTLS: mqtt.js `ca` / `cert` / `key` authProvider: () => ({ username: "cloud", password: jwt }), // called on every connect }); ``` ```ts @MqttController() class DeviceMqtt { @Subscribe("uvs/{product}/status/{leaf}/{deviceId}", { qos: 1 }) onStatus( @Topic() topic: string, @Param("deviceId") deviceId: string, @Param("leaf") leaf: string, @Payload() payload: unknown, ) {} } await mqtt.subscribe("uvs/{product}/push/{kind}/{deviceId}", handler, { qos: 1 }); await mqtt.publish("uvs/{product}/ctrl/{action}/{deviceId}", body, { qos: 1, params: { product: "ugv", action: "estop", deviceId }, }); ``` `{name}` is one topic level (`+` on the broker). `{rest:#}` is the remainder (`#`). Runtime `subscribe` / `unsubscribe` / `publish({ params })` can change while the process is up. `@Subscribe` handlers are dispatched without blocking the MQTT client. There is **no default handler timeout** (unlike HTTP). Set `MqttModule.forRoot({ handlerTimeoutMs })` or `@Timeout(ms)` only when a subscribe must fail closed. First connect failure does **not** crash the HTTP server (`abortOnConnectError` defaults to false). Publishes while offline go to a bounded queue. `/readyz` stays 503 until the broker is up. ## TCP ```ts @SocketController({ port: 9000, framing: { type: "delimiter", delimiter: "\n" }, }) class LineServer { @OnSocketData() onData(data: Buffer) { return data; } } ``` Framing: `raw` | `delimiter` | `length-prefix`. Outbound client: `SocketClient` with reconnect. ## Logging Every log line has a timestamp and level. In development (`NODE_ENV !== "production"`, or `logging.source: true`) app logs also include the caller **file / method / line**. **Disk always stores the full record.** Console output is filtered by level, access whitelist/blacklist, burst mute, and field selection. ```ts const app = await YestFactory.create(AppModule, { logging: { level: "debug", // console minimum timestamp: true, // default source: "debug", // file:method:line in non-production console: true, // set false to keep disk only file: { dir: "./logs", prefix: "ugv-cloud", // logs/app/2026-09-09/ugv-cloud-15-08-16.482Z.log maxFileSizeBytes: 10 * 1024 * 1024, maxFiles: 20, maxTotalBytes: 200 * 1024 * 1024, maxDays: 14, interval: "daily", // or "hourly" }, access: { console: "slow", // default: duration ≥ 1s, errors, whitelist slowMs: 1000, burstPerSecond: 20, // same key faster than this → console mute whitelist: ["/api/auth/**", "mqtt:uvs/**", "tcp:command"], blacklist: ["/healthz", "/readyz"], fields: { url: true, method: true, body: true, params: true, topic: true, message: true }, }, }, }); ``` | Access console | Behavior | |---|---| | `slow` (default) | Print if duration ≥ `slowMs` (1s), 4xx/5xx, error, whitelist, or MQTT interval ≥ `slowMs`. Burst-muted keys stay quiet unless slow/error/whitelist. | | `all` | Print everything except blacklist / burst mute | | `none` | Console off except whitelist | HTTP/HTTPS, SSE, MQTT, TCP and WebSocket handlers emit access records. `/healthz` and `/readyz` are blacklisted on the console by default but still go to disk when `file` is enabled. MQTT on a blacklist still prints when the same topic is **infrequent** (gap ≥ `slowMs`, default 1s), slow, or failed. Set `logger: false` for a no-op logger (no files). Disk layout is `{dir}/{app|access}/{UTC date}/{prefix}-{firstRecordTime}.log`. The filename suffix is the first line’s **time** (ISO-8601 after `T`, `:` → `-`); the date lives only in the folder. A later size/hour rotation opens a new file named after that file’s first record. Console `[LEVEL]` uses Nest-style colors (green / yellow / red / magenta / cyan); HTTP/MQTT **code** is green for 2xx and red for 4xx/5xx. Access console lines also append compact JSON: HTTP request (`method/path/query/params/body`) or the MQTT message. `file: true` is shorthand for `{ dir: "./logs", prefix: "yest" }`. ## Application options | Option | Default | Meaning | |---|---|---| | `architecture` | `classic` | `classic` is the existing module style. `ddd` enables bounded contexts (see below) | | `cors` | — | `true` or Hono CORS options | | `bodyLimit` | 1 MiB | JSON / urlencoded / raw limit | | `upload.maxFileSize` | `bodyLimit` | Multipart file cap | | `healthCheck` | `/healthz` | `false` to disable | | `readyCheck` | `/readyz` | `false` to disable | | `gracefulShutdown` | true | SIGINT / SIGTERM | | `exitOnUncaught` | true | Exit after uncaughtException | | `sseHeartbeatMs` | 15000 | SSE comment keepalive | | `swagger` | off | `true` or `{ path, title, bearerAuth, ui }` | | `logger` | `YestLogger` | `false`, a custom `Logger`, or logging options | | `logging` | — | timestamps, source, disk path/prefix/rotation, access whitelist | | `audit` | on | Fills `create_user_id`, `create_time`, `update_user_id`, `update_time`. Pass property names, or `false` to disable the filler | | `tenant` | off | Shared-schema tenancy. `true` uses `tenant_id`. Pass `{ tenantId, userProperty }` to match a schema | | `maxInflight` | 4096 | Handler cap across transports. `0` disables it | Listen: `{ port, hostname, https, websocket, socket }`. Shutdown order: drain flag → WebSocket/TCP → stop HTTP → module destroy (MQTT disconnect). ## TypeScript ```json { "compilerOptions": { "target": "ES2022", "module": "NodeNext", "experimentalDecorators": true, "emitDecoratorMetadata": true } } ``` Entry: `yestjs`, `yestjs/common`, `yestjs/core`, `yestjs/mqtt`, `yestjs/ddd`. ## License MIT ## Publish to npm The registry name `yest` is taken. This package publishes as **`yestjs`**. ```bash npm login npm publish --access public ``` `prepublishOnly` runs typecheck, build and smoke tests. Node.js 20+ is required.