@silkweave/nestjs 1.9.0 → 1.10.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@silkweave/nestjs",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
4
  "description": "Silkweave NestJS Adapter",
5
5
  "repository": {
6
6
  "type": "git",
@@ -16,19 +16,25 @@
16
16
  "dependencies": {
17
17
  "@trpc/server": "^11.7.1",
18
18
  "change-case": "^5.4.4",
19
+ "cors": "^2.8.6",
20
+ "express": "^5.2.1",
19
21
  "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"
22
+ "@silkweave/auth": "1.10.0",
23
+ "@silkweave/core": "1.10.0",
24
+ "@silkweave/trpc": "1.10.0",
25
+ "@silkweave/mcp": "1.10.0",
26
+ "@silkweave/logger": "1.10.0",
27
+ "@silkweave/typegen": "1.10.0"
25
28
  },
26
29
  "devDependencies": {
27
30
  "@eslint/js": "^10.0.1",
28
31
  "@nestjs/common": "^11.0.0",
29
32
  "@nestjs/core": "^11.0.0",
30
33
  "@stylistic/eslint-plugin": "^5.10.0",
34
+ "@types/cors": "^2.8.19",
35
+ "@types/express": "^5.0.6",
31
36
  "@types/node": "^22.0.0",
37
+ "eslint": "^10.4.0",
32
38
  "reflect-metadata": "^0.2.2",
33
39
  "rimraf": "^6.1.3",
34
40
  "rxjs": "^7.8.1",
@@ -1,55 +1,102 @@
1
- import type { HttpAdapterHost } from '@nestjs/core'
2
1
  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'
2
+ import {
3
+ authMiddleware,
4
+ mcpCors,
5
+ mcpTransport,
6
+ oauthRoutes,
7
+ protectedResourceMetadata,
8
+ sideloadResource
9
+ } from '@silkweave/mcp'
10
+ import { type CorsOptions } from 'cors'
11
+ import express, { type RequestHandler } from 'express'
12
+ import type { NestAdapterRegisterContext, NestSilkweaveAdapter } from '../lib/types.js'
7
13
 
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. */
14
+ export interface McpAdapterOptions {
15
+ /** URL prefix the MCP namespace lives under - the transport itself is at this exact path. Default `'/mcp'`. */
10
16
  basePath?: string
11
- /** Optional bearer-token / OAuth auth applied to MCP requests. Same shape as `@silkweave/mcp`'s `http()` auth. */
17
+ /** Optional bearer-token / OAuth 2.1 config. */
12
18
  auth?: AuthConfig
19
+ /** CORS configuration. `false` to disable, `true`/omitted for permissive defaults, or a `CorsOptions` object. */
20
+ cors?: CorsOptions | boolean
21
+ /** Mount the sideload resource route at `${basePath}/resource/:id`. Default `true`. */
22
+ sideloadResources?: boolean
23
+ /** Directory the sideload route reads from. Default `'resources'`. */
24
+ resourceDir?: string
25
+ }
26
+
27
+ function compose(...handlers: RequestHandler[]): RequestHandler {
28
+ return (req, res, next) => {
29
+ let i = 0
30
+ const dispatch: () => void = () => {
31
+ const h = handlers[i++]
32
+ if (!h) { return next() }
33
+ h(req, res, (err) => err ? next(err) : dispatch())
34
+ }
35
+ dispatch()
36
+ }
13
37
  }
14
38
 
15
39
  /**
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.
40
+ * MCP adapter for `@silkweave/nestjs`. Registers the MCP Streamable HTTP
41
+ * transport, sideload, well-known and OAuth routes individually on Nest's
42
+ * HTTP adapter at the configured `basePath` (default `/mcp`):
20
43
  *
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)
44
+ * - `POST/GET/DELETE ${basePath}` - Streamable HTTP transport
45
+ * - `GET ${basePath}/resource/:id` - sideload (`sideloadResources` opt-out)
46
+ * - `GET ${basePath}/.well-known/oauth-protected-resource` - RFC 9728 metadata (when `auth.resourceUrl`/`auth.authorizationServers` set)
47
+ * - `GET ${basePath}/authorize`, `POST ${basePath}/token`, `POST ${basePath}/register`, `GET ${basePath}${callbackPath}` (when `auth.provider` set)
26
48
  *
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.
49
+ * Each route is a real Nest-level route - they show up in
50
+ * `RoutesResolver`'s log and there is no sub-app or middleware-slot
51
+ * indirection.
30
52
  */
31
53
  export function mcp(options: McpAdapterOptions = {}): NestSilkweaveAdapter {
32
54
  return {
33
55
  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.')
56
+ register({ httpAdapter, silkweaveOptions, baseContext, actions }: NestAdapterRegisterContext): void {
57
+ const basePath = (options.basePath ?? '/mcp').replace(/\/$/, '')
58
+ if (!basePath) { throw new Error('@silkweave/nestjs mcp(): basePath cannot be empty or "/" - pick a path like "/mcp".') }
59
+
60
+ const adapter = httpAdapter as unknown as {
61
+ get: (path: string, ...h: RequestHandler[]) => unknown
62
+ post: (path: string, ...h: RequestHandler[]) => unknown
63
+ delete: (path: string, ...h: RequestHandler[]) => unknown
64
+ }
65
+
66
+ const corsHandler = mcpCors(options.cors ?? true)
67
+ const auth = options.auth
68
+ const guard = auth ? authMiddleware(auth, baseContext) : null
69
+ const prefix = (...handlers: (RequestHandler | null)[]): RequestHandler[] =>
70
+ handlers.filter((h): h is RequestHandler => Boolean(h))
71
+
72
+ // Public auth-discovery / OAuth routes - registered first so they're
73
+ // never inadvertently guarded by the auth middleware.
74
+ if (auth?.authorizationServers?.length && auth.resourceUrl) {
75
+ adapter.get(
76
+ `${basePath}/.well-known/oauth-protected-resource`,
77
+ ...prefix(corsHandler, protectedResourceMetadata(auth))
78
+ )
79
+ }
80
+ if (auth?.provider) {
81
+ const oauth = oauthRoutes(auth)
82
+ adapter.get(`${basePath}/.well-known/oauth-authorization-server`, ...prefix(corsHandler, oauth.wellKnownAuthServer))
83
+ adapter.get(`${basePath}/authorize`, ...prefix(corsHandler, oauth.authorize))
84
+ adapter.get(`${basePath}${oauth.callbackPath}`, ...prefix(corsHandler, oauth.callback))
85
+ adapter.post(`${basePath}/token`, ...prefix(corsHandler, ...oauth.token))
86
+ adapter.post(`${basePath}/register`, ...prefix(corsHandler, ...oauth.register))
38
87
  }
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
- }
88
+
89
+ // Protected routes - wrapped with auth middleware (when configured).
90
+ const protect = guard ? (h: RequestHandler) => compose(guard, h) : (h: RequestHandler) => h
91
+
92
+ if (options.sideloadResources !== false) {
93
+ adapter.get(`${basePath}/resource/:id`, ...prefix(corsHandler, protect(sideloadResource({ resourceDir: options.resourceDir }))))
52
94
  }
95
+
96
+ const transport = mcpTransport(silkweaveOptions, baseContext, actions)
97
+ adapter.post(basePath, ...prefix(corsHandler, express.json(), protect(transport.post)))
98
+ adapter.get(basePath, ...prefix(corsHandler, protect(transport.stream)))
99
+ adapter.delete(basePath, ...prefix(corsHandler, protect(transport.stream)))
53
100
  }
54
101
  }
55
102
  }
@@ -1,12 +1,11 @@
1
+ /* eslint-disable @typescript-eslint/no-unsafe-assignment */
1
2
  import { HttpException } from '@nestjs/common'
2
- import type { HttpAdapterHost } from '@nestjs/core'
3
3
  import { AuthConfig, AuthInfo, validateToken } from '@silkweave/auth'
4
- import { type Action, type Adapter, type AdapterGenerator, type SilkweaveContext, SilkweaveError } from '@silkweave/core'
4
+ import { type Action, type SilkweaveContext, SilkweaveError } from '@silkweave/core'
5
5
  import { buildLogLevels, type Logger, type LogLevel } from '@silkweave/logger'
6
6
  import type { IncomingMessage, ServerResponse } from 'http'
7
7
  import { z } from 'zod/v4'
8
- import { reserveSlot, type NodeMiddleware } from '../lib/slot.js'
9
- import type { NestSilkweaveAdapter } from '../lib/types.js'
8
+ import type { NestAdapterRegisterContext, NestSilkweaveAdapter } from '../lib/types.js'
10
9
 
11
10
  const CONSOLE_LEVEL_MAP: Record<LogLevel, 'log' | 'info' | 'warn' | 'error'> = {
12
11
  emergency: 'error',
@@ -20,21 +19,12 @@ const CONSOLE_LEVEL_MAP: Record<LogLevel, 'log' | 'info' | 'warn' | 'error'> = {
20
19
  }
21
20
 
22
21
  export interface RestAdapterOptions {
23
- /** URL prefix at which the REST routes are mounted. e.g. `'/api'` → `POST /api/users/list`. Default: `'/'`. */
22
+ /** URL prefix joined to each action's path. e.g. `'/api'` → `POST /api/users/list`. Default: `'/api'`. */
24
23
  basePath?: string
25
24
  /** Optional bearer-token auth applied to every REST action. */
26
25
  auth?: AuthConfig
27
26
  }
28
27
 
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
28
  interface RequestLike {
39
29
  headers: Record<string, string | string[] | undefined>
40
30
  body?: unknown
@@ -43,6 +33,13 @@ interface RequestLike {
43
33
  method?: string
44
34
  }
45
35
 
36
+ function createRestLogger(): Logger {
37
+ return {
38
+ ...buildLogLevels((level, data) => { console[CONSOLE_LEVEL_MAP[level]](data) }),
39
+ progress: () => { /* progress notifications not supported on REST */ }
40
+ }
41
+ }
42
+
46
43
  function actionNameToPath(name: string): string {
47
44
  return `/${name.replace(/\./g, '/')}`
48
45
  }
@@ -53,7 +50,11 @@ async function readJsonBody(req: IncomingMessage): Promise<unknown> {
53
50
  req.on('data', (c: Buffer) => chunks.push(c))
54
51
  req.on('end', () => {
55
52
  if (chunks.length === 0) { return resolve({}) }
56
- try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf-8'))) } catch (err) { reject(err) }
53
+ try {
54
+ resolve(JSON.parse(Buffer.concat(chunks).toString('utf-8')))
55
+ } catch (err) {
56
+ reject(err instanceof Error ? err : new Error('Unable to parse JSON body'))
57
+ }
57
58
  })
58
59
  req.on('error', reject)
59
60
  })
@@ -95,22 +96,13 @@ function handleRestError(err: unknown, res: ServerResponse): void {
95
96
  sendJson(res, { error: 'internal', message: 'Internal error' }, 500)
96
97
  }
97
98
 
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
- }
99
+ function createActionHandler(
100
+ action: Action,
101
+ context: SilkweaveContext,
102
+ auth: AuthConfig | undefined,
103
+ logger: Logger
104
+ ): (req: IncomingMessage, res: ServerResponse) => Promise<void> {
105
+ return async (req, res) => {
114
106
  try {
115
107
  const reqLike = req as unknown as RequestLike
116
108
  let authInfo: AuthInfo | undefined
@@ -118,9 +110,7 @@ function buildHandler(actions: Action[], context: SilkweaveContext, auth: AuthCo
118
110
  const authHeader = typeof reqLike.headers.authorization === 'string' ? reqLike.headers.authorization : undefined
119
111
  const result = await validateToken(authHeader, auth, context.fork({ request: req }))
120
112
  if (result.error) {
121
- for (const [k, v] of Object.entries(result.error.headers)) {
122
- res.setHeader(k, v)
123
- }
113
+ for (const [k, v] of Object.entries(result.error.headers)) { res.setHeader(k, v) }
124
114
  sendJson(res, result.error.body, result.error.statusCode)
125
115
  return
126
116
  }
@@ -144,35 +134,27 @@ function buildHandler(actions: Action[], context: SilkweaveContext, auth: AuthCo
144
134
  }
145
135
 
146
136
  /**
147
- * REST adapter for `@silkweave/nestjs`. Mounts each discovered `@Action` as a
148
- * route on Nest's running HTTP server:
137
+ * REST adapter for `@silkweave/nestjs`. Registers each discovered `@Action`
138
+ * as an individual route on Nest's HTTP adapter:
149
139
  *
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)
140
+ * - `kind: 'query'` → `GET ${basePath}/${actionName-with-slashes}` (input from query string)
141
+ * - `kind: 'mutation'` → `POST ${basePath}/${actionName-with-slashes}` (input from JSON body)
152
142
  *
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.
143
+ * Routes show up in Nest's `RoutesResolver` logger and are eligible for
144
+ * `@nestjs/swagger` scanning. Works on `@nestjs/platform-express` out of the
145
+ * box. For `@nestjs/platform-fastify`, register `@fastify/express` first.
156
146
  */
157
147
  export function rest(options: RestAdapterOptions = {}): NestSilkweaveAdapter {
158
148
  return {
159
149
  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
- }
150
+ register({ httpAdapter, baseContext, actions }: NestAdapterRegisterContext): void {
151
+ const basePath = (options.basePath ?? '/api').replace(/\/$/, '')
152
+ const logger = createRestLogger()
153
+ for (const action of actions) {
154
+ const path = `${basePath}${actionNameToPath(action.name)}`
155
+ const method = action.kind === 'query' ? 'get' : 'post'
156
+ const handler = createActionHandler(action, baseContext, options.auth, logger)
157
+ ; (httpAdapter as unknown as Record<string, (path: string, h: typeof handler) => unknown>)[method](path, handler)
176
158
  }
177
159
  }
178
160
  }
@@ -1,11 +1,8 @@
1
- import type { HttpAdapterHost } from '@nestjs/core'
2
1
  import { AuthConfig } from '@silkweave/auth'
3
- import type { Adapter, AdapterGenerator } from '@silkweave/core'
4
2
  import { buildRouter, createActionLogger, resolveAuth, type TrpcHandlerContext } from '@silkweave/trpc'
5
3
  import { createExpressMiddleware } from '@trpc/server/adapters/express'
6
4
  import type { IncomingMessage, ServerResponse } from 'http'
7
- import { reserveSlot, type NodeMiddleware } from '../lib/slot.js'
8
- import type { NestSilkweaveAdapter } from '../lib/types.js'
5
+ import type { NestAdapterRegisterContext, NestSilkweaveAdapter } from '../lib/types.js'
9
6
 
10
7
  export interface TrpcAdapterOptions {
11
8
  /** URL prefix at which the tRPC handler is mounted. Default `'/trpc'`. */
@@ -16,65 +13,44 @@ export interface TrpcAdapterOptions {
16
13
 
17
14
  /**
18
15
  * 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.
16
+ * `@Action` methods and mounts the resulting express middleware at
17
+ * `basePath` on Nest's HTTP adapter.
22
18
  *
23
19
  * Action names with dots (e.g. `users.list` from `@Actions('users')`) collapse
24
- * to camelCase procedure keys (`usersList`) for v1 — flat router only.
20
+ * to camelCase procedure keys (`usersList`).
25
21
  *
26
22
  * Works on `@nestjs/platform-express`. On `@nestjs/platform-fastify`, register
27
- * `@fastify/express` first so Nest can mount Express-style middleware.
23
+ * `@fastify/express` first so Nest can serve Express-style middleware.
28
24
  */
29
25
  export function trpc(options: TrpcAdapterOptions = {}): NestSilkweaveAdapter {
30
26
  return {
31
27
  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
- }
28
+ register({ httpAdapter, baseContext, actions }: NestAdapterRegisterContext): void {
37
29
  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' })
30
+ const router = buildRouter(actions)
31
+ const logger = createActionLogger()
32
+ const createContext = async (
33
+ opts: { req: IncomingMessage; res: ServerResponse }
34
+ ): Promise<TrpcHandlerContext> => {
35
+ const resolved = await resolveAuth(options.auth, opts.req.headers.authorization, baseContext.fork({ request: opts.req }))
36
+ if (resolved.kind === 'error') {
37
+ for (const [key, value] of Object.entries<string>(resolved.error.headers)) { opts.res.setHeader(key, value) }
38
+ opts.res.statusCode = resolved.error.statusCode
39
+ opts.res.setHeader('Content-Type', 'application/json')
40
+ opts.res.end(JSON.stringify(resolved.error.body))
41
+ throw new Error('Unauthorized')
42
+ }
41
43
  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 */ }
44
+ silkweaveContext: baseContext.fork({
45
+ logger,
46
+ request: opts.req,
47
+ response: opts.res,
48
+ ...(resolved.authInfo ? { auth: resolved.authInfo } : {})
49
+ })
76
50
  }
77
51
  }
52
+ const middleware = createExpressMiddleware({ router, createContext })
53
+ ;(httpAdapter as unknown as { use: (path: string, h: unknown) => unknown }).use(basePath, middleware)
78
54
  }
79
55
  }
80
56
  }
@@ -0,0 +1,55 @@
1
+ import { renderTypegen, type TypegenFormat } from '@silkweave/typegen'
2
+ import { mkdir, writeFile } from 'node:fs/promises'
3
+ import { dirname, resolve } from 'node:path'
4
+ import type { NestAdapterRegisterContext, NestSilkweaveAdapter } from '../lib/types.js'
5
+
6
+ export interface TypegenAdapterOptions {
7
+ /** Output file path for the generated `.d.ts` file. Resolved against `process.cwd()`. Parent directories are created automatically. */
8
+ path: string
9
+ /**
10
+ * What to emit (default `'all'`):
11
+ * - `'interfaces'` - `{Name}Input` / `{Name}Output` interfaces per action
12
+ * - `'trpc-router'` - `AppRouter` type alias for `createTRPCClient<AppRouter>()`
13
+ * - `'all'` - both
14
+ */
15
+ format?: TypegenFormat
16
+ /**
17
+ * Whether to write the file at all. Default: `process.env.NODE_ENV !== 'production'`.
18
+ * Server processes generally shouldn't write to disk in production deploys -
19
+ * leave it on the default unless you know you want otherwise.
20
+ */
21
+ enabled?: boolean
22
+ }
23
+
24
+ /**
25
+ * Typegen adapter for `@silkweave/nestjs`. Discovers every `@Action`-decorated
26
+ * method (regardless of `transports` filtering) and writes a single `.d.ts`
27
+ * file with REST input/output interfaces and/or a tRPC `AppRouter` type alias.
28
+ *
29
+ * Designed for the monorepo pattern where the server (this app) is the source
30
+ * of truth for types and consumer apps import from `path` - e.g.
31
+ * `path: '../app/src/types/silkweave.ts'`.
32
+ */
33
+ export function typegen(options: TypegenAdapterOptions): NestSilkweaveAdapter {
34
+ const enabled = options.enabled ?? (process.env['NODE_ENV'] !== 'production')
35
+ return {
36
+ name: 'typegen',
37
+ allActions: true,
38
+ register({ actions }: NestAdapterRegisterContext): void {
39
+ if (!enabled) { return }
40
+ const output = renderTypegen(actions, options.format ?? 'all')
41
+ const target = resolve(options.path)
42
+ // Fire-and-forget: we don't want to make `configure()` await disk IO and
43
+ // delay app startup. Errors are surfaced via console.
44
+ void (async () => {
45
+ try {
46
+ await mkdir(dirname(target), { recursive: true })
47
+ await writeFile(target, output, 'utf-8')
48
+ console.info(`@silkweave/nestjs typegen: wrote ${actions.length} action types to ${target}`)
49
+ } catch (err) {
50
+ console.error('@silkweave/nestjs typegen:', err)
51
+ }
52
+ })()
53
+ }
54
+ }
55
+ }
@@ -31,8 +31,8 @@ import { ACTION_METADATA, type ActionMetadata } from '../lib/metadata.js'
31
31
  * }
32
32
  * ```
33
33
  */
34
- export function Action<I extends object = object, O extends object = object>(
35
- options: ActionMetadata<I, O>
34
+ export function Action<I extends object = object, O extends object = object, C = unknown>(
35
+ options: ActionMetadata<I, O, C>
36
36
  ): MethodDecorator {
37
37
  return SetMetadata(ACTION_METADATA, options)
38
38
  }
@@ -6,7 +6,7 @@ import { ACTIONS_METADATA, type ActionsClassMetadata, type Transport } from '../
6
6
  * prefix. The prefix is joined to each method's action name with a dot
7
7
  * (e.g. `@Actions('users')` + method `list` → action name `users.list`).
8
8
  *
9
- * The class itself remains a normal Nest provider add `@Injectable()`
9
+ * The class itself remains a normal Nest provider - add `@Injectable()`
10
10
  * separately so it can be resolved by the DI container.
11
11
  *
12
12
  * Accepts either a prefix string (shorthand) or a full options object:
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export * from './adapter/mcp.js'
2
2
  export * from './adapter/rest.js'
3
3
  export * from './adapter/trpc.js'
4
+ export * from './adapter/typegen.js'
4
5
  export * from './decorator/action.js'
5
6
  export * from './decorator/actions.js'
6
7
  export * from './lib/discovery.js'
@@ -8,5 +9,4 @@ export * from './lib/filter.js'
8
9
  export * from './lib/guards.js'
9
10
  export * from './lib/metadata.js'
10
11
  export * from './lib/silkweave.module.js'
11
- export * from './lib/silkweave.service.js'
12
12
  export * from './lib/types.js'
@@ -1,6 +1,7 @@
1
+ /* eslint-disable @typescript-eslint/no-unsafe-assignment */
1
2
  import { Injectable, type Type } from '@nestjs/common'
2
3
  import { DiscoveryService, MetadataScanner, ModuleRef, Reflector } from '@nestjs/core'
3
- import { createAction, type Action, type SilkweaveContext } from '@silkweave/core'
4
+ import { createAction, type Action, type ActionKind, type SilkweaveContext, type StreamingActionInput } from '@silkweave/core'
4
5
  import { kebabCase } from 'change-case'
5
6
  import { buildIsEnabled } from './filter.js'
6
7
  import { collectGuards, runGuards } from './guards.js'
@@ -22,7 +23,7 @@ export class ActionDiscovery {
22
23
  private readonly scanner: MetadataScanner,
23
24
  private readonly reflector: Reflector,
24
25
  private readonly moduleRef: ModuleRef
25
- ) {}
26
+ ) { }
26
27
 
27
28
  /**
28
29
  * Walk every Nest provider, find methods annotated with `@Action`, and build
@@ -63,25 +64,51 @@ export class ActionDiscovery {
63
64
  const moduleRef = this.moduleRef
64
65
  const reflector = this.reflector
65
66
 
67
+ const applyGuards = async (context: SilkweaveContext): Promise<void> => {
68
+ if (guards.length === 0) { return }
69
+ const request = context.getOptional<unknown>('request')
70
+ const response = context.getOptional<unknown>('response')
71
+ await runGuards(guards, moduleRef, reflector, d.classRef, d.method, request, response)
72
+ }
73
+
66
74
  // 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]
75
+ // (zod@3.25 + zod@4.x can both be present transitively). Runtime is fine -
76
+ // they share the same /v4 surface - but the structural types are distinct.
77
+ const streaming = d.method.constructor?.name === 'AsyncGeneratorFunction'
78
+
79
+ if (streaming) {
80
+ if (!d.meta.chunk) {
81
+ throw new Error(`@Action "${name}" is an async generator but has no \`chunk\` schema`)
82
+ }
83
+ const method = d.method
84
+ const instance = d.instance
85
+ return createAction({
86
+ name,
87
+ description: d.meta.description,
88
+ input: d.meta.input,
89
+ chunk: d.meta.chunk,
90
+ kind: d.meta.kind ?? 'mutation',
91
+ args: d.meta.args,
92
+ isEnabled,
93
+ toolResult: d.meta.toolResult,
94
+ run: async function* (input: object, context: SilkweaveContext) {
95
+ await applyGuards(context)
96
+ yield* (method.call(instance, input, context) as AsyncGenerator<object, void, void>)
97
+ }
98
+ } as StreamingActionInput<object, unknown, string, ActionKind>) as Action
99
+ }
100
+
70
101
  return createAction({
71
102
  name,
72
103
  description: d.meta.description,
73
- input: d.meta.input as unknown as CreateActionArg['input'],
74
- output: d.meta.output as unknown as CreateActionArg['output'],
104
+ input: d.meta.input,
105
+ output: d.meta.output,
75
106
  kind: d.meta.kind ?? 'mutation',
76
- args: d.meta.args as CreateActionArg['args'],
107
+ args: d.meta.args,
77
108
  isEnabled,
78
- toolResult: d.meta.toolResult as CreateActionArg['toolResult'],
109
+ toolResult: d.meta.toolResult,
79
110
  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
- }
111
+ await applyGuards(context)
85
112
  const result = await (d.method.call(d.instance, input, context) as Promise<object>)
86
113
  return result
87
114
  }
@@ -1,6 +1,6 @@
1
1
  import type { ArgumentsHost, ContextType, ExecutionContext, Type } from '@nestjs/common'
2
2
 
3
- /** Subset of `HttpArgumentsHost` we need re-declared inline to avoid deep `@nestjs/common/interfaces` imports. */
3
+ /** Subset of `HttpArgumentsHost` we need - re-declared inline to avoid deep `@nestjs/common/interfaces` imports. */
4
4
  interface HttpHost {
5
5
  getRequest<T = unknown>(): T
6
6
  getResponse<T = unknown>(): T
@@ -28,8 +28,8 @@ export class SilkweaveExecutionContext implements ExecutionContext, ArgumentsHos
28
28
  private readonly args: unknown[],
29
29
  private readonly classRef: Type<unknown>,
30
30
  private readonly handler: (...handlerArgs: unknown[]) => unknown,
31
- private readonly contextType: string = 'http'
32
- ) {}
31
+ private readonly contextType = 'http'
32
+ ) { }
33
33
 
34
34
  getType<T extends string = ContextType>(): T {
35
35
  return this.contextType as T