@aventara/nest 1.0.0-rc.10 → 1.0.0-rc.11

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/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # @aventara/nest
2
2
 
3
+ ## 1.0.0-rc.11
4
+
5
+ ### Patch Changes
6
+
7
+ - e0e89c1: `POST {api}/_all` now streams when the client asks for it: the hosts forward `Accept`, so with
8
+ `application/x-ndjson` each entry's line is written as it settles (Nest on Express and Fastify writes it to the response,
9
+ flushed through `compression` when present; Next returns a `ReadableStream`), and plain JSON stays the answer otherwise.
10
+ When the client disconnects, the set stops: entries not yet started never start.
11
+ - 186bc29: `POST {api}/_all` is mounted with the other protocol routes and answered by the binding's `handleAll`.
12
+ - 33b5f83: The hosts hand the request URL's query to the protocol, so a cached read's `GET` (a read with a `cache({ for, maxAge })` stage)
13
+ is mounted and answered like every other route in `protocol.surface()`, on Express, Fastify and Next.js.
14
+ - bedab57: A cached read's `Vary` joins the `Vary` the platform already set instead of replacing it. With `enableCors()` on, a
15
+ `for: "anonymous"` or `"user"` answer carries `Vary: Origin, Authorization, Cookie` on Express and Fastify, where it
16
+ carried only `Vary: Authorization, Cookie` and dropped CORS's `Vary: Origin`, so a CDN could serve one origin's
17
+ `Access-Control-Allow-Origin` to another. Each name appears once, compared without case, the platform's first.
18
+ - Updated dependencies [6197c08]
19
+ - Updated dependencies [e0e89c1]
20
+ - Updated dependencies [186bc29]
21
+ - Updated dependencies [33b5f83]
22
+ - Updated dependencies [6d8b589]
23
+ - Updated dependencies [11ece49]
24
+ - Updated dependencies [92fd093]
25
+ - Updated dependencies [e5e98e6]
26
+ - Updated dependencies [a05d970]
27
+ - @aventara/core@1.0.0-rc.11
28
+ - @aventara/mcp@1.0.0-rc.11
29
+
3
30
  ## 1.0.0-rc.10
4
31
 
5
32
  ### Patch Changes
package/README.md CHANGED
@@ -140,7 +140,7 @@ rest of `<api>/_*`. Nest's `RouterExplorer` does not list platform routes, so th
140
140
  own when it mounts them, through Nest's `Logger` (context `AventaraModule`; `logger: false` silences it like any other):
141
141
 
142
142
  ```text
143
- [Nest] LOG [AventaraModule] Aventara mounted 10 operations at /api (+ GET /api/_contract, POST /api/_transactions)
143
+ [Nest] LOG [AventaraModule] Aventara mounted 10 operations at /api (+ GET /api/_contract, POST /api/_transactions, POST /api/_all)
144
144
  ```
145
145
 
146
146
  An internal failure (`A3000`–`A3004`, answered as a 500) is logged as one error line through Nest's `Logger`
@@ -245,7 +245,7 @@ It is an optional peer dependency: an application without `mcp` does not need it
245
245
  package refuses to start, naming it and the command, with the version filled in:
246
246
 
247
247
  ```text
248
- 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/mcp@1.0.0-rc.10), or remove `mcp` from the configuration.
248
+ 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/mcp@1.0.0-rc.11), or remove `mcp` from the configuration.
249
249
  ```
250
250
  As for the protocol's routes, the module only mounts: the endpoint's body is read
251
251
  raw, the request's URL comes from the platform's `protocol` and `host` (set `trust proxy` on Express or `trustProxy` on
@@ -1,4 +1,4 @@
1
- import { mcpRequest, NO_BYTES, pathnameOf, protocolRequest, writeAnswer, } from "./node-exchange.translator.js";
1
+ import { disconnectOf, mcpRequest, NO_BYTES, pathnameOf, protocolRequest, writeAnswer, } from "./node-exchange.translator.js";
2
2
  import { MCP_ROUTES, PROTOCOL_ROUTES, } from "./protocol-route.table.js";
3
3
  import { readRequestBody, unreadBodyAnswer, } from "./request-body.reader.js";
4
4
  const REGISTER = { GET: "get", POST: "post", DELETE: "delete" };
@@ -27,7 +27,7 @@ export function createExpressProtocolHost(platform, binding, entrypoint, maxRequ
27
27
  for (const method of route.methods) {
28
28
  platform[REGISTER[method]](literalPath(route.path), exactly(route.path, async (request, response) => {
29
29
  const body = bodyOf(request);
30
- writeAnswer(response, await answer(mcpRequest(request.method, request, request.originalUrl, request, body.kind === "read" ? body.content : NO_BYTES, request.socket.remoteAddress), state === undefined ? undefined : () => state(request)));
30
+ await writeAnswer(response, await answer(mcpRequest(request.method, request, request.originalUrl, request, body.kind === "read" ? body.content : NO_BYTES, request.socket.remoteAddress), state === undefined ? undefined : () => state(request)));
31
31
  }));
32
32
  }
33
33
  }
@@ -35,8 +35,9 @@ export function createExpressProtocolHost(platform, binding, entrypoint, maxRequ
35
35
  const answer = PROTOCOL_ROUTES[route.kind](binding);
36
36
  platform[REGISTER[route.method]](literalPath(`${entrypoint}${route.path}`), async (request, response) => {
37
37
  const body = bodyOf(request);
38
- writeAnswer(response, body.kind === "read"
39
- ? await answer(translated(request, entrypoint, body.content), state === undefined ? undefined : () => state(request))
38
+ const disconnect = disconnectOf(response);
39
+ await writeAnswer(response, body.kind === "read"
40
+ ? await answer(translated(request, entrypoint, body.content), state === undefined ? undefined : () => state(request), disconnect)
40
41
  : unreadBodyAnswer(binding, body));
41
42
  });
42
43
  }
@@ -51,7 +52,7 @@ export function createExpressProtocolHost(platform, binding, entrypoint, maxRequ
51
52
  handOn(next);
52
53
  return;
53
54
  }
54
- writeAnswer(response, absent);
55
+ void writeAnswer(response, absent);
55
56
  });
56
57
  },
57
58
  };
@@ -1,4 +1,4 @@
1
- import { mcpRequest, NO_BYTES, pathnameOf, protocolRequest, writeAnswer, } from "./node-exchange.translator.js";
1
+ import { disconnectOf, mcpRequest, NO_BYTES, pathnameOf, protocolRequest, writeAnswer, } from "./node-exchange.translator.js";
2
2
  import { MCP_ROUTES, PROTOCOL_ROUTES } from "./protocol-route.table.js";
3
3
  import { readRequestBody, unreadBodyAnswer } from "./request-body.reader.js";
4
4
  const UNMATCHABLE = /\*|%(?:3A|2B|23|24|26|2C|2F|3B|3D|3F|40)/i;
@@ -37,7 +37,7 @@ export function createFastifyProtocolHost(platform, binding, entrypoint, maxRequ
37
37
  exposeHeadRoute: false,
38
38
  onRequest: async (request, reply) => {
39
39
  const body = await readRequestBody(request.raw, undefined, mcp.maxRequestBytes);
40
- write(reply, await answer(mcpRequest(request.raw.method, request, request.raw.url, request.raw, body.kind === "read" ? body.content : NO_BYTES, request.raw.socket.remoteAddress), state === undefined ? undefined : () => state(request)));
40
+ await write(reply, await answer(mcpRequest(request.raw.method, request, request.raw.url, request.raw, body.kind === "read" ? body.content : NO_BYTES, request.raw.socket.remoteAddress), state === undefined ? undefined : () => state(request)));
41
41
  return reply;
42
42
  },
43
43
  handler: () => {
@@ -53,8 +53,9 @@ export function createFastifyProtocolHost(platform, binding, entrypoint, maxRequ
53
53
  exposeHeadRoute: false,
54
54
  onRequest: async (request, reply) => {
55
55
  const body = await readRequestBody(request.raw, request.raw.headersDistinct["content-encoding"], maxRequestBytes);
56
- write(reply, body.kind === "read"
57
- ? await answer(translated(request, body.content), state === undefined ? undefined : () => state(request))
56
+ const disconnect = disconnectOf(reply.raw);
57
+ await write(reply, body.kind === "read"
58
+ ? await answer(translated(request, body.content), state === undefined ? undefined : () => state(request), disconnect)
58
59
  : unreadBodyAnswer(binding, body));
59
60
  return reply;
60
61
  },
@@ -72,7 +73,7 @@ export function createFastifyProtocolHost(platform, binding, entrypoint, maxRequ
72
73
  if (absent === undefined) {
73
74
  return undefined;
74
75
  }
75
- write(reply, absent);
76
+ await write(reply, absent);
76
77
  return reply;
77
78
  });
78
79
  },
@@ -83,5 +84,5 @@ function literalPath(path) {
83
84
  }
84
85
  function write(reply, answer) {
85
86
  reply.hijack();
86
- writeAnswer(reply.raw, answer, reply.getHeaders());
87
+ return writeAnswer(reply.raw, answer, reply.getHeaders());
87
88
  }
@@ -1,11 +1,28 @@
1
- import type { AvProtocolRequest, AvProtocolResponse } from "@aventara/core/protocol";
1
+ import type { AvProtocolRequest } from "@aventara/core/protocol";
2
2
  import type { McpRequest } from "./mcp-binding.loader.js";
3
+ import type { RouteResponse } from "./protocol-route.table.js";
3
4
  export type NodeIncoming = AsyncIterable<Uint8Array> & {
4
5
  readonly headersDistinct: Readonly<Record<string, readonly string[]>>;
5
6
  };
6
7
  export type NodeOutgoing = {
7
8
  writeHead(status: number, headers: Readonly<Record<string, string | number | readonly string[]>>): unknown;
8
- end(body: string): unknown;
9
+ end(body?: string): unknown;
10
+ /** A header already set on the response (Express's middleware sets its own here). */
11
+ getHeader(name: string): string | number | readonly string[] | undefined;
12
+ /** `false` when the socket's buffer is full: wait for `drain`. */
13
+ write(chunk: string): boolean;
14
+ once(event: "close" | "drain", listener: () => void): unknown;
15
+ removeListener(event: "close" | "drain", listener: () => void): unknown;
16
+ destroy(): unknown;
17
+ /** Sends the status line and headers now, before any body is written. */
18
+ flushHeaders(): void;
19
+ /** Whether the whole answer was handed to the socket. */
20
+ readonly writableFinished: boolean;
21
+ /** Whether the response was ended or the socket went away. */
22
+ readonly writableEnded: boolean;
23
+ readonly destroyed: boolean;
24
+ /** The `compression` middleware's: sends what it has compressed so far. */
25
+ flush?(): void;
9
26
  };
10
27
  /** Headers a platform's own hooks set on the reply before the protocol answered. */
11
28
  export type PlatformHeaders = Readonly<Record<string, string | number | readonly string[] | undefined>>;
@@ -13,16 +30,23 @@ export type PlatformHeaders = Readonly<Record<string, string | number | readonly
13
30
  export declare const NO_BYTES: Uint8Array<ArrayBuffer>;
14
31
  /** A request URL's raw pathname: the query is dropped and nothing is URL-decoded. */
15
32
  export declare function pathnameOf(url: string): string;
16
- /** The request as the protocol reads it: path relative to the entrypoint, distinct headers, raw body. */
33
+ /** The request as the protocol reads it: path relative to the entrypoint, raw query, distinct headers, raw body. */
17
34
  export declare function protocolRequest(method: string, url: string, incoming: NodeIncoming, entrypoint: string, content: Uint8Array): AvProtocolRequest;
18
35
  /** The request as `@aventara/mcp` reads it: absolute URL, distinct headers, raw body, peer address. */
19
36
  export declare function mcpRequest(method: string, origin: {
20
37
  readonly protocol: string;
21
38
  readonly host: string;
22
39
  }, url: string, incoming: NodeIncoming, content: Uint8Array, remoteAddress: string | undefined): McpRequest;
40
+ /** Aborted when the client goes away before the answer was written. */
41
+ export declare function disconnectOf(response: NodeOutgoing): AbortSignal;
23
42
  /**
24
43
  * Writes the protocol's answer as it is. Headers the platform's hooks already
25
44
  * set (CORS, say) go beneath it; a protocol header of the same name, in any
26
- * case, wins.
45
+ * case, wins, except `Vary`: the protocol's names join the platform's
46
+ * (`mergedVary`), which Express holds on the response and Fastify hands in
47
+ * `beneath`. A streamed body (`/_all` under NDJSON) is piped: each line
48
+ * written as core yields it, and flushed through `compression` when that
49
+ * middleware wraps the response. Writing stops when the client goes away,
50
+ * and the lines are no longer pulled (`return()`).
27
51
  */
28
- export declare function writeAnswer(response: NodeOutgoing, answer: AvProtocolResponse, beneath?: PlatformHeaders): void;
52
+ export declare function writeAnswer(response: NodeOutgoing, answer: RouteResponse, beneath?: PlatformHeaders): Promise<void>;
@@ -3,10 +3,15 @@ export function pathnameOf(url) {
3
3
  const query = url.indexOf("?");
4
4
  return query === -1 ? url : url.slice(0, query);
5
5
  }
6
+ function queryOf(url) {
7
+ const query = url.indexOf("?");
8
+ return query === -1 ? "" : url.slice(query + 1);
9
+ }
6
10
  export function protocolRequest(method, url, incoming, entrypoint, content) {
7
11
  return {
8
12
  method,
9
13
  path: pathnameOf(url).slice(entrypoint.length),
14
+ query: queryOf(url),
10
15
  headers: incoming.headersDistinct,
11
16
  body: { kind: "raw", content },
12
17
  };
@@ -20,7 +25,35 @@ export function mcpRequest(method, origin, url, incoming, content, remoteAddress
20
25
  remoteAddress,
21
26
  };
22
27
  }
23
- export function writeAnswer(response, answer, beneath = {}) {
28
+ export function disconnectOf(response) {
29
+ const disconnect = new AbortController();
30
+ response.once("close", () => {
31
+ if (!response.writableFinished) {
32
+ disconnect.abort();
33
+ }
34
+ });
35
+ return disconnect.signal;
36
+ }
37
+ function textOf(value) {
38
+ return value === undefined
39
+ ? ""
40
+ : typeof value === "object"
41
+ ? value.join(", ")
42
+ : String(value);
43
+ }
44
+ function mergedVary(lists) {
45
+ const names = new Map();
46
+ for (const list of lists) {
47
+ for (const name of list.split(",")) {
48
+ const trimmed = name.trim();
49
+ if (trimmed !== "" && !names.has(trimmed.toLowerCase())) {
50
+ names.set(trimmed.toLowerCase(), trimmed);
51
+ }
52
+ }
53
+ }
54
+ return names.has("*") ? "*" : [...names.values()].join(", ");
55
+ }
56
+ export async function writeAnswer(response, answer, beneath = {}) {
24
57
  const own = new Set(Object.keys(answer.headers).map((name) => name.toLowerCase()));
25
58
  const headers = {};
26
59
  for (const [name, value] of Object.entries(beneath)) {
@@ -28,6 +61,52 @@ export function writeAnswer(response, answer, beneath = {}) {
28
61
  headers[name] = value;
29
62
  }
30
63
  }
31
- response.writeHead(answer.status, { ...headers, ...answer.headers });
32
- response.end(answer.body);
64
+ const written = {
65
+ ...headers,
66
+ ...answer.headers,
67
+ };
68
+ const vary = Object.keys(answer.headers).find((name) => name.toLowerCase() === "vary");
69
+ if (vary !== undefined) {
70
+ const platform = Object.entries(beneath).flatMap(([name, value]) => name.toLowerCase() === "vary" ? [textOf(value)] : []);
71
+ written[vary] = mergedVary([
72
+ textOf(response.getHeader("vary")),
73
+ ...platform,
74
+ textOf(answer.headers[vary]),
75
+ ]);
76
+ }
77
+ response.writeHead(answer.status, written);
78
+ if (typeof answer.body === "string") {
79
+ response.end(answer.body);
80
+ return;
81
+ }
82
+ response.flushHeaders();
83
+ try {
84
+ for await (const line of answer.body) {
85
+ if (response.destroyed || response.writableEnded) {
86
+ break;
87
+ }
88
+ if (!response.write(line)) {
89
+ await drained(response);
90
+ }
91
+ response.flush?.();
92
+ }
93
+ }
94
+ catch {
95
+ response.destroy();
96
+ return;
97
+ }
98
+ if (!response.destroyed) {
99
+ response.end();
100
+ }
101
+ }
102
+ function drained(response) {
103
+ return new Promise((resolve) => {
104
+ const done = () => {
105
+ response.removeListener("drain", done);
106
+ response.removeListener("close", done);
107
+ resolve();
108
+ };
109
+ response.once("drain", done);
110
+ response.once("close", done);
111
+ });
33
112
  }
@@ -5,8 +5,12 @@ import type { McpBinding, McpRequest, McpRoute } from "./mcp-binding.loader.js";
5
5
  export type SurfaceRoute = ReturnType<AvProtocolBinding["surface"]>[number];
6
6
  /** The developer's `state` option over one platform request: seeds its `ctx.$state`. */
7
7
  type StateSeed = () => AvState | Promise<AvState>;
8
+ /** An answer a route writes: the protocol's, its body text or, for a streamed `/_all`, lines as they settle. */
9
+ export type RouteResponse = Awaited<ReturnType<AvProtocolBinding["handleAll"]>>;
8
10
  /** What a mounted route answers its request with: a bound protocol member. */
9
- export type RouteAnswer = (request: AvProtocolRequest, state: StateSeed | undefined) => AvProtocolResponse | Promise<AvProtocolResponse>;
11
+ export type RouteAnswer = (request: AvProtocolRequest, state: StateSeed | undefined,
12
+ /** Aborted when the client goes away; only `/_all` reads it. */
13
+ disconnect: AbortSignal) => RouteResponse | Promise<RouteResponse>;
10
14
  /** The bound member each kind of surface route is answered by. */
11
15
  export type ProtocolRouteTable = {
12
16
  readonly [K in SurfaceRoute["kind"]]: (binding: AvProtocolBinding) => RouteAnswer;
@@ -3,6 +3,9 @@ export const PROTOCOL_ROUTES = {
3
3
  contract: (binding) => (request) => binding.encodeContract(request),
4
4
  transactions: (binding) => (request, state) => binding.handleTransaction(request, state === undefined ? {} : { state }),
5
5
  can: (binding) => (request, state) => binding.handleCan(request, state === undefined ? {} : { state }),
6
+ all: (binding) => (request, state, disconnect) => binding.handleAll(request, state === undefined
7
+ ? { signal: disconnect }
8
+ : { state, signal: disconnect }),
6
9
  };
7
10
  export const MCP_ROUTES = {
8
11
  endpoint: (binding) => (request, state) => binding.handleEndpoint(request, state === undefined ? {} : { state }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aventara/nest",
3
- "version": "1.0.0-rc.10",
3
+ "version": "1.0.0-rc.11",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "description": "NestJS host integration for Aventara.",
6
6
  "type": "module",
@@ -22,10 +22,10 @@
22
22
  "CHANGELOG.md"
23
23
  ],
24
24
  "dependencies": {
25
- "@aventara/core": "1.0.0-rc.10"
25
+ "@aventara/core": "1.0.0-rc.11"
26
26
  },
27
27
  "peerDependencies": {
28
- "@aventara/mcp": "1.0.0-rc.10",
28
+ "@aventara/mcp": "1.0.0-rc.11",
29
29
  "@nestjs/common": "^12.0.0",
30
30
  "@nestjs/core": "^12.0.0"
31
31
  },
@@ -50,9 +50,9 @@
50
50
  "access": "public"
51
51
  },
52
52
  "devDependencies": {
53
- "@aventara/mcp": "1.0.0-rc.10",
54
- "@aventara/prisma7-adapter": "1.0.0-rc.10",
55
- "@aventara/testing": "1.0.0-rc.10",
53
+ "@aventara/mcp": "1.0.0-rc.11",
54
+ "@aventara/prisma7-adapter": "1.0.0-rc.11",
55
+ "@aventara/testing": "1.0.0-rc.11",
56
56
  "@modelcontextprotocol/sdk": "1.32.2",
57
57
  "@nestjs/common": "^12.2.0",
58
58
  "@nestjs/core": "^12.2.0",