@znight/http 0.3.0 → 0.4.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/dist/node.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { s as HttpAdapter } from "./adapter-J0UXFnNM.mjs";
1
+ import { s as HttpAdapter } from "./adapter-DmhQGqAR.mjs";
2
2
  import { IncomingMessage, Server, ServerOptions, ServerResponse } from "node:http";
3
3
  /** Options of {@link nodeAdapter}. */
4
4
  export interface NodeAdapterOptions {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@znight/http",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Znight HTTP plugin: controllers, routing, guards, interceptors, validation with Fastify, Express or Hono",
5
5
  "license": "MIT",
6
6
  "author": "Ugho STEPHAN",
@@ -79,7 +79,7 @@
79
79
  "access": "public"
80
80
  },
81
81
  "dependencies": {
82
- "@znight/core": "0.3.0"
82
+ "@znight/core": "0.4.0"
83
83
  },
84
84
  "peerDependencies": {
85
85
  "@hono/node-server": "^2.1.5",
@@ -87,7 +87,7 @@
87
87
  "fastify": "^5.12.7",
88
88
  "hono": "^4.13.14",
89
89
  "light-my-request": "^6.8.0",
90
- "@znight/compiler": "^0.3.0"
90
+ "@znight/compiler": "^0.4.0"
91
91
  },
92
92
  "peerDependenciesMeta": {
93
93
  "@hono/node-server": {
@@ -118,7 +118,7 @@
118
118
  "valibot": "1.5.0",
119
119
  "zod": "4.6.7",
120
120
  "light-my-request": "6.8.0",
121
- "@znight/compiler": "0.3.0"
121
+ "@znight/compiler": "0.4.0"
122
122
  },
123
123
  "znight": {
124
124
  "apiVersion": 1,
@@ -11,6 +11,7 @@ import type {
11
11
  DecoratorInfo,
12
12
  Location,
13
13
  MethodInfo,
14
+ ParamDecoratorUse,
14
15
  ParameterInfo,
15
16
  StaticValue,
16
17
  TypeInfo,
@@ -813,6 +814,24 @@ function analyzeParams(
813
814
  const decorators = unique(param.decorators).filter(
814
815
  decorator => decorator.owner === EXTENSION_NAME && PARAM_SOURCES.has(decorator.identity ?? ''),
815
816
  )
817
+ // custom parameter decorators (RFC 0010); compilers before it have no paramDecorator
818
+ const custom = typeof ctx.paramDecorator === 'function' ? ctx.paramDecorator(param, 'http', cls) : undefined
819
+ if (custom !== undefined) {
820
+ for (const other of decorators) {
821
+ error(
822
+ ctx,
823
+ other.location,
824
+ `Parameter '${param.name}' of ${where} has several parameter decorators (@${custom.decorator.name} and @${other.name})`,
825
+ ['Keep a single parameter decorator'],
826
+ )
827
+ }
828
+ if (custom.ok && decorators.length === 0) {
829
+ params.push(customParam(param, custom))
830
+ } else {
831
+ complete = false
832
+ }
833
+ continue
834
+ }
816
835
  const [decorator, ...extra] = decorators
817
836
  const foreign = decorator === undefined ? foreignParameterDecorator(param) : undefined
818
837
  if (foreign !== undefined) {
@@ -852,6 +871,30 @@ function analyzeParams(
852
871
  return { params, complete }
853
872
  }
854
873
 
874
+ /** A custom parameter (`source: 'custom'`, the decorator name as `name`). */
875
+ function customParam(param: ParameterInfo, use: Extract<ParamDecoratorUse, { readonly ok: true }>): HttpParamData {
876
+ const data = use.data
877
+ return {
878
+ index: param.index,
879
+ name: param.name,
880
+ source: 'custom',
881
+ key: use.name,
882
+ type: undefined,
883
+ optional: param.optional || param.hasDefault,
884
+ schema: undefined,
885
+ coercion: undefined,
886
+ custom: {
887
+ identity: use.identity,
888
+ name: use.name,
889
+ data: data === undefined ? undefined : { value: data.ref ?? (data.value as StaticValue) },
890
+ resolver: use.resolver.kind === 'class' ? use.resolver.ref : undefined,
891
+ ...(use.resolver.kind === 'class' && use.resolver.arity !== undefined ? { arity: use.resolver.arity } : {}),
892
+ cast: use.cast,
893
+ },
894
+ location: use.location,
895
+ }
896
+ }
897
+
855
898
  function analyzeParam(
856
899
  ctx: Context,
857
900
  cls: ClassInfo,
@@ -3,9 +3,10 @@
3
3
  * (README, "Compiler contract"). Controllers are sorted by token, routes keep their source order and
4
4
  * every route gets an `invoke(c, ctx)` arrow extracting, coercing and validating its arguments.
5
5
  */
6
- import type { ClassRef, GenerateContext, GenInput, GenNode, ValueRef } from '@znight/compiler'
6
+ import type { ClassRef, GenerateContext, GenInput, GenNode, StaticValue, ValueRef } from '@znight/compiler'
7
7
  import type {
8
8
  CoercionPlan,
9
+ CustomParamData,
9
10
  HttpControllerData,
10
11
  HttpParamData,
11
12
  HttpRouteData,
@@ -17,6 +18,7 @@ import type {
17
18
 
18
19
  import { compareStrings } from '../path.ts'
19
20
  import { HTTP_PACKAGE } from './model.ts'
21
+ import { isValueRef } from './route-decorators.ts'
20
22
 
21
23
  type Context = GenerateContext<HttpControllerData>
22
24
 
@@ -33,6 +35,9 @@ interface Helpers {
33
35
  isThenable(): GenNode
34
36
  uploads(): GenNode
35
37
  context(): GenNode
38
+ paramFunctionOf(): GenNode
39
+ /** The resolver instances of the route being generated (`x`), with the index of each token. */
40
+ readonly resolvers?: { readonly x: GenNode; readonly index: ReadonlyMap<string, number> }
36
41
  }
37
42
 
38
43
  /** Generates `metadata.extensions.http` (`undefined` when the application has no controller). */
@@ -102,6 +107,7 @@ function createHelpers(ctx: Context): Helpers {
102
107
  let isThenable: GenNode | undefined
103
108
  let uploads: GenNode | undefined
104
109
  let context: GenNode | undefined
110
+ let paramFunctionOf: GenNode | undefined
105
111
  return {
106
112
  coerce: () => {
107
113
  coerce ??= ctx.importFrom(HTTP_PACKAGE, 'coerce')
@@ -123,6 +129,10 @@ function createHelpers(ctx: Context): Helpers {
123
129
  context ??= ctx.importFrom(HTTP_PACKAGE, 'HttpContext', { type: true })
124
130
  return context
125
131
  },
132
+ paramFunctionOf: () => {
133
+ paramFunctionOf ??= ctx.importFrom('@znight/core', 'ParamFunctionOf', { type: true })
134
+ return paramFunctionOf
135
+ },
126
136
  }
127
137
  }
128
138
 
@@ -169,7 +179,11 @@ function routeNode(ctx: Context, helpers: Helpers, controller: HttpControllerDat
169
179
  if (route.sse !== undefined) {
170
180
  entries.push(['sse', sseNode(ctx, route.sse)])
171
181
  }
172
- entries.push(['invoke', invokeNode(ctx, helpers, controller, route)])
182
+ const resolvers = paramResolvers(route)
183
+ if (resolvers.length > 0) {
184
+ entries.push(['paramResolvers', tokens(ctx, resolvers)])
185
+ }
186
+ entries.push(['invoke', invokeNode(ctx, helpers, controller, route, resolvers)])
173
187
  return ctx.obj(entries)
174
188
  }
175
189
 
@@ -251,12 +265,63 @@ function tokens(ctx: Context, refs: readonly ClassRef[]): GenNode {
251
265
  * `@Body()` parameter reads the body in `invoke`: that arrow is `async` and `await`s every
252
266
  * `validate()`.
253
267
  */
254
- function invokeNode(ctx: Context, helpers: Helpers, controller: HttpControllerData, route: HttpRouteData): GenNode {
268
+ function invokeNode(
269
+ ctx: Context,
270
+ helpers: Helpers,
271
+ controller: HttpControllerData,
272
+ route: HttpRouteData,
273
+ resolvers: readonly ClassRef[],
274
+ ): GenNode {
255
275
  const params: [string, GenNode][] = [['c', ctx.importType(controller.identity)]]
256
- // `ctx` is declared only when used (generated code type-checks with `noUnusedParameters`)
276
+ // `ctx` is declared only when used (generated code type-checks with `noUnusedParameters`), and as
277
+ // `_ctx` before `x` when only class resolvers whose `resolve()` takes no parameter need arguments
257
278
  if (route.params.length > 0 || route.bodySchema !== undefined) {
258
- params.push(['ctx', helpers.context()])
279
+ params.push([readsContext(route) ? 'ctx' : '_ctx', helpers.context()])
280
+ }
281
+ // `x`: the instances of the parameter resolvers (RFC 0010), only for routes that have some
282
+ if (resolvers.length > 0) {
283
+ const tuple = resolvers.map(ref => ctx.importType(ref))
284
+ params.push(['x', ctx.code`readonly [${joinNodes(ctx, tuple)}]`])
285
+ const index = new Map(resolvers.map((ref, position) => [ref.token, position]))
286
+ const withResolvers = (x: GenNode | undefined): Helpers =>
287
+ x === undefined ? helpers : { ...helpers, resolvers: { x, index } }
288
+ return invokeArrow(ctx, params, route, (c, http, x) => [c, http, withResolvers(x)])
289
+ }
290
+ return invokeArrow(ctx, params, route, (c, http) => [c, http, helpers])
291
+ }
292
+
293
+ /** `true` when an argument (or the body validation) reads the context: all but a `resolve()` without parameter. */
294
+ function readsContext(route: HttpRouteData): boolean {
295
+ return (
296
+ route.bodySchema !== undefined ||
297
+ route.params.some(param => param.custom?.resolver === undefined || param.custom.arity !== 0)
298
+ )
299
+ }
300
+
301
+ /** The provider classes resolving custom parameters, in order of first use (`x[i]`). */
302
+ function paramResolvers(route: HttpRouteData): ClassRef[] {
303
+ const found = new Map<string, ClassRef>()
304
+ for (const param of route.params) {
305
+ const resolver = param.custom?.resolver
306
+ if (resolver !== undefined && !found.has(resolver.token)) {
307
+ found.set(resolver.token, resolver)
308
+ }
259
309
  }
310
+ return [...found.values()]
311
+ }
312
+
313
+ /** `a, b, c`. */
314
+ function joinNodes(ctx: Context, nodes: readonly GenNode[]): GenNode {
315
+ return nodes.slice(1).reduce((joined, node) => ctx.code`${joined}, ${node}`, nodes[0] as GenNode)
316
+ }
317
+
318
+ /** The arrow of {@link invokeNode}; `bind` maps the arrow parameters to the controller, the context and the helpers. */
319
+ function invokeArrow(
320
+ ctx: Context,
321
+ params: readonly [string, GenNode][],
322
+ route: HttpRouteData,
323
+ bind: (...params: GenNode[]) => readonly [GenNode, GenNode | undefined, Helpers],
324
+ ): GenNode {
260
325
  // arguments in evaluation order (handler parameter order); a hole is passed as `undefined`
261
326
  const slots: (HttpParamData | undefined)[] = []
262
327
  for (const param of route.params) {
@@ -266,7 +331,8 @@ function invokeNode(ctx: Context, helpers: Helpers, controller: HttpControllerDa
266
331
  if (bodySchema !== undefined) {
267
332
  return ctx.arrow(
268
333
  params,
269
- (c, http) => {
334
+ (...bound) => {
335
+ const [c, http, helpers] = bind(...bound)
270
336
  const call = handlerCall(
271
337
  ctx,
272
338
  c,
@@ -281,17 +347,26 @@ function invokeNode(ctx: Context, helpers: Helpers, controller: HttpControllerDa
281
347
  { async: true },
282
348
  )
283
349
  }
284
- const last = slots.findLastIndex(param => param?.schema !== undefined)
285
- return ctx.arrow(params, (c, http) =>
286
- last === -1
350
+ const last = slots.findLastIndex(param => param !== undefined && isDeferred(param))
351
+ return ctx.arrow(params, (...bound) => {
352
+ const [c, http, helpers] = bind(...bound)
353
+ return last === -1
287
354
  ? handlerCall(
288
355
  ctx,
289
356
  c,
290
357
  route,
291
358
  Array.from(slots, param => plainArgument(ctx, helpers, http as GenNode, param)),
292
359
  )
293
- : validatingBlock(ctx, helpers, route, slots, last, c, http as GenNode),
294
- )
360
+ : validatingBlock(ctx, helpers, route, slots, last, c, http as GenNode)
361
+ })
362
+ }
363
+
364
+ /**
365
+ * `true` for an argument whose expression may return a thenable: a validation with a schema, or a
366
+ * custom parameter (its resolver may return a promise).
367
+ */
368
+ function isDeferred(param: HttpParamData): boolean {
369
+ return param.schema !== undefined || param.custom !== undefined
295
370
  }
296
371
 
297
372
  /** `c.handler(args)`. */
@@ -346,7 +421,7 @@ function validatingBlock(
346
421
  const param = slots[index] as HttpParamData
347
422
  const name = local.get(index) as GenNode
348
423
  statements.push(ctx.code`const ${name} = ${plainArgument(ctx, helpers, http, param)}`)
349
- if (param.schema !== undefined) {
424
+ if (isDeferred(param)) {
350
425
  const rest = Array.from(slots, (other, position) => {
351
426
  if (position < index) {
352
427
  return evaluated(position)
@@ -369,7 +444,7 @@ function validatingBlock(
369
444
  /** An argument evaluated in an `async` arrow: its validation, if any, is `await`ed. */
370
445
  function awaitedArgument(ctx: Context, helpers: Helpers, http: GenNode, param: HttpParamData | undefined): GenNode {
371
446
  const node = plainArgument(ctx, helpers, http, param)
372
- return param?.schema === undefined ? node : ctx.code`await ${node}`
447
+ return param === undefined || !isDeferred(param) ? node : ctx.code`await ${node}`
373
448
  }
374
449
 
375
450
  /** An argument as an expression (`validate()` not awaited); `undefined` for a hole. */
@@ -435,9 +510,43 @@ function argumentNode(ctx: Context, helpers: Helpers, http: GenNode, param: Http
435
510
  }
436
511
  return coercion(ctx, helpers, param.coercion ?? { plan: { kind: 'raw' }, cast: false }, value, label)
437
512
  }
513
+ case 'custom':
514
+ return customArgument(ctx, helpers, http, param.custom as CustomParamData)
438
515
  }
439
516
  }
440
517
 
518
+ /**
519
+ * A custom parameter (RFC 0010): `(D.resolvers.http as ParamFunctionOf<typeof D, 'http'>)(ctx, data)`
520
+ * for a function resolver, `x[i].resolve(ctx, data)` for a class resolver; cast with `as never` when
521
+ * the value type could not be checked against the parameter.
522
+ */
523
+ function customArgument(ctx: Context, helpers: Helpers, http: GenNode, custom: CustomParamData): GenNode {
524
+ const data = custom.data === undefined ? ctx.lit(undefined) : dataNode(ctx, custom.data.value)
525
+ let call: GenNode
526
+ if (custom.resolver === undefined) {
527
+ const decorator = ctx.importValue(custom.identity)
528
+ const callee = ctx.code`${ctx.member(decorator, 'resolvers', 'http')} as ${helpers.paramFunctionOf()}<typeof ${decorator}, 'http'>`
529
+ call = ctx.call(callee, [http, data])
530
+ } else {
531
+ const resolvers = helpers.resolvers as NonNullable<Helpers['resolvers']>
532
+ const index = resolvers.index.get(custom.resolver.token) as number
533
+ if (custom.arity === undefined) {
534
+ // a `resolve` the compiler cannot read (inherited from a mixin...): the instance is cast to a
535
+ // method taking both arguments (method parameters are bivariant), the value with `as never`
536
+ return ctx.code`(${resolvers.x}[${index}] as { resolve(ctx: unknown, data: unknown): unknown }).resolve(${http}, ${data}) as never`
537
+ }
538
+ // a `resolve(ctx)` that ignores the data gets no data argument (TypeScript checks the arity)
539
+ const args = [http, data].slice(0, custom.arity)
540
+ call = ctx.code`${resolvers.x}[${index}].resolve(${args.length === 0 ? ctx.code`` : joinNodes(ctx, args)})`
541
+ }
542
+ return custom.cast ? ctx.code`${call} as never` : call
543
+ }
544
+
545
+ /** The data argument of a custom parameter: a literal (references imported) or a reference. */
546
+ function dataNode(ctx: Context, value: StaticValue): GenNode {
547
+ return isValueRef(value) ? ctx.ref(value) : ctx.lit(value as Parameters<Context['lit']>[0])
548
+ }
549
+
441
550
  /** `validate(schema, value, location)` (the value, or a promise when the schema validates asynchronously). */
442
551
  function validation(ctx: Context, helpers: Helpers, schema: ValueRef, value: GenNode, location: string): GenNode {
443
552
  return ctx.call(helpers.validate(), [ctx.ref(schema), value, location])
@@ -150,6 +150,25 @@ export interface UploadData {
150
150
  readonly cast: boolean
151
151
  }
152
152
 
153
+ /** A custom parameter (`createParamDecorator`, RFC 0010). */
154
+ export interface CustomParamData {
155
+ /** Canonical identity of the decorator `const` (imported by the generated module). */
156
+ readonly identity: string
157
+ /** Name of the decorator (`CurrentUser`). */
158
+ readonly name: string
159
+ /** The data argument: evaluated value (it may contain references) or a reference; `undefined` without argument. */
160
+ readonly data: { readonly value: StaticValue } | undefined
161
+ /** The class resolver (`x[i].resolve(ctx, data)`); `undefined` for a function resolver. */
162
+ readonly resolver: ClassRef | undefined
163
+ /**
164
+ * Parameters of the class resolver's `resolve` (own, inherited or an arrow function property);
165
+ * `undefined` for a function resolver, or when the compiler cannot read it (the call is cast).
166
+ */
167
+ readonly arity?: number
168
+ /** The value type could not be checked against the parameter: `as never`. */
169
+ readonly cast: boolean
170
+ }
171
+
153
172
  /** A handler parameter. */
154
173
  export interface HttpParamData {
155
174
  /** Position in the handler signature. */
@@ -169,6 +188,8 @@ export interface HttpParamData {
169
188
  readonly coercion: ParamCoercion | undefined
170
189
  /** Upload parameter (`source` `file` / `files`). */
171
190
  readonly upload?: UploadData
191
+ /** Custom parameter (`source` `custom`). */
192
+ readonly custom?: CustomParamData
172
193
  /**
173
194
  * The default value of the handler parameter (`@Query('page') page = 1` → `1`), when it is
174
195
  * statically evaluable (literals, `const` references; references to exported bindings are left
package/src/context.ts CHANGED
@@ -63,6 +63,13 @@ export interface HttpRouteInfo {
63
63
  readonly meta: Readonly<Record<string, unknown>>
64
64
  }
65
65
 
66
+ declare module '@znight/core' {
67
+ /** Custom parameter decorators (`createParamDecorator`) resolve HTTP parameters from the {@link HttpContext}. */
68
+ interface ParamContexts {
69
+ http: HttpContext
70
+ }
71
+ }
72
+
66
73
  /**
67
74
  * Adapter-neutral view of the current HTTP request, shared by guards, interceptors, exception
68
75
  * mappers and handlers (`@Ctx()`), and injectable in request-scoped providers.
package/src/metadata.ts CHANGED
@@ -26,6 +26,8 @@ export type HttpParamSource =
26
26
  | 'files'
27
27
  | 'cookie'
28
28
  | 'signedCookie'
29
+ /** A custom parameter decorator (`createParamDecorator`, RFC 0010); `name` is the decorator's name. */
30
+ | 'custom'
29
31
 
30
32
  /** A handler parameter (informational: extraction, coercion and validation happen in `invoke`). */
31
33
  export interface HttpParamDefinition {
@@ -114,11 +116,18 @@ export interface HttpRouteDefinition {
114
116
  */
115
117
  readonly sse?: HttpSseDefinition
116
118
  /**
117
- * Calls the handler: extracts, coerces and validates every argument from the context, then calls
118
- * the method on the controller instance. May return a promise.
119
+ * Provider classes resolving custom parameters (`ParamResolver`, RFC 0010), in order of first use:
120
+ * the plugin resolves them in the request scope and passes the instances to `invoke` as `x`
121
+ * (`x[i]` is the instance of `paramResolvers[i]`). Absent when the route has none.
119
122
  */
120
- // biome-ignore lint/suspicious/noExplicitAny: generated functions take the concrete controller class
121
- readonly invoke: (controller: any, ctx: HttpContext) => unknown
123
+ readonly paramResolvers?: readonly Token[]
124
+ /**
125
+ * Calls the handler: extracts, coerces and validates every argument from the context (custom
126
+ * parameters included), then calls the method on the controller instance. May return a promise.
127
+ * `resolvers` is passed when the route has {@link paramResolvers}.
128
+ */
129
+ // biome-ignore lint/suspicious/noExplicitAny: generated functions take the concrete controller class and resolver tuple
130
+ readonly invoke: (controller: any, ctx: HttpContext, resolvers?: any) => unknown
122
131
  }
123
132
 
124
133
  /** A controller: a provider whose methods are routes. */
package/src/pipeline.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { Container, CoreLogger, Hooks, HttpRequestEvent, OpenedScope, Token } from '@znight/core'
2
2
  import type { AdapterHandler, AdapterReply, AdapterRequest, ReplyBody } from './adapter.ts'
3
+ import type { HttpContext } from './context.ts'
3
4
  import type { CookieSettings } from './cookie-settings.ts'
4
5
  import type { SerializedCookie } from './cookies.ts'
5
6
  import type { CorsPolicy } from './cors.ts'
@@ -53,6 +54,11 @@ export type Resolvable<T> = { readonly instance: T } | { readonly token: Token }
53
54
  /** Everything the pipeline needs to serve one registered route (internal). */
54
55
  export interface RoutePlan {
55
56
  readonly definition: HttpRouteDefinition
57
+ /**
58
+ * Calls the handler: `definition.invoke`, or for a route with parameter resolvers a function
59
+ * passing their instances (resolved in the request scope) as the third argument.
60
+ */
61
+ readonly invoke: (controller: object, ctx: HttpContext) => unknown
56
62
  /** The registered path (a trailing-slash variant has an extra empty static segment). */
57
63
  readonly path: RoutePath
58
64
  readonly info: HttpRouteInfo
@@ -436,7 +442,7 @@ export class Pipeline {
436
442
  let result: unknown
437
443
  let reply: AdapterReply | undefined
438
444
  try {
439
- result = plan.definition.invoke(this.#resolve(plan.controller), ctx)
445
+ result = plan.invoke(this.#resolve(plan.controller), ctx)
440
446
  if (isThenable(result)) return this.#fastLater(route, request, ctx, result, startTime)
441
447
  reply = this.#fastReply(route, ctx, result)
442
448
  } catch (error) {
@@ -480,7 +486,7 @@ export class Pipeline {
480
486
  if (result === NO_RESULT) {
481
487
  const controller = this.#resolve(plan.controller)
482
488
  await ctx.parseBody()
483
- result = plan.definition.invoke(controller, ctx)
489
+ result = plan.invoke(controller, ctx)
484
490
  }
485
491
  if (isThenable(result)) result = await result
486
492
  reply = this.#fastReply(route, ctx, result)
@@ -584,7 +590,7 @@ export class Pipeline {
584
590
  if (plan.parseBody) return this.#fastOperationLater(x, route, NO_RESULT)
585
591
  let result: unknown
586
592
  try {
587
- result = plan.definition.invoke(this.#resolve(plan.controller), ctx)
593
+ result = plan.invoke(this.#resolve(plan.controller), ctx)
588
594
  } catch (error) {
589
595
  return this.#fastChecked(x, this.#failed(error, ctx, x.event))
590
596
  }
@@ -601,7 +607,7 @@ export class Pipeline {
601
607
  if (result === NO_RESULT) {
602
608
  const controller = this.#resolve(plan.controller)
603
609
  await ctx.parseBody()
604
- result = plan.definition.invoke(controller, ctx)
610
+ result = plan.invoke(controller, ctx)
605
611
  }
606
612
  if (isThenable(result)) result = await result
607
613
  } catch (error) {
@@ -1235,13 +1241,12 @@ export class Pipeline {
1235
1241
  if (allowed !== true) throw new ForbiddenException('Forbidden resource')
1236
1242
  }
1237
1243
  if (plan.parseBody) await ctx.parseBody()
1238
- const definition = plan.definition
1239
1244
  const interceptors = plan.interceptors
1240
1245
  if (interceptors.length === 0) {
1241
- result = definition.invoke(controller, ctx)
1246
+ result = plan.invoke(controller, ctx)
1242
1247
  } else {
1243
1248
  const chain = async (index: number): Promise<unknown> => {
1244
- if (index === interceptors.length) return await definition.invoke(controller, ctx)
1249
+ if (index === interceptors.length) return await plan.invoke(controller, ctx)
1245
1250
  const interceptor = this.#resolve(interceptors[index] as Resolvable<HttpInterceptor>)
1246
1251
  return await interceptor.intercept(ctx, () => chain(index + 1))
1247
1252
  }
package/src/plugin.ts CHANGED
@@ -969,6 +969,7 @@ function buildRegistrations(
969
969
  const body = bodyPlan(route, options.bodyLimit, options.uploads)
970
970
  const parseBody = body.upload || route.params.some(param => param.source === 'body')
971
971
  const chain = guards.map(guard => guard.resolvable)
972
+ const invoke = routeInvoke(container, route, lookup, where)
972
973
  // computed once every group is declared (guards of every chain, plugin routes), then frozen
973
974
  let meta: RouteMeta | undefined
974
975
  const effective = (): RouteMeta => {
@@ -981,6 +982,7 @@ function buildRegistrations(
981
982
  path,
982
983
  plan: (method: string): RoutePlan => ({
983
984
  definition: route,
985
+ invoke,
984
986
  path,
985
987
  info: Object.freeze({
986
988
  method,
@@ -1052,6 +1054,7 @@ function buildRegistrations(
1052
1054
  path,
1053
1055
  plan: (method: string): RoutePlan => ({
1054
1056
  definition,
1057
+ invoke: definition.invoke,
1055
1058
  path,
1056
1059
  info: Object.freeze({ method, path: path.path, controller: route.plugin, handler: route.name, meta }),
1057
1060
  controller: instance,
@@ -1264,6 +1267,9 @@ function assertRoute(route: HttpRouteDefinition, where: string): void {
1264
1267
  }
1265
1268
  if (route.uploads !== undefined) assertUploads(route, where)
1266
1269
  if (route.sse !== undefined) assertSse(route, where)
1270
+ if (route.paramResolvers !== undefined && !Array.isArray(route.paramResolvers)) {
1271
+ throw new ZnightError(ErrorCode.InvalidRoute, `Invalid route ${where}: paramResolvers must be an array of tokens`)
1272
+ }
1267
1273
  }
1268
1274
 
1269
1275
  function assertSse(route: HttpRouteDefinition, where: string): void {
@@ -1340,6 +1346,33 @@ function describeValue(value: unknown): string {
1340
1346
  // Provider resolution
1341
1347
  // -------------------------------------------------------------------------------------------------
1342
1348
 
1349
+ /**
1350
+ * The `invoke` of a route plan: the generated `invoke` itself, unless the route has parameter
1351
+ * resolvers (RFC 0010), whose instances are passed as its third argument: one frozen array resolved
1352
+ * at bootstrap when they are all singletons, otherwise an array resolved in the request scope.
1353
+ */
1354
+ function routeInvoke(
1355
+ container: Container,
1356
+ route: HttpRouteDefinition,
1357
+ lookup: <T>(token: Token, what: string) => Resolvable<T>,
1358
+ where: string,
1359
+ ): RoutePlan['invoke'] {
1360
+ const invoke = route.invoke
1361
+ const tokens = route.paramResolvers
1362
+ if (tokens === undefined || tokens.length === 0) return invoke
1363
+ const resolvers = tokens.map(token => lookup<object>(token, `parameter resolver of ${where}`))
1364
+ if (resolvers.every(resolver => 'instance' in resolver)) {
1365
+ const instances = Object.freeze(resolvers.map(resolver => (resolver as { readonly instance: object }).instance))
1366
+ return (controller, ctx) => invoke(controller, ctx, instances)
1367
+ }
1368
+ return (controller, ctx) =>
1369
+ invoke(
1370
+ controller,
1371
+ ctx,
1372
+ resolvers.map(resolver => ('instance' in resolver ? resolver.instance : container.get(resolver.token))),
1373
+ )
1374
+ }
1375
+
1343
1376
  /**
1344
1377
  * Singletons (and registered values) are resolved once at bootstrap; request-scoped and transient
1345
1378
  * providers on every request.