@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.
@@ -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
+ }