@nestia/core 14.0.0 → 14.0.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/MIGRATION.md +20 -0
- package/README.md +1 -1
- package/lib/adaptors/McpAdaptor.d.ts +5 -0
- package/lib/adaptors/McpAdaptor.js +114 -26
- package/lib/adaptors/McpAdaptor.js.map +1 -1
- package/lib/adaptors/WebSocketAdaptor.js +124 -32
- package/lib/adaptors/WebSocketAdaptor.js.map +1 -1
- package/lib/adaptors/internal/RoutePathMatcher.d.ts +1 -0
- package/lib/adaptors/internal/RoutePathMatcher.js +88 -0
- package/lib/adaptors/internal/RoutePathMatcher.js.map +1 -0
- package/lib/adaptors/internal/external_context.d.ts +1 -0
- package/lib/adaptors/internal/external_context.js +43 -0
- package/lib/adaptors/internal/external_context.js.map +1 -0
- package/lib/decorators/EncryptedBody.js +2 -2
- package/lib/decorators/EncryptedBody.js.map +1 -1
- package/lib/decorators/EncryptedController.js +1 -1
- package/lib/decorators/EncryptedController.js.map +1 -1
- package/lib/decorators/EncryptedModule.d.ts +7 -3
- package/lib/decorators/EncryptedModule.js +69 -15
- package/lib/decorators/EncryptedModule.js.map +1 -1
- package/lib/decorators/EncryptedRoute.js +7 -2
- package/lib/decorators/EncryptedRoute.js.map +1 -1
- package/lib/decorators/TypedBody.d.ts +1 -1
- package/lib/decorators/TypedBody.js +1 -1
- package/lib/decorators/TypedFormData.d.ts +22 -8
- package/lib/decorators/TypedFormData.js +116 -31
- package/lib/decorators/TypedFormData.js.map +1 -1
- package/lib/decorators/TypedParam.d.ts +3 -3
- package/lib/decorators/TypedParam.js +3 -3
- package/lib/decorators/TypedQuery.d.ts +1 -1
- package/lib/decorators/TypedQuery.js +9 -4
- package/lib/decorators/TypedQuery.js.map +1 -1
- package/lib/decorators/internal/EncryptedConstant.js +13 -2
- package/lib/decorators/internal/EncryptedConstant.js.map +1 -1
- package/lib/decorators/internal/get_encryption_password.d.ts +1 -0
- package/lib/decorators/internal/get_encryption_password.js +14 -0
- package/lib/decorators/internal/get_encryption_password.js.map +1 -0
- package/lib/decorators/internal/get_path_and_querify.js +21 -3
- package/lib/decorators/internal/get_path_and_querify.js.map +1 -1
- package/lib/decorators/internal/get_text_body.js +9 -3
- package/lib/decorators/internal/get_text_body.js.map +1 -1
- package/lib/decorators/internal/group_entries.d.ts +1 -0
- package/lib/decorators/internal/group_entries.js +19 -0
- package/lib/decorators/internal/group_entries.js.map +1 -0
- package/lib/decorators/internal/validate_request_form_data.js +3 -1
- package/lib/decorators/internal/validate_request_form_data.js.map +1 -1
- package/lib/decorators/internal/validate_request_headers.js +2 -1
- package/lib/decorators/internal/validate_request_headers.js.map +1 -1
- package/lib/decorators/internal/validate_request_query.js +3 -1
- package/lib/decorators/internal/validate_request_query.js.map +1 -1
- package/lib/options/IResponseBodyQuerifier.d.ts +5 -1
- package/lib/utils/ExceptionManager.d.ts +3 -4
- package/lib/utils/ExceptionManager.js +10 -6
- package/lib/utils/ExceptionManager.js.map +1 -1
- package/lib/utils/SourceFinder.js +26 -3
- package/lib/utils/SourceFinder.js.map +1 -1
- package/lib/utils/VersioningStrategy.d.ts +9 -1
- package/lib/utils/VersioningStrategy.js +18 -6
- package/lib/utils/VersioningStrategy.js.map +1 -1
- package/native/go.mod +1 -1
- package/native/go.sum +2 -2
- package/native/transform/ast.go +4 -2
- package/native/transform/core_querify.go +26 -6
- package/native/transform/core_transform.go +76 -1
- package/native/transform/core_websocket.go +94 -13
- package/native/transform/node_transform.go +6 -0
- package/native/transform/transform.go +8 -18
- package/native/transform/typia_fast.go +6 -0
- package/native/transform.cjs +49 -0
- package/package.json +12 -15
- package/src/adaptors/McpAdaptor.ts +150 -28
- package/src/adaptors/WebSocketAdaptor.ts +136 -38
- package/src/adaptors/internal/RoutePathMatcher.ts +91 -0
- package/src/adaptors/internal/external_context.ts +50 -0
- package/src/decorators/EncryptedBody.ts +2 -2
- package/src/decorators/EncryptedController.ts +6 -2
- package/src/decorators/EncryptedModule.ts +71 -15
- package/src/decorators/EncryptedRoute.ts +7 -5
- package/src/decorators/TypedBody.ts +1 -1
- package/src/decorators/TypedFormData.ts +148 -44
- package/src/decorators/TypedParam.ts +3 -3
- package/src/decorators/TypedQuery.ts +19 -7
- package/src/decorators/internal/EncryptedConstant.ts +14 -1
- package/src/decorators/internal/get_encryption_password.ts +19 -0
- package/src/decorators/internal/get_path_and_querify.ts +29 -2
- package/src/decorators/internal/get_text_body.ts +8 -4
- package/src/decorators/internal/group_entries.ts +17 -0
- package/src/decorators/internal/validate_request_form_data.ts +3 -1
- package/src/decorators/internal/validate_request_headers.ts +2 -2
- package/src/decorators/internal/validate_request_query.ts +3 -2
- package/src/options/IResponseBodyQuerifier.ts +6 -1
- package/src/utils/ExceptionManager.ts +10 -6
- package/src/utils/SourceFinder.ts +24 -3
- package/src/utils/VersioningStrategy.ts +21 -7
- package/lib/transform.d.ts +0 -10
- package/lib/transform.js +0 -25
- package/lib/transform.js.map +0 -1
- package/src/transform.ts +0 -26
package/native/transform.cjs
CHANGED
|
@@ -16,16 +16,65 @@ const path = require("node:path");
|
|
|
16
16
|
// - When the `@nestia/core` plugin itself is disabled, this descriptor is
|
|
17
17
|
// never evaluated, so the SDK contributor is never linked either.
|
|
18
18
|
function createTtscPlugin(context) {
|
|
19
|
+
const typia = assertTypiaVersion(context);
|
|
19
20
|
const plugin = {
|
|
20
21
|
name: "@nestia/core",
|
|
21
22
|
source: path.resolve(__dirname, "cmd", "ttsc-nestia"),
|
|
22
23
|
composes: ["typia/lib/transform"],
|
|
24
|
+
// a typia upgrade must evaluate the version check again
|
|
25
|
+
hostInputs: typia,
|
|
23
26
|
};
|
|
24
27
|
const sdk = resolveSdkContributorSource(context);
|
|
25
28
|
if (sdk !== null) plugin.contributors = [{ name: "sdk", source: sdk }];
|
|
26
29
|
return plugin;
|
|
27
30
|
}
|
|
28
31
|
|
|
32
|
+
// The typia transform this host runs is compiled from the typia Go source
|
|
33
|
+
// nestia's own `go.mod` pins, never from the typia package the project installs,
|
|
34
|
+
// while the code it emits calls that installed package's runtime. A project
|
|
35
|
+
// resolving another typia version would run mismatched code with no error until
|
|
36
|
+
// a call reaches the difference (#1663), so the build stops and names both
|
|
37
|
+
// versions. The version nestia is built for is the typia @nestia/core itself
|
|
38
|
+
// resolves, which its exact dependency holds at the Go pin's release.
|
|
39
|
+
//
|
|
40
|
+
// Returns the typia manifests read, for ttsc to watch.
|
|
41
|
+
function assertTypiaVersion(context) {
|
|
42
|
+
const expected = resolveTypiaManifest([__dirname]);
|
|
43
|
+
const actual =
|
|
44
|
+
context && typeof context.projectRoot === "string"
|
|
45
|
+
? resolveTypiaManifest([context.projectRoot])
|
|
46
|
+
: null;
|
|
47
|
+
if (
|
|
48
|
+
expected !== null &&
|
|
49
|
+
actual !== null &&
|
|
50
|
+
expected.version !== actual.version
|
|
51
|
+
)
|
|
52
|
+
throw new Error(
|
|
53
|
+
[
|
|
54
|
+
`@nestia/core runs the typia ${expected.version} transform, but this project resolves typia ${actual.version} (${actual.file}).`,
|
|
55
|
+
`The generated code would call typia ${actual.version}'s runtime with typia ${expected.version}'s output.`,
|
|
56
|
+
`Install typia@${expected.version}, or a @nestia/core release built for typia ${actual.version}.`,
|
|
57
|
+
].join(" "),
|
|
58
|
+
);
|
|
59
|
+
return [
|
|
60
|
+
...new Set(
|
|
61
|
+
[expected, actual]
|
|
62
|
+
.filter((manifest) => manifest !== null)
|
|
63
|
+
.map((manifest) => manifest.file),
|
|
64
|
+
),
|
|
65
|
+
];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function resolveTypiaManifest(paths) {
|
|
69
|
+
try {
|
|
70
|
+
const file = require.resolve("typia/package.json", { paths });
|
|
71
|
+
const version = JSON.parse(fs.readFileSync(file, "utf8")).version;
|
|
72
|
+
return typeof version === "string" ? { file, version } : null;
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
29
78
|
function resolveSdkContributorSource(context) {
|
|
30
79
|
const paths = [__dirname];
|
|
31
80
|
if (context && typeof context.projectRoot === "string")
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nestia/core",
|
|
3
|
-
"version": "14.0.
|
|
3
|
+
"version": "14.0.1",
|
|
4
4
|
"description": "Super-fast validation decorators of NestJS",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"exports": {
|
|
@@ -8,10 +8,7 @@
|
|
|
8
8
|
"types": "./lib/index.d.ts",
|
|
9
9
|
"default": "./lib/index.js"
|
|
10
10
|
},
|
|
11
|
-
"./lib/transform":
|
|
12
|
-
"types": "./lib/transform.d.ts",
|
|
13
|
-
"default": "./lib/transform.js"
|
|
14
|
-
},
|
|
11
|
+
"./lib/transform": "./native/transform.cjs",
|
|
15
12
|
"./native/transform.cjs": "./native/transform.cjs",
|
|
16
13
|
"./package.json": "./package.json"
|
|
17
14
|
},
|
|
@@ -49,24 +46,24 @@
|
|
|
49
46
|
"@typia/utils": "^15.0.0",
|
|
50
47
|
"get-function-location": "^2.0.0",
|
|
51
48
|
"glob": "^11.0.3",
|
|
52
|
-
"path-
|
|
49
|
+
"path-to-regexp": "^8.4.2",
|
|
53
50
|
"raw-body": "^2.0.0",
|
|
54
51
|
"reflect-metadata": ">=0.1.12",
|
|
55
|
-
"rxjs": ">=
|
|
52
|
+
"rxjs": ">=7.1.0",
|
|
56
53
|
"tgrid": "^1.1.0",
|
|
57
|
-
"typia": "
|
|
54
|
+
"typia": "15.0.0",
|
|
58
55
|
"ws": "^7.5.3",
|
|
59
|
-
"@nestia/fetcher": "^14.0.
|
|
56
|
+
"@nestia/fetcher": "^14.0.1"
|
|
60
57
|
},
|
|
61
58
|
"peerDependencies": {
|
|
62
59
|
"@modelcontextprotocol/sdk": "^1.18.0",
|
|
63
|
-
"@nestjs/common": ">=
|
|
64
|
-
"@nestjs/core": ">=
|
|
60
|
+
"@nestjs/common": ">=11.0.0",
|
|
61
|
+
"@nestjs/core": ">=11.0.0",
|
|
65
62
|
"reflect-metadata": ">=0.1.12",
|
|
66
|
-
"rxjs": ">=
|
|
63
|
+
"rxjs": ">=7.1.0",
|
|
67
64
|
"ttsc": ">=0.19.2",
|
|
68
|
-
"typia": "
|
|
69
|
-
"@nestia/fetcher": "^14.0.
|
|
65
|
+
"typia": "15.0.0",
|
|
66
|
+
"@nestia/fetcher": "^14.0.1"
|
|
70
67
|
},
|
|
71
68
|
"peerDependenciesMeta": {
|
|
72
69
|
"@modelcontextprotocol/sdk": {
|
|
@@ -110,7 +107,7 @@
|
|
|
110
107
|
"scripts": {
|
|
111
108
|
"build": "rimraf lib && ttsc",
|
|
112
109
|
"dev": "ttsc --watch",
|
|
113
|
-
"test:go": "cd test && go test ./..."
|
|
110
|
+
"test:go": "cd test && go test -count=1 ./..."
|
|
114
111
|
},
|
|
115
112
|
"types": "lib/index.d.ts"
|
|
116
113
|
}
|
|
@@ -3,9 +3,18 @@ import {
|
|
|
3
3
|
HttpException,
|
|
4
4
|
INestApplication,
|
|
5
5
|
} from "@nestjs/common";
|
|
6
|
-
import {
|
|
6
|
+
import { RouteParamtypes } from "@nestjs/common/enums/route-paramtypes.enum";
|
|
7
|
+
import { ContextId, NestContainer } from "@nestjs/core";
|
|
8
|
+
import { ExternalContextCreator } from "@nestjs/core/helpers/external-context-creator";
|
|
9
|
+
import { Injector } from "@nestjs/core/injector/injector";
|
|
10
|
+
import { InstanceWrapper } from "@nestjs/core/injector/instance-wrapper";
|
|
11
|
+
import { Module } from "@nestjs/core/injector/module";
|
|
7
12
|
|
|
8
13
|
import { IMcpRouteReflect } from "../decorators/internal/IMcpRouteReflect";
|
|
14
|
+
import {
|
|
15
|
+
create_external_context_creator,
|
|
16
|
+
get_request_context_id,
|
|
17
|
+
} from "./internal/external_context";
|
|
9
18
|
|
|
10
19
|
/**
|
|
11
20
|
* MCP (Model Context Protocol) adaptor.
|
|
@@ -23,6 +32,11 @@ import { IMcpRouteReflect } from "../decorators/internal/IMcpRouteReflect";
|
|
|
23
32
|
* registration API of `McpServer` is bypassed by accessing the low-level
|
|
24
33
|
* `.server` handler.
|
|
25
34
|
*
|
|
35
|
+
* A tool call passes the guards, interceptors, pipes, and exception filters
|
|
36
|
+
* NestJS applies to an HTTP route on the same method, with the MCP HTTP request
|
|
37
|
+
* as the execution context. The endpoint is mounted at `path` as given, outside
|
|
38
|
+
* the application's global prefix.
|
|
39
|
+
*
|
|
26
40
|
* Error mapping follows the MCP specification:
|
|
27
41
|
*
|
|
28
42
|
* - Unknown tool name: JSON-RPC `-32601`.
|
|
@@ -69,7 +83,12 @@ export class McpAdaptor {
|
|
|
69
83
|
|
|
70
84
|
const tools: McpAdaptor.ITool[] = [];
|
|
71
85
|
const container = (app as any).container as NestContainer;
|
|
72
|
-
|
|
86
|
+
const injector: Injector = new Injector();
|
|
87
|
+
for (const [moduleKey, module] of container.getModules()) {
|
|
88
|
+
const creator: ExternalContextCreator = create_external_context_creator(
|
|
89
|
+
container,
|
|
90
|
+
moduleKey,
|
|
91
|
+
);
|
|
73
92
|
for (const wrapper of module.controllers.values()) {
|
|
74
93
|
const instance = wrapper.instance;
|
|
75
94
|
if (!instance) continue;
|
|
@@ -94,15 +113,18 @@ export class McpAdaptor {
|
|
|
94
113
|
const params: IMcpRouteReflect.IArgument[] =
|
|
95
114
|
Reflect.getMetadata("nestia/McpRoute/Parameters", proto, key) ??
|
|
96
115
|
[];
|
|
97
|
-
const paramValidator = params.find(
|
|
98
|
-
(p) => p.category === "params",
|
|
99
|
-
)?.validate;
|
|
100
|
-
|
|
101
116
|
tools.push({
|
|
102
117
|
meta,
|
|
103
118
|
source: `${wrapper.metatype?.name ?? proto.constructor?.name ?? "UnknownController"}.${String(key)}`,
|
|
104
|
-
|
|
105
|
-
|
|
119
|
+
handler: createHandler({
|
|
120
|
+
container,
|
|
121
|
+
injector,
|
|
122
|
+
creator,
|
|
123
|
+
module,
|
|
124
|
+
wrapper,
|
|
125
|
+
key,
|
|
126
|
+
argument: params.find((p) => p.category === "params"),
|
|
127
|
+
}),
|
|
106
128
|
});
|
|
107
129
|
}
|
|
108
130
|
}
|
|
@@ -153,25 +175,14 @@ export class McpAdaptor {
|
|
|
153
175
|
);
|
|
154
176
|
|
|
155
177
|
const args = reqMsg.params.arguments ?? {};
|
|
156
|
-
if (tool.validateArgs) {
|
|
157
|
-
const err: Error | null = tool.validateArgs(args);
|
|
158
|
-
if (err !== null) {
|
|
159
|
-
const body =
|
|
160
|
-
err instanceof BadRequestException
|
|
161
|
-
? (err.getResponse() as any)
|
|
162
|
-
: undefined;
|
|
163
|
-
throw new McpError(ErrorCode.InvalidParams, err.message, {
|
|
164
|
-
errors: body?.errors,
|
|
165
|
-
path: body?.path,
|
|
166
|
-
expected: body?.expected,
|
|
167
|
-
value: body?.value,
|
|
168
|
-
reason: body?.reason,
|
|
169
|
-
});
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
178
|
try {
|
|
174
|
-
const result = await tool.handler(
|
|
179
|
+
const result = await tool.handler({
|
|
180
|
+
request: req,
|
|
181
|
+
response: res,
|
|
182
|
+
args,
|
|
183
|
+
});
|
|
184
|
+
if (tookOver(res)) return { content: [] };
|
|
185
|
+
if (result instanceof Error) throw result;
|
|
175
186
|
if (result === undefined) return { content: [] };
|
|
176
187
|
return {
|
|
177
188
|
content: [
|
|
@@ -183,6 +194,17 @@ export class McpAdaptor {
|
|
|
183
194
|
],
|
|
184
195
|
};
|
|
185
196
|
} catch (e) {
|
|
197
|
+
if (tookOver(res)) return { content: [] };
|
|
198
|
+
if (INVALID_ARGUMENTS.has(e as object)) {
|
|
199
|
+
const body = (e as BadRequestException).getResponse() as any;
|
|
200
|
+
throw new McpError(ErrorCode.InvalidParams, (e as Error).message, {
|
|
201
|
+
errors: body?.errors,
|
|
202
|
+
path: body?.path,
|
|
203
|
+
expected: body?.expected,
|
|
204
|
+
value: body?.value,
|
|
205
|
+
reason: body?.reason,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
186
208
|
if (e instanceof HttpException) {
|
|
187
209
|
return {
|
|
188
210
|
content: [{ type: "text" as const, text: e.message }],
|
|
@@ -211,6 +233,103 @@ export class McpAdaptor {
|
|
|
211
233
|
}
|
|
212
234
|
}
|
|
213
235
|
|
|
236
|
+
/**
|
|
237
|
+
* The tool's call through the enhancers NestJS applies to a route of the same
|
|
238
|
+
* method: guards, interceptors, pipes, and exception filters, bound to the
|
|
239
|
+
* method, its controller, or globally. The MCP HTTP request is the execution
|
|
240
|
+
* context, so a guard reading `switchToHttp().getRequest()` works unchanged.
|
|
241
|
+
*
|
|
242
|
+
* The arguments reach the method through the pipes as its body would, validated
|
|
243
|
+
* by typia at that stage: after the guards, as `@TypedBody()` is. An invalid
|
|
244
|
+
* argument is thrown as the validator's `BadRequestException`, which an
|
|
245
|
+
* exception filter may map; unmapped, it becomes JSON-RPC `-32602`.
|
|
246
|
+
*
|
|
247
|
+
* A controller that is request-scoped, itself or through an enhancer or a
|
|
248
|
+
* dependency, is built per request with its enhancers, as NestJS builds it for
|
|
249
|
+
* an HTTP route; the static instance is only a placeholder without them.
|
|
250
|
+
*/
|
|
251
|
+
const createHandler = (props: {
|
|
252
|
+
container: NestContainer;
|
|
253
|
+
injector: Injector;
|
|
254
|
+
creator: ExternalContextCreator;
|
|
255
|
+
module: Module;
|
|
256
|
+
wrapper: InstanceWrapper;
|
|
257
|
+
key: string;
|
|
258
|
+
argument: IMcpRouteReflect.IArgument | undefined;
|
|
259
|
+
}): McpAdaptor.ITool["handler"] => {
|
|
260
|
+
// the arguments stand at the params' position, or first when undecorated
|
|
261
|
+
const index: number = props.argument?.index ?? 0;
|
|
262
|
+
Reflect.defineMetadata(
|
|
263
|
+
PARAMS_METADATA,
|
|
264
|
+
{ [`${RouteParamtypes.BODY}:${index}`]: { index, data: undefined } },
|
|
265
|
+
props.wrapper.instance.constructor,
|
|
266
|
+
props.key,
|
|
267
|
+
);
|
|
268
|
+
const validate = props.argument?.validate;
|
|
269
|
+
const create = (instance: any, contextId?: ContextId) =>
|
|
270
|
+
props.creator.create(
|
|
271
|
+
instance,
|
|
272
|
+
instance[props.key],
|
|
273
|
+
props.key,
|
|
274
|
+
PARAMS_METADATA,
|
|
275
|
+
{
|
|
276
|
+
// [request, response, next] as an HTTP route has, then the arguments
|
|
277
|
+
exchangeKeyForValue: (_type, _data, [, , , args]) => {
|
|
278
|
+
const error: Error | null = validate ? validate(args) : null;
|
|
279
|
+
if (error === null) return args;
|
|
280
|
+
INVALID_ARGUMENTS.add(error);
|
|
281
|
+
throw error;
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
contextId,
|
|
285
|
+
contextId && props.wrapper.id,
|
|
286
|
+
);
|
|
287
|
+
let target: ((...args: any[]) => Promise<unknown>) | undefined;
|
|
288
|
+
return async (input) => {
|
|
289
|
+
const call = (fn: (...args: any[]) => Promise<unknown>) =>
|
|
290
|
+
fn(input.request, input.response, undefined, input.args);
|
|
291
|
+
// resolved at the first call, so global enhancers init() registers count
|
|
292
|
+
if (props.wrapper.isDependencyTreeStatic())
|
|
293
|
+
return call((target ??= create(props.wrapper.instance)));
|
|
294
|
+
const contextId: ContextId = get_request_context_id(
|
|
295
|
+
props.container,
|
|
296
|
+
input.request as object,
|
|
297
|
+
props.wrapper.isDependencyTreeDurable(),
|
|
298
|
+
);
|
|
299
|
+
const instance: object = await props.injector.loadPerContext(
|
|
300
|
+
props.wrapper.instance,
|
|
301
|
+
props.module,
|
|
302
|
+
props.module.controllers,
|
|
303
|
+
contextId,
|
|
304
|
+
);
|
|
305
|
+
return call(create(instance, contextId));
|
|
306
|
+
};
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Whether an exception filter already wrote the HTTP response itself. The
|
|
311
|
+
* response it wrote stands, so the transport's own is dropped: writing again
|
|
312
|
+
* would throw, and the transport would destroy the connection mid-response.
|
|
313
|
+
*/
|
|
314
|
+
const tookOver = (response: any): boolean => {
|
|
315
|
+
const raw: any = response.raw ?? response;
|
|
316
|
+
if (raw.headersSent !== true) return false;
|
|
317
|
+
raw.writeHead = () => raw;
|
|
318
|
+
raw.flushHeaders = () => {};
|
|
319
|
+
raw.write = () => true;
|
|
320
|
+
raw.end = (...args: unknown[]) => {
|
|
321
|
+
const callback: unknown = args.find((a) => typeof a === "function");
|
|
322
|
+
if (typeof callback === "function") queueMicrotask(() => callback());
|
|
323
|
+
return raw;
|
|
324
|
+
};
|
|
325
|
+
return true;
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
const PARAMS_METADATA = "nestia/McpRoute/ExternalParameters";
|
|
329
|
+
|
|
330
|
+
/** The validators' exceptions, told apart from a handler's own. */
|
|
331
|
+
const INVALID_ARGUMENTS: WeakSet<object> = new WeakSet();
|
|
332
|
+
|
|
214
333
|
const assertUniqueTools = (tools: McpAdaptor.ITool[]): void => {
|
|
215
334
|
const dict: Map<string, McpAdaptor.ITool[]> = new Map();
|
|
216
335
|
for (const tool of tools) {
|
|
@@ -278,7 +397,10 @@ export namespace McpAdaptor {
|
|
|
278
397
|
export interface ITool {
|
|
279
398
|
meta: IMcpRouteReflect;
|
|
280
399
|
source: string;
|
|
281
|
-
handler: (
|
|
282
|
-
|
|
400
|
+
handler: (input: {
|
|
401
|
+
request: unknown;
|
|
402
|
+
response: unknown;
|
|
403
|
+
args: unknown;
|
|
404
|
+
}) => Promise<unknown>;
|
|
283
405
|
}
|
|
284
406
|
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
/// <reference path="../typings/get-function-location.d.ts" />
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
HttpException,
|
|
4
|
+
INestApplication,
|
|
5
|
+
VersioningType,
|
|
6
|
+
} from "@nestjs/common";
|
|
3
7
|
import {
|
|
4
8
|
HOST_METADATA,
|
|
5
9
|
MODULE_PATH,
|
|
@@ -14,7 +18,6 @@ import { Module } from "@nestjs/core/injector/module";
|
|
|
14
18
|
import getFunctionLocation from "get-function-location";
|
|
15
19
|
import { IncomingMessage, Server } from "http";
|
|
16
20
|
import path from "path";
|
|
17
|
-
import { Path } from "path-parser";
|
|
18
21
|
import { Duplex } from "stream";
|
|
19
22
|
import { WebSocketAcceptor } from "tgrid";
|
|
20
23
|
import typia from "typia";
|
|
@@ -23,6 +26,7 @@ import WebSocket from "ws";
|
|
|
23
26
|
import { IWebSocketRouteReflect } from "../decorators/internal/IWebSocketRouteReflect";
|
|
24
27
|
import { ArrayUtil } from "../utils/ArrayUtil";
|
|
25
28
|
import { VersioningStrategy } from "../utils/VersioningStrategy";
|
|
29
|
+
import { RoutePathMatcher } from "./internal/RoutePathMatcher";
|
|
26
30
|
|
|
27
31
|
export class WebSocketAdaptor {
|
|
28
32
|
public static async upgrade(
|
|
@@ -62,25 +66,19 @@ export class WebSocketAdaptor {
|
|
|
62
66
|
})();
|
|
63
67
|
for (const op of this.operators) {
|
|
64
68
|
const params: Record<string, string> | null = op.parser.test(path);
|
|
65
|
-
if (params
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
)
|
|
73
|
-
await acceptor.reject(
|
|
74
|
-
1008,
|
|
75
|
-
error instanceof Error
|
|
76
|
-
? JSON.stringify({ ...error })
|
|
77
|
-
: "unknown error",
|
|
78
|
-
);
|
|
79
|
-
} finally {
|
|
80
|
-
return;
|
|
81
|
-
}
|
|
69
|
+
if (params === null) continue;
|
|
70
|
+
try {
|
|
71
|
+
await op.handler({ params, acceptor });
|
|
72
|
+
} catch (error) {
|
|
73
|
+
await terminate({ acceptor, socket: client, error });
|
|
74
|
+
}
|
|
75
|
+
return;
|
|
82
76
|
}
|
|
83
|
-
await
|
|
77
|
+
await terminate({
|
|
78
|
+
acceptor,
|
|
79
|
+
socket: client,
|
|
80
|
+
error: new WebSocketRejection(1002, "WebSocket API not found"),
|
|
81
|
+
});
|
|
84
82
|
},
|
|
85
83
|
),
|
|
86
84
|
);
|
|
@@ -107,10 +105,10 @@ const visitApplication = async (
|
|
|
107
105
|
return versioning === undefined || versioning.type !== VersioningType.URI
|
|
108
106
|
? undefined
|
|
109
107
|
: {
|
|
108
|
+
// as NestJS's RoutePathFactory.getVersionPrefix(): `false` is no
|
|
109
|
+
// prefix at all, and only an absent one is the default "v"
|
|
110
110
|
prefix:
|
|
111
|
-
versioning.prefix ===
|
|
112
|
-
? "v"
|
|
113
|
-
: versioning.prefix,
|
|
111
|
+
versioning.prefix === false ? "" : (versioning.prefix ?? "v"),
|
|
114
112
|
defaultVersion: versioning.defaultVersion,
|
|
115
113
|
};
|
|
116
114
|
})(),
|
|
@@ -282,16 +280,16 @@ const visitMethod = (props: {
|
|
|
282
280
|
].join("\n"),
|
|
283
281
|
);
|
|
284
282
|
|
|
285
|
-
const versions: string[] = VersioningStrategy.merge(props.config.versioning)(
|
|
286
|
-
|
|
287
|
-
|
|
283
|
+
const versions: string[] = VersioningStrategy.merge(props.config.versioning)({
|
|
284
|
+
controller: props.controller.versions,
|
|
285
|
+
method: VersioningStrategy.cast(
|
|
288
286
|
Reflect.getMetadata(VERSION_METADATA, props.method.value),
|
|
289
287
|
),
|
|
290
|
-
|
|
288
|
+
});
|
|
291
289
|
for (const v of versions)
|
|
292
290
|
for (const cp of wrapPaths(props.controller.prefixes))
|
|
293
291
|
for (const mp of wrapPaths(route.paths)) {
|
|
294
|
-
const parser:
|
|
292
|
+
const parser: RoutePathMatcher = new RoutePathMatcher(
|
|
295
293
|
"/" +
|
|
296
294
|
[
|
|
297
295
|
props.config.globalPrefix ?? "",
|
|
@@ -350,10 +348,12 @@ const visitMethod = (props: {
|
|
|
350
348
|
} else if (p.category === "param")
|
|
351
349
|
args.push(p.assert(input.params[p.field]!));
|
|
352
350
|
else if (p.category === "query") {
|
|
351
|
+
// the query is all after the first "?", which it may hold too
|
|
352
|
+
const index: number = input.acceptor.path.indexOf("?");
|
|
353
353
|
const query: any | Error = p.validate(
|
|
354
354
|
new URLSearchParams(
|
|
355
|
-
|
|
356
|
-
? input.acceptor.path.
|
|
355
|
+
index !== -1
|
|
356
|
+
? input.acceptor.path.substring(index + 1)
|
|
357
357
|
: "",
|
|
358
358
|
),
|
|
359
359
|
);
|
|
@@ -361,13 +361,8 @@ const visitMethod = (props: {
|
|
|
361
361
|
args.push(query);
|
|
362
362
|
}
|
|
363
363
|
} catch (exp) {
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
exp instanceof Error
|
|
367
|
-
? JSON.stringify({ ...exp })
|
|
368
|
-
: "unknown error",
|
|
369
|
-
);
|
|
370
|
-
return;
|
|
364
|
+
// an invalid handshake: rejected before the handler runs
|
|
365
|
+
throw new WebSocketRejection(1003, exp);
|
|
371
366
|
}
|
|
372
367
|
await props.method.value.call(props.controller.instance, ...args);
|
|
373
368
|
},
|
|
@@ -375,6 +370,109 @@ const visitMethod = (props: {
|
|
|
375
370
|
}
|
|
376
371
|
};
|
|
377
372
|
|
|
373
|
+
/**
|
|
374
|
+
* A handshake rejected on purpose, with its close code: 1002 for no route, 1003
|
|
375
|
+
* for a header, param, or query failing its type.
|
|
376
|
+
*/
|
|
377
|
+
class WebSocketRejection {
|
|
378
|
+
public constructor(
|
|
379
|
+
public readonly code: number,
|
|
380
|
+
public readonly cause: unknown,
|
|
381
|
+
) {}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Ends a WebSocket request that failed, so the client always learns it: a
|
|
386
|
+
* handshake not accepted yet is rejected, 1008 for an error of the route
|
|
387
|
+
* itself, and an accepted connection is closed with 1011. When tgrid refuses
|
|
388
|
+
* either, as it does while `accept()` is still running, the socket is closed
|
|
389
|
+
* directly. Nothing escapes, as the upgrade callback has no one to catch it.
|
|
390
|
+
*/
|
|
391
|
+
const terminate = async (props: {
|
|
392
|
+
acceptor: WebSocketAcceptor<any, any, any>;
|
|
393
|
+
socket: WebSocket;
|
|
394
|
+
error: unknown;
|
|
395
|
+
}): Promise<void> => {
|
|
396
|
+
const reason: string = closeReason(
|
|
397
|
+
props.error instanceof WebSocketRejection ? props.error.cause : props.error,
|
|
398
|
+
);
|
|
399
|
+
const state: WebSocketAcceptor.State = props.acceptor.state;
|
|
400
|
+
if (
|
|
401
|
+
state === WebSocketAcceptor.State.REJECTING ||
|
|
402
|
+
state === WebSocketAcceptor.State.CLOSING ||
|
|
403
|
+
state === WebSocketAcceptor.State.CLOSED
|
|
404
|
+
)
|
|
405
|
+
return; // the route already ends the connection itself
|
|
406
|
+
const code: number =
|
|
407
|
+
state !== WebSocketAcceptor.State.NONE
|
|
408
|
+
? 1011
|
|
409
|
+
: props.error instanceof WebSocketRejection
|
|
410
|
+
? props.error.code
|
|
411
|
+
: 1008;
|
|
412
|
+
try {
|
|
413
|
+
if (state === WebSocketAcceptor.State.NONE)
|
|
414
|
+
return await props.acceptor.reject(code, reason);
|
|
415
|
+
else if (state === WebSocketAcceptor.State.OPEN)
|
|
416
|
+
return await props.acceptor.close(code, reason);
|
|
417
|
+
} catch {}
|
|
418
|
+
try {
|
|
419
|
+
props.socket.close(code, reason);
|
|
420
|
+
} catch {
|
|
421
|
+
props.socket.terminate();
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* The reason a close frame carries: the error's message, cut to the 123 bytes
|
|
427
|
+
* of UTF-8 a close reason may hold, never inside a character. A longer reason
|
|
428
|
+
* makes `ws` throw instead of closing, and the client waits forever.
|
|
429
|
+
*
|
|
430
|
+
* A validator's `BadRequestException` carries a generic message, so the
|
|
431
|
+
* property it names is preferred: typia's `reason` from an assertion, or the
|
|
432
|
+
* first of the `errors` from a validation.
|
|
433
|
+
*/
|
|
434
|
+
const closeReason = (error: unknown): string => {
|
|
435
|
+
const message: string =
|
|
436
|
+
invalidProperty(error) ??
|
|
437
|
+
(error instanceof Error
|
|
438
|
+
? error.message
|
|
439
|
+
: typeof error === "string"
|
|
440
|
+
? error
|
|
441
|
+
: "unknown error");
|
|
442
|
+
const encoded: Buffer = Buffer.from(message, "utf8");
|
|
443
|
+
if (encoded.length <= MAX_CLOSE_REASON_BYTES) return message;
|
|
444
|
+
let end: number = MAX_CLOSE_REASON_BYTES;
|
|
445
|
+
// back off continuation bytes (10xxxxxx) to a character boundary
|
|
446
|
+
while (end > 0 && (encoded[end]! & 0xc0) === 0x80) --end;
|
|
447
|
+
return encoded.subarray(0, end).toString("utf8");
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
const invalidProperty = (error: unknown): string | null => {
|
|
451
|
+
if (!(error instanceof HttpException)) return null;
|
|
452
|
+
const response: unknown = error.getResponse();
|
|
453
|
+
if (typeof response !== "object" || response === null) return null;
|
|
454
|
+
const { reason, errors } = response as {
|
|
455
|
+
reason?: unknown;
|
|
456
|
+
errors?: unknown;
|
|
457
|
+
};
|
|
458
|
+
if (typeof reason === "string") return reason;
|
|
459
|
+
if (Array.isArray(errors) && errors.length !== 0) {
|
|
460
|
+
const first: { path?: unknown; expected?: unknown } = errors[0];
|
|
461
|
+
if (typeof first?.path === "string" && typeof first.expected === "string")
|
|
462
|
+
return [
|
|
463
|
+
`invalid type on ${first.path}, expect to be ${first.expected}`,
|
|
464
|
+
errors.length > 1 ? ` (and ${errors.length - 1} more)` : "",
|
|
465
|
+
].join("");
|
|
466
|
+
}
|
|
467
|
+
return null;
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* RFC 6455 §5.5: a control frame's payload is at most 125 bytes, 2 of them the
|
|
472
|
+
* code.
|
|
473
|
+
*/
|
|
474
|
+
const MAX_CLOSE_REASON_BYTES: number = 123;
|
|
475
|
+
|
|
378
476
|
const wrapPaths = (value: string[]) => (value.length === 0 ? [""] : value);
|
|
379
477
|
const getOwnPropertyNames = (prototype: any): string[] => {
|
|
380
478
|
const result: Set<string> = new Set();
|
|
@@ -402,7 +500,7 @@ interface IController {
|
|
|
402
500
|
modulePrefix: string;
|
|
403
501
|
}
|
|
404
502
|
interface IOperator {
|
|
405
|
-
parser:
|
|
503
|
+
parser: RoutePathMatcher;
|
|
406
504
|
handler: (props: {
|
|
407
505
|
params: Record<string, string>;
|
|
408
506
|
acceptor: WebSocketAcceptor<any, any, any>;
|