@aventara/nest 0.1.0-pilot.4 → 1.0.0-rc.1
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 +314 -0
- package/README.md +134 -59
- package/dist/aventara.module.d.ts +19 -8
- package/dist/aventara.module.js +18 -6
- package/dist/express-protocol.mounter.d.ts +13 -3
- package/dist/express-protocol.mounter.js +35 -6
- package/dist/fastify-protocol.mounter.d.ts +13 -4
- package/dist/fastify-protocol.mounter.js +29 -5
- package/dist/framework.token.d.ts +1 -1
- package/dist/host-bootstrap.validator.js +7 -2
- package/dist/internal-failure.logger.d.ts +4 -4
- package/dist/internal-failure.logger.js +5 -3
- package/dist/mcp-binding.loader.d.ts +30 -0
- package/dist/mcp-binding.loader.js +25 -0
- package/dist/node-exchange.translator.d.ts +6 -0
- package/dist/node-exchange.translator.js +9 -0
- package/dist/protocol-host.factory.d.ts +13 -3
- package/dist/protocol-host.factory.js +21 -10
- package/dist/protocol-route.table.d.ts +11 -1
- package/dist/protocol-route.table.js +6 -2
- package/package.json +16 -6
|
@@ -1,24 +1,35 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type AvAdapterModel, type AvConfig, type AvConfigAny, type AvConfigExact, type AvFramework, type AvState } from "@aventara/core";
|
|
2
2
|
import { type DynamicModule, type FactoryProvider, type ModuleMetadata, type NestModule, type OnModuleInit } from "@nestjs/common";
|
|
3
3
|
import { type PendingProtocolHost } from "./protocol-host.factory.js";
|
|
4
4
|
/**
|
|
5
5
|
* How the application's Framework enters Nest: your own instance
|
|
6
6
|
* (`{ framework }`, from `createFramework`) or a configuration the module
|
|
7
|
-
* passes to `createFramework` itself (`{ config }`). Pass exactly one.
|
|
7
|
+
* passes to `createFramework` itself (`{ config }`). Pass exactly one. Either
|
|
8
|
+
* arm may add `state`, which seeds each protocol request's `ctx.$state`.
|
|
8
9
|
*/
|
|
9
|
-
export type AventaraModuleOptions<M extends
|
|
10
|
-
readonly framework:
|
|
10
|
+
export type AventaraModuleOptions<M extends AvAdapterModel = AvAdapterModel, Cfg = AvConfigAny> = ({
|
|
11
|
+
readonly framework: AvFramework;
|
|
11
12
|
readonly config?: never;
|
|
12
13
|
} | {
|
|
13
|
-
readonly config: Cfg &
|
|
14
|
+
readonly config: Cfg & AvConfig<M, Cfg> & AvConfigExact<M, Cfg>;
|
|
14
15
|
readonly framework?: never;
|
|
16
|
+
}) & {
|
|
17
|
+
/**
|
|
18
|
+
* Seeds a protocol request's `ctx.$state`, once per request, with the
|
|
19
|
+
* platform's own request: Express's `req`, or Fastify's `request` (its Node
|
|
20
|
+
* request is `request.raw`). What middleware already set there, such as
|
|
21
|
+
* Passport's `req.user`, flows into every stage of the request. A throw is
|
|
22
|
+
* answered like any developer failure: a thrown `AvError` as its own
|
|
23
|
+
* outcome, anything else `500 A3000`. Omitted, the state is `{}`.
|
|
24
|
+
*/
|
|
25
|
+
state?(request: unknown): AvState | Promise<AvState>;
|
|
15
26
|
};
|
|
16
27
|
/**
|
|
17
28
|
* `forRootAsync`'s options: the Nest idiom when what builds the Framework
|
|
18
29
|
* (a Prisma client, a configuration service) is itself a provider. The
|
|
19
30
|
* factory resolves to either arm of {@link AventaraModuleOptions}.
|
|
20
31
|
*/
|
|
21
|
-
export type AventaraModuleAsyncOptions<M extends
|
|
32
|
+
export type AventaraModuleAsyncOptions<M extends AvAdapterModel = AvAdapterModel, Cfg = AvConfigAny> = {
|
|
22
33
|
/** Modules whose providers the factory injects. */
|
|
23
34
|
readonly imports?: ModuleMetadata["imports"];
|
|
24
35
|
/** The providers passed to `useFactory`, in order. */
|
|
@@ -43,11 +54,11 @@ export declare class AventaraModule implements NestModule, OnModuleInit {
|
|
|
43
54
|
*/
|
|
44
55
|
onModuleInit(): void;
|
|
45
56
|
/** Registers the module with a Framework instance or a configuration. */
|
|
46
|
-
static forRoot<M extends
|
|
57
|
+
static forRoot<M extends AvAdapterModel, const Cfg = AvConfigAny>(options: AventaraModuleOptions<M, Cfg>): DynamicModule;
|
|
47
58
|
/**
|
|
48
59
|
* Registers the module with options built by a factory, for when the
|
|
49
60
|
* Framework's inputs (a Prisma client, a config service) are providers.
|
|
50
61
|
*/
|
|
51
|
-
static forRootAsync<M extends
|
|
62
|
+
static forRootAsync<M extends AvAdapterModel, const Cfg = AvConfigAny>(options: AventaraModuleAsyncOptions<M, Cfg>): DynamicModule;
|
|
52
63
|
private static mount;
|
|
53
64
|
}
|
package/dist/aventara.module.js
CHANGED
|
@@ -4,12 +4,14 @@ import { HttpAdapterHost } from "@nestjs/core";
|
|
|
4
4
|
import { AVENTARA_FRAMEWORK } from "./framework.token.js";
|
|
5
5
|
import { checkedOptions, recordMount } from "./host-bootstrap.validator.js";
|
|
6
6
|
import { withInternalFailureLog } from "./internal-failure.logger.js";
|
|
7
|
+
import { loadMcpBinding } from "./mcp-binding.loader.js";
|
|
7
8
|
import { createPendingProtocolHost, } from "./protocol-host.factory.js";
|
|
8
9
|
const MOUNT = Symbol("AVENTARA_MOUNT");
|
|
9
10
|
let mounts = 0;
|
|
10
11
|
const PROTOCOL_HOST = Symbol("AVENTARA_PROTOCOL_HOST");
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
const OPTIONS = Symbol("AVENTARA_OPTIONS");
|
|
13
|
+
const MCP_BINDING = Symbol("AVENTARA_MCP_BINDING");
|
|
14
|
+
async function frameworkOf(checked) {
|
|
13
15
|
if (checked.framework !== undefined) {
|
|
14
16
|
return checked.framework;
|
|
15
17
|
}
|
|
@@ -44,17 +46,27 @@ export class AventaraModule {
|
|
|
44
46
|
providers: [
|
|
45
47
|
{ provide: MOUNT, useValue: mounts },
|
|
46
48
|
{
|
|
47
|
-
provide:
|
|
49
|
+
provide: OPTIONS,
|
|
48
50
|
inject: [HttpAdapterHost, ...inject],
|
|
49
51
|
useFactory: async (application, ...deps) => {
|
|
50
52
|
recordMount(application);
|
|
51
|
-
return
|
|
53
|
+
return checkedOptions(await resolve(...deps));
|
|
52
54
|
},
|
|
53
55
|
},
|
|
56
|
+
{
|
|
57
|
+
provide: AVENTARA_FRAMEWORK,
|
|
58
|
+
inject: [OPTIONS],
|
|
59
|
+
useFactory: (options) => frameworkOf(options),
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
provide: MCP_BINDING,
|
|
63
|
+
inject: [AVENTARA_FRAMEWORK],
|
|
64
|
+
useFactory: async (framework) => (await loadMcpBinding(framework)) ?? null,
|
|
65
|
+
},
|
|
54
66
|
{
|
|
55
67
|
provide: PROTOCOL_HOST,
|
|
56
|
-
inject: [HttpAdapterHost, AVENTARA_FRAMEWORK],
|
|
57
|
-
useFactory: (application, framework) => createPendingProtocolHost(application, framework),
|
|
68
|
+
inject: [HttpAdapterHost, AVENTARA_FRAMEWORK, OPTIONS, MCP_BINDING],
|
|
69
|
+
useFactory: (application, framework, options, mcp) => createPendingProtocolHost(application, framework, options.state?.bind(options), mcp ?? undefined),
|
|
58
70
|
},
|
|
59
71
|
],
|
|
60
72
|
exports: [AVENTARA_FRAMEWORK],
|
|
@@ -1,16 +1,25 @@
|
|
|
1
1
|
import type { AvProtocolBinding } from "@aventara/core/protocol";
|
|
2
|
+
import type { McpBinding } from "./mcp-binding.loader.js";
|
|
2
3
|
import { type NodeIncoming, type NodeOutgoing } from "./node-exchange.translator.js";
|
|
3
|
-
import type { ProtocolHost } from "./protocol-host.factory.js";
|
|
4
|
+
import type { HostState, ProtocolHost } from "./protocol-host.factory.js";
|
|
4
5
|
/**
|
|
5
6
|
* The protocol on Nest's Express platform: one platform route per protocol
|
|
6
7
|
* route under the entrypoint, translating the Express request into the
|
|
7
8
|
* protocol's request and the protocol's response back onto the wire. A path the
|
|
8
9
|
* protocol does not own goes on to Nest.
|
|
9
10
|
*/
|
|
10
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* Express's request: Node's `IncomingMessage`, plus `originalUrl`, and the
|
|
13
|
+
* protocol and host Express reports (its `trust proxy` setting applied).
|
|
14
|
+
*/
|
|
11
15
|
type ExpressRequest = NodeIncoming & {
|
|
12
16
|
readonly method: string;
|
|
13
17
|
readonly originalUrl: string;
|
|
18
|
+
readonly protocol: string;
|
|
19
|
+
readonly host: string;
|
|
20
|
+
readonly socket: {
|
|
21
|
+
readonly remoteAddress?: string | undefined;
|
|
22
|
+
};
|
|
14
23
|
};
|
|
15
24
|
/** Express's response, written through Node's own `writeHead`. */
|
|
16
25
|
type ExpressResponse = NodeOutgoing;
|
|
@@ -19,11 +28,12 @@ type ExpressHandler = (request: ExpressRequest, response: ExpressResponse, next?
|
|
|
19
28
|
export type ExpressPlatform = {
|
|
20
29
|
get(path: string, handler: ExpressHandler): unknown;
|
|
21
30
|
post(path: string, handler: ExpressHandler): unknown;
|
|
31
|
+
delete(path: string, handler: ExpressHandler): unknown;
|
|
22
32
|
use(handler: ExpressHandler): unknown;
|
|
23
33
|
};
|
|
24
34
|
/**
|
|
25
35
|
* Installs the pre-parser on `platform` now, and returns the host whose
|
|
26
36
|
* `mount` registers the routes and the absence fallback under `entrypoint`.
|
|
27
37
|
*/
|
|
28
|
-
export declare function createExpressProtocolHost(platform: ExpressPlatform, binding: AvProtocolBinding, entrypoint: string, maxRequestBytes: number): ProtocolHost;
|
|
38
|
+
export declare function createExpressProtocolHost(platform: ExpressPlatform, binding: AvProtocolBinding, entrypoint: string, maxRequestBytes: number, state: HostState | undefined, mcp: McpBinding | undefined): ProtocolHost;
|
|
29
39
|
export {};
|
|
@@ -1,25 +1,42 @@
|
|
|
1
|
-
import { NO_BYTES, pathnameOf, protocolRequest, writeAnswer, } from "./node-exchange.translator.js";
|
|
2
|
-
import { PROTOCOL_ROUTES } from "./protocol-route.table.js";
|
|
1
|
+
import { mcpRequest, NO_BYTES, pathnameOf, protocolRequest, writeAnswer, } from "./node-exchange.translator.js";
|
|
2
|
+
import { MCP_ROUTES, PROTOCOL_ROUTES, } from "./protocol-route.table.js";
|
|
3
3
|
import { readRequestBody, unreadBodyAnswer, } from "./request-body.reader.js";
|
|
4
|
-
const REGISTER = { GET: "get", POST: "post" };
|
|
4
|
+
const REGISTER = { GET: "get", POST: "post", DELETE: "delete" };
|
|
5
5
|
const NOTHING_READ = { kind: "read", content: NO_BYTES };
|
|
6
|
-
export function createExpressProtocolHost(platform, binding, entrypoint, maxRequestBytes) {
|
|
6
|
+
export function createExpressProtocolHost(platform, binding, entrypoint, maxRequestBytes, state, mcp) {
|
|
7
7
|
const bodies = new WeakMap();
|
|
8
8
|
const bodyOf = (request) => bodies.get(request) ?? NOTHING_READ;
|
|
9
|
+
const mcpEndpoint = mcp
|
|
10
|
+
?.surface()
|
|
11
|
+
.find((route) => route.kind === "endpoint")?.path;
|
|
12
|
+
const namespace = asciiLowerCase(`${entrypoint}/_`);
|
|
9
13
|
platform.use(async (request, _response, next) => {
|
|
10
|
-
|
|
14
|
+
const pathname = pathnameOf(request.originalUrl);
|
|
15
|
+
if (asciiLowerCase(pathname.slice(0, namespace.length)) === namespace) {
|
|
11
16
|
bodies.set(request, await readRequestBody(request, request.headersDistinct["content-encoding"], maxRequestBytes));
|
|
12
17
|
}
|
|
18
|
+
else if (mcp !== undefined && pathname === mcpEndpoint) {
|
|
19
|
+
bodies.set(request, await readRequestBody(request, undefined, mcp.maxRequestBytes));
|
|
20
|
+
}
|
|
13
21
|
handOn(next);
|
|
14
22
|
});
|
|
15
23
|
return {
|
|
16
24
|
mount: () => {
|
|
25
|
+
for (const route of mcp?.surface() ?? []) {
|
|
26
|
+
const answer = MCP_ROUTES[route.kind](mcp);
|
|
27
|
+
for (const method of route.methods) {
|
|
28
|
+
platform[REGISTER[method]](literalPath(route.path), exactly(route.path, async (request, response) => {
|
|
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)));
|
|
31
|
+
}));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
17
34
|
for (const route of binding.surface()) {
|
|
18
35
|
const answer = PROTOCOL_ROUTES[route.kind](binding);
|
|
19
36
|
platform[REGISTER[route.method]](literalPath(`${entrypoint}${route.path}`), async (request, response) => {
|
|
20
37
|
const body = bodyOf(request);
|
|
21
38
|
writeAnswer(response, body.kind === "read"
|
|
22
|
-
? await answer(translated(request, entrypoint, body.content))
|
|
39
|
+
? await answer(translated(request, entrypoint, body.content), state === undefined ? undefined : () => state(request))
|
|
23
40
|
: unreadBodyAnswer(binding, body));
|
|
24
41
|
});
|
|
25
42
|
}
|
|
@@ -39,6 +56,18 @@ export function createExpressProtocolHost(platform, binding, entrypoint, maxRequ
|
|
|
39
56
|
},
|
|
40
57
|
};
|
|
41
58
|
}
|
|
59
|
+
function exactly(path, handler) {
|
|
60
|
+
return (request, response, next) => {
|
|
61
|
+
if (pathnameOf(request.originalUrl) !== path) {
|
|
62
|
+
handOn(next);
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
return handler(request, response, next);
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
function asciiLowerCase(text) {
|
|
69
|
+
return text.replace(/[A-Z]/g, (letter) => letter.toLowerCase());
|
|
70
|
+
}
|
|
42
71
|
function handOn(next) {
|
|
43
72
|
if (typeof next === "function") {
|
|
44
73
|
next();
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { AvProtocolBinding } from "@aventara/core/protocol";
|
|
2
|
+
import type { McpBinding } from "./mcp-binding.loader.js";
|
|
2
3
|
import { type NodeIncoming, type NodeOutgoing, type PlatformHeaders } from "./node-exchange.translator.js";
|
|
3
|
-
import type { ProtocolHost } from "./protocol-host.factory.js";
|
|
4
|
+
import type { HostState, ProtocolHost } from "./protocol-host.factory.js";
|
|
4
5
|
/**
|
|
5
6
|
* The protocol on Nest's Fastify platform: one Fastify route per protocol route
|
|
6
7
|
* under the entrypoint, answered from its own `onRequest` hook before Fastify's
|
|
@@ -10,13 +11,21 @@ import type { ProtocolHost } from "./protocol-host.factory.js";
|
|
|
10
11
|
* reserved escape) is refused while Nest builds the application, naming the
|
|
11
12
|
* Resource keys.
|
|
12
13
|
*/
|
|
13
|
-
/**
|
|
14
|
+
/**
|
|
15
|
+
* Fastify's request: Node's own under `raw`, whether a route matched, and the
|
|
16
|
+
* protocol and host Fastify reports (its `trustProxy` setting applied).
|
|
17
|
+
*/
|
|
14
18
|
type FastifyRequest = {
|
|
15
19
|
readonly raw: NodeIncoming & {
|
|
16
20
|
readonly method: string;
|
|
17
21
|
readonly url: string;
|
|
22
|
+
readonly socket: {
|
|
23
|
+
readonly remoteAddress?: string | undefined;
|
|
24
|
+
};
|
|
18
25
|
};
|
|
19
26
|
readonly is404: boolean;
|
|
27
|
+
readonly protocol: string;
|
|
28
|
+
readonly host: string;
|
|
20
29
|
};
|
|
21
30
|
/** Fastify's reply: hijacked, then written through Node's response under `raw`. */
|
|
22
31
|
type FastifyReply = {
|
|
@@ -27,7 +36,7 @@ type FastifyReply = {
|
|
|
27
36
|
type OnRequestHook = (request: FastifyRequest, reply: FastifyReply) => Promise<FastifyReply | undefined>;
|
|
28
37
|
export type FastifyPlatform = {
|
|
29
38
|
route(options: {
|
|
30
|
-
readonly method: string;
|
|
39
|
+
readonly method: string | readonly string[];
|
|
31
40
|
readonly url: string;
|
|
32
41
|
readonly exposeHeadRoute: false;
|
|
33
42
|
readonly onRequest: OnRequestHook;
|
|
@@ -39,5 +48,5 @@ export type FastifyPlatform = {
|
|
|
39
48
|
* The host whose `mount` registers the routes and the absence hook under
|
|
40
49
|
* `entrypoint` — after refusing, now, a surface it cannot mount literally.
|
|
41
50
|
*/
|
|
42
|
-
export declare function createFastifyProtocolHost(platform: FastifyPlatform, binding: AvProtocolBinding, entrypoint: string, maxRequestBytes: number): ProtocolHost;
|
|
51
|
+
export declare function createFastifyProtocolHost(platform: FastifyPlatform, binding: AvProtocolBinding, entrypoint: string, maxRequestBytes: number, state: HostState | undefined, mcp: McpBinding | undefined): ProtocolHost;
|
|
43
52
|
export {};
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { NO_BYTES, pathnameOf, protocolRequest, writeAnswer, } from "./node-exchange.translator.js";
|
|
2
|
-
import { PROTOCOL_ROUTES } from "./protocol-route.table.js";
|
|
1
|
+
import { mcpRequest, NO_BYTES, pathnameOf, protocolRequest, writeAnswer, } from "./node-exchange.translator.js";
|
|
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;
|
|
5
5
|
function checkLiteralRoutes(binding, entrypoint) {
|
|
6
6
|
if (entrypoint.includes("*")) {
|
|
7
|
-
throw new Error(`AventaraModule cannot mount the
|
|
7
|
+
throw new Error(`AventaraModule cannot mount the api path \`${entrypoint}\` as a literal path on Nest's Fastify platform: it may not carry \`*\`. Change \`api\`, or host on Express.`);
|
|
8
8
|
}
|
|
9
9
|
const refused = binding
|
|
10
10
|
.surface()
|
|
@@ -16,11 +16,35 @@ function checkLiteralRoutes(binding, entrypoint) {
|
|
|
16
16
|
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.`);
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
|
-
|
|
19
|
+
function checkLiteralMcpRoutes(mcp) {
|
|
20
|
+
for (const route of mcp?.surface() ?? []) {
|
|
21
|
+
if (UNMATCHABLE.test(route.path)) {
|
|
22
|
+
throw new Error(`AventaraModule cannot mount the mcp path \`${route.path}\` as a literal path on Nest's Fastify platform: it may not carry \`*\` or a reserved escape. Change \`mcp\`, or host on Express.`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function createFastifyProtocolHost(platform, binding, entrypoint, maxRequestBytes, state, mcp) {
|
|
20
27
|
checkLiteralRoutes(binding, entrypoint);
|
|
28
|
+
checkLiteralMcpRoutes(mcp);
|
|
21
29
|
const translated = (request, content) => protocolRequest(request.raw.method, request.raw.url, request.raw, entrypoint, content);
|
|
22
30
|
return {
|
|
23
31
|
mount: () => {
|
|
32
|
+
for (const route of mcp?.surface() ?? []) {
|
|
33
|
+
const answer = MCP_ROUTES[route.kind](mcp);
|
|
34
|
+
platform.route({
|
|
35
|
+
method: [...route.methods],
|
|
36
|
+
url: literalPath(route.path),
|
|
37
|
+
exposeHeadRoute: false,
|
|
38
|
+
onRequest: async (request, reply) => {
|
|
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)));
|
|
41
|
+
return reply;
|
|
42
|
+
},
|
|
43
|
+
handler: () => {
|
|
44
|
+
throw new Error("AventaraModule: an MCP route's onRequest hook answers every request; its handler is unreachable.");
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
}
|
|
24
48
|
for (const route of binding.surface()) {
|
|
25
49
|
const answer = PROTOCOL_ROUTES[route.kind](binding);
|
|
26
50
|
platform.route({
|
|
@@ -30,7 +54,7 @@ export function createFastifyProtocolHost(platform, binding, entrypoint, maxRequ
|
|
|
30
54
|
onRequest: async (request, reply) => {
|
|
31
55
|
const body = await readRequestBody(request.raw, request.raw.headersDistinct["content-encoding"], maxRequestBytes);
|
|
32
56
|
write(reply, body.kind === "read"
|
|
33
|
-
? await answer(translated(request, body.content))
|
|
57
|
+
? await answer(translated(request, body.content), state === undefined ? undefined : () => state(request))
|
|
34
58
|
: unreadBodyAnswer(binding, body));
|
|
35
59
|
return reply;
|
|
36
60
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The injection token under which `AventaraModule` provides the application's
|
|
3
|
-
* `
|
|
3
|
+
* `AvFramework`. Type the injected value with your own `AvFramework<M, Cfg>` alias.
|
|
4
4
|
*/
|
|
5
5
|
export declare const AVENTARA_FRAMEWORK: unique symbol;
|
|
6
6
|
/** `@InjectFramework()`: `@Inject(AVENTARA_FRAMEWORK)` in one call. */
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const NEITHER_ARM = "AventaraModule: the options carry neither `framework` nor `config`; pass exactly one.";
|
|
2
2
|
const BOTH_ARMS = "AventaraModule: the options carry both `framework` and `config`; pass exactly one.";
|
|
3
|
-
const NOT_A_FRAMEWORK = "AventaraModule: `framework` is not an Aventara Framework (it has no string `
|
|
3
|
+
const NOT_A_FRAMEWORK = "AventaraModule: `framework` is not an Aventara Framework (it has no string `api` and no `contracts.client`); pass what `createFramework` resolved to.";
|
|
4
|
+
const STATE_NOT_A_FUNCTION = "AventaraModule: `state` is not a function; pass `(request) => ({ … })`, called once per protocol request.";
|
|
4
5
|
const MOUNTED_TWICE = "AventaraModule is mounted twice in one application; import AventaraModule.forRoot or forRootAsync once.";
|
|
5
6
|
export function checkedOptions(options) {
|
|
6
7
|
const record = typeof options === "object" && options !== null
|
|
@@ -17,6 +18,10 @@ export function checkedOptions(options) {
|
|
|
17
18
|
if (hasFramework && !isFramework(record.framework)) {
|
|
18
19
|
throw new Error(NOT_A_FRAMEWORK);
|
|
19
20
|
}
|
|
21
|
+
const state = record.state;
|
|
22
|
+
if (state !== undefined && typeof state !== "function") {
|
|
23
|
+
throw new Error(STATE_NOT_A_FUNCTION);
|
|
24
|
+
}
|
|
20
25
|
return record;
|
|
21
26
|
}
|
|
22
27
|
function isFramework(value) {
|
|
@@ -24,7 +29,7 @@ function isFramework(value) {
|
|
|
24
29
|
return false;
|
|
25
30
|
}
|
|
26
31
|
const candidate = value;
|
|
27
|
-
return (typeof candidate.
|
|
32
|
+
return (typeof candidate.api === "string" &&
|
|
28
33
|
typeof candidate.contracts?.client === "object" &&
|
|
29
34
|
candidate.contracts.client !== null);
|
|
30
35
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { AvDiagnostic, AvDiagnosticsSink } from "@aventara/core";
|
|
2
2
|
/** Wraps the application's sink (if any): log an internal failure, then forward. */
|
|
3
|
-
export declare function withInternalFailureLog(sink:
|
|
3
|
+
export declare function withInternalFailureLog(sink: AvDiagnosticsSink | undefined): AvDiagnosticsSink;
|
|
4
4
|
/**
|
|
5
|
-
* e.g. `A3001 on Post.create.one (client
|
|
5
|
+
* e.g. `A3001 on Post.create.one (client origin, request 7f3c…): Error. The raw
|
|
6
6
|
* error goes to the framework's diagnostics sink.`
|
|
7
7
|
*/
|
|
8
|
-
export declare function internalFailureLine(diagnostic:
|
|
8
|
+
export declare function internalFailureLine(diagnostic: AvDiagnostic): string;
|
|
@@ -15,16 +15,18 @@ export function withInternalFailureLog(sink) {
|
|
|
15
15
|
export function internalFailureLine(diagnostic) {
|
|
16
16
|
const subject = diagnostic.kind === "operation"
|
|
17
17
|
? `${diagnostic.resource}.${diagnostic.family}.${diagnostic.variant}`
|
|
18
|
-
: "
|
|
18
|
+
: diagnostic.kind === "transaction"
|
|
19
|
+
? "a transaction"
|
|
20
|
+
: "the MCP endpoint";
|
|
19
21
|
const step = diagnostic.operation === undefined
|
|
20
22
|
? ""
|
|
21
23
|
: `, plan operation ${diagnostic.operation}`;
|
|
22
|
-
return `${diagnostic.code} on ${subject} (${diagnostic.
|
|
24
|
+
return `${diagnostic.code} on ${subject} (${diagnostic.origin} origin, request ${diagnostic.requestId}${step}): ${thrownKind(diagnostic.error)}${frameworkDetail(diagnostic.error)}. The raw error goes to the framework's diagnostics sink.`;
|
|
23
25
|
}
|
|
24
26
|
function frameworkDetail(error) {
|
|
25
27
|
try {
|
|
26
28
|
if (error instanceof Error &&
|
|
27
|
-
error.name === "
|
|
29
|
+
error.name === "AvPipeOutputError" &&
|
|
28
30
|
typeof error.message === "string") {
|
|
29
31
|
return ` — ${error.message}`;
|
|
30
32
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { AvFramework, AvState } from "@aventara/core";
|
|
2
|
+
import type { AvProtocolResponse } from "@aventara/core/protocol";
|
|
3
|
+
/** One request to the MCP endpoint, as `@aventara/mcp` reads it. */
|
|
4
|
+
export type McpRequest = {
|
|
5
|
+
readonly method: string;
|
|
6
|
+
readonly url: string;
|
|
7
|
+
readonly headers: Readonly<Record<string, readonly string[]>>;
|
|
8
|
+
readonly body: Uint8Array;
|
|
9
|
+
readonly remoteAddress: string | undefined;
|
|
10
|
+
};
|
|
11
|
+
/** One route the MCP binding lists. */
|
|
12
|
+
export type McpRoute = {
|
|
13
|
+
readonly kind: "endpoint" | "metadata";
|
|
14
|
+
readonly path: string;
|
|
15
|
+
readonly methods: readonly ("GET" | "POST" | "DELETE")[];
|
|
16
|
+
};
|
|
17
|
+
export type McpBinding = {
|
|
18
|
+
readonly surface: () => readonly McpRoute[];
|
|
19
|
+
readonly maxRequestBytes: number;
|
|
20
|
+
readonly handleEndpoint: (request: McpRequest, options?: {
|
|
21
|
+
readonly state?: () => AvState | Promise<AvState>;
|
|
22
|
+
}) => Promise<AvProtocolResponse>;
|
|
23
|
+
readonly encodeMetadata: (request: McpRequest) => AvProtocolResponse;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* The MCP binding for `framework`, or `undefined` when its configuration has no
|
|
27
|
+
* `mcp`. Called while Nest instantiates providers, so a missing package rejects
|
|
28
|
+
* `NestFactory.create` before the application listens, naming the package.
|
|
29
|
+
*/
|
|
30
|
+
export declare function loadMcpBinding(framework: AvFramework, specifier?: string): Promise<McpBinding | undefined>;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const MCP_PACKAGE = "@aventara/mcp";
|
|
2
|
+
const missing = (specifier) => `AventaraModule: the configuration has \`mcp\`, and the package that serves it, ${specifier}, is not installed. Install it beside @aventara/nest, at the same version (npm install ${specifier}), or remove \`mcp\` from the configuration.`;
|
|
3
|
+
function isMissing(error, specifier) {
|
|
4
|
+
const { code, message } = (error ?? {});
|
|
5
|
+
return ((code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND") &&
|
|
6
|
+
typeof message === "string" &&
|
|
7
|
+
message.includes(`'${specifier}'`));
|
|
8
|
+
}
|
|
9
|
+
export async function loadMcpBinding(framework, specifier = MCP_PACKAGE) {
|
|
10
|
+
if (framework.mcp === undefined) {
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
let loaded;
|
|
14
|
+
try {
|
|
15
|
+
loaded = await import(specifier);
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
if (isMissing(error, specifier)) {
|
|
19
|
+
throw new Error(missing(specifier));
|
|
20
|
+
}
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
const { AvMcp } = loaded;
|
|
24
|
+
return AvMcp.bind(framework);
|
|
25
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AvProtocolRequest, AvProtocolResponse } from "@aventara/core/protocol";
|
|
2
|
+
import type { McpRequest } from "./mcp-binding.loader.js";
|
|
2
3
|
export type NodeIncoming = AsyncIterable<Uint8Array> & {
|
|
3
4
|
readonly headersDistinct: Readonly<Record<string, readonly string[]>>;
|
|
4
5
|
};
|
|
@@ -14,6 +15,11 @@ export declare const NO_BYTES: Uint8Array<ArrayBuffer>;
|
|
|
14
15
|
export declare function pathnameOf(url: string): string;
|
|
15
16
|
/** The request as the protocol reads it: path relative to the entrypoint, distinct headers, raw body. */
|
|
16
17
|
export declare function protocolRequest(method: string, url: string, incoming: NodeIncoming, entrypoint: string, content: Uint8Array): AvProtocolRequest;
|
|
18
|
+
/** The request as `@aventara/mcp` reads it: absolute URL, distinct headers, raw body, peer address. */
|
|
19
|
+
export declare function mcpRequest(method: string, origin: {
|
|
20
|
+
readonly protocol: string;
|
|
21
|
+
readonly host: string;
|
|
22
|
+
}, url: string, incoming: NodeIncoming, content: Uint8Array, remoteAddress: string | undefined): McpRequest;
|
|
17
23
|
/**
|
|
18
24
|
* Writes the protocol's answer as it is. Headers the platform's hooks already
|
|
19
25
|
* set (CORS, say) go beneath it; a protocol header of the same name, in any
|
|
@@ -11,6 +11,15 @@ export function protocolRequest(method, url, incoming, entrypoint, content) {
|
|
|
11
11
|
body: { kind: "raw", content },
|
|
12
12
|
};
|
|
13
13
|
}
|
|
14
|
+
export function mcpRequest(method, origin, url, incoming, content, remoteAddress) {
|
|
15
|
+
return {
|
|
16
|
+
method,
|
|
17
|
+
url: `${origin.protocol}://${origin.host}${url}`,
|
|
18
|
+
headers: incoming.headersDistinct,
|
|
19
|
+
body: content,
|
|
20
|
+
remoteAddress,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
14
23
|
export function writeAnswer(response, answer, beneath = {}) {
|
|
15
24
|
const own = new Set(Object.keys(answer.headers).map((name) => name.toLowerCase()));
|
|
16
25
|
const headers = {};
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { AvFramework, AvState } from "@aventara/core";
|
|
2
2
|
import type { AbstractHttpAdapter } from "@nestjs/core";
|
|
3
|
+
import type { McpBinding, McpRoute } from "./mcp-binding.loader.js";
|
|
3
4
|
import type { SurfaceRoute } from "./protocol-route.table.js";
|
|
5
|
+
/** The developer's `state` option: seeds a request's `ctx.$state` from its platform request. */
|
|
6
|
+
export type HostState = (request: unknown) => AvState | Promise<AvState>;
|
|
4
7
|
/** The protocol, bound once at startup, and the one call that mounts it. */
|
|
5
8
|
export type ProtocolHost = {
|
|
6
9
|
readonly mount: () => void;
|
|
@@ -11,7 +14,14 @@ export type ProtocolHost = {
|
|
|
11
14
|
* rejects before the application listens. The routes are mounted later, from
|
|
12
15
|
* the module's `configure()`.
|
|
13
16
|
*/
|
|
14
|
-
export declare function createProtocolHost(adapter: AbstractHttpAdapter | undefined, framework:
|
|
17
|
+
export declare function createProtocolHost(adapter: AbstractHttpAdapter | undefined, framework: AvFramework, state?: HostState, mcp?: McpBinding): ProtocolHost;
|
|
18
|
+
/**
|
|
19
|
+
* What the MCP mount registered: the endpoint, and its OAuth metadata route
|
|
20
|
+
* or, without one, the rule an open endpoint answers by — e.g. `Aventara
|
|
21
|
+
* mounted the MCP endpoint at /mcp (+ GET
|
|
22
|
+
* /.well-known/oauth-protected-resource/mcp)`.
|
|
23
|
+
*/
|
|
24
|
+
export declare function mcpMountedLine(routes: readonly McpRoute[]): string;
|
|
15
25
|
/**
|
|
16
26
|
* What one mount registered, read off `protocol.surface()`: the operation
|
|
17
27
|
* count under the entrypoint, then each framework route by method and path —
|
|
@@ -35,4 +45,4 @@ export type AdapterHostView = {
|
|
|
35
45
|
* A host for an application whose HTTP adapter may arrive after the providers
|
|
36
46
|
* are instantiated (Nest's testing module).
|
|
37
47
|
*/
|
|
38
|
-
export declare function createPendingProtocolHost(application: AdapterHostView, framework:
|
|
48
|
+
export declare function createPendingProtocolHost(application: AdapterHostView, framework: AvFramework, state?: HostState, mcp?: McpBinding): PendingProtocolHost;
|
|
@@ -4,24 +4,35 @@ import { createExpressProtocolHost } from "./express-protocol.mounter.js";
|
|
|
4
4
|
import { createFastifyProtocolHost, } from "./fastify-protocol.mounter.js";
|
|
5
5
|
import { checkedPlatform, } from "./host-bootstrap.validator.js";
|
|
6
6
|
const PLATFORM_MOUNTERS = {
|
|
7
|
-
express: (adapter, binding, entrypoint, maxRequestBytes) => createExpressProtocolHost({
|
|
7
|
+
express: (adapter, binding, entrypoint, maxRequestBytes, state, mcp) => createExpressProtocolHost({
|
|
8
8
|
get: (path, handler) => adapter.get(path, handler),
|
|
9
9
|
post: (path, handler) => adapter.post(path, handler),
|
|
10
|
+
delete: (path, handler) => adapter.delete(path, handler),
|
|
10
11
|
use: (handler) => adapter.use(handler),
|
|
11
|
-
}, binding, entrypoint, maxRequestBytes),
|
|
12
|
-
fastify: (adapter, binding, entrypoint, maxRequestBytes) => createFastifyProtocolHost(adapter.getInstance(), binding, entrypoint, maxRequestBytes),
|
|
12
|
+
}, binding, entrypoint, maxRequestBytes, state, mcp),
|
|
13
|
+
fastify: (adapter, binding, entrypoint, maxRequestBytes, state, mcp) => createFastifyProtocolHost(adapter.getInstance(), binding, entrypoint, maxRequestBytes, state, mcp),
|
|
13
14
|
};
|
|
14
|
-
export function createProtocolHost(adapter, framework) {
|
|
15
|
+
export function createProtocolHost(adapter, framework, state, mcp) {
|
|
15
16
|
const platform = checkedPlatform(adapter?.getType());
|
|
16
17
|
const binding = AvProtocol.bind(framework);
|
|
17
|
-
const host = PLATFORM_MOUNTERS[platform](adapter, binding, framework.
|
|
18
|
+
const host = PLATFORM_MOUNTERS[platform](adapter, binding, framework.api, framework.contracts.client.limits.maxRequestBytes, state, mcp);
|
|
18
19
|
return {
|
|
19
20
|
mount: () => {
|
|
20
21
|
host.mount();
|
|
21
|
-
MOUNT_LOGGER.log(mountedLine(binding.surface(), framework.
|
|
22
|
+
MOUNT_LOGGER.log(mountedLine(binding.surface(), framework.api));
|
|
23
|
+
if (mcp !== undefined) {
|
|
24
|
+
MOUNT_LOGGER.log(mcpMountedLine(mcp.surface()));
|
|
25
|
+
}
|
|
22
26
|
},
|
|
23
27
|
};
|
|
24
28
|
}
|
|
29
|
+
export function mcpMountedLine(routes) {
|
|
30
|
+
const endpoint = routes.find((route) => route.kind === "endpoint");
|
|
31
|
+
const metadata = routes.find((route) => route.kind === "metadata");
|
|
32
|
+
return `Aventara mounted the MCP endpoint at ${endpoint?.path ?? "/"} (${metadata === undefined
|
|
33
|
+
? "no authorization: it answers callers on this machine only"
|
|
34
|
+
: `+ GET ${metadata.path}`})`;
|
|
35
|
+
}
|
|
25
36
|
const MOUNT_LOGGER = new Logger("AventaraModule");
|
|
26
37
|
export function mountedLine(routes, entrypoint) {
|
|
27
38
|
const operations = routes.filter((route) => route.kind === "operation");
|
|
@@ -30,16 +41,16 @@ export function mountedLine(routes, entrypoint) {
|
|
|
30
41
|
.map((route) => `${route.method} ${entrypoint}${route.path}`);
|
|
31
42
|
return `Aventara mounted ${operations.length} ${operations.length === 1 ? "operation" : "operations"} at ${entrypoint === "" ? "/" : entrypoint} (+ ${framework.join(", ")})`;
|
|
32
43
|
}
|
|
33
|
-
export function createPendingProtocolHost(application, framework) {
|
|
44
|
+
export function createPendingProtocolHost(application, framework, state, mcp) {
|
|
34
45
|
if (application.httpAdapter !== undefined) {
|
|
35
|
-
const host = createProtocolHost(application.httpAdapter, framework);
|
|
46
|
+
const host = createProtocolHost(application.httpAdapter, framework, state, mcp);
|
|
36
47
|
return { mount: host.mount, assertReady: () => undefined };
|
|
37
48
|
}
|
|
38
49
|
let host;
|
|
39
50
|
let refusal;
|
|
40
51
|
application.init$.subscribe(() => {
|
|
41
52
|
try {
|
|
42
|
-
host = createProtocolHost(application.httpAdapter, framework);
|
|
53
|
+
host = createProtocolHost(application.httpAdapter, framework, state, mcp);
|
|
43
54
|
}
|
|
44
55
|
catch (error) {
|
|
45
56
|
refusal = error;
|
|
@@ -49,7 +60,7 @@ export function createPendingProtocolHost(application, framework) {
|
|
|
49
60
|
if (refusal !== undefined) {
|
|
50
61
|
throw refusal;
|
|
51
62
|
}
|
|
52
|
-
return host ?? createProtocolHost(undefined, framework);
|
|
63
|
+
return host ?? createProtocolHost(undefined, framework, state, mcp);
|
|
53
64
|
};
|
|
54
65
|
return {
|
|
55
66
|
mount: () => ready().mount(),
|
|
@@ -1,10 +1,20 @@
|
|
|
1
|
+
import type { AvState } from "@aventara/core";
|
|
1
2
|
import type { AvProtocolBinding, AvProtocolRequest, AvProtocolResponse } from "@aventara/core/protocol";
|
|
3
|
+
import type { McpBinding, McpRequest, McpRoute } from "./mcp-binding.loader.js";
|
|
2
4
|
/** One route `protocol.surface()` lists. */
|
|
3
5
|
export type SurfaceRoute = ReturnType<AvProtocolBinding["surface"]>[number];
|
|
6
|
+
/** The developer's `state` option over one platform request: seeds its `ctx.$state`. */
|
|
7
|
+
export type StateSeed = () => AvState | Promise<AvState>;
|
|
4
8
|
/** What a mounted route answers its request with: a bound protocol member. */
|
|
5
|
-
export type RouteAnswer = (request: AvProtocolRequest) => AvProtocolResponse | Promise<AvProtocolResponse>;
|
|
9
|
+
export type RouteAnswer = (request: AvProtocolRequest, state: StateSeed | undefined) => AvProtocolResponse | Promise<AvProtocolResponse>;
|
|
6
10
|
/** The bound member each kind of surface route is answered by. */
|
|
7
11
|
export type ProtocolRouteTable = {
|
|
8
12
|
readonly [K in SurfaceRoute["kind"]]: (binding: AvProtocolBinding) => RouteAnswer;
|
|
9
13
|
};
|
|
10
14
|
export declare const PROTOCOL_ROUTES: ProtocolRouteTable;
|
|
15
|
+
/** What a mounted MCP route answers its request with: a bound `@aventara/mcp` member. */
|
|
16
|
+
export type McpRouteAnswer = (request: McpRequest, state: StateSeed | undefined) => AvProtocolResponse | Promise<AvProtocolResponse>;
|
|
17
|
+
/** The bound `@aventara/mcp` member each kind of MCP route is answered by. */
|
|
18
|
+
export declare const MCP_ROUTES: {
|
|
19
|
+
readonly [K in McpRoute["kind"]]: (binding: McpBinding) => McpRouteAnswer;
|
|
20
|
+
};
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
export const PROTOCOL_ROUTES = {
|
|
2
|
-
operation: (binding) => (request) => binding.handleOperation(request),
|
|
2
|
+
operation: (binding) => (request, state) => binding.handleOperation(request, state === undefined ? {} : { state }),
|
|
3
3
|
contract: (binding) => (request) => binding.encodeContract(request),
|
|
4
|
-
transactions: (binding) => (request) => binding.handleTransaction(request),
|
|
4
|
+
transactions: (binding) => (request, state) => binding.handleTransaction(request, state === undefined ? {} : { state }),
|
|
5
|
+
};
|
|
6
|
+
export const MCP_ROUTES = {
|
|
7
|
+
endpoint: (binding) => (request, state) => binding.handleEndpoint(request, state === undefined ? {} : { state }),
|
|
8
|
+
metadata: (binding) => (request) => binding.encodeMetadata(request),
|
|
5
9
|
};
|