@aventara/nest

中文文档
1.0.0-rc.12发布于 2026/11/01SEE LICENSE IN LICENSETypeScriptES Module近 30 天下载 2426

NestJS host integration for Aventara.

原始使用文档
文档目录8 节
  1. Mounting it
  2. Calling it by hand
  3. What runs on a protocol route
  4. Request state
  5. Requests
  6. The MCP endpoint
  7. Platform differences
  8. License

@aventara/nest

Hosts an Aventara AvFramework in a NestJS application. It mounts the framework's HTTP protocol (AvProtocol.bind(framework) from @aventara/core/protocol) on Nest's HTTP platform, and puts the AvFramework into Nest's dependency injection. Every rule of the protocol (routes, decoding, statuses, envelopes, headers, absence) is core's; this package only translates a Nest request into the protocol's request and writes the protocol's answer back.

It runs on Nest 12 (@nestjs/common and @nestjs/core ^12.0.0), on Express (Nest's default) or Fastify. @aventara/core is its one dependency.

Mounting it

AventaraModule is a global module, imported once in your root module. It takes the AvFramework in one of four forms.

// src/app.module.ts — the shape `aventara init` writes (`@aventara/cli`)
import { Module } from "@nestjs/common";
import { AventaraModule } from "@aventara/nest";
import { aventaraConfig } from "./aventara.config.js";
import { PrismaModule, PrismaService } from "./prisma.service.js";

@Module({
    imports: [
        PrismaModule,
        AventaraModule.forRootAsync({
            imports: [PrismaModule],
            inject: [PrismaService],
            useFactory: async (prisma: PrismaService) => ({
                config: await aventaraConfig(prisma),
            }),
        }),
    ],
})
export class AppModule {}

// src/aventara.config.ts — the framework's configuration: api, exposure, pipelines, resources
import type { AvDiagnostic } from "@aventara/core";
import { createPrismaAdapter } from "@aventara/prisma7-adapter";
import { Logger } from "@nestjs/common";
import { discovery } from "./generated/aventara/discovery.artifact.js";
import type { PrismaService } from "./prisma.service.js";

const logger = new Logger("Aventara");

export async function aventaraConfig(prisma: PrismaService) {
    return {
        api: "/api",
        exposed: true,
        // The raw error of a server failure, with its stack, in Nest's log.
        diagnostics: (diagnostic: AvDiagnostic) => {
            const { error } = diagnostic;
            logger.error(
                `${diagnostic.code} (request ${diagnostic.requestId}): raw error`,
                (error instanceof Error && error.stack) || String(error),
            );
        },
        adapter: await createPrismaAdapter({
            client: prisma,
            discovery,
            provider: "postgresql",
            driver: "@prisma/adapter-pg",
        }),
    } as const;
}
FormWhen
AventaraModule.forRoot({ framework })You called createFramework(config) yourself and want your own typed AvFramework<M, Cfg> in code.
AventaraModule.forRoot({ config })The module calls createFramework(config) for you.
AventaraModule.forRootAsync({ imports, inject, useFactory }) resolving { framework } or { config }What builds the Framework (a Prisma client, a configuration service) is itself a provider.

Each form also takes state, which seeds every protocol request's ctx.$state from the platform request (see Request state).

The config form is typed exactly like createFramework's parameter: an annotated AvConfig<M>, an inline literal, an inferred constant and a satisfies check are all accepted, and checked against your Adapter's model.

@InjectFramework() (or @Inject(AVENTARA_FRAMEWORK)) injects the AvFramework. DI carries no type argument, so type the parameter with your own alias:

export type AppFramework = Awaited<ReturnType<typeof buildFramework>>;

@Injectable()
export class ReportsService {
    constructor(@InjectFramework() private readonly framework: AppFramework) {}
}

These reject while Nest builds the application, before it listens: both or neither of framework and config; a framework value that is not a Framework; a state option that is not a function; the module mounted twice in one application; a platform other than Express or Fastify (or none, as in createApplicationContext); an AvConstructionError, yours or the module's own, which propagates unchanged. Nest's default abortOnError: true exits the process on a bootstrap error instead of rejecting; pass abortOnError: false to NestFactory.create to catch it.

Nest's testing module works. Test.createTestingModule({ imports: [AppModule] }).compile() builds the providers before any HTTP platform exists; the module waits for the one createNestApplication() supplies, and still refuses an unsupported platform before the application listens. A testing module initialized with no application at all is refused at init(), as a platform of none.

The module owns no resource, so there is nothing to shut down. Your Prisma provider closes its own client in its onModuleDestroy, with app.enableShutdownHooks() for signals.

Calling it by hand

Against the shape aventara init writes (the api mount path /api, a Prisma User model):

# 1. The contract. Its protocol.hash is what every resource request sends as Aventara-Contract-Hash.
HASH=$(curl -s http://localhost:3000/api/_contract | node -p 'JSON.parse(require("fs").readFileSync(0, "utf8")).protocol.hash')

# 2. find.many on User, with both identity headers.
curl -s -X POST http://localhost:3000/api/_resources/User/find/many \
  -H 'Content-Type: application/json' \
  -H 'Aventara-Protocol-Version: 1' \
  -H "Aventara-Contract-Hash: $HASH" \
  -d '{}'
# {"data":[],"code":"A1000","cause":null}

Leave out an identity header and the answer is 400 A2000, naming the header that is missing. The hash changes whenever the schema or the configuration does: fetch it again, and regenerate the client.

Resource keys are the model names, as written. model User is User everywhere: on the wire (/_resources/User/find/many) and in the generated client (avClient.User.find.many({})). Nothing is renamed, lower-cased or pluralized.

Results are read-only. A list result is a readonly array, so type it readonly User[]: const users: readonly User[] = await avClient.User.find.many({});.

What runs on a protocol route

The protocol's routes are platform routes, registered from the module's configure(). There is one per protocol.surface() entry (every advertised operation, GET <api>/_contract, and POST <api>/_transactions only when the ClientContract advertises interactive), plus the absence handler for the rest of <api>/_*. Nest's RouterExplorer does not list platform routes, so the module logs one line of its own when it mounts them, through Nest's Logger (context AventaraModule; logger: false silences it like any other):

[Nest] LOG [AventaraModule] Aventara mounted 10 operations at /api (+ GET /api/_contract, POST /api/_transactions, POST /api/_all)

An internal failure (A3000–A3004, answered as a 500) is logged as one error line through Nest's Logger (context Aventara): the code, the operation, the origin and the request id, and the thrown value's class name. It never logs the message, which can hold SQL or a connection string. The raw error still goes to your diagnostics sink, when you configure one. The aventara.config.ts that aventara new and aventara init write has one: it logs the raw error with its stack through the same Logger, at error level, right after this line.

[Nest] ERROR [Aventara] A3001 on Post.create.one (client origin, request 3f1c…): PrismaClientValidationError. The raw error goes to the framework's diagnostics sink.
[Nest] ERROR [Aventara] A3001 (request 3f1c…): raw error
PrismaClientValidationError: …

For A3004 (one of your pipes returned arguments the server contract refuses), the line also names the pipe, the operation, the offending field and its V-code. The caller only ever sees the generic message.

The line is added when the module builds the framework from config; a framework you build yourself and pass as framework keeps exactly the diagnostics sink you gave it.

The framework's validation is outermost:

ExpressFastify
Your app.use(...) middleware, app.enableCors(), helmetrunsruns
Nest guards, interceptors, pipes (global or not)nevernever
MiddlewareConsumer middlewareneverruns, because Fastify runs Nest middleware before routing; add .exclude("api/_*path") (your api path) to keep it off
Your controller at <api>/_contract (or any _ path)never reachedthe application does not start
Your controller at <api>/health (outside _)reachedreached

Authorization therefore belongs in Aventara pipelines (guards). A guard reads the request's headers as ctx.$transport.headers (lower-cased names, repeats joined), and whatever your middleware found as ctx.$state, through the module's state option (next section).

Request state

state seeds each protocol request's ctx.$state, the one object every stage of that request reads and writes. It is called once per request, with the platform's own request, after the protocol has accepted it (a stale client or an unknown route never calls it):

declare module "@aventara/core" {
    interface AvState {
        user?: { id: string; roles: string[] };
    }
}

AventaraModule.forRoot({
    config,
    state: (req: Request) => ({ user: req.user }), // what your auth middleware set
});

// In the configuration: a guard reads it.
const signedIn: AvGuard = (ctx) => ctx.$state.user !== undefined;
  • forRootAsync's useFactory returns state beside framework or config.
  • On Express, req is Express's request. On Fastify it is Fastify's request; app.use middleware writes to its Node request, so read request.raw.user. Fastify answers a protocol route from its onRequest hook, so only what runs before it (app.use middleware, an earlier onRequest hook) has run.
  • Use app.use(...) for the middleware that finds the user (a Passport authenticate call, a session or JWT check). Nest guards never run on protocol routes, and MiddlewareConsumer middleware does not on Express (the table above).
  • A Framework built with access (an access provider, see @aventara/core) seeds the state itself: the module calls the provider's state with { headers, request }, the platform request's headers and the request. Pass no state beside it; the module refuses the pair while Nest builds the application, and as a type error on a config that states access. It works the same through config and framework: AventaraModule.forRoot({ config: { ...config, access } }).
  • Throw new AvError("A4001", "…") from state to refuse the request (403); any other throw is 500 A3000, with the error in your diagnostics sink.
  • A plan sent to /_transactions gets one state for all its steps and transaction stages. A ctx call a stage makes inherits it. Compute reads it but cannot write it.
  • Server code that calls the framework in process seeds it per call: framework.application.Post.create.one(args, { state: { user } }).

AvConfig.api alone decides where the protocol is mounted. app.setGlobalPrefix(...) does not move it; set api to where you want it, and point the generated client at the same URL.

Requests

  • Bodies on protocol routes are read raw, never through Nest's body parser, up to limits.maxRequestBytes. The protocol's decoder answers an oversized body (413 A2010), another content type (415 A2011) and malformed JSON (400 A2000). Your own routes keep Nest's parsed req.body. No NestFactory.create option is needed.
  • For Content-Encoding, gzip, deflate and br are inflated, and the limit counts decoded bytes. Any other coding answers 415 A2011; a body that does not decode in the coding it names answers 400 A2000. These two answers carry a newly minted Aventara-Request-Id, not the client's.
  • The request id is the client's Aventara-Request-Id as it arrived, or one core mints. A middleware that rewrites req.headers cannot change it, and X-Request-Id is never copied into it, so set Aventara-Request-Id at your proxy.
  • A query string is not part of the protocol path; it is ignored.

The MCP endpoint

With mcp in the configuration, the module also mounts the MCP server of @aventara/mcp at that path, on Express and Fastify alike, and logs Aventara mounted the MCP endpoint at /mcp (…). Install it beside this package, at @aventara/core's exact version:

npm install --save-exact @aventara/mcp@<your @aventara/core version>

It is an optional peer dependency: an application without mcp does not need it, and one with mcp and without the package refuses to start, naming it and the command, with the version filled in:

AventaraModule: the configuration has `mcp`, and the package that serves it, @aventara/mcp, is not installed. Install it at @aventara/core's exact version (npm install --save-exact @aventara/[email protected]), or remove `mcp` from the configuration.

As for the protocol's routes, the module only mounts: the endpoint's body is read raw, the request's URL comes from the platform's protocol and host (set trust proxy on Express or trustProxy on Fastify behind a proxy), and every answer is @aventara/mcp's. The state option seeds each tool call's ctx.$state. See @aventara/mcp's README for the tools, the contract resource and authorization.

Platform differences

  • Fastify cannot mount some Resource keys or api paths as literal routes. A Resource key that needs * or a reserved character (: + # $ & , / ; = ? @) in its route path, or an api path containing *, is refused at startup with a message naming it. Rename it, or host on Express, which mounts every key.
  • Express routing is case-insensitive by default, so it also answers the protocol under, say, /API/_contract; Fastify's is not. That is Express's own case sensitive routing setting, off by default, and yours to change. The MCP endpoint is the exception: on both platforms it answers its own path only, exactly as written (/mcp, not /MCP or /mcp/).
  • Express answers 500 A3000 to a protocol route requested with a percent-encoding a generated client never sends (for example %6Eotes for notes); Fastify and the in-process protocol execute it. This is a known divergence.
  • On Express the response carries Express's X-Powered-By header; app.disable("x-powered-by") removes it.

License

PolyForm Shield 1.0.0 with an additional permission. It is free to use, including in commercial applications, but you may not use it to build a product that competes with Aventara itself. See LICENSE and LICENSE-ADDITIONAL-PERMISSION.md.

版本与发布记录

@aventara/nest 共发布 16 个版本,首次发布于 2026/10/06;过去 12 个月发布了 16 个版本。

版本发布日期距今
1.0.0-rc.12latest2026/11/017 天前
1.0.0-rc.112026/10/317 天前
1.0.0-rc.102026/10/2711 天前
1.0.0-rc.92026/10/2315 天前
1.0.0-rc.82026/10/2216 天前
1.0.0-rc.42026/10/2117 天前

其他发布通道:pilot → 0.1.0-pilot.4。

关于 @aventara/nest 的常见问题

@aventara/nest 是什么?

NestJS host integration for Aventara.

如何安装 @aventara/nest?

在项目目录运行 npm install @aventara/nest,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:@nestjs/core(^12.0.0)、@nestjs/common(^12.0.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@aventara/nest 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@aventara/nest 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@aventara/nest 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js ^22.23.4 || >=24.22.1。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@aventara/nest 可以商用吗?

@aventara/nest 使用 SEE LICENSE IN LICENSE 许可。许可条款写在包内的独立文件中,请阅读该文件确认可否商用。以上为概括,不构成法律意见,请以许可原文为准。

@aventara/nest 的下载量有多少?

根据 npm 官方下载统计,@aventara/nest 在 2026/10/07 – 2026/11/05 这 30 天内被下载 2,426 次,日均约 81 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@aventara/nest 还在维护吗?

最新版本 1.0.0-rc.12 发布于 2026/11/01(7 天前);至今共发布 16 个版本,其中过去 12 个月发布了 16 个;首次发布于 2026/10/06。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。