@aventara/nest 0.0.0-stage → 0.1.0-pilot.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,91 @@
1
+ Required Notice: Copyright 2026 Mohamed Ragheb
2
+
3
+ # PolyForm Shield License 1.0.0
4
+
5
+ <https://polyformproject.org/licenses/shield/1.0.0>
6
+
7
+ ## Acceptance
8
+
9
+ In order to get any license under these terms, you must agree to them as both strict obligations and conditions to all your licenses.
10
+
11
+ ## Copyright License
12
+
13
+ The licensor grants you a copyright license for the software to do everything you might do with the software that would otherwise infringe the licensor's copyright in it for any permitted purpose. However, you may only distribute the software according to [Distribution License](#distribution-license) and make changes or new works based on the software according to [Changes and New Works License](#changes-and-new-works-license).
14
+
15
+ ## Distribution License
16
+
17
+ The licensor grants you an additional copyright license to distribute copies of the software. Your license to distribute covers distributing the software with changes and new works permitted by [Changes and New Works License](#changes-and-new-works-license).
18
+
19
+ ## Notices
20
+
21
+ You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms or the URL for them above, as well as copies of any plain-text lines beginning with `Required Notice:` that the licensor provided with the software. For example:
22
+
23
+ > Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
24
+
25
+ ## Changes and New Works License
26
+
27
+ The licensor grants you an additional copyright license to make changes and new works based on the software for any permitted purpose.
28
+
29
+ ## Patent License
30
+
31
+ The licensor grants you a patent license for the software that covers patent claims the licensor can license, or becomes able to license, that you would infringe by using the software.
32
+
33
+ ## Noncompete
34
+
35
+ Any purpose is a permitted purpose, except for providing any product that competes with the software or any product the licensor or any of its affiliates provides using the software.
36
+
37
+ ## Competition
38
+
39
+ Goods and services compete even when they provide functionality through different kinds of interfaces or for different technical platforms. Applications can compete with services, libraries with plugins, frameworks with development tools, and so on, even if they're written in different programming languages or for different computer architectures. Goods and services compete even when provided free of charge. If you market a product as a practical substitute for the software or another product, it definitely competes.
40
+
41
+ ## New Products
42
+
43
+ If you are using the software to provide a product that does not compete, but the licensor or any of its affiliates brings your product into competition by providing a new version of the software or another product using the software, you may continue using versions of the software available under these terms beforehand to provide your competing product, but not any later versions.
44
+
45
+ ## Discontinued Products
46
+
47
+ You may begin using the software to compete with a product or service that the licensor or any of its affiliates has stopped providing, unless the licensor includes a plain-text line beginning with `Licensor Line of Business:` with the software that mentions that line of business. For example:
48
+
49
+ > Licensor Line of Business: YoyodyneCMS Content Management System (http://example.com/cms)
50
+
51
+ ## Sales of Business
52
+
53
+ If the licensor or any of its affiliates sells a line of business developing the software or using the software to provide a product, the buyer can also enforce [Noncompete](#noncompete) for that product.
54
+
55
+ ## Fair Use
56
+
57
+ You may have "fair use" rights for the software under the law. These terms do not limit them.
58
+
59
+ ## No Other Rights
60
+
61
+ These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the licensor from granting licenses to anyone else. These terms do not imply any other licenses.
62
+
63
+ ## Patent Defense
64
+
65
+ If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company.
66
+
67
+ ## Violations
68
+
69
+ The first time you are notified in writing that you have violated any of these terms, or done anything with the software not covered by your licenses, your licenses can nonetheless continue if you come into full compliance with these terms, and take practical steps to correct past violations, within 32 days of receiving notice. Otherwise, all your licenses end immediately.
70
+
71
+ ## No Liability
72
+
73
+ ***As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.***
74
+
75
+ ## Definitions
76
+
77
+ The **licensor** is the individual or entity offering these terms, and the **software** is the software the licensor makes available under these terms.
78
+
79
+ A **product** can be a good or service, or a combination of them.
80
+
81
+ **You** refers to the individual or entity agreeing to these terms.
82
+
83
+ **Your company** is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all its affiliates.
84
+
85
+ **Affiliates** means the other organizations than an organization has control over, is under the control of, or is under common control with.
86
+
87
+ **Control** means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect.
88
+
89
+ **Your licenses** are all the licenses granted to you for the software under these terms.
90
+
91
+ **Use** means anything you do with the software requiring one of your licenses.
@@ -0,0 +1,9 @@
1
+ # Additional Permission — Aventara
2
+
3
+ Copyright 2026 Mohamed Ragheb
4
+
5
+ In addition to the permissions of the PolyForm Shield License 1.0.0 in `LICENSE`, the licensor grants the following additional permission:
6
+
7
+ The Noncompete section applies only to providing a product that competes with the software itself — the Aventara framework, its packages and its tooling. Providing a product that competes with any other product the licensor or its affiliates provide using the software is a permitted purpose.
8
+
9
+ This additional permission only adds to your permissions; it does not restrict or replace any term of `LICENSE`.
package/README.md CHANGED
@@ -1,3 +1,138 @@
1
- # Temporary Holding Version
1
+ # `@aventara/nest`
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Hosts an Aventara `Framework` in a NestJS application. It does one thing: it mounts the framework's HTTP protocol —
4
+ `AvProtocol.bind(framework)` from `@aventara/core/protocol` — on Nest's HTTP platform, and puts the `Framework` into
5
+ Nest's dependency injection. Every rule of the protocol (routes, decoding, statuses, envelopes, headers, absence) is
6
+ core's; this package only translates a Nest request into the protocol's request and writes the protocol's answer back.
7
+
8
+ - **Nest 12** (`@nestjs/common` and `@nestjs/core` `^12.0.0`), on **Express** (Nest's default) or **Fastify**.
9
+ - `@aventara/core` is its one dependency.
10
+
11
+ ## Mounting it
12
+
13
+ `AventaraModule` is a global module, imported once in your root module. It takes the `Framework` in one of four forms.
14
+
15
+ ```ts
16
+ // src/app.module.ts — the shape `aventara init` writes (`@aventara/cli`)
17
+ import { Module } from "@nestjs/common";
18
+ import { AventaraModule } from "@aventara/nest";
19
+ import { aventaraConfig } from "./aventara.config.js";
20
+ import { PrismaModule, PrismaService } from "./prisma.service.js";
21
+
22
+ @Module({
23
+ imports: [
24
+ PrismaModule,
25
+ AventaraModule.forRootAsync({
26
+ imports: [PrismaModule],
27
+ inject: [PrismaService],
28
+ useFactory: async (prisma: PrismaService) => ({
29
+ config: await aventaraConfig(prisma),
30
+ }),
31
+ }),
32
+ ],
33
+ })
34
+ export class AppModule {}
35
+
36
+ // src/aventara.config.ts — the framework's configuration: entrypoint, restrictions, pipelines
37
+ import { createPrismaAdapter } from "@aventara/prisma7-adapter";
38
+ import { discovery } from "./generated/aventara/discovery.artifact.js";
39
+ import type { PrismaService } from "./prisma.service.js";
40
+
41
+ export async function aventaraConfig(prisma: PrismaService) {
42
+ return {
43
+ entrypoint: "/api",
44
+ adapter: await createPrismaAdapter({
45
+ client: prisma,
46
+ discovery,
47
+ provider: "postgresql",
48
+ driver: "@prisma/adapter-pg",
49
+ }),
50
+ } as const;
51
+ }
52
+ ```
53
+
54
+ | Form | When |
55
+ |---|---|
56
+ | `AventaraModule.forRoot({ framework })` | You called `createFramework(config)` yourself and want your own typed `Framework<M, Cfg>` in code. |
57
+ | `AventaraModule.forRoot({ config })` | The module calls `createFramework(config)` for you. |
58
+ | `AventaraModule.forRootAsync({ imports, inject, useFactory })` resolving `{ framework }` or `{ config }` | What builds the Framework (a Prisma client, a configuration service) is itself a provider. |
59
+
60
+ The `config` form is typed exactly like `createFramework`'s parameter: an annotated `FrameworkConfig<M>`, an inline
61
+ literal, an inferred constant and a `satisfies` check are all accepted, and checked against your Adapter's model.
62
+
63
+ **Injecting it.** `@InjectFramework()` (or `@Inject(AVENTARA_FRAMEWORK)`) injects the `Framework`. DI carries no type
64
+ argument, so type the parameter with your own alias:
65
+
66
+ ```ts
67
+ export type AppFramework = Awaited<ReturnType<typeof buildFramework>>;
68
+
69
+ @Injectable()
70
+ export class ReportsService {
71
+ constructor(@InjectFramework() private readonly framework: AppFramework) {}
72
+ }
73
+ ```
74
+
75
+ **Fails before listen.** These reject while Nest builds the application, before it listens: both or neither of
76
+ `framework` and `config`; a `framework` value that is not a Framework; the module mounted twice in one application; a
77
+ platform other than Express or Fastify (or none, as in `createApplicationContext`); a `FrameworkConstructionError`,
78
+ yours or the module's own, which propagates unchanged. Nest's default `abortOnError: true` **exits the process** on a
79
+ bootstrap error instead of rejecting; pass `abortOnError: false` to `NestFactory.create` to catch it.
80
+
81
+ **Nest's testing module works.** `Test.createTestingModule({ imports: [AppModule] }).compile()` builds the providers
82
+ before any HTTP platform exists; the module waits for the one `createNestApplication()` supplies, and still refuses an
83
+ unsupported platform before the application listens (ADR 0011's amendment). A testing module initialized with no
84
+ application at all is refused at `init()`, as a platform of `none`.
85
+
86
+ **Nothing to shut down.** The module owns no resource. Your Prisma provider closes its own client — in its
87
+ `onModuleDestroy`, with `app.enableShutdownHooks()` for signals.
88
+
89
+ ## What runs on a protocol route
90
+
91
+ The protocol's routes are platform routes, registered from the module's `configure()`, one per
92
+ `protocol.surface()` entry — every advertised operation, `GET <entrypoint>/_contract`, and `POST
93
+ <entrypoint>/_transactions` only when the ClientContract advertises `interactive` — plus the absence handler for the
94
+ rest of `<entrypoint>/_*`. The framework's validation is outermost:
95
+
96
+ | | Express | Fastify |
97
+ |---|---|---|
98
+ | Your `app.use(...)` middleware, `app.enableCors()`, helmet | runs | runs |
99
+ | Nest guards, interceptors, pipes (global or not) | **never** | **never** |
100
+ | `MiddlewareConsumer` middleware | **never** | **runs** — Fastify runs Nest middleware before routing; add `.exclude("api/_*path")` (your entrypoint) to keep it off |
101
+ | Your controller at `<entrypoint>/_contract` (or any `_` path) | never reached | the application does not start |
102
+ | Your controller at `<entrypoint>/health` (outside `_`) | reached | reached |
103
+
104
+ Authentication and authorization therefore belong in Aventara **pipelines** (guards), which read
105
+ `transport.headers` — lower-cased names, repeats joined. A Passport strategy run as Nest middleware sets `req.user`,
106
+ which a pipeline cannot see: verify the bearer token or cookie in the Aventara guard instead.
107
+
108
+ **Where it is mounted.** `FrameworkConfig.entrypoint` alone decides it. `app.setGlobalPrefix(...)` does not move the
109
+ protocol; set the entrypoint to where you want it, and point the generated client at the same URL.
110
+
111
+ ## Requests
112
+
113
+ - **Bodies** on protocol routes are read raw — never through Nest's body parser — up to `limits.maxRequestBytes`; the
114
+ protocol's decoder answers an oversized body (`413 A2010`), another content type (`415 A2011`) and malformed JSON
115
+ (`400 A2000`). Your own routes keep Nest's parsed `req.body`. No `NestFactory.create` option is needed.
116
+ - **`Content-Encoding`:** `gzip`, `deflate` and `br` are inflated, and the limit counts decoded bytes. Any other coding
117
+ answers `415 A2011`; a body that does not decode in the coding it names answers `400 A2000`. These two answers carry a
118
+ newly minted `Aventara-Request-Id`, not the client's.
119
+ - **The request id** is the client's `Aventara-Request-Id` as it arrived, or one core mints. A middleware that rewrites
120
+ `req.headers` cannot change it, and `X-Request-Id` is never copied into it — set `Aventara-Request-Id` at your proxy.
121
+ - **A query string** is not part of the protocol path; it is ignored.
122
+
123
+ ## Platform differences
124
+
125
+ - **Fastify cannot mount some Resource keys or entrypoints as literal routes.** A Resource key that needs `*` or a
126
+ reserved character (`: + # $ & , / ; = ? @`) in its route path, or an entrypoint containing `*`, is refused at startup
127
+ with a message naming it. Rename it, or host on Express, which mounts every key.
128
+ - **Express routing is case-insensitive** by default, so it also answers the protocol under, say, `/API/_contract`;
129
+ Fastify's is not. That is Express's own `case sensitive routing` setting, off by default, and yours to change.
130
+ - **Express answers `500 A3000`** to a protocol route requested with a percent-encoding a generated client never sends
131
+ (for example `%6Eotes` for `notes`); Fastify and the in-process protocol execute it. A known divergence (register
132
+ F-843).
133
+ - On Express the response carries Express's `X-Powered-By` header; `app.disable("x-powered-by")` removes it.
134
+
135
+ ## License
136
+
137
+ PolyForm Shield 1.0.0 with an additional permission — free to use, including in commercial applications; you may not
138
+ use it to build a product that competes with Aventara itself. See `LICENSE` and `LICENSE-ADDITIONAL-PERMISSION.md`.
@@ -0,0 +1,49 @@
1
+ import { type AdapterModel, type AnyFrameworkConfig, type Framework, type FrameworkConfig } from "@aventara/core";
2
+ import { type DynamicModule, type FactoryProvider, type ModuleMetadata, type NestModule, type OnModuleInit } from "@nestjs/common";
3
+ import { type PendingProtocolHost } from "./protocol-host.factory.js";
4
+ /**
5
+ * How the application's Framework enters Nest (Q1, ruled): either the
6
+ * developer's own instance — they called `createFramework` — or the
7
+ * configuration the module hands to `createFramework` itself. Exactly one arm;
8
+ * the `?: never` halves make both a type error.
9
+ *
10
+ * The `config` arm is typed exactly as `createFramework`'s parameter
11
+ * (`Cfg & FrameworkConfig<M, Cfg>`; U12, architect 2026-10-05), so a
12
+ * configuration annotated `FrameworkConfig<M>`, written inline, inferred or
13
+ * checked with `satisfies` is accepted — and checked against its own Adapter
14
+ * model — wherever `createFramework` would accept it. The defaults are the
15
+ * untyped forms.
16
+ */
17
+ export type AventaraModuleOptions<M extends AdapterModel = AdapterModel, Cfg = AnyFrameworkConfig> = {
18
+ readonly framework: Framework;
19
+ readonly config?: never;
20
+ } | {
21
+ readonly config: Cfg & FrameworkConfig<M, Cfg>;
22
+ readonly framework?: never;
23
+ };
24
+ /**
25
+ * `forRootAsync`'s options: the Nest idiom when what builds the Framework
26
+ * (a Prisma client, a configuration service) is itself a provider. The
27
+ * factory resolves to either arm of {@link AventaraModuleOptions} (Q1).
28
+ */
29
+ export type AventaraModuleAsyncOptions<M extends AdapterModel = AdapterModel, Cfg = AnyFrameworkConfig> = {
30
+ readonly imports?: ModuleMetadata["imports"];
31
+ readonly inject?: FactoryProvider["inject"];
32
+ readonly useFactory: (...deps: never[]) => AventaraModuleOptions<M, Cfg> | Promise<AventaraModuleOptions<M, Cfg>>;
33
+ };
34
+ /**
35
+ * The global module that hosts one Aventara Framework (Q2, Q3), and mounts its
36
+ * protocol on the application's platform from `configure()` (Q5): after the
37
+ * developer's `app.use` / `enableCors`, before `MiddlewareConsumer` middleware
38
+ * and every controller, so the reserved `_` namespace cannot be shadowed.
39
+ */
40
+ export declare class AventaraModule implements NestModule, OnModuleInit {
41
+ private readonly host;
42
+ constructor(host: PendingProtocolHost);
43
+ configure(): void;
44
+ /** An application context never receives a platform: refused here, at its init (N1). */
45
+ onModuleInit(): void;
46
+ static forRoot<M extends AdapterModel, const Cfg = AnyFrameworkConfig>(options: AventaraModuleOptions<M, Cfg>): DynamicModule;
47
+ static forRootAsync<M extends AdapterModel, const Cfg = AnyFrameworkConfig>(options: AventaraModuleAsyncOptions<M, Cfg>): DynamicModule;
48
+ private static mount;
49
+ }
@@ -0,0 +1,77 @@
1
+ import { createFramework, } from "@aventara/core";
2
+ import { Inject, Module, } from "@nestjs/common";
3
+ import { HttpAdapterHost } from "@nestjs/core";
4
+ import { AVENTARA_FRAMEWORK } from "./framework.token.js";
5
+ import { checkedOptions, recordMount } from "./host-bootstrap.validator.js";
6
+ import { createPendingProtocolHost, } from "./protocol-host.factory.js";
7
+ /**
8
+ * One value per `forRoot`/`forRootAsync` call, carried in the module's
9
+ * metadata: under Nest's opt-in `deep-hash` module ids, two calls whose
10
+ * metadata serialized alike would collapse into one module and the second
11
+ * mount would vanish instead of being refused (Q18).
12
+ */
13
+ const MOUNT = Symbol("AVENTARA_MOUNT");
14
+ let mounts = 0;
15
+ /** The bound protocol and its mounter for this application's platform. */
16
+ const PROTOCOL_HOST = Symbol("AVENTARA_PROTOCOL_HOST");
17
+ /**
18
+ * The Framework the options name: the developer's instance, or the one
19
+ * `createFramework` builds from the configuration — whose
20
+ * `FrameworkConstructionError` propagates unchanged (Q1).
21
+ */
22
+ async function frameworkOf(options) {
23
+ const checked = checkedOptions(options);
24
+ return checked.framework ?? createFramework(checked.config);
25
+ }
26
+ /**
27
+ * The global module that hosts one Aventara Framework (Q2, Q3), and mounts its
28
+ * protocol on the application's platform from `configure()` (Q5): after the
29
+ * developer's `app.use` / `enableCors`, before `MiddlewareConsumer` middleware
30
+ * and every controller, so the reserved `_` namespace cannot be shadowed.
31
+ */
32
+ export class AventaraModule {
33
+ host;
34
+ constructor(host) {
35
+ this.host = host;
36
+ }
37
+ configure() {
38
+ this.host.mount();
39
+ }
40
+ /** An application context never receives a platform: refused here, at its init (N1). */
41
+ onModuleInit() {
42
+ this.host.assertReady();
43
+ }
44
+ static forRoot(options) {
45
+ return AventaraModule.mount([], [], () => options);
46
+ }
47
+ static forRootAsync(options) {
48
+ return AventaraModule.mount(options.imports ?? [], options.inject ?? [], options.useFactory);
49
+ }
50
+ static mount(imports, inject, resolve) {
51
+ mounts += 1;
52
+ return {
53
+ module: AventaraModule,
54
+ global: true,
55
+ imports,
56
+ providers: [
57
+ { provide: MOUNT, useValue: mounts },
58
+ {
59
+ provide: AVENTARA_FRAMEWORK,
60
+ inject: [HttpAdapterHost, ...inject],
61
+ useFactory: async (application, ...deps) => {
62
+ recordMount(application);
63
+ return frameworkOf(await resolve(...deps));
64
+ },
65
+ },
66
+ {
67
+ provide: PROTOCOL_HOST,
68
+ inject: [HttpAdapterHost, AVENTARA_FRAMEWORK],
69
+ useFactory: (application, framework) => createPendingProtocolHost(application, framework),
70
+ },
71
+ ],
72
+ exports: [AVENTARA_FRAMEWORK],
73
+ };
74
+ }
75
+ }
76
+ Inject(PROTOCOL_HOST)(AventaraModule, undefined, 0);
77
+ Module({})(AventaraModule);
@@ -0,0 +1,42 @@
1
+ import type { AvProtocolBinding } from "@aventara/core/protocol";
2
+ import { type NodeIncoming, type NodeOutgoing } from "./node-exchange.translator.js";
3
+ import type { ProtocolHost } from "./protocol-host.factory.js";
4
+ /**
5
+ * The protocol on Nest's Express platform (Q4, Q5, Q8, Q9, Q11, Q12, P1–P3).
6
+ *
7
+ * - **The pre-parser** (Q8), installed while Nest instantiates providers —
8
+ * before Nest's own body parsers: a request under `<entrypoint>/_` has its
9
+ * body read raw, bounded and decoded, and kept for its route. Its stream is
10
+ * then finished, so Nest's parsers skip it; every other request keeps
11
+ * Nest's parsed body. It reads; it answers nothing.
12
+ * - **The routes**, mounted from the module's `configure()` (Q5): one platform
13
+ * route per `protocol.surface()` entry, each at its escaped literal path
14
+ * under the entrypoint, then one fallback — `protocol.answerAbsent`, the
15
+ * absence handler, not a catch-all: it executes nothing, and a path it does
16
+ * not own (`undefined`) goes on to Nest.
17
+ *
18
+ * Its whole job is translation: the Express request into an
19
+ * `AvProtocolRequest`, the `AvProtocolResponse` back onto the wire. Express's
20
+ * types are the slices below (P4: no `@types/node`, no `@types/express`).
21
+ */
22
+ /** Express's request: Node's `IncomingMessage`, plus `originalUrl`. */
23
+ type ExpressRequest = NodeIncoming & {
24
+ readonly method: string;
25
+ readonly originalUrl: string;
26
+ };
27
+ /** Express's response, written through Node's own `writeHead` (P1: never `send`/`json`). */
28
+ type ExpressResponse = NodeOutgoing;
29
+ /** Express passes `next`; Nest's handler type declares it as an optional `Function`. */
30
+ type ExpressHandler = (request: ExpressRequest, response: ExpressResponse, next?: unknown) => unknown;
31
+ /** The slice of Nest's `ExpressAdapter` this mounter registers on. */
32
+ export type ExpressPlatform = {
33
+ get(path: string, handler: ExpressHandler): unknown;
34
+ post(path: string, handler: ExpressHandler): unknown;
35
+ use(handler: ExpressHandler): unknown;
36
+ };
37
+ /**
38
+ * Installs the pre-parser on `platform` now, and returns the host whose
39
+ * `mount` registers the routes and the absence fallback under `entrypoint`.
40
+ */
41
+ export declare function createExpressProtocolHost(platform: ExpressPlatform, binding: AvProtocolBinding, entrypoint: string, maxRequestBytes: number): ProtocolHost;
42
+ export {};
@@ -0,0 +1,65 @@
1
+ import { NO_BYTES, pathnameOf, protocolRequest, writeAnswer, } from "./node-exchange.translator.js";
2
+ import { PROTOCOL_ROUTES } from "./protocol-route.table.js";
3
+ import { readRequestBody, unreadBodyAnswer, } from "./request-body.reader.js";
4
+ /** The platform method that registers a route of each surface method. */
5
+ const REGISTER = { GET: "get", POST: "post" };
6
+ /** A request the pre-parser did not read: zero bytes. */
7
+ const NOTHING_READ = { kind: "read", content: NO_BYTES };
8
+ /**
9
+ * Installs the pre-parser on `platform` now, and returns the host whose
10
+ * `mount` registers the routes and the absence fallback under `entrypoint`.
11
+ */
12
+ export function createExpressProtocolHost(platform, binding, entrypoint, maxRequestBytes) {
13
+ const bodies = new WeakMap();
14
+ const bodyOf = (request) => bodies.get(request) ?? NOTHING_READ;
15
+ platform.use(async (request, _response, next) => {
16
+ if (pathnameOf(request.originalUrl).startsWith(`${entrypoint}/_`)) {
17
+ bodies.set(request, await readRequestBody(request, request.headersDistinct["content-encoding"], maxRequestBytes));
18
+ }
19
+ handOn(next);
20
+ });
21
+ return {
22
+ mount: () => {
23
+ for (const route of binding.surface()) {
24
+ const answer = PROTOCOL_ROUTES[route.kind](binding);
25
+ platform[REGISTER[route.method]](literalPath(`${entrypoint}${route.path}`), async (request, response) => {
26
+ const body = bodyOf(request);
27
+ writeAnswer(response, body.kind === "read"
28
+ ? await answer(translated(request, entrypoint, body.content))
29
+ : unreadBodyAnswer(binding, body));
30
+ });
31
+ }
32
+ platform.use((request, response, next) => {
33
+ if (!pathnameOf(request.originalUrl).startsWith(`${entrypoint}/`)) {
34
+ handOn(next);
35
+ return;
36
+ }
37
+ const body = bodyOf(request);
38
+ const absent = binding.answerAbsent(translated(request, entrypoint, body.kind === "read" ? body.content : NO_BYTES));
39
+ if (absent === undefined) {
40
+ handOn(next);
41
+ return;
42
+ }
43
+ writeAnswer(response, absent);
44
+ });
45
+ },
46
+ };
47
+ }
48
+ /** Hands the request on to whatever Express runs next — Nest's own routes. */
49
+ function handOn(next) {
50
+ if (typeof next === "function") {
51
+ next();
52
+ }
53
+ }
54
+ /**
55
+ * The route path as a literal for Express's router (`path-to-regexp` 8):
56
+ * every character it reads as syntax is escaped, so a Resource key never
57
+ * mounts a parameter or a wildcard (H11, DA-13).
58
+ */
59
+ function literalPath(path) {
60
+ return path.replace(/[()[\]{}*+?!:\\]/g, "\\$&");
61
+ }
62
+ /** The Express request as the protocol reads it. */
63
+ function translated(request, entrypoint, content) {
64
+ return protocolRequest(request.method, request.originalUrl, request, entrypoint, content);
65
+ }
@@ -0,0 +1,68 @@
1
+ import type { AvProtocolBinding } from "@aventara/core/protocol";
2
+ import { type NodeIncoming, type NodeOutgoing, type PlatformHeaders } from "./node-exchange.translator.js";
3
+ import type { ProtocolHost } from "./protocol-host.factory.js";
4
+ /**
5
+ * The protocol on Nest's Fastify platform (Q10; Q8, Q9, Q11, Q12, P1–P3 as on
6
+ * Express), mounted from the module's `configure()` (Q5). Measured in S4:
7
+ *
8
+ * - **One Fastify route per `protocol.surface()` entry**, at its literal path
9
+ * under the entrypoint, with Fastify's automatic `HEAD` route off (U9), so
10
+ * `HEAD /_contract` reaches the protocol's `405` as on Express (P3).
11
+ * - **Each route is answered from its own `onRequest` hook** — Fastify's first
12
+ * step, before its content-type check and body parsing: the body is read raw
13
+ * off Node's request by the bounded reader, the bound member answers, and the
14
+ * reply is hijacked. Fastify's parsers, `bodyLimit` and early `415` (on a
15
+ * `Content-Type` it cannot parse) never run on a protocol route, so every
16
+ * body failure is the protocol's own answer, as on Express (director,
17
+ * 2026-10-05). The route handler is never reached.
18
+ * - **The absence fallback** is a root `onRequest` hook acting only on a
19
+ * request Fastify matched to no route (`request.is404`) under
20
+ * `<entrypoint>/`: `protocol.answerAbsent`, and `undefined` hands the request
21
+ * on to Nest's own not-found handler (U8, director, 2026-10-05). A not-found
22
+ * handler of our own could not hand back: inside one, Fastify answers its
23
+ * plain-text 404, not Nest's.
24
+ * - **Literal paths** (architect, 2026-10-05): each route is registered
25
+ * `decodeURI`-ed, as find-my-way decodes a request path before matching it
26
+ * (`café`, `a[b]`). A path find-my-way cannot match as a literal — a `*`
27
+ * (its wildcard) or a reserved escape (it keeps those encoded, and never
28
+ * matches them against a static route) — is refused while Nest builds the
29
+ * application, naming the Resource keys; Express mounts them all.
30
+ * - **Writes** go through Node's response after `reply.hijack()` (P1: no
31
+ * charset rewrite, no lower-cased names, no added `Content-Type`), with the
32
+ * headers Fastify hooks already set (CORS) beneath the protocol's own —
33
+ * what Node's `writeHead` does on Express (director, 2026-10-05).
34
+ *
35
+ * Fastify's types are the slices below (P4).
36
+ */
37
+ /** Fastify's request: Node's own under `raw`, and whether a route matched. */
38
+ type FastifyRequest = {
39
+ readonly raw: NodeIncoming & {
40
+ readonly method: string;
41
+ readonly url: string;
42
+ };
43
+ readonly is404: boolean;
44
+ };
45
+ /** Fastify's reply: hijacked, then written through Node's response under `raw`. */
46
+ type FastifyReply = {
47
+ hijack(): unknown;
48
+ getHeaders(): PlatformHeaders;
49
+ readonly raw: NodeOutgoing;
50
+ };
51
+ type OnRequestHook = (request: FastifyRequest, reply: FastifyReply) => Promise<FastifyReply | undefined>;
52
+ /** The slice of the Fastify instance (`httpAdapter.getInstance()`) this mounter registers on. */
53
+ export type FastifyPlatform = {
54
+ route(options: {
55
+ readonly method: string;
56
+ readonly url: string;
57
+ readonly exposeHeadRoute: false;
58
+ readonly onRequest: OnRequestHook;
59
+ readonly handler: () => never;
60
+ }): unknown;
61
+ addHook(name: "onRequest", hook: OnRequestHook): unknown;
62
+ };
63
+ /**
64
+ * The host whose `mount` registers the routes and the absence hook under
65
+ * `entrypoint` — after refusing, now, a surface it cannot mount literally.
66
+ */
67
+ export declare function createFastifyProtocolHost(platform: FastifyPlatform, binding: AvProtocolBinding, entrypoint: string, maxRequestBytes: number): ProtocolHost;
68
+ export {};
@@ -0,0 +1,83 @@
1
+ import { NO_BYTES, pathnameOf, protocolRequest, writeAnswer, } from "./node-exchange.translator.js";
2
+ import { PROTOCOL_ROUTES } from "./protocol-route.table.js";
3
+ import { readRequestBody, unreadBodyAnswer } from "./request-body.reader.js";
4
+ /**
5
+ * What find-my-way cannot match in a static route path: its wildcard, and the
6
+ * escapes of the characters `decodeURI` leaves encoded (: + # $ & , / ; = ? @).
7
+ */
8
+ const UNMATCHABLE = /\*|%(?:3A|2B|23|24|26|2C|2F|3B|3D|3F|40)/i;
9
+ /**
10
+ * Refuses an entrypoint find-my-way cannot match literally — a `*`, its
11
+ * wildcard; every other character a canonical entrypoint may carry raw
12
+ * mounts (measured S6; a `:` is doubled) — then, naming them, the Resource
13
+ * keys whose routes it cannot match literally.
14
+ */
15
+ function checkLiteralRoutes(binding, entrypoint) {
16
+ if (entrypoint.includes("*")) {
17
+ throw new Error(`AventaraModule cannot mount the entrypoint \`${entrypoint}\` as a literal path on Nest's Fastify platform: it may not carry \`*\`. Change the entrypoint, or host on Express.`);
18
+ }
19
+ const refused = binding
20
+ .surface()
21
+ .flatMap((route) => route.kind === "operation" && UNMATCHABLE.test(route.path)
22
+ ? [route.resource]
23
+ : [])
24
+ .filter((key, index, keys) => keys.indexOf(key) === index);
25
+ if (refused.length > 0) {
26
+ throw new Error(`AventaraModule cannot mount Resource ${refused.map((key) => `\`${key}\``).join(", ")} as literal routes on Nest's Fastify platform: a route path may not carry \`*\` or a reserved character (: + # $ & , / ; = ? @). Rename the Resource, or host on Express.`);
27
+ }
28
+ }
29
+ /**
30
+ * The host whose `mount` registers the routes and the absence hook under
31
+ * `entrypoint` — after refusing, now, a surface it cannot mount literally.
32
+ */
33
+ export function createFastifyProtocolHost(platform, binding, entrypoint, maxRequestBytes) {
34
+ checkLiteralRoutes(binding, entrypoint);
35
+ const translated = (request, content) => protocolRequest(request.raw.method, request.raw.url, request.raw, entrypoint, content);
36
+ return {
37
+ mount: () => {
38
+ for (const route of binding.surface()) {
39
+ const answer = PROTOCOL_ROUTES[route.kind](binding);
40
+ platform.route({
41
+ method: route.method,
42
+ url: literalPath(`${entrypoint}${route.path}`),
43
+ exposeHeadRoute: false,
44
+ onRequest: async (request, reply) => {
45
+ const body = await readRequestBody(request.raw, request.raw.headersDistinct["content-encoding"], maxRequestBytes);
46
+ write(reply, body.kind === "read"
47
+ ? await answer(translated(request, body.content))
48
+ : unreadBodyAnswer(binding, body));
49
+ return reply;
50
+ },
51
+ handler: () => {
52
+ throw new Error("AventaraModule: a protocol route's onRequest hook answers every request; its handler is unreachable.");
53
+ },
54
+ });
55
+ }
56
+ platform.addHook("onRequest", async (request, reply) => {
57
+ if (!request.is404 ||
58
+ !pathnameOf(request.raw.url).startsWith(`${entrypoint}/`)) {
59
+ return undefined;
60
+ }
61
+ const absent = binding.answerAbsent(translated(request, NO_BYTES));
62
+ if (absent === undefined) {
63
+ return undefined;
64
+ }
65
+ write(reply, absent);
66
+ return reply;
67
+ });
68
+ },
69
+ };
70
+ }
71
+ /**
72
+ * The route path as find-my-way matches it: `decodeURI`-ed, as it decodes a
73
+ * request path before the lookup, and every `:` (raw only in the entrypoint —
74
+ * the surface encodes it) doubled, find-my-way's escape for a literal colon.
75
+ */
76
+ function literalPath(path) {
77
+ return decodeURI(path).replaceAll(":", "::");
78
+ }
79
+ /** The protocol's answer on the hijacked reply, the hooks' headers beneath it. */
80
+ function write(reply, answer) {
81
+ reply.hijack();
82
+ writeAnswer(reply.raw, answer, reply.getHeaders());
83
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The DI token under which `AventaraModule` provides the application's
3
+ * `Framework` (Q2). A symbol, so injection is exact: an abstract-class token
4
+ * would type the injected value as an empty class, not the application's own
5
+ * `Framework<M, Cfg>` (Q3 — the application types the injection with its
6
+ * own alias).
7
+ */
8
+ export declare const AVENTARA_FRAMEWORK: unique symbol;
9
+ /** `@InjectFramework()` — `@Inject(AVENTARA_FRAMEWORK)` in one call (Q2). */
10
+ export declare function InjectFramework(): ParameterDecorator;
@@ -0,0 +1,13 @@
1
+ import { Inject } from "@nestjs/common";
2
+ /**
3
+ * The DI token under which `AventaraModule` provides the application's
4
+ * `Framework` (Q2). A symbol, so injection is exact: an abstract-class token
5
+ * would type the injected value as an empty class, not the application's own
6
+ * `Framework<M, Cfg>` (Q3 — the application types the injection with its
7
+ * own alias).
8
+ */
9
+ export const AVENTARA_FRAMEWORK = Symbol("AVENTARA_FRAMEWORK");
10
+ /** `@InjectFramework()` — `@Inject(AVENTARA_FRAMEWORK)` in one call (Q2). */
11
+ export function InjectFramework() {
12
+ return Inject(AVENTARA_FRAMEWORK);
13
+ }
@@ -0,0 +1,9 @@
1
+ import type { AventaraModuleOptions } from "./aventara.module.js";
2
+ /** The options as given — `forRootAsync`'s factory may resolve anything at runtime. */
3
+ export declare function checkedOptions(options: unknown): AventaraModuleOptions;
4
+ /** Records one mount in `application`; a second is refused. */
5
+ export declare function recordMount(application: object): void;
6
+ /** The Nest HTTP platforms the module mounts the protocol on (Q10). */
7
+ export type HostPlatform = "express" | "fastify";
8
+ /** The application's platform, as `httpAdapter.getType()` names it — or a refusal. */
9
+ export declare function checkedPlatform(type: string | undefined): HostPlatform;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Q18's bootstrap checks, all at module setup, so `NestFactory.create`
3
+ * rejects before the application listens: plain `Error`s with fixed messages
4
+ * (no new public name — Q18 a). A `FrameworkConstructionError` is core's and
5
+ * passes through untouched; nothing here wraps it.
6
+ */
7
+ const NEITHER_ARM = "AventaraModule: the options carry neither `framework` nor `config`; pass exactly one.";
8
+ const BOTH_ARMS = "AventaraModule: the options carry both `framework` and `config`; pass exactly one.";
9
+ const NOT_A_FRAMEWORK = "AventaraModule: `framework` is not an Aventara Framework (it has no string `entrypoint` and no `contracts.client`); pass what `createFramework` resolved to.";
10
+ const MOUNTED_TWICE = "AventaraModule is mounted twice in one application; import AventaraModule.forRoot or forRootAsync once.";
11
+ /** The options as given — `forRootAsync`'s factory may resolve anything at runtime. */
12
+ export function checkedOptions(options) {
13
+ const record = typeof options === "object" && options !== null
14
+ ? options
15
+ : {};
16
+ const hasFramework = record.framework !== undefined;
17
+ const hasConfig = record.config !== undefined;
18
+ if (hasFramework && hasConfig) {
19
+ throw new Error(BOTH_ARMS);
20
+ }
21
+ if (!hasFramework && !hasConfig) {
22
+ throw new Error(NEITHER_ARM);
23
+ }
24
+ if (hasFramework && !isFramework(record.framework)) {
25
+ throw new Error(NOT_A_FRAMEWORK);
26
+ }
27
+ return record;
28
+ }
29
+ /** What the host reads of a Framework: its entrypoint, and its compiled ClientContract. */
30
+ function isFramework(value) {
31
+ if (typeof value !== "object" || value === null) {
32
+ return false;
33
+ }
34
+ const candidate = value;
35
+ return (typeof candidate.entrypoint === "string" &&
36
+ typeof candidate.contracts?.client === "object" &&
37
+ candidate.contracts.client !== null);
38
+ }
39
+ /**
40
+ * Applications that already mount the module, keyed by an object Nest creates
41
+ * once per application (its `HttpAdapterHost`). Weak, so a closed
42
+ * application's entry goes with it.
43
+ */
44
+ const mounted = new WeakSet();
45
+ /** Records one mount in `application`; a second is refused. */
46
+ export function recordMount(application) {
47
+ if (mounted.has(application)) {
48
+ throw new Error(MOUNTED_TWICE);
49
+ }
50
+ mounted.add(application);
51
+ }
52
+ const HOST_PLATFORMS = ["express", "fastify"];
53
+ /** The application's platform, as `httpAdapter.getType()` names it — or a refusal. */
54
+ export function checkedPlatform(type) {
55
+ const platform = HOST_PLATFORMS.find((supported) => supported === type);
56
+ if (platform === undefined) {
57
+ throw new Error(`AventaraModule mounts the protocol on Nest's Express or Fastify platform; this application's platform is \`${type ?? "none"}\`.`);
58
+ }
59
+ return platform;
60
+ }
@@ -0,0 +1,3 @@
1
+ export type { AventaraModuleAsyncOptions, AventaraModuleOptions, } from "./aventara.module.js";
2
+ export { AventaraModule } from "./aventara.module.js";
3
+ export { AVENTARA_FRAMEWORK, InjectFramework } from "./framework.token.js";
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { AventaraModule } from "./aventara.module.js";
2
+ export { AVENTARA_FRAMEWORK, InjectFramework } from "./framework.token.js";
@@ -0,0 +1,30 @@
1
+ import type { AvProtocolRequest, AvProtocolResponse } from "@aventara/core/protocol";
2
+ /**
3
+ * The translation both platforms share, because both hand the host Node's own
4
+ * request and response (Q10: shared only as measured — S4 ran the corpus on
5
+ * Express and Fastify through it). Node's types are the slices below (P4).
6
+ */
7
+ /** The slice of Node's `IncomingMessage` the host reads. */
8
+ export type NodeIncoming = AsyncIterable<Uint8Array> & {
9
+ readonly headersDistinct: Readonly<Record<string, readonly string[]>>;
10
+ };
11
+ /** The slice of Node's `ServerResponse` the host writes (P1: no platform reply path). */
12
+ export type NodeOutgoing = {
13
+ writeHead(status: number, headers: Readonly<Record<string, string | number | readonly string[]>>): unknown;
14
+ end(body: string): unknown;
15
+ };
16
+ /** Headers a platform's own hooks set on the reply before the protocol answered. */
17
+ export type PlatformHeaders = Readonly<Record<string, string | number | readonly string[] | undefined>>;
18
+ /** Zero bytes: the body of a request whose body the protocol does not read. */
19
+ export declare const NO_BYTES: Uint8Array<ArrayBuffer>;
20
+ /** A request URL's raw pathname — query dropped (Q12), not URL-decoded (P2). */
21
+ export declare function pathnameOf(url: string): string;
22
+ /** The request as the protocol reads it: path relative to the entrypoint, `headersDistinct` (Q11), the raw arm (Q8). */
23
+ export declare function protocolRequest(method: string, url: string, incoming: NodeIncoming, entrypoint: string, content: Uint8Array): AvProtocolRequest;
24
+ /**
25
+ * The protocol's answer, written as it is (P1). Headers the platform's hooks
26
+ * already set (CORS, say) go beneath it; a protocol header of the same name,
27
+ * in any case, wins — what Node's `writeHead` does with `setHeader`'s on
28
+ * Express.
29
+ */
30
+ export declare function writeAnswer(response: NodeOutgoing, answer: AvProtocolResponse, beneath?: PlatformHeaders): void;
@@ -0,0 +1,33 @@
1
+ /** Zero bytes: the body of a request whose body the protocol does not read. */
2
+ export const NO_BYTES = new Uint8Array();
3
+ /** A request URL's raw pathname — query dropped (Q12), not URL-decoded (P2). */
4
+ export function pathnameOf(url) {
5
+ const query = url.indexOf("?");
6
+ return query === -1 ? url : url.slice(0, query);
7
+ }
8
+ /** The request as the protocol reads it: path relative to the entrypoint, `headersDistinct` (Q11), the raw arm (Q8). */
9
+ export function protocolRequest(method, url, incoming, entrypoint, content) {
10
+ return {
11
+ method,
12
+ path: pathnameOf(url).slice(entrypoint.length),
13
+ headers: incoming.headersDistinct,
14
+ body: { kind: "raw", content },
15
+ };
16
+ }
17
+ /**
18
+ * The protocol's answer, written as it is (P1). Headers the platform's hooks
19
+ * already set (CORS, say) go beneath it; a protocol header of the same name,
20
+ * in any case, wins — what Node's `writeHead` does with `setHeader`'s on
21
+ * Express.
22
+ */
23
+ export function writeAnswer(response, answer, beneath = {}) {
24
+ const own = new Set(Object.keys(answer.headers).map((name) => name.toLowerCase()));
25
+ const headers = {};
26
+ for (const [name, value] of Object.entries(beneath)) {
27
+ if (value !== undefined && !own.has(name.toLowerCase())) {
28
+ headers[name] = value;
29
+ }
30
+ }
31
+ response.writeHead(answer.status, { ...headers, ...answer.headers });
32
+ response.end(answer.body);
33
+ }
@@ -0,0 +1,40 @@
1
+ import type { Framework } from "@aventara/core";
2
+ import type { AbstractHttpAdapter } from "@nestjs/core";
3
+ /** The protocol, bound once at startup, and the one call that mounts it. */
4
+ export type ProtocolHost = {
5
+ readonly mount: () => void;
6
+ };
7
+ /**
8
+ * Binds `framework`'s protocol for the application's platform, refusing an
9
+ * unsupported platform while providers are instantiated — so
10
+ * `NestFactory.create` rejects before the application listens (Q18). The
11
+ * routes are mounted later, from the module's `configure()` (Q5).
12
+ */
13
+ export declare function createProtocolHost(adapter: AbstractHttpAdapter | undefined, framework: Framework): ProtocolHost;
14
+ /** What `AventaraModule` holds: a host, perhaps waiting for its platform, and the check that it has one. */
15
+ export type PendingProtocolHost = ProtocolHost & {
16
+ /** Throws when no platform arrived, or the one that did is refused. */
17
+ readonly assertReady: () => void;
18
+ };
19
+ /** The parts of Nest's `HttpAdapterHost` the module reads. */
20
+ export type AdapterHostView = {
21
+ readonly httpAdapter: AbstractHttpAdapter | undefined;
22
+ readonly init$: {
23
+ subscribe(next: () => void): unknown;
24
+ };
25
+ };
26
+ /**
27
+ * N1 (plan B23) — a host for an application whose HTTP adapter may arrive after
28
+ * the providers are instantiated.
29
+ *
30
+ * `NestFactory.create` sets the adapter first: the host is created at once, and
31
+ * an unsupported platform rejects then, as before (Q18). Nest's testing module
32
+ * compiles first and receives its adapter from `createNestApplication()`, which
33
+ * sets it on the `HttpAdapterHost` — `init$` fires — before `app.init()`
34
+ * registers Nest's body parsers: the host is created at that moment, so a
35
+ * platform's body reader is still installed ahead of them (Q8). A refusal there
36
+ * is kept and raised by `mount()` or `assertReady()`, from `app.init()` — still
37
+ * before the application listens. With no adapter at all
38
+ * (`createApplicationContext`), `assertReady()` refuses from `onModuleInit`.
39
+ */
40
+ export declare function createPendingProtocolHost(application: AdapterHostView, framework: Framework): PendingProtocolHost;
@@ -0,0 +1,65 @@
1
+ import { AvProtocol } from "@aventara/core/protocol";
2
+ import { createExpressProtocolHost } from "./express-protocol.mounter.js";
3
+ import { createFastifyProtocolHost, } from "./fastify-protocol.mounter.js";
4
+ import { checkedPlatform, } from "./host-bootstrap.validator.js";
5
+ const PLATFORM_MOUNTERS = {
6
+ express: (adapter, binding, entrypoint, maxRequestBytes) => createExpressProtocolHost({
7
+ get: (path, handler) => adapter.get(path, handler),
8
+ post: (path, handler) => adapter.post(path, handler),
9
+ use: (handler) => adapter.use(handler),
10
+ }, binding, entrypoint, maxRequestBytes),
11
+ fastify: (adapter, binding, entrypoint, maxRequestBytes) => createFastifyProtocolHost(adapter.getInstance(), binding, entrypoint, maxRequestBytes),
12
+ };
13
+ /**
14
+ * Binds `framework`'s protocol for the application's platform, refusing an
15
+ * unsupported platform while providers are instantiated — so
16
+ * `NestFactory.create` rejects before the application listens (Q18). The
17
+ * routes are mounted later, from the module's `configure()` (Q5).
18
+ */
19
+ export function createProtocolHost(adapter, framework) {
20
+ const platform = checkedPlatform(adapter?.getType());
21
+ return PLATFORM_MOUNTERS[platform](adapter, AvProtocol.bind(framework), framework.entrypoint, framework.contracts.client.limits.maxRequestBytes);
22
+ }
23
+ /**
24
+ * N1 (plan B23) — a host for an application whose HTTP adapter may arrive after
25
+ * the providers are instantiated.
26
+ *
27
+ * `NestFactory.create` sets the adapter first: the host is created at once, and
28
+ * an unsupported platform rejects then, as before (Q18). Nest's testing module
29
+ * compiles first and receives its adapter from `createNestApplication()`, which
30
+ * sets it on the `HttpAdapterHost` — `init$` fires — before `app.init()`
31
+ * registers Nest's body parsers: the host is created at that moment, so a
32
+ * platform's body reader is still installed ahead of them (Q8). A refusal there
33
+ * is kept and raised by `mount()` or `assertReady()`, from `app.init()` — still
34
+ * before the application listens. With no adapter at all
35
+ * (`createApplicationContext`), `assertReady()` refuses from `onModuleInit`.
36
+ */
37
+ export function createPendingProtocolHost(application, framework) {
38
+ if (application.httpAdapter !== undefined) {
39
+ const host = createProtocolHost(application.httpAdapter, framework);
40
+ return { mount: host.mount, assertReady: () => undefined };
41
+ }
42
+ let host;
43
+ let refusal;
44
+ application.init$.subscribe(() => {
45
+ try {
46
+ host = createProtocolHost(application.httpAdapter, framework);
47
+ }
48
+ catch (error) {
49
+ refusal = error;
50
+ }
51
+ });
52
+ const ready = () => {
53
+ if (refusal !== undefined) {
54
+ throw refusal;
55
+ }
56
+ // No adapter ever arrived: refused with the same sentence as at creation.
57
+ return host ?? createProtocolHost(undefined, framework);
58
+ };
59
+ return {
60
+ mount: () => ready().mount(),
61
+ assertReady: () => {
62
+ ready();
63
+ },
64
+ };
65
+ }
@@ -0,0 +1,18 @@
1
+ import type { AvProtocolBinding, AvProtocolRequest, AvProtocolResponse } from "@aventara/core/protocol";
2
+ /**
3
+ * One route `protocol.surface()` lists. Core keeps `SurfaceRoute` a module
4
+ * type, so the host derives it from the binding — no new core export.
5
+ */
6
+ export type SurfaceRoute = ReturnType<AvProtocolBinding["surface"]>[number];
7
+ /** What a mounted route answers its request with: a bound protocol member. */
8
+ export type RouteAnswer = (request: AvProtocolRequest) => AvProtocolResponse | Promise<AvProtocolResponse>;
9
+ /**
10
+ * The bound member each kind of surface route is answered by — total over
11
+ * `SurfaceRoute["kind"]`, so a route kind core adds fails to compile here
12
+ * rather than mounting a route nothing answers. Every platform's mounter
13
+ * reads this one table; none decides an answer of its own.
14
+ */
15
+ export type ProtocolRouteTable = {
16
+ readonly [K in SurfaceRoute["kind"]]: (binding: AvProtocolBinding) => RouteAnswer;
17
+ };
18
+ export declare const PROTOCOL_ROUTES: ProtocolRouteTable;
@@ -0,0 +1,5 @@
1
+ export const PROTOCOL_ROUTES = {
2
+ operation: (binding) => (request) => binding.handleOperation(request),
3
+ contract: (binding) => (request) => binding.encodeContract(request),
4
+ transactions: (binding) => (request) => binding.handleTransaction(request),
5
+ };
@@ -0,0 +1,45 @@
1
+ import type { AvProtocolBinding, AvProtocolResponse } from "@aventara/core/protocol";
2
+ /**
3
+ * The raw arm's body reader (Q8, Q9): a request body read off a Node stream,
4
+ * decoded per its `Content-Encoding`, and stored up to `maxRequestBytes + 1`
5
+ * DECODED bytes — one more than the limit is all the decoder needs to answer
6
+ * `A2010`, and counting decoded bytes is also the decompression-bomb bound
7
+ * (§12.3, §20.1). Past the bound the stream is still read to its end and
8
+ * discarded, so the connection stays usable (U1); nothing more is inflated.
9
+ *
10
+ * Platform-free: it reads an async iterable of bytes, as Node's
11
+ * `IncomingMessage` is. `node:zlib` is loaded by a specifier the compiler does
12
+ * not resolve, typed by the slice below (P4: no `@types/node`).
13
+ */
14
+ /**
15
+ * What the reader hands on: the stored bytes; an unsupported coding (Q9,
16
+ * answered `A2011`); or a body that does not decode in the coding it names —
17
+ * a malformed request (director, 2026-10-05: answered `A2000`).
18
+ */
19
+ export type RequestBodyRead = {
20
+ readonly kind: "read";
21
+ readonly content: Uint8Array;
22
+ } | {
23
+ readonly kind: "unsupported-coding";
24
+ } | {
25
+ readonly kind: "undecodable";
26
+ };
27
+ /**
28
+ * The answer to a body the reader could not hand on, through the bound
29
+ * `failure` member — the one place the host names an A-code (Q9): an
30
+ * unsupported coding is `415 A2011`; a body that does not decode in its
31
+ * coding is malformed, `400 A2000` (director, 2026-10-05). The request id is
32
+ * minted by core: it exposes no adoption of the client's id to a host
33
+ * (register row, S8).
34
+ */
35
+ export declare function unreadBodyAnswer(binding: AvProtocolBinding, read: Exclude<RequestBodyRead, {
36
+ readonly kind: "read";
37
+ }>): AvProtocolResponse;
38
+ /**
39
+ * Reads `source` to its end. `contentEncoding` is the header's values as
40
+ * received; absent, empty or `identity` is no coding; one of `gzip`,
41
+ * `deflate`, `br` (any case) is inflated; anything else — a stacked coding
42
+ * included — is unsupported. A body that does not decode in the coding it
43
+ * names is undecodable. Either way the stream is still read to its end.
44
+ */
45
+ export declare function readRequestBody(source: AsyncIterable<Uint8Array>, contentEncoding: readonly string[] | undefined, cap: number): Promise<RequestBodyRead>;
@@ -0,0 +1,130 @@
1
+ /**
2
+ * The answer to a body the reader could not hand on, through the bound
3
+ * `failure` member — the one place the host names an A-code (Q9): an
4
+ * unsupported coding is `415 A2011`; a body that does not decode in its
5
+ * coding is malformed, `400 A2000` (director, 2026-10-05). The request id is
6
+ * minted by core: it exposes no adoption of the client's id to a host
7
+ * (register row, S8).
8
+ */
9
+ export function unreadBodyAnswer(binding, read) {
10
+ return read.kind === "unsupported-coding"
11
+ ? binding.failure("A2011")
12
+ : binding.failure("A2000");
13
+ }
14
+ const INFLATERS = {
15
+ gzip: (zlib) => zlib.createGunzip(),
16
+ deflate: (zlib) => zlib.createInflate(),
17
+ br: (zlib) => zlib.createBrotliDecompress(),
18
+ };
19
+ let zlibModule;
20
+ const zlib = () => {
21
+ const specifier = "node:zlib";
22
+ zlibModule ??= import(specifier);
23
+ return zlibModule;
24
+ };
25
+ /**
26
+ * Reads `source` to its end. `contentEncoding` is the header's values as
27
+ * received; absent, empty or `identity` is no coding; one of `gzip`,
28
+ * `deflate`, `br` (any case) is inflated; anything else — a stacked coding
29
+ * included — is unsupported. A body that does not decode in the coding it
30
+ * names is undecodable. Either way the stream is still read to its end.
31
+ */
32
+ export async function readRequestBody(source, contentEncoding, cap) {
33
+ const coding = codingOf(contentEncoding);
34
+ if (coding === undefined) {
35
+ // Read to the end and discarded all the same: the stream finished is
36
+ // what keeps the platform's own parsers off it (Q8), and the
37
+ // connection usable.
38
+ for await (const _ of source) {
39
+ }
40
+ return { kind: "unsupported-coding" };
41
+ }
42
+ const stored = boundedStore(cap + 1);
43
+ if (coding === "identity") {
44
+ for await (const chunk of source) {
45
+ stored.add(chunk);
46
+ }
47
+ return { kind: "read", content: stored.content() };
48
+ }
49
+ const inflater = INFLATERS[coding](await zlib());
50
+ const inflated = (async () => {
51
+ for await (const chunk of inflater) {
52
+ if (!stored.add(chunk)) {
53
+ inflater.destroy();
54
+ return;
55
+ }
56
+ }
57
+ })();
58
+ // Settles when the inflater stops — at its end, at the bound (destroyed),
59
+ // or at a decoding error. Node calls no write callback once an inflater
60
+ // has failed, so each write also waits on this, never on the callback
61
+ // alone. Marking it handled here keeps an early decoding error from being
62
+ // reported as unhandled before it is awaited below.
63
+ const stopped = inflated.then(() => undefined, () => undefined);
64
+ for await (const chunk of source) {
65
+ if (!inflater.destroyed) {
66
+ await Promise.race([
67
+ new Promise((written) => {
68
+ inflater.write(chunk, () => written());
69
+ }),
70
+ stopped,
71
+ ]);
72
+ }
73
+ }
74
+ if (!inflater.destroyed) {
75
+ inflater.end();
76
+ }
77
+ try {
78
+ await inflated;
79
+ }
80
+ catch {
81
+ return { kind: "undecodable" };
82
+ }
83
+ return { kind: "read", content: stored.content() };
84
+ }
85
+ /** The coding the header names, `identity` for none, `undefined` for an unsupported one. */
86
+ function codingOf(contentEncoding) {
87
+ const named = (contentEncoding ?? [])
88
+ .join(",")
89
+ .split(",")
90
+ .map((token) => token.trim().toLowerCase())
91
+ .filter((token) => token !== "");
92
+ if (named.length === 0) {
93
+ return "identity";
94
+ }
95
+ if (named.length > 1) {
96
+ return undefined;
97
+ }
98
+ const [only] = named;
99
+ return only === "identity" ||
100
+ only === "gzip" ||
101
+ only === "deflate" ||
102
+ only === "br"
103
+ ? only
104
+ : undefined;
105
+ }
106
+ /** Stores bytes up to `limit`; `add` answers whether there is room for more. */
107
+ function boundedStore(limit) {
108
+ const chunks = [];
109
+ let size = 0;
110
+ return {
111
+ add(chunk) {
112
+ const room = limit - size;
113
+ if (room > 0) {
114
+ const kept = chunk.byteLength <= room ? chunk : chunk.subarray(0, room);
115
+ chunks.push(kept);
116
+ size += kept.byteLength;
117
+ }
118
+ return size < limit;
119
+ },
120
+ content() {
121
+ const bytes = new Uint8Array(size);
122
+ let offset = 0;
123
+ for (const chunk of chunks) {
124
+ bytes.set(chunk, offset);
125
+ offset += chunk.byteLength;
126
+ }
127
+ return bytes;
128
+ },
129
+ };
130
+ }
package/package.json CHANGED
@@ -1,6 +1,51 @@
1
1
  {
2
2
  "name": "@aventara/nest",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0-pilot.0",
4
+ "license": "SEE LICENSE IN LICENSE",
5
+ "description": "NestJS host integration for Aventara.",
6
+ "type": "module",
7
+ "engines": {
8
+ "node": "^22.18.0 || >=24.2.0"
9
+ },
10
+ "main": "./dist/index.js",
11
+ "types": "./dist/index.d.ts",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "import": "./dist/index.js",
16
+ "default": "./dist/index.js"
17
+ }
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "LICENSE-ADDITIONAL-PERMISSION.md"
22
+ ],
23
+ "dependencies": {
24
+ "@aventara/core": "0.1.0-pilot.0"
25
+ },
26
+ "peerDependencies": {
27
+ "@nestjs/common": "^12.0.0",
28
+ "@nestjs/core": "^12.0.0"
29
+ },
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "devDependencies": {
34
+ "@aventara/prisma7-adapter": "0.1.0-pilot.0",
35
+ "@aventara/testing": "0.1.0-pilot.0",
36
+ "@nestjs/common": "^12.1.2",
37
+ "@nestjs/core": "^12.1.2",
38
+ "@nestjs/platform-express": "^12.1.2",
39
+ "@nestjs/platform-fastify": "^12.1.2",
40
+ "@nestjs/testing": "^12.1.2",
41
+ "@prisma/adapter-better-sqlite3": "7.10.0",
42
+ "reflect-metadata": "^0.2.2",
43
+ "rxjs": "^7.8.2"
44
+ },
45
+ "scripts": {
46
+ "build": "tsc -p tsconfig.json",
47
+ "typecheck": "tsc -p tsconfig.typecheck.json --noEmit",
48
+ "test": "vitest run --typecheck --config ../../vitest.config.mts --root .",
49
+ "lint": "biome lint src"
50
+ }
6
51
  }