@silkweave/nestjs 1.9.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/.turbo/turbo-build.log +17 -0
- package/.turbo/turbo-check.log +4 -0
- package/README.md +210 -0
- package/build/index.d.mts +315 -0
- package/build/index.d.mts.map +1 -0
- package/build/index.mjs +9231 -0
- package/build/index.mjs.map +1 -0
- package/eslint.config.mjs +93 -0
- package/package.json +52 -0
- package/src/adapter/mcp.ts +55 -0
- package/src/adapter/rest.ts +179 -0
- package/src/adapter/trpc.ts +82 -0
- package/src/decorator/action.ts +38 -0
- package/src/decorator/actions.ts +25 -0
- package/src/index.ts +12 -0
- package/src/lib/discovery.ts +90 -0
- package/src/lib/executionContext.ts +76 -0
- package/src/lib/filter.ts +26 -0
- package/src/lib/guards.ts +64 -0
- package/src/lib/metadata.ts +45 -0
- package/src/lib/silkweave.module.ts +52 -0
- package/src/lib/silkweave.service.ts +58 -0
- package/src/lib/slot.ts +51 -0
- package/src/lib/types.ts +37 -0
- package/tsconfig.json +25 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/tsdown.config.ts +7 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import eslint from '@eslint/js'
|
|
2
|
+
import stylistic from '@stylistic/eslint-plugin'
|
|
3
|
+
import { defineConfig } from 'eslint/config'
|
|
4
|
+
import tseslint from 'typescript-eslint'
|
|
5
|
+
|
|
6
|
+
export default defineConfig(eslint.configs.recommended, tseslint.configs.recommendedTypeChecked, tseslint.configs.stylisticTypeChecked, {
|
|
7
|
+
languageOptions: {
|
|
8
|
+
parserOptions: {
|
|
9
|
+
project: 'tsconfig.json',
|
|
10
|
+
tsconfigRootDir: import.meta.dirname
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
plugins: { '@stylistic': stylistic },
|
|
14
|
+
rules: {
|
|
15
|
+
'@stylistic/space-in-parens': ['error'],
|
|
16
|
+
'@stylistic/comma-spacing': ['error'],
|
|
17
|
+
'@stylistic/no-multi-spaces': ['error'],
|
|
18
|
+
'@stylistic/no-trailing-spaces': ['error'],
|
|
19
|
+
'@stylistic/no-whitespace-before-property': ['error'],
|
|
20
|
+
'@stylistic/array-bracket-newline': ['error', 'consistent'],
|
|
21
|
+
'@stylistic/array-bracket-spacing': ['error'],
|
|
22
|
+
'@stylistic/arrow-spacing': ['error'],
|
|
23
|
+
'@stylistic/arrow-parens': ['error', 'always'],
|
|
24
|
+
'@stylistic/block-spacing': ['error', 'always'],
|
|
25
|
+
'@stylistic/brace-style': ['error', '1tbs', { 'allowSingleLine': true }],
|
|
26
|
+
'@stylistic/comma-dangle': ['error', 'never'],
|
|
27
|
+
'@stylistic/key-spacing': ['error'],
|
|
28
|
+
'@stylistic/keyword-spacing': ['error'],
|
|
29
|
+
'@stylistic/member-delimiter-style': ['error', { 'multiline': { 'delimiter': 'none' } }],
|
|
30
|
+
'@stylistic/no-extra-semi': ['error'],
|
|
31
|
+
'@stylistic/indent': ['error', 2],
|
|
32
|
+
'@stylistic/no-multiple-empty-lines': ['error', { 'max': 1, 'maxEOF': 0, 'maxBOF': 0 }],
|
|
33
|
+
'@stylistic/object-curly-spacing': ['error', 'always'],
|
|
34
|
+
'@stylistic/quotes': ['error', 'single'],
|
|
35
|
+
'@stylistic/semi': ['error', 'never'],
|
|
36
|
+
'@stylistic/space-before-blocks': ['error', 'always'],
|
|
37
|
+
'@stylistic/space-before-function-paren': ['error', { 'anonymous': 'always', 'named': 'never', 'asyncArrow': 'always' }],
|
|
38
|
+
'@typescript-eslint/adjacent-overload-signatures': 'error',
|
|
39
|
+
'@typescript-eslint/array-type': 'off',
|
|
40
|
+
'@typescript-eslint/await-thenable': 'error',
|
|
41
|
+
'@typescript-eslint/ban-types': 'off',
|
|
42
|
+
'@typescript-eslint/consistent-type-assertions': 'off',
|
|
43
|
+
'@typescript-eslint/explicit-function-return-type': 'off',
|
|
44
|
+
'@typescript-eslint/explicit-member-accessibility': 'off',
|
|
45
|
+
'@typescript-eslint/no-angle-bracket-type-assertion': 'off',
|
|
46
|
+
'@typescript-eslint/no-empty-function': 'off',
|
|
47
|
+
'@typescript-eslint/no-empty-interface': 'off',
|
|
48
|
+
'@typescript-eslint/no-extra-non-null-assertion': 'error',
|
|
49
|
+
'@typescript-eslint/no-floating-promises': 'off',
|
|
50
|
+
'@typescript-eslint/no-misused-new': 'error',
|
|
51
|
+
'@typescript-eslint/no-misused-promises': 'off',
|
|
52
|
+
'@typescript-eslint/no-namespace': 'off',
|
|
53
|
+
'@typescript-eslint/no-non-null-asserted-optional-chain': 'error',
|
|
54
|
+
'@typescript-eslint/no-non-null-assertion': 'off',
|
|
55
|
+
'@typescript-eslint/no-object-literal-type-assertion': 'off',
|
|
56
|
+
'@typescript-eslint/no-parameter-properties': 'off',
|
|
57
|
+
'@typescript-eslint/no-shadow': 'error',
|
|
58
|
+
'@typescript-eslint/no-triple-slash-reference': 'off',
|
|
59
|
+
'@typescript-eslint/no-unused-vars': ['error', {
|
|
60
|
+
'args': 'all',
|
|
61
|
+
'argsIgnorePattern': '^_',
|
|
62
|
+
'caughtErrors': 'all',
|
|
63
|
+
'caughtErrorsIgnorePattern': '^_',
|
|
64
|
+
'destructuredArrayIgnorePattern': '^_',
|
|
65
|
+
'varsIgnorePattern': '^_',
|
|
66
|
+
'ignoreRestSiblings': true
|
|
67
|
+
}],
|
|
68
|
+
'@typescript-eslint/no-use-before-define': 'off',
|
|
69
|
+
'@typescript-eslint/no-var-requires': 'off',
|
|
70
|
+
'@typescript-eslint/prefer-for-of': 'error',
|
|
71
|
+
'@typescript-eslint/prefer-interface': 'off',
|
|
72
|
+
'@typescript-eslint/prefer-nullish-coalescing': 'off',
|
|
73
|
+
'@typescript-eslint/prefer-optional-chain': 'error',
|
|
74
|
+
'@typescript-eslint/return-await': 'error',
|
|
75
|
+
'@typescript-eslint/unified-signatures': 'error',
|
|
76
|
+
'@typescript-eslint/no-unsafe-return': 'off',
|
|
77
|
+
'@typescript-eslint/no-redundant-type-constituents': 'off',
|
|
78
|
+
'@typescript-eslint/no-unsafe-member-access': 'off',
|
|
79
|
+
'@typescript-eslint/no-unsafe-call': 'off',
|
|
80
|
+
'@typescript-eslint/no-unsafe-argument': 'off',
|
|
81
|
+
'@typescript-eslint/dot-notation': 'off',
|
|
82
|
+
'@typescript-eslint/prefer-regexp-exec': 'off',
|
|
83
|
+
'@typescript-eslint/require-await': 'off',
|
|
84
|
+
'@typescript-eslint/only-throw-error': 'error',
|
|
85
|
+
'@typescript-eslint/no-base-to-string': 'off',
|
|
86
|
+
'@typescript-eslint/restrict-template-expressions': 'off'
|
|
87
|
+
}
|
|
88
|
+
}, {
|
|
89
|
+
files: ['eslint.config.mjs'],
|
|
90
|
+
extends: [tseslint.configs.disableTypeChecked]
|
|
91
|
+
}, {
|
|
92
|
+
ignores: ['build', 'tsdown.config.ts', 'scratch']
|
|
93
|
+
})
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@silkweave/nestjs",
|
|
3
|
+
"version": "1.9.0",
|
|
4
|
+
"description": "Silkweave NestJS Adapter",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+ssh://git@github.com/silkweave/silkweave.git",
|
|
8
|
+
"directory": "packages/nestjs"
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"main": "build/index.mjs",
|
|
12
|
+
"peerDependencies": {
|
|
13
|
+
"@nestjs/common": "^10.0.0 || ^11.0.0",
|
|
14
|
+
"@nestjs/core": "^10.0.0 || ^11.0.0"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@trpc/server": "^11.7.1",
|
|
18
|
+
"change-case": "^5.4.4",
|
|
19
|
+
"zod": "^3.25.0",
|
|
20
|
+
"@silkweave/auth": "1.9.0",
|
|
21
|
+
"@silkweave/logger": "1.9.0",
|
|
22
|
+
"@silkweave/core": "1.9.0",
|
|
23
|
+
"@silkweave/trpc": "1.9.0",
|
|
24
|
+
"@silkweave/mcp": "1.9.0"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@eslint/js": "^10.0.1",
|
|
28
|
+
"@nestjs/common": "^11.0.0",
|
|
29
|
+
"@nestjs/core": "^11.0.0",
|
|
30
|
+
"@stylistic/eslint-plugin": "^5.10.0",
|
|
31
|
+
"@types/node": "^22.0.0",
|
|
32
|
+
"reflect-metadata": "^0.2.2",
|
|
33
|
+
"rimraf": "^6.1.3",
|
|
34
|
+
"rxjs": "^7.8.1",
|
|
35
|
+
"tsdown": "^0.21.8",
|
|
36
|
+
"tsx": "^4.21.0",
|
|
37
|
+
"typescript": "^5.9.3",
|
|
38
|
+
"typescript-eslint": "^8.56.1"
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"clean": "rimraf build",
|
|
42
|
+
"build": "tsdown",
|
|
43
|
+
"watch": "tsdown --watch",
|
|
44
|
+
"typecheck": "tsc --noEmit",
|
|
45
|
+
"lint": "eslint",
|
|
46
|
+
"check": "pnpm lint && pnpm typecheck"
|
|
47
|
+
},
|
|
48
|
+
"exports": {
|
|
49
|
+
".": "./build/index.mjs"
|
|
50
|
+
},
|
|
51
|
+
"types": "./build/index.d.ts"
|
|
52
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { HttpAdapterHost } from '@nestjs/core'
|
|
2
|
+
import { AuthConfig } from '@silkweave/auth'
|
|
3
|
+
import type { Adapter, AdapterGenerator } from '@silkweave/core'
|
|
4
|
+
import { createMcpExpressHandler, type CreateMcpExpressHandlerOptions } from '@silkweave/mcp'
|
|
5
|
+
import { reserveSlot, type NodeMiddleware } from '../lib/slot.js'
|
|
6
|
+
import type { NestSilkweaveAdapter } from '../lib/types.js'
|
|
7
|
+
|
|
8
|
+
export interface McpAdapterOptions extends Omit<CreateMcpExpressHandlerOptions, 'auth'> {
|
|
9
|
+
/** URL prefix at which the MCP sub-app is mounted. Default `'/'` — the MCP transport then lives at `/mcp`, OAuth routes at `/authorize`, etc. */
|
|
10
|
+
basePath?: string
|
|
11
|
+
/** Optional bearer-token / OAuth auth applied to MCP requests. Same shape as `@silkweave/mcp`'s `http()` auth. */
|
|
12
|
+
auth?: AuthConfig
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* MCP Streamable HTTP adapter for `@silkweave/nestjs`. Builds the same Express
|
|
17
|
+
* sub-app that `@silkweave/mcp`'s `http()` adapter uses internally (via
|
|
18
|
+
* `createMcpExpressHandler`) and mounts it on Nest's running HTTP server at
|
|
19
|
+
* the configured base path.
|
|
20
|
+
*
|
|
21
|
+
* Routes provided by the mounted sub-app:
|
|
22
|
+
* - `POST /mcp`, `GET /mcp`, `DELETE /mcp` — MCP Streamable HTTP transport
|
|
23
|
+
* - `GET /resource/:id` — sideload resources (large MCP responses)
|
|
24
|
+
* - `GET /.well-known/oauth-protected-resource` (when `auth.resourceUrl`/`auth.authorizationServers` set)
|
|
25
|
+
* - `GET /authorize`, `POST /token`, `POST /register`, `GET /auth/callback` (when `auth.provider` set)
|
|
26
|
+
*
|
|
27
|
+
* Note: this adapter mounts an Express sub-app. On `@nestjs/platform-fastify`,
|
|
28
|
+
* register `@fastify/express` before this adapter so Nest can serve Express
|
|
29
|
+
* middleware.
|
|
30
|
+
*/
|
|
31
|
+
export function mcp(options: McpAdapterOptions = {}): NestSilkweaveAdapter {
|
|
32
|
+
return {
|
|
33
|
+
name: 'mcp',
|
|
34
|
+
install: (host: HttpAdapterHost): AdapterGenerator => {
|
|
35
|
+
const httpAdapter = host.httpAdapter
|
|
36
|
+
if (!httpAdapter) {
|
|
37
|
+
throw new Error('@silkweave/nestjs mcp(): HttpAdapterHost.httpAdapter is not available.')
|
|
38
|
+
}
|
|
39
|
+
const { basePath = '/', auth, ...handlerOptions } = options
|
|
40
|
+
const mountPath = basePath === '/' ? '/' : basePath.replace(/\/$/, '')
|
|
41
|
+
const setHandler = reserveSlot(httpAdapter as unknown as { use: (path: string, h: NodeMiddleware) => unknown }, mountPath, 'MCP')
|
|
42
|
+
return (silkweaveOptions, baseContext): Adapter => {
|
|
43
|
+
const context = baseContext.fork({ adapter: 'mcp' })
|
|
44
|
+
return {
|
|
45
|
+
context,
|
|
46
|
+
start: async (actions) => {
|
|
47
|
+
const app = createMcpExpressHandler(silkweaveOptions, context, actions, { ...handlerOptions, auth })
|
|
48
|
+
setHandler(app as unknown as NodeMiddleware)
|
|
49
|
+
},
|
|
50
|
+
stop: async () => { /* Nest owns the HTTP server */ }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { HttpException } from '@nestjs/common'
|
|
2
|
+
import type { HttpAdapterHost } from '@nestjs/core'
|
|
3
|
+
import { AuthConfig, AuthInfo, validateToken } from '@silkweave/auth'
|
|
4
|
+
import { type Action, type Adapter, type AdapterGenerator, type SilkweaveContext, SilkweaveError } from '@silkweave/core'
|
|
5
|
+
import { buildLogLevels, type Logger, type LogLevel } from '@silkweave/logger'
|
|
6
|
+
import type { IncomingMessage, ServerResponse } from 'http'
|
|
7
|
+
import { z } from 'zod/v4'
|
|
8
|
+
import { reserveSlot, type NodeMiddleware } from '../lib/slot.js'
|
|
9
|
+
import type { NestSilkweaveAdapter } from '../lib/types.js'
|
|
10
|
+
|
|
11
|
+
const CONSOLE_LEVEL_MAP: Record<LogLevel, 'log' | 'info' | 'warn' | 'error'> = {
|
|
12
|
+
emergency: 'error',
|
|
13
|
+
alert: 'error',
|
|
14
|
+
critical: 'error',
|
|
15
|
+
error: 'error',
|
|
16
|
+
warning: 'warn',
|
|
17
|
+
notice: 'info',
|
|
18
|
+
info: 'info',
|
|
19
|
+
debug: 'log'
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface RestAdapterOptions {
|
|
23
|
+
/** URL prefix at which the REST routes are mounted. e.g. `'/api'` → `POST /api/users/list`. Default: `'/'`. */
|
|
24
|
+
basePath?: string
|
|
25
|
+
/** Optional bearer-token auth applied to every REST action. */
|
|
26
|
+
auth?: AuthConfig
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function createRestLogger(): Logger {
|
|
30
|
+
return {
|
|
31
|
+
...buildLogLevels((level, data) => {
|
|
32
|
+
console[CONSOLE_LEVEL_MAP[level]](data)
|
|
33
|
+
}),
|
|
34
|
+
progress: () => { /* progress notifications not supported on REST */ }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface RequestLike {
|
|
39
|
+
headers: Record<string, string | string[] | undefined>
|
|
40
|
+
body?: unknown
|
|
41
|
+
query?: unknown
|
|
42
|
+
url?: string
|
|
43
|
+
method?: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function actionNameToPath(name: string): string {
|
|
47
|
+
return `/${name.replace(/\./g, '/')}`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function readJsonBody(req: IncomingMessage): Promise<unknown> {
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
const chunks: Buffer[] = []
|
|
53
|
+
req.on('data', (c: Buffer) => chunks.push(c))
|
|
54
|
+
req.on('end', () => {
|
|
55
|
+
if (chunks.length === 0) { return resolve({}) }
|
|
56
|
+
try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf-8'))) } catch (err) { reject(err) }
|
|
57
|
+
})
|
|
58
|
+
req.on('error', reject)
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function parseQuery(url: string | undefined): Record<string, string> {
|
|
63
|
+
if (!url) { return {} }
|
|
64
|
+
const qIndex = url.indexOf('?')
|
|
65
|
+
if (qIndex === -1) { return {} }
|
|
66
|
+
const params: Record<string, string> = {}
|
|
67
|
+
const search = new URLSearchParams(url.slice(qIndex + 1))
|
|
68
|
+
for (const [k, v] of search.entries()) { params[k] = v }
|
|
69
|
+
return params
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function sendJson(res: ServerResponse, body: unknown, status: number): void {
|
|
73
|
+
if (res.headersSent) { return }
|
|
74
|
+
res.statusCode = status
|
|
75
|
+
res.setHeader('Content-Type', 'application/json')
|
|
76
|
+
res.end(JSON.stringify(body))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function handleRestError(err: unknown, res: ServerResponse): void {
|
|
80
|
+
if (err instanceof z.ZodError) {
|
|
81
|
+
sendJson(res, { error: 'validation_error', issues: err.issues }, 400)
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
if (err instanceof SilkweaveError) {
|
|
85
|
+
sendJson(res, { error: err.code, message: err.message }, err.statusCode)
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
if (err instanceof HttpException) {
|
|
89
|
+
const body = err.getResponse()
|
|
90
|
+
const payload = typeof body === 'string' ? { error: err.name, message: body } : body
|
|
91
|
+
sendJson(res, payload, err.getStatus())
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
console.error(err)
|
|
95
|
+
sendJson(res, { error: 'internal', message: 'Internal error' }, 500)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function buildHandler(actions: Action[], context: SilkweaveContext, auth: AuthConfig | undefined): NodeMiddleware {
|
|
99
|
+
const routes = new Map<string, Action>()
|
|
100
|
+
for (const action of actions) {
|
|
101
|
+
const method = action.kind === 'query' ? 'GET' : 'POST'
|
|
102
|
+
routes.set(`${method} ${actionNameToPath(action.name)}`, action)
|
|
103
|
+
}
|
|
104
|
+
const logger = createRestLogger()
|
|
105
|
+
return async (req, res, next) => {
|
|
106
|
+
const pathOnly = (req.url ?? '/').split('?')[0]
|
|
107
|
+
const key = `${req.method ?? ''} ${pathOnly}`
|
|
108
|
+
const action = routes.get(key)
|
|
109
|
+
if (!action) {
|
|
110
|
+
if (next) { return next() }
|
|
111
|
+
sendJson(res, { error: 'not_found', message: `No route for ${req.method} ${pathOnly}` }, 404)
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
const reqLike = req as unknown as RequestLike
|
|
116
|
+
let authInfo: AuthInfo | undefined
|
|
117
|
+
if (auth) {
|
|
118
|
+
const authHeader = typeof reqLike.headers.authorization === 'string' ? reqLike.headers.authorization : undefined
|
|
119
|
+
const result = await validateToken(authHeader, auth, context.fork({ request: req }))
|
|
120
|
+
if (result.error) {
|
|
121
|
+
for (const [k, v] of Object.entries(result.error.headers)) {
|
|
122
|
+
res.setHeader(k, v)
|
|
123
|
+
}
|
|
124
|
+
sendJson(res, result.error.body, result.error.statusCode)
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
authInfo = result.auth
|
|
128
|
+
}
|
|
129
|
+
const raw = action.kind === 'query'
|
|
130
|
+
? (reqLike.query ?? parseQuery(req.url))
|
|
131
|
+
: (reqLike.body ?? await readJsonBody(req))
|
|
132
|
+
const input = action.input.parse(raw) as object
|
|
133
|
+
const result = await action.run(input, context.fork({
|
|
134
|
+
logger,
|
|
135
|
+
request: req,
|
|
136
|
+
response: res,
|
|
137
|
+
...(authInfo ? { auth: authInfo } : {})
|
|
138
|
+
}))
|
|
139
|
+
sendJson(res, result, 200)
|
|
140
|
+
} catch (err) {
|
|
141
|
+
handleRestError(err, res)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* REST adapter for `@silkweave/nestjs`. Mounts each discovered `@Action` as a
|
|
148
|
+
* route on Nest's running HTTP server:
|
|
149
|
+
*
|
|
150
|
+
* - `kind: 'query'` → `GET ${basePath}/${actionName-with-slashes}` (input read from query string)
|
|
151
|
+
* - `kind: 'mutation'` → `POST ${basePath}/${actionName-with-slashes}` (input read from JSON body)
|
|
152
|
+
*
|
|
153
|
+
* Works on `@nestjs/platform-express` out of the box. For
|
|
154
|
+
* `@nestjs/platform-fastify`, register `@fastify/express` before this adapter
|
|
155
|
+
* so Nest can serve Express-style middleware.
|
|
156
|
+
*/
|
|
157
|
+
export function rest(options: RestAdapterOptions = {}): NestSilkweaveAdapter {
|
|
158
|
+
return {
|
|
159
|
+
name: 'rest',
|
|
160
|
+
install: (host: HttpAdapterHost): AdapterGenerator => {
|
|
161
|
+
const httpAdapter = host.httpAdapter
|
|
162
|
+
if (!httpAdapter) {
|
|
163
|
+
throw new Error('@silkweave/nestjs rest(): HttpAdapterHost.httpAdapter is not available.')
|
|
164
|
+
}
|
|
165
|
+
const basePath = (options.basePath ?? '').replace(/\/$/, '') || '/'
|
|
166
|
+
const setHandler = reserveSlot(httpAdapter as unknown as { use: (path: string, h: NodeMiddleware) => unknown }, basePath, 'REST')
|
|
167
|
+
return (_silkweaveOptions, baseContext): Adapter => {
|
|
168
|
+
const context = baseContext.fork({ adapter: 'rest' })
|
|
169
|
+
return {
|
|
170
|
+
context,
|
|
171
|
+
start: async (actions) => {
|
|
172
|
+
setHandler(buildHandler(actions, context, options.auth))
|
|
173
|
+
},
|
|
174
|
+
stop: async () => { /* Nest owns the HTTP server */ }
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { HttpAdapterHost } from '@nestjs/core'
|
|
2
|
+
import { AuthConfig } from '@silkweave/auth'
|
|
3
|
+
import type { Adapter, AdapterGenerator } from '@silkweave/core'
|
|
4
|
+
import { buildRouter, createActionLogger, resolveAuth, type TrpcHandlerContext } from '@silkweave/trpc'
|
|
5
|
+
import { createExpressMiddleware } from '@trpc/server/adapters/express'
|
|
6
|
+
import type { IncomingMessage, ServerResponse } from 'http'
|
|
7
|
+
import { reserveSlot, type NodeMiddleware } from '../lib/slot.js'
|
|
8
|
+
import type { NestSilkweaveAdapter } from '../lib/types.js'
|
|
9
|
+
|
|
10
|
+
export interface TrpcAdapterOptions {
|
|
11
|
+
/** URL prefix at which the tRPC handler is mounted. Default `'/trpc'`. */
|
|
12
|
+
basePath?: string
|
|
13
|
+
/** Optional bearer-token auth applied to every tRPC procedure. */
|
|
14
|
+
auth?: AuthConfig
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* tRPC adapter for `@silkweave/nestjs`. Builds a tRPC router from discovered
|
|
19
|
+
* `@Action` methods via `@silkweave/trpc`'s `buildRouter` and mounts the
|
|
20
|
+
* resulting `createExpressMiddleware()` at the configured base path on Nest's
|
|
21
|
+
* underlying HTTP server.
|
|
22
|
+
*
|
|
23
|
+
* Action names with dots (e.g. `users.list` from `@Actions('users')`) collapse
|
|
24
|
+
* to camelCase procedure keys (`usersList`) for v1 — flat router only.
|
|
25
|
+
*
|
|
26
|
+
* Works on `@nestjs/platform-express`. On `@nestjs/platform-fastify`, register
|
|
27
|
+
* `@fastify/express` first so Nest can mount Express-style middleware.
|
|
28
|
+
*/
|
|
29
|
+
export function trpc(options: TrpcAdapterOptions = {}): NestSilkweaveAdapter {
|
|
30
|
+
return {
|
|
31
|
+
name: 'trpc',
|
|
32
|
+
install: (host: HttpAdapterHost): AdapterGenerator => {
|
|
33
|
+
const httpAdapter = host.httpAdapter
|
|
34
|
+
if (!httpAdapter) {
|
|
35
|
+
throw new Error('@silkweave/nestjs trpc(): HttpAdapterHost.httpAdapter is not available.')
|
|
36
|
+
}
|
|
37
|
+
const basePath = (options.basePath ?? '/trpc').replace(/\/$/, '') || '/'
|
|
38
|
+
const setHandler = reserveSlot(httpAdapter as unknown as { use: (path: string, h: NodeMiddleware) => unknown }, basePath, 'tRPC')
|
|
39
|
+
return (_silkweaveOptions, baseContext): Adapter => {
|
|
40
|
+
const context = baseContext.fork({ adapter: 'trpc' })
|
|
41
|
+
return {
|
|
42
|
+
context,
|
|
43
|
+
start: async (actions) => {
|
|
44
|
+
const router = buildRouter(actions)
|
|
45
|
+
const logger = createActionLogger()
|
|
46
|
+
const createContext = async (
|
|
47
|
+
opts: { req: IncomingMessage; res: ServerResponse }
|
|
48
|
+
): Promise<TrpcHandlerContext> => {
|
|
49
|
+
const resolved = await resolveAuth(
|
|
50
|
+
options.auth,
|
|
51
|
+
opts.req.headers.authorization,
|
|
52
|
+
context.fork({ request: opts.req })
|
|
53
|
+
)
|
|
54
|
+
if (resolved.kind === 'error') {
|
|
55
|
+
for (const [key, value] of Object.entries<string>(resolved.error.headers)) {
|
|
56
|
+
opts.res.setHeader(key, value)
|
|
57
|
+
}
|
|
58
|
+
opts.res.statusCode = resolved.error.statusCode
|
|
59
|
+
opts.res.setHeader('Content-Type', 'application/json')
|
|
60
|
+
opts.res.end(JSON.stringify(resolved.error.body))
|
|
61
|
+
throw new Error('Unauthorized')
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
silkweaveContext: context.fork({
|
|
65
|
+
logger,
|
|
66
|
+
request: opts.req,
|
|
67
|
+
response: opts.res,
|
|
68
|
+
...(resolved.authInfo ? { auth: resolved.authInfo } : {})
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const middleware = createExpressMiddleware({ router, createContext })
|
|
73
|
+
setHandler(middleware as unknown as NodeMiddleware)
|
|
74
|
+
},
|
|
75
|
+
stop: async () => { /* Nest owns the HTTP server */ }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export { type InferTrpcRouter } from '@silkweave/trpc'
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { SetMetadata } from '@nestjs/common'
|
|
2
|
+
import { ACTION_METADATA, type ActionMetadata } from '../lib/metadata.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Method decorator that registers a Silkweave action.
|
|
6
|
+
*
|
|
7
|
+
* The decorated method becomes an Action and is exposed via every adapter
|
|
8
|
+
* configured on `SilkweaveModule` (REST/tRPC/MCP), unless `transports` is
|
|
9
|
+
* provided to restrict it.
|
|
10
|
+
*
|
|
11
|
+
* The method receives `(input, context)` where `input` is the parsed Zod input
|
|
12
|
+
* and `context` is the `SilkweaveContext` (with `logger`, `request`, optional
|
|
13
|
+
* `auth`). The class instance is a normal Nest provider, so other services can
|
|
14
|
+
* be injected via the constructor.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```ts
|
|
18
|
+
* @Injectable()
|
|
19
|
+
* @Actions('users')
|
|
20
|
+
* export class UserActions {
|
|
21
|
+
* constructor(private db: DbService) {}
|
|
22
|
+
*
|
|
23
|
+
* @Action({
|
|
24
|
+
* description: 'List users',
|
|
25
|
+
* input: z.object({ limit: z.number().optional() }),
|
|
26
|
+
* kind: 'query'
|
|
27
|
+
* })
|
|
28
|
+
* list(input: { limit?: number }, ctx: SilkweaveContext) {
|
|
29
|
+
* return this.db.listUsers(input.limit)
|
|
30
|
+
* }
|
|
31
|
+
* }
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export function Action<I extends object = object, O extends object = object>(
|
|
35
|
+
options: ActionMetadata<I, O>
|
|
36
|
+
): MethodDecorator {
|
|
37
|
+
return SetMetadata(ACTION_METADATA, options)
|
|
38
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { SetMetadata } from '@nestjs/common'
|
|
2
|
+
import { ACTIONS_METADATA, type ActionsClassMetadata, type Transport } from '../lib/metadata.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Class decorator that groups a provider's `@Action` methods under a common
|
|
6
|
+
* prefix. The prefix is joined to each method's action name with a dot
|
|
7
|
+
* (e.g. `@Actions('users')` + method `list` → action name `users.list`).
|
|
8
|
+
*
|
|
9
|
+
* The class itself remains a normal Nest provider — add `@Injectable()`
|
|
10
|
+
* separately so it can be resolved by the DI container.
|
|
11
|
+
*
|
|
12
|
+
* Accepts either a prefix string (shorthand) or a full options object:
|
|
13
|
+
* ```ts
|
|
14
|
+
* @Actions('users')
|
|
15
|
+
* @Actions({ prefix: 'users', transports: ['rest', 'trpc'] })
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
export function Actions(prefixOrOptions: string | ActionsClassMetadata = {}): ClassDecorator {
|
|
19
|
+
const options: ActionsClassMetadata = typeof prefixOrOptions === 'string'
|
|
20
|
+
? { prefix: prefixOrOptions }
|
|
21
|
+
: prefixOrOptions
|
|
22
|
+
return SetMetadata(ACTIONS_METADATA, options)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type { ActionsClassMetadata, Transport }
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export * from './adapter/mcp.js'
|
|
2
|
+
export * from './adapter/rest.js'
|
|
3
|
+
export * from './adapter/trpc.js'
|
|
4
|
+
export * from './decorator/action.js'
|
|
5
|
+
export * from './decorator/actions.js'
|
|
6
|
+
export * from './lib/discovery.js'
|
|
7
|
+
export * from './lib/filter.js'
|
|
8
|
+
export * from './lib/guards.js'
|
|
9
|
+
export * from './lib/metadata.js'
|
|
10
|
+
export * from './lib/silkweave.module.js'
|
|
11
|
+
export * from './lib/silkweave.service.js'
|
|
12
|
+
export * from './lib/types.js'
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { Injectable, type Type } from '@nestjs/common'
|
|
2
|
+
import { DiscoveryService, MetadataScanner, ModuleRef, Reflector } from '@nestjs/core'
|
|
3
|
+
import { createAction, type Action, type SilkweaveContext } from '@silkweave/core'
|
|
4
|
+
import { kebabCase } from 'change-case'
|
|
5
|
+
import { buildIsEnabled } from './filter.js'
|
|
6
|
+
import { collectGuards, runGuards } from './guards.js'
|
|
7
|
+
import { ACTION_METADATA, ACTIONS_METADATA, type ActionMetadata, type ActionsClassMetadata } from './metadata.js'
|
|
8
|
+
|
|
9
|
+
interface DiscoveredAction {
|
|
10
|
+
instance: object
|
|
11
|
+
classRef: Type<unknown>
|
|
12
|
+
method: (...args: unknown[]) => unknown
|
|
13
|
+
methodName: string
|
|
14
|
+
meta: ActionMetadata
|
|
15
|
+
classMeta: ActionsClassMetadata
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
@Injectable()
|
|
19
|
+
export class ActionDiscovery {
|
|
20
|
+
constructor(
|
|
21
|
+
private readonly discovery: DiscoveryService,
|
|
22
|
+
private readonly scanner: MetadataScanner,
|
|
23
|
+
private readonly reflector: Reflector,
|
|
24
|
+
private readonly moduleRef: ModuleRef
|
|
25
|
+
) {}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Walk every Nest provider, find methods annotated with `@Action`, and build
|
|
29
|
+
* a list of core `Action` objects ready to feed into `silkweave().actions()`.
|
|
30
|
+
*
|
|
31
|
+
* Action invocation is wrapped to (a) run `@UseGuards` guards declared on the
|
|
32
|
+
* method or its class against the incoming HTTP request (read from
|
|
33
|
+
* `ctx.get('request')`) and (b) bind `this` to the resolved Nest provider so
|
|
34
|
+
* DI-injected dependencies remain available.
|
|
35
|
+
*/
|
|
36
|
+
discover(): Action[] {
|
|
37
|
+
const discovered: DiscoveredAction[] = []
|
|
38
|
+
const wrappers = this.discovery.getProviders()
|
|
39
|
+
for (const wrapper of wrappers) {
|
|
40
|
+
const { instance } = wrapper
|
|
41
|
+
if (!instance || typeof instance !== 'object') { continue }
|
|
42
|
+
const proto = Object.getPrototypeOf(instance) as object | null
|
|
43
|
+
if (!proto) { continue }
|
|
44
|
+
const classRef = instance.constructor as Type<unknown>
|
|
45
|
+
const classMeta = (this.reflector.get<ActionsClassMetadata>(ACTIONS_METADATA, classRef) ?? {})
|
|
46
|
+
for (const methodName of this.scanner.getAllMethodNames(proto)) {
|
|
47
|
+
const method = (proto as Record<string, unknown>)[methodName] as ((...args: unknown[]) => unknown) | undefined
|
|
48
|
+
if (typeof method !== 'function') { continue }
|
|
49
|
+
const meta = this.reflector.get<ActionMetadata>(ACTION_METADATA, method)
|
|
50
|
+
if (!meta) { continue }
|
|
51
|
+
discovered.push({ instance, classRef, method, methodName, meta, classMeta })
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return discovered.map((d) => this.toAction(d))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private toAction(d: DiscoveredAction): Action {
|
|
58
|
+
const baseName = d.meta.name ?? kebabCase(d.methodName)
|
|
59
|
+
const name = d.classMeta.prefix ? `${d.classMeta.prefix}.${baseName}` : baseName
|
|
60
|
+
const transports = d.meta.transports ?? d.classMeta.transports
|
|
61
|
+
const isEnabled = buildIsEnabled(transports, d.meta.isEnabled)
|
|
62
|
+
const guards = collectGuards(this.reflector, d.classRef, d.method)
|
|
63
|
+
const moduleRef = this.moduleRef
|
|
64
|
+
const reflector = this.reflector
|
|
65
|
+
|
|
66
|
+
// Cast at the createAction boundary to bridge dual-zod-version installs
|
|
67
|
+
// (zod@3.25 + zod@4.x can both be present transitively). Runtime is fine —
|
|
68
|
+
// they share the same /v4 surface — but the structural types are distinct.
|
|
69
|
+
type CreateActionArg = Parameters<typeof createAction>[0]
|
|
70
|
+
return createAction({
|
|
71
|
+
name,
|
|
72
|
+
description: d.meta.description,
|
|
73
|
+
input: d.meta.input as unknown as CreateActionArg['input'],
|
|
74
|
+
output: d.meta.output as unknown as CreateActionArg['output'],
|
|
75
|
+
kind: d.meta.kind ?? 'mutation',
|
|
76
|
+
args: d.meta.args as CreateActionArg['args'],
|
|
77
|
+
isEnabled,
|
|
78
|
+
toolResult: d.meta.toolResult as CreateActionArg['toolResult'],
|
|
79
|
+
run: async (input: object, context: SilkweaveContext): Promise<object> => {
|
|
80
|
+
if (guards.length > 0) {
|
|
81
|
+
const request = context.getOptional<unknown>('request')
|
|
82
|
+
const response = context.getOptional<unknown>('response')
|
|
83
|
+
await runGuards(guards, moduleRef, reflector, d.classRef, d.method, request, response)
|
|
84
|
+
}
|
|
85
|
+
const result = await (d.method.call(d.instance, input, context) as Promise<object>)
|
|
86
|
+
return result
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
}
|