@silkweave/nestjs 1.12.0 → 2.2.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/README.md +108 -163
- package/build/index.d.mts +250 -267
- package/build/index.d.mts.map +1 -1
- package/build/index.mjs +736 -511
- package/build/index.mjs.map +1 -1
- package/package.json +22 -16
- package/.turbo/turbo-build.log +0 -16
- package/.turbo/turbo-check.log +0 -3
- package/.turbo/turbo-lint.log +0 -2
- package/.turbo/turbo-prepack.log +0 -18
- package/eslint.config.mjs +0 -93
- package/src/adapter/mcp.ts +0 -102
- package/src/adapter/rest.ts +0 -173
- package/src/adapter/trpc.ts +0 -58
- package/src/adapter/typegen.ts +0 -55
- package/src/decorator/action.ts +0 -38
- package/src/decorator/actions.ts +0 -25
- package/src/index.ts +0 -14
- package/src/lib/discovery.ts +0 -131
- package/src/lib/executionContext.ts +0 -76
- package/src/lib/filter.ts +0 -26
- package/src/lib/guards.ts +0 -70
- package/src/lib/metadata.ts +0 -58
- package/src/lib/openapi.ts +0 -116
- package/src/lib/silkweave.module.ts +0 -75
- package/src/lib/swagger.ts +0 -74
- package/src/lib/types.ts +0 -58
- package/tsconfig.json +0 -25
- package/tsconfig.tsbuildinfo +0 -1
- package/tsdown.config.ts +0 -7
package/src/index.ts
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
export * from './adapter/mcp.js'
|
|
2
|
-
export * from './adapter/rest.js'
|
|
3
|
-
export * from './adapter/trpc.js'
|
|
4
|
-
export * from './adapter/typegen.js'
|
|
5
|
-
export * from './decorator/action.js'
|
|
6
|
-
export * from './decorator/actions.js'
|
|
7
|
-
export * from './lib/discovery.js'
|
|
8
|
-
export * from './lib/filter.js'
|
|
9
|
-
export * from './lib/guards.js'
|
|
10
|
-
export * from './lib/metadata.js'
|
|
11
|
-
export * from './lib/openapi.js'
|
|
12
|
-
export * from './lib/silkweave.module.js'
|
|
13
|
-
export * from './lib/swagger.js'
|
|
14
|
-
export * from './lib/types.js'
|
package/src/lib/discovery.ts
DELETED
|
@@ -1,131 +0,0 @@
|
|
|
1
|
-
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
|
2
|
-
import { Injectable, type Type } from '@nestjs/common'
|
|
3
|
-
import { DiscoveryService, MetadataScanner, ModuleRef, Reflector } from '@nestjs/core'
|
|
4
|
-
import { createAction, type Action, type ActionKind, type SilkweaveContext, type StreamingActionInput } from '@silkweave/core'
|
|
5
|
-
import { kebabCase } from 'change-case'
|
|
6
|
-
import { buildIsEnabled } from './filter.js'
|
|
7
|
-
import { collectGuards, runGuards } from './guards.js'
|
|
8
|
-
import { ACTION_METADATA, ACTIONS_METADATA, type ActionMetadata, type ActionsClassMetadata } from './metadata.js'
|
|
9
|
-
|
|
10
|
-
interface DiscoveredAction {
|
|
11
|
-
instance: object
|
|
12
|
-
classRef: Type<unknown>
|
|
13
|
-
method: (...args: unknown[]) => unknown
|
|
14
|
-
methodName: string
|
|
15
|
-
meta: ActionMetadata
|
|
16
|
-
classMeta: ActionsClassMetadata
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
@Injectable()
|
|
20
|
-
export class ActionDiscovery {
|
|
21
|
-
constructor(
|
|
22
|
-
private readonly discovery: DiscoveryService,
|
|
23
|
-
private readonly scanner: MetadataScanner,
|
|
24
|
-
private readonly reflector: Reflector,
|
|
25
|
-
private readonly moduleRef: ModuleRef
|
|
26
|
-
) { }
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Walk every Nest provider, find methods annotated with `@Action`, and build
|
|
30
|
-
* a list of core `Action` objects ready to feed into `silkweave().actions()`.
|
|
31
|
-
*
|
|
32
|
-
* Action invocation is wrapped to (a) run `@UseGuards` guards declared on the
|
|
33
|
-
* method or its class against the incoming request (read from
|
|
34
|
-
* `ctx.get('request')`, populated by REST/tRPC and by MCP-over-HTTP from the
|
|
35
|
-
* SDK's `extra.requestInfo`) and (b) bind `this` to the resolved Nest provider
|
|
36
|
-
* so DI-injected dependencies remain available.
|
|
37
|
-
*/
|
|
38
|
-
discover(): Action[] {
|
|
39
|
-
const discovered: DiscoveredAction[] = []
|
|
40
|
-
const wrappers = this.discovery.getProviders()
|
|
41
|
-
for (const wrapper of wrappers) {
|
|
42
|
-
const { instance } = wrapper
|
|
43
|
-
if (!instance || typeof instance !== 'object') { continue }
|
|
44
|
-
const proto = Object.getPrototypeOf(instance) as object | null
|
|
45
|
-
if (!proto) { continue }
|
|
46
|
-
const classRef = instance.constructor as Type<unknown>
|
|
47
|
-
const classMeta = (this.reflector.get<ActionsClassMetadata>(ACTIONS_METADATA, classRef) ?? {})
|
|
48
|
-
for (const methodName of this.scanner.getAllMethodNames(proto)) {
|
|
49
|
-
const method = (proto as Record<string, unknown>)[methodName] as ((...args: unknown[]) => unknown) | undefined
|
|
50
|
-
if (typeof method !== 'function') { continue }
|
|
51
|
-
const meta = this.reflector.get<ActionMetadata>(ACTION_METADATA, method)
|
|
52
|
-
if (!meta) { continue }
|
|
53
|
-
discovered.push({ instance, classRef, method, methodName, meta, classMeta })
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
return discovered.map((d) => this.toAction(d))
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
private toAction(d: DiscoveredAction): Action {
|
|
60
|
-
const baseName = d.meta.name ?? kebabCase(d.methodName)
|
|
61
|
-
const name = d.classMeta.prefix ? `${d.classMeta.prefix}.${baseName}` : baseName
|
|
62
|
-
const transports = d.meta.transports ?? d.classMeta.transports
|
|
63
|
-
const isEnabled = buildIsEnabled(transports, d.meta.isEnabled)
|
|
64
|
-
const guards = collectGuards(this.reflector, d.classRef, d.method)
|
|
65
|
-
const moduleRef = this.moduleRef
|
|
66
|
-
const reflector = this.reflector
|
|
67
|
-
|
|
68
|
-
const applyGuards = async (context: SilkweaveContext): Promise<void> => {
|
|
69
|
-
if (guards.length === 0) { return }
|
|
70
|
-
// REST and tRPC populate `request`/`response`; MCP-over-HTTP populates
|
|
71
|
-
// `request` (a `{ headers, url, params, query }` stand-in built from the
|
|
72
|
-
// SDK's `extra.requestInfo`). Transports with no HTTP request at all (e.g.
|
|
73
|
-
// MCP stdio) get a header-less stand-in so guards reading `req.headers`
|
|
74
|
-
// degrade gracefully (deny) instead of dereferencing `undefined`.
|
|
75
|
-
const request = context.getOptional<unknown>('request')
|
|
76
|
-
const response = context.getOptional<unknown>('response') ?? null
|
|
77
|
-
const hasRequest = request != null
|
|
78
|
-
const guardRequest = hasRequest ? request : { headers: {}, params: {}, query: {} }
|
|
79
|
-
await runGuards(guards, moduleRef, reflector, d.classRef, d.method, guardRequest, response, hasRequest ? 'http' : 'rpc')
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// Cast at the createAction boundary to bridge dual-zod-version installs
|
|
83
|
-
// (zod@3.25 + zod@4.x can both be present transitively). Runtime is fine -
|
|
84
|
-
// they share the same /v4 surface - but the structural types are distinct.
|
|
85
|
-
const streaming = d.method.constructor?.name === 'AsyncGeneratorFunction'
|
|
86
|
-
|
|
87
|
-
if (streaming) {
|
|
88
|
-
if (!d.meta.chunk) {
|
|
89
|
-
throw new Error(`@Action "${name}" is an async generator but has no \`chunk\` schema`)
|
|
90
|
-
}
|
|
91
|
-
const method = d.method
|
|
92
|
-
const instance = d.instance
|
|
93
|
-
return createAction({
|
|
94
|
-
name,
|
|
95
|
-
description: d.meta.description,
|
|
96
|
-
input: d.meta.input,
|
|
97
|
-
chunk: d.meta.chunk,
|
|
98
|
-
kind: d.meta.kind ?? 'mutation',
|
|
99
|
-
method: d.meta.method,
|
|
100
|
-
path: d.meta.path,
|
|
101
|
-
queryParams: d.meta.queryParams,
|
|
102
|
-
args: d.meta.args,
|
|
103
|
-
isEnabled,
|
|
104
|
-
toolResult: d.meta.toolResult,
|
|
105
|
-
run: async function* (input: object, context: SilkweaveContext) {
|
|
106
|
-
await applyGuards(context)
|
|
107
|
-
yield* (method.call(instance, input, context) as AsyncGenerator<object, void, void>)
|
|
108
|
-
}
|
|
109
|
-
} as StreamingActionInput<object, unknown, string, ActionKind>) as Action
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
return createAction({
|
|
113
|
-
name,
|
|
114
|
-
description: d.meta.description,
|
|
115
|
-
input: d.meta.input,
|
|
116
|
-
output: d.meta.output,
|
|
117
|
-
kind: d.meta.kind ?? 'mutation',
|
|
118
|
-
method: d.meta.method,
|
|
119
|
-
path: d.meta.path,
|
|
120
|
-
queryParams: d.meta.queryParams,
|
|
121
|
-
args: d.meta.args,
|
|
122
|
-
isEnabled,
|
|
123
|
-
toolResult: d.meta.toolResult,
|
|
124
|
-
run: async (input: object, context: SilkweaveContext): Promise<object> => {
|
|
125
|
-
await applyGuards(context)
|
|
126
|
-
const result = await (d.method.call(d.instance, input, context) as Promise<object>)
|
|
127
|
-
return result
|
|
128
|
-
}
|
|
129
|
-
})
|
|
130
|
-
}
|
|
131
|
-
}
|
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
import type { ArgumentsHost, ContextType, ExecutionContext, Type } from '@nestjs/common'
|
|
2
|
-
|
|
3
|
-
/** Subset of `HttpArgumentsHost` we need - re-declared inline to avoid deep `@nestjs/common/interfaces` imports. */
|
|
4
|
-
interface HttpHost {
|
|
5
|
-
getRequest<T = unknown>(): T
|
|
6
|
-
getResponse<T = unknown>(): T
|
|
7
|
-
getNext<T = unknown>(): T
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
interface RpcHost {
|
|
11
|
-
getData<T = unknown>(): T
|
|
12
|
-
getContext<T = unknown>(): T
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
interface WsHost {
|
|
16
|
-
getClient<T = unknown>(): T
|
|
17
|
-
getData<T = unknown>(): T
|
|
18
|
-
getPattern(): string
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Minimal `ExecutionContext` impl Nest guards can consume. We only need
|
|
23
|
-
* `switchToHttp()` to work for our HTTP-backed transports; the RPC/WS shims are
|
|
24
|
-
* stubbed so guards that introspect type can still call them without crashing.
|
|
25
|
-
*/
|
|
26
|
-
export class SilkweaveExecutionContext implements ExecutionContext, ArgumentsHost {
|
|
27
|
-
constructor(
|
|
28
|
-
private readonly args: unknown[],
|
|
29
|
-
private readonly classRef: Type<unknown>,
|
|
30
|
-
private readonly handler: (...handlerArgs: unknown[]) => unknown,
|
|
31
|
-
private readonly contextType = 'http'
|
|
32
|
-
) { }
|
|
33
|
-
|
|
34
|
-
getType<T extends string = ContextType>(): T {
|
|
35
|
-
return this.contextType as T
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
getClass<T = unknown>(): Type<T> {
|
|
39
|
-
return this.classRef as Type<T>
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
getHandler(): (...handlerArgs: unknown[]) => unknown {
|
|
43
|
-
return this.handler
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
getArgs<T extends unknown[] = unknown[]>(): T {
|
|
47
|
-
return this.args as T
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
getArgByIndex<T = unknown>(index: number): T {
|
|
51
|
-
return this.args[index] as T
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
switchToHttp(): HttpHost {
|
|
55
|
-
return {
|
|
56
|
-
getRequest: <T = unknown>(): T => this.args[0] as T,
|
|
57
|
-
getResponse: <T = unknown>(): T => this.args[1] as T,
|
|
58
|
-
getNext: <T = unknown>(): T => this.args[2] as T
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
switchToRpc(): RpcHost {
|
|
63
|
-
return {
|
|
64
|
-
getData: <T = unknown>(): T => this.args[0] as T,
|
|
65
|
-
getContext: <T = unknown>(): T => this.args[1] as T
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
switchToWs(): WsHost {
|
|
70
|
-
return {
|
|
71
|
-
getClient: <T = unknown>(): T => this.args[0] as T,
|
|
72
|
-
getData: <T = unknown>(): T => this.args[1] as T,
|
|
73
|
-
getPattern: (): string => ''
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
}
|
package/src/lib/filter.ts
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import type { SilkweaveContext } from '@silkweave/core'
|
|
2
|
-
import type { Transport } from './metadata.js'
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Compile a `transports` allowlist + optional user `isEnabled` into a single
|
|
6
|
-
* `(ctx) => boolean` callback compatible with `Action.isEnabled`.
|
|
7
|
-
*
|
|
8
|
-
* - If `transports` is omitted, the action runs on every adapter.
|
|
9
|
-
* - If `transports` is set, the action is gated on `ctx.get<string>('adapter')`
|
|
10
|
-
* matching one of the listed transports. Each adapter in `@silkweave/nestjs`
|
|
11
|
-
* forks its context with `{ adapter: 'rest' | 'trpc' | 'mcp' }`.
|
|
12
|
-
* - If both `transports` and `userIsEnabled` are set, they are AND-combined.
|
|
13
|
-
*/
|
|
14
|
-
export function buildIsEnabled(
|
|
15
|
-
transports: Transport[] | undefined,
|
|
16
|
-
userIsEnabled: ((ctx: SilkweaveContext) => boolean) | undefined
|
|
17
|
-
): ((ctx: SilkweaveContext) => boolean) | undefined {
|
|
18
|
-
if (!transports && !userIsEnabled) { return undefined }
|
|
19
|
-
return (ctx) => {
|
|
20
|
-
if (transports) {
|
|
21
|
-
const adapter = ctx.getOptional<string>('adapter')
|
|
22
|
-
if (adapter && !transports.includes(adapter as Transport)) { return false }
|
|
23
|
-
}
|
|
24
|
-
return userIsEnabled ? userIsEnabled(ctx) : true
|
|
25
|
-
}
|
|
26
|
-
}
|
package/src/lib/guards.ts
DELETED
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
import { ForbiddenException, type CanActivate, type Type } from '@nestjs/common'
|
|
2
|
-
import { GUARDS_METADATA } from '@nestjs/common/constants.js'
|
|
3
|
-
import { ModuleRef, Reflector } from '@nestjs/core'
|
|
4
|
-
import { isObservable, lastValueFrom } from 'rxjs'
|
|
5
|
-
import { SilkweaveExecutionContext } from './executionContext.js'
|
|
6
|
-
|
|
7
|
-
type GuardRef = Type<CanActivate> | CanActivate
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Read `@UseGuards(...)` metadata for both the method and its class and merge
|
|
11
|
-
* the two lists. Method-level guards run AFTER class-level guards (matching
|
|
12
|
-
* Nest's own behavior).
|
|
13
|
-
*/
|
|
14
|
-
export function collectGuards(
|
|
15
|
-
reflector: Reflector,
|
|
16
|
-
classRef: Type<unknown>,
|
|
17
|
-
handler: (...args: unknown[]) => unknown
|
|
18
|
-
): GuardRef[] {
|
|
19
|
-
const classGuards = reflector.get<GuardRef[]>(GUARDS_METADATA, classRef) ?? []
|
|
20
|
-
const methodGuards = reflector.get<GuardRef[]>(GUARDS_METADATA, handler) ?? []
|
|
21
|
-
return [...classGuards, ...methodGuards]
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
async function resolveGuard(ref: GuardRef, moduleRef: ModuleRef): Promise<CanActivate> {
|
|
25
|
-
if (typeof ref === 'function') {
|
|
26
|
-
try {
|
|
27
|
-
return await moduleRef.get(ref, { strict: false })
|
|
28
|
-
} catch {
|
|
29
|
-
return moduleRef.create(ref)
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
return ref
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Run the configured guards against a request. Throws `ForbiddenException`
|
|
37
|
-
* if any guard rejects, mirroring Nest's HTTP request-pipeline behavior.
|
|
38
|
-
*
|
|
39
|
-
* Pass `null` for `response` when running on top of a tRPC or MCP request that
|
|
40
|
-
* doesn't surface a raw response object; guards that introspect the response
|
|
41
|
-
* will receive `null`.
|
|
42
|
-
*
|
|
43
|
-
* `contextType` is reflected through `ExecutionContext.getType()` so guards can
|
|
44
|
-
* branch on the transport. It is `'http'` for REST/tRPC and for MCP-over-HTTP
|
|
45
|
-
* (where a header-bearing request stand-in is available); transports without any
|
|
46
|
-
* HTTP request (e.g. MCP stdio) pass `'rpc'`.
|
|
47
|
-
*/
|
|
48
|
-
export async function runGuards(
|
|
49
|
-
guards: GuardRef[],
|
|
50
|
-
moduleRef: ModuleRef,
|
|
51
|
-
reflector: Reflector,
|
|
52
|
-
classRef: Type<unknown>,
|
|
53
|
-
handler: (...args: unknown[]) => unknown,
|
|
54
|
-
request: unknown,
|
|
55
|
-
response: unknown,
|
|
56
|
-
contextType: 'http' | 'rpc' = 'http'
|
|
57
|
-
): Promise<void> {
|
|
58
|
-
if (guards.length === 0) { return }
|
|
59
|
-
const context = new SilkweaveExecutionContext([request, response], classRef, handler, contextType)
|
|
60
|
-
for (const ref of guards) {
|
|
61
|
-
const guard = await resolveGuard(ref, moduleRef)
|
|
62
|
-
const result = guard.canActivate(context)
|
|
63
|
-
const allowed = isObservable(result) ? await lastValueFrom(result) : await Promise.resolve(result)
|
|
64
|
-
if (!allowed) {
|
|
65
|
-
throw new ForbiddenException('Forbidden resource')
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
// Reflector kept in the signature for future use (e.g., per-guard metadata) - unused here.
|
|
69
|
-
void reflector
|
|
70
|
-
}
|
package/src/lib/metadata.ts
DELETED
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
import { type Action, type ActionKind, type HttpMethod, type SilkweaveContext } from '@silkweave/core'
|
|
2
|
-
import type z from 'zod/v4'
|
|
3
|
-
|
|
4
|
-
export const ACTION_METADATA = '__silkweave_action__'
|
|
5
|
-
export const ACTIONS_METADATA = '__silkweave_actions__'
|
|
6
|
-
|
|
7
|
-
export type Transport = 'rest' | 'trpc' | 'mcp'
|
|
8
|
-
|
|
9
|
-
export interface ActionMetadata<I extends object = object, O extends object = object, C = unknown> {
|
|
10
|
-
/** Action name. Defaults to the kebab-cased method name. */
|
|
11
|
-
name?: string
|
|
12
|
-
/** Human-readable description. Becomes the MCP tool description and REST OpenAPI summary. */
|
|
13
|
-
description: string
|
|
14
|
-
/** Zod object schema for the action's input. */
|
|
15
|
-
input: z.ZodType<I> & { shape: Record<string, z.ZodTypeAny> }
|
|
16
|
-
/** Optional Zod object schema for the action's output (used by tRPC type inference). */
|
|
17
|
-
output?: z.ZodType<O> & { shape: Record<string, z.ZodTypeAny> }
|
|
18
|
-
/**
|
|
19
|
-
* Zod schema for individual chunks yielded by a streaming (async-generator)
|
|
20
|
-
* action method. Required when the decorated method is an `async function*`.
|
|
21
|
-
* Mirrors `Action.chunk` in @silkweave/core; typegen/trpc use it to expose
|
|
22
|
-
* the action as a tRPC subscription.
|
|
23
|
-
*/
|
|
24
|
-
chunk?: z.ZodType<C>
|
|
25
|
-
/** `'query'` (GET in REST, `.query()` in tRPC) or `'mutation'` (POST in REST, `.mutation()` in tRPC). Default: `'mutation'`. */
|
|
26
|
-
kind?: ActionKind
|
|
27
|
-
/** HTTP verb for the REST route. Defaults to `POST` (or `GET` when `kind` is `'query'`). Overrides the `kind`-derived default. */
|
|
28
|
-
method?: HttpMethod
|
|
29
|
-
/** REST route path, optionally with `:param` placeholders (e.g. `'spaces/:spaceId/users'`). Each placeholder must be a key of `input`. Defaults to the action name with dots as slashes. */
|
|
30
|
-
path?: string
|
|
31
|
-
/** Input fields read from the URL query string instead of the request body (e.g. `['offset', 'limit']`). Each must be a key of `input`. */
|
|
32
|
-
queryParams?: (keyof I)[]
|
|
33
|
-
/** Allowlist of transports that should expose this action. Default: all registered transports. */
|
|
34
|
-
transports?: Transport[]
|
|
35
|
-
/** Dynamic enable check (in addition to `transports`). AND-combined with the transports filter. */
|
|
36
|
-
isEnabled?: (context: SilkweaveContext) => boolean
|
|
37
|
-
/** Custom MCP `CallToolResult` formatter. See `@silkweave/mcp`'s `smartToolResult`. */
|
|
38
|
-
toolResult?: Action<I, O>['toolResult']
|
|
39
|
-
/** CLI positional-argument keys (unused for HTTP-only adapters, kept for parity with `createAction`). */
|
|
40
|
-
args?: (keyof I)[]
|
|
41
|
-
/** Custom MCP tool name override. If unset, derived from action name as PascalCase. */
|
|
42
|
-
mcpToolName?: string
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export interface ActionsClassMetadata {
|
|
46
|
-
/** Prefix joined to the method-level action name with a dot. e.g. `Actions('users')` + method `list` → `users.list`. */
|
|
47
|
-
prefix?: string
|
|
48
|
-
/** Class-level transports allowlist. Method-level `transports` overrides; otherwise inherits this. */
|
|
49
|
-
transports?: Transport[]
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export interface ResultToolResult<O extends object = object> {
|
|
53
|
-
toolResult?: Action<object, O>['toolResult']
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export type AnyActionMetadata = ActionMetadata<object, object> & ResultToolResult<object>
|
|
57
|
-
|
|
58
|
-
export const ACTION_RESPONSE_KEY = '__silkweave_response__'
|
package/src/lib/openapi.ts
DELETED
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
import { type Action, actionMethod, methodHasBody, pathParamNames } from '@silkweave/core'
|
|
2
|
-
import { camelCase } from 'change-case'
|
|
3
|
-
import { z } from 'zod/v4'
|
|
4
|
-
|
|
5
|
-
export interface ActionPathsOptions {
|
|
6
|
-
/** URL prefix the `rest()` adapter mounts on. Default `'/api'`. */
|
|
7
|
-
basePath?: string
|
|
8
|
-
/** OpenAPI tag the actions are grouped under. Default `'Actions'`. */
|
|
9
|
-
tag?: string
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
interface JsonSchemaObject {
|
|
13
|
-
properties?: Record<string, unknown>
|
|
14
|
-
required?: string[]
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/** Convert a route template's `:param` placeholders to OpenAPI `{param}` form. */
|
|
18
|
-
function toOpenApiPath(template: string): string {
|
|
19
|
-
return template.replace(/:([A-Za-z0-9_]+)/g, '{$1}')
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** The OpenAPI path key for an action - mirrors the `rest()` adapter's routing. */
|
|
23
|
-
function actionRoute(action: Action, basePath: string): string {
|
|
24
|
-
const sub = action.path ? action.path.replace(/^\//, '') : action.name.replace(/\./g, '/')
|
|
25
|
-
return toOpenApiPath(`${basePath}/${sub}`.replace(/\/{2,}/g, '/'))
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** `z.toJSONSchema` tags a top-level `$schema`; drop it for a tidy OpenAPI doc. */
|
|
29
|
-
function toJsonSchema(schema: z.ZodType): Record<string, unknown> {
|
|
30
|
-
const { $schema: _schema, ...rest } = z.toJSONSchema(schema) as Record<string, unknown>
|
|
31
|
-
return rest
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function responseSchema(action: Action): unknown {
|
|
35
|
-
if (action.output) { return toJsonSchema(action.output) }
|
|
36
|
-
if (action.chunk) { return { type: 'array', items: toJsonSchema(action.chunk) } }
|
|
37
|
-
return undefined
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
interface SourceSplit {
|
|
41
|
-
parameters: Array<Record<string, unknown>>
|
|
42
|
-
bodyProps: Record<string, unknown>
|
|
43
|
-
bodyRequired: string[]
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** Split an action's input fields into OpenAPI path/query parameters and a body, matching the `rest()` adapter. */
|
|
47
|
-
function splitInputSources(action: Action, hasBody: boolean): SourceSplit {
|
|
48
|
-
const json = z.toJSONSchema(action.input) as JsonSchemaObject
|
|
49
|
-
const required = new Set(json.required ?? [])
|
|
50
|
-
const pathParams = new Set(pathParamNames(action.path))
|
|
51
|
-
const queryKeys = new Set((action.queryParams ?? []).map(String))
|
|
52
|
-
|
|
53
|
-
const split: SourceSplit = { parameters: [], bodyProps: {}, bodyRequired: [] }
|
|
54
|
-
for (const [key, schema] of Object.entries(json.properties ?? {})) {
|
|
55
|
-
if (pathParams.has(key)) {
|
|
56
|
-
split.parameters.push({ name: key, in: 'path', required: true, schema })
|
|
57
|
-
} else if (!hasBody || queryKeys.has(key)) {
|
|
58
|
-
split.parameters.push({ name: key, in: 'query', required: required.has(key), schema })
|
|
59
|
-
} else {
|
|
60
|
-
split.bodyProps[key] = schema
|
|
61
|
-
if (required.has(key)) { split.bodyRequired.push(key) }
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
return split
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** Build the OpenAPI operation object for a single action. */
|
|
68
|
-
function buildOperation(action: Action, tag: string): Record<string, unknown> {
|
|
69
|
-
const hasBody = methodHasBody(actionMethod(action))
|
|
70
|
-
const { parameters, bodyProps, bodyRequired } = splitInputSources(action, hasBody)
|
|
71
|
-
const response = responseSchema(action)
|
|
72
|
-
|
|
73
|
-
const operation: Record<string, unknown> = {
|
|
74
|
-
tags: [tag],
|
|
75
|
-
summary: action.description,
|
|
76
|
-
operationId: camelCase(action.name),
|
|
77
|
-
responses: {
|
|
78
|
-
200: {
|
|
79
|
-
description: 'Successful response',
|
|
80
|
-
...(response ? { content: { 'application/json': { schema: response } } } : {})
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
if (parameters.length) { operation.parameters = parameters }
|
|
85
|
-
if (hasBody && Object.keys(bodyProps).length) {
|
|
86
|
-
operation.requestBody = {
|
|
87
|
-
required: bodyRequired.length > 0,
|
|
88
|
-
content: { 'application/json': { schema: { type: 'object', properties: bodyProps, required: bodyRequired } } }
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
return operation
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Build an OpenAPI `paths` fragment for a set of Silkweave actions, mirroring
|
|
96
|
-
* exactly how the `rest()` adapter routes them: the same HTTP verb (`method` ??
|
|
97
|
-
* `kind`), the same `path`/`name`-derived route, and the same path/query/body
|
|
98
|
-
* field split (`pathParamNames` + `queryParams`). Schemas are inlined from the
|
|
99
|
-
* Zod input/output via `z.toJSONSchema`; no shared components are emitted.
|
|
100
|
-
*/
|
|
101
|
-
export function buildActionPaths(
|
|
102
|
-
actions: Action[],
|
|
103
|
-
options: ActionPathsOptions = {}
|
|
104
|
-
): Record<string, Record<string, unknown>> {
|
|
105
|
-
const basePath = (options.basePath ?? '/api').replace(/\/$/, '')
|
|
106
|
-
const tag = options.tag ?? 'Actions'
|
|
107
|
-
const paths: Record<string, Record<string, unknown>> = {}
|
|
108
|
-
|
|
109
|
-
for (const action of actions) {
|
|
110
|
-
const method = actionMethod(action).toLowerCase()
|
|
111
|
-
const route = actionRoute(action, basePath)
|
|
112
|
-
paths[route] = { ...(paths[route] ?? {}), [method]: buildOperation(action, tag) }
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
return paths
|
|
116
|
-
}
|
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
import { Inject, Module, type DynamicModule, type MiddlewareConsumer, type NestModule } from '@nestjs/common'
|
|
2
|
-
import { DiscoveryModule, HttpAdapterHost } from '@nestjs/core'
|
|
3
|
-
import { createContext } from '@silkweave/core'
|
|
4
|
-
import { ActionDiscovery } from './discovery.js'
|
|
5
|
-
import { SILKWEAVE_MODULE_OPTIONS, type SilkweaveModuleOptions } from './types.js'
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Root module for `@silkweave/nestjs`.
|
|
9
|
-
*
|
|
10
|
-
* Discovers every `@Action`-decorated method via `DiscoveryService` and
|
|
11
|
-
* registers the configured adapters (`rest()`, `trpc()`, `mcp()`) directly on
|
|
12
|
-
* Nest's HTTP adapter inside `configure()`. Because `configure()` runs during
|
|
13
|
-
* `registerModules` - before Nest's `registerRouter()` step - Silkweave's
|
|
14
|
-
* routes always sit ahead of every controller in the Express stack. There is
|
|
15
|
-
* no slot middleware, no race with Nest's 404 catch-all, and every route
|
|
16
|
-
* shows up in Nest's `RoutesResolver` logger.
|
|
17
|
-
*
|
|
18
|
-
* @example
|
|
19
|
-
* ```ts
|
|
20
|
-
* @Module({
|
|
21
|
-
* imports: [
|
|
22
|
-
* SilkweaveModule.forRoot({
|
|
23
|
-
* silkweave: { name: 'app', description: 'My App', version: '1.0.0' },
|
|
24
|
-
* adapters: [
|
|
25
|
-
* rest({ basePath: '/api' }),
|
|
26
|
-
* trpc({ basePath: '/trpc' }),
|
|
27
|
-
* mcp({ basePath: '/mcp' })
|
|
28
|
-
* ]
|
|
29
|
-
* })
|
|
30
|
-
* ]
|
|
31
|
-
* })
|
|
32
|
-
* export class AppModule {}
|
|
33
|
-
* ```
|
|
34
|
-
*/
|
|
35
|
-
@Module({})
|
|
36
|
-
export class SilkweaveModule implements NestModule {
|
|
37
|
-
constructor(
|
|
38
|
-
@Inject(SILKWEAVE_MODULE_OPTIONS) private readonly options: SilkweaveModuleOptions,
|
|
39
|
-
private readonly discovery: ActionDiscovery,
|
|
40
|
-
private readonly httpAdapterHost: HttpAdapterHost
|
|
41
|
-
) { }
|
|
42
|
-
|
|
43
|
-
static forRoot(options: SilkweaveModuleOptions): DynamicModule {
|
|
44
|
-
return {
|
|
45
|
-
module: SilkweaveModule,
|
|
46
|
-
global: true,
|
|
47
|
-
imports: [DiscoveryModule],
|
|
48
|
-
providers: [
|
|
49
|
-
{ provide: SILKWEAVE_MODULE_OPTIONS, useValue: options },
|
|
50
|
-
ActionDiscovery
|
|
51
|
-
],
|
|
52
|
-
exports: []
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
configure(_consumer: MiddlewareConsumer): void {
|
|
57
|
-
const httpAdapter = this.httpAdapterHost.httpAdapter
|
|
58
|
-
if (!httpAdapter) {
|
|
59
|
-
throw new Error('@silkweave/nestjs: HttpAdapterHost.httpAdapter is not available.')
|
|
60
|
-
}
|
|
61
|
-
const allActions = this.discovery.discover()
|
|
62
|
-
for (const adapter of this.options.adapters) {
|
|
63
|
-
const baseContext = createContext({ ...(this.options.context ?? {}), adapter: adapter.name })
|
|
64
|
-
const actions = adapter.allActions
|
|
65
|
-
? allActions
|
|
66
|
-
: allActions.filter((a) => !a.isEnabled || a.isEnabled(baseContext))
|
|
67
|
-
adapter.register({
|
|
68
|
-
httpAdapter,
|
|
69
|
-
silkweaveOptions: this.options.silkweave,
|
|
70
|
-
baseContext,
|
|
71
|
-
actions
|
|
72
|
-
})
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
}
|
package/src/lib/swagger.ts
DELETED
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
import { type INestApplication } from '@nestjs/common'
|
|
2
|
-
import type { OpenAPIObject } from '@nestjs/swagger'
|
|
3
|
-
import { type Action, createContext } from '@silkweave/core'
|
|
4
|
-
import { ActionDiscovery } from './discovery.js'
|
|
5
|
-
import { buildActionPaths } from './openapi.js'
|
|
6
|
-
import { SILKWEAVE_MODULE_OPTIONS, type SilkweaveModuleOptions } from './types.js'
|
|
7
|
-
|
|
8
|
-
export interface SilkweaveSwaggerOptions {
|
|
9
|
-
/**
|
|
10
|
-
* URL prefix the `rest()` adapter mounts on. Defaults to the configured
|
|
11
|
-
* `rest()` adapter's `basePath`, falling back to `'/api'`.
|
|
12
|
-
*/
|
|
13
|
-
basePath?: string
|
|
14
|
-
/** OpenAPI tag the actions are grouped under. Default `'Actions'`. */
|
|
15
|
-
tag?: string
|
|
16
|
-
/**
|
|
17
|
-
* Include actions that are *not* enabled on the REST transport (gated out via
|
|
18
|
-
* `transports` / `isEnabled`). Default `false` - the document mirrors the
|
|
19
|
-
* routes the `rest()` adapter actually registers.
|
|
20
|
-
*/
|
|
21
|
-
includeDisabled?: boolean
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Merge every REST-exposed Silkweave `@Action` into a NestJS Swagger
|
|
26
|
-
* `OpenAPIObject`.
|
|
27
|
-
*
|
|
28
|
-
* `@nestjs/swagger` builds its document by scanning **controllers**, but
|
|
29
|
-
* Silkweave registers action routes directly on the HTTP adapter (so they sit
|
|
30
|
-
* ahead of controllers in the request pipeline) - which means the scanner never
|
|
31
|
-
* sees them. This helper closes that gap: it discovers the actions through the
|
|
32
|
-
* same `ActionDiscovery` provider the `rest()` adapter uses, builds OpenAPI
|
|
33
|
-
* paths with the same routing logic (`buildActionPaths`), and merges them into
|
|
34
|
-
* the document. The result stays in sync with the live routes without any
|
|
35
|
-
* dynamic controllers.
|
|
36
|
-
*
|
|
37
|
-
* Call it between `SwaggerModule.createDocument()` and `SwaggerModule.setup()`:
|
|
38
|
-
*
|
|
39
|
-
* @example
|
|
40
|
-
* ```ts
|
|
41
|
-
* const document = SwaggerModule.createDocument(app, config)
|
|
42
|
-
* addSilkweaveActions(app, document)
|
|
43
|
-
* SwaggerModule.setup('api/docs', app, document)
|
|
44
|
-
* ```
|
|
45
|
-
*/
|
|
46
|
-
export function addSilkweaveActions(
|
|
47
|
-
app: INestApplication,
|
|
48
|
-
document: OpenAPIObject,
|
|
49
|
-
options: SilkweaveSwaggerOptions = {}
|
|
50
|
-
): OpenAPIObject {
|
|
51
|
-
const discovery = app.get(ActionDiscovery)
|
|
52
|
-
const moduleOptions = app.get<SilkweaveModuleOptions>(SILKWEAVE_MODULE_OPTIONS)
|
|
53
|
-
const restAdapter = moduleOptions.adapters.find((adapter) => adapter.name === 'rest')
|
|
54
|
-
const basePath = options.basePath ?? restAdapter?.basePath ?? '/api'
|
|
55
|
-
|
|
56
|
-
const allActions = discovery.discover()
|
|
57
|
-
const actions = options.includeDisabled
|
|
58
|
-
? allActions
|
|
59
|
-
: allActions.filter((action) => isRestEnabled(action, moduleOptions))
|
|
60
|
-
|
|
61
|
-
const paths = buildActionPaths(actions, { basePath, tag: options.tag })
|
|
62
|
-
|
|
63
|
-
document.paths ??= {}
|
|
64
|
-
for (const [route, item] of Object.entries(paths)) {
|
|
65
|
-
document.paths[route] = { ...(document.paths[route] ?? {}), ...item }
|
|
66
|
-
}
|
|
67
|
-
return document
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/** Whether an action would be registered by the `rest()` adapter (adapter: 'rest'). */
|
|
71
|
-
function isRestEnabled(action: Action, moduleOptions: SilkweaveModuleOptions): boolean {
|
|
72
|
-
if (!action.isEnabled) { return true }
|
|
73
|
-
return action.isEnabled(createContext({ ...(moduleOptions.context ?? {}), adapter: 'rest' }))
|
|
74
|
-
}
|