@znight/messaging 0.2.2 → 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.
@@ -18,7 +18,7 @@
18
18
  * }
19
19
  * ```
20
20
  */
21
- import type { GenerateContext, GenInput, GenNode } from '@znight/compiler'
21
+ import type { ClassRef, GenerateContext, GenInput, GenNode, StaticValue, ValueRef } from '@znight/compiler'
22
22
  import type { ConsumerData, HandlerData, HandlerParamData, MessagingData } from './data.ts'
23
23
 
24
24
  import { compareStrings, RUNTIME_MODULE } from './constants.ts'
@@ -33,6 +33,13 @@ interface Helpers {
33
33
  inboundMessage(): GenNode
34
34
  validateMessage(): GenNode
35
35
  messageHeader(): GenNode
36
+ paramFunctionOf(): GenNode
37
+ }
38
+
39
+ /** The class resolver instances of the handler being generated (`x`), with the position of each token. */
40
+ interface Resolvers {
41
+ readonly x: GenNode | undefined
42
+ readonly index: ReadonlyMap<string, number>
36
43
  }
37
44
 
38
45
  /** Builds the messaging section; `undefined` (no section) when the application has no consumer. */
@@ -68,36 +75,77 @@ function handlerNode(ctx: Context, handler: HandlerData, consumerType: () => Gen
68
75
  const entries: [string, GenInput][] = [['method', handler.method]]
69
76
  if (handler.type !== undefined) entries.push(['type', handler.type])
70
77
  if (handler.retry !== undefined) entries.push(['retry', ctx.lit({ ...handler.retry })])
71
- entries.push(['invoke', invokeNode(ctx, handler, consumerType, helpers)])
78
+ const resolvers = paramResolvers(handler)
79
+ if (resolvers.length > 0) {
80
+ entries.push(['paramResolvers', ctx.lit(resolvers.map(ref => ctx.tokenOf(ref) ?? ref.token))])
81
+ }
82
+ entries.push(['invoke', invokeNode(ctx, handler, consumerType, helpers, resolvers)])
72
83
  return ctx.obj(entries)
73
84
  }
74
85
 
86
+ /** The provider classes resolving custom parameters, in order of first use (`x[i]`). */
87
+ function paramResolvers(handler: HandlerData): ClassRef[] {
88
+ const found = new Map<string, ClassRef>()
89
+ for (const param of handler.params) {
90
+ const resolver = param.kind === 'custom' ? param.resolver : undefined
91
+ if (resolver !== undefined && !found.has(resolver.token)) found.set(resolver.token, resolver)
92
+ }
93
+ return [...found.values()]
94
+ }
95
+
75
96
  /**
76
97
  * `(c: Consumer, m: InboundMessage) => c.method(...args)`, `async` only when it awaits a schema
77
- * validation. `m` is omitted when no argument uses it (`noUnusedParameters`).
98
+ * validation or a custom parameter. `m` is omitted when no argument uses it (`noUnusedParameters`);
99
+ * `x` (the class resolver instances) is declared only when the handler has class resolvers.
78
100
  */
79
- function invokeNode(ctx: Context, handler: HandlerData, consumerType: () => GenNode, helpers: Helpers): GenNode {
101
+ function invokeNode(
102
+ ctx: Context,
103
+ handler: HandlerData,
104
+ consumerType: () => GenNode,
105
+ helpers: Helpers,
106
+ resolvers: readonly ClassRef[],
107
+ ): GenNode {
80
108
  const params = [...handler.params]
81
109
  while (params.length > 0 && params[params.length - 1]?.kind === 'undefined') params.pop()
82
110
  if (params.length === 0) {
83
111
  return ctx.arrow([['c', consumerType()]], c => ctx.call(ctx.member(c, handler.method)))
84
112
  }
113
+ // `_m` when only class resolvers whose `resolve()` takes no parameter need arguments (`noUnusedParameters`)
114
+ const readsMessage = params.some(
115
+ param =>
116
+ param.kind !== 'undefined' && (param.kind !== 'custom' || param.resolver === undefined || param.arity !== 0),
117
+ )
118
+ const arrowParams: [string, GenNode][] = [
119
+ ['c', consumerType()],
120
+ [readsMessage ? 'm' : '_m', helpers.inboundMessage()],
121
+ ]
122
+ if (resolvers.length > 0) {
123
+ const types = resolvers.map(ref => ctx.importType(ref))
124
+ const joined = types.slice(1).reduce((all, node) => ctx.code`${all}, ${node}`, types[0] as GenNode)
125
+ arrowParams.push(['x', ctx.code`readonly [${joined}]`])
126
+ }
127
+ const index = new Map(resolvers.map((ref, position) => [ref.token, position]))
85
128
  return ctx.arrow(
86
- [
87
- ['c', consumerType()],
88
- ['m', helpers.inboundMessage()],
89
- ],
90
- (c, m) =>
129
+ arrowParams,
130
+ (c, m, x) =>
91
131
  ctx.call(
92
132
  ctx.member(c, handler.method),
93
- params.map(param => argumentNode(ctx, param, m as GenNode, helpers)),
133
+ params.map(param => argumentNode(ctx, param, m as GenNode, helpers, { x, index })),
94
134
  ),
95
- { async: params.some(param => param.kind === 'validated') },
135
+ { async: params.some(param => param.kind === 'validated' || param.kind === 'custom') },
96
136
  )
97
137
  }
98
138
 
99
- function argumentNode(ctx: Context, param: HandlerParamData, m: GenNode, helpers: Helpers): GenNode {
139
+ function argumentNode(
140
+ ctx: Context,
141
+ param: HandlerParamData,
142
+ m: GenNode,
143
+ helpers: Helpers,
144
+ resolvers: Resolvers,
145
+ ): GenNode {
100
146
  switch (param.kind) {
147
+ case 'custom':
148
+ return customArgument(ctx, param, m, helpers, resolvers)
101
149
  case 'payload':
102
150
  return ctx.code`${ctx.member(m, 'payload')} as never`
103
151
  case 'validated':
@@ -113,6 +161,54 @@ function argumentNode(ctx: Context, param: HandlerParamData, m: GenNode, helpers
113
161
  }
114
162
  }
115
163
 
164
+ /**
165
+ * A custom parameter (RFC 0010), awaited (the invoke is `async`):
166
+ * `await (D.resolvers.messaging as ParamFunctionOf<typeof D, 'messaging'>)(m, data)` or
167
+ * `await x[i].resolve(m, data)`, cast with `as never` when the value type could not be checked.
168
+ */
169
+ function customArgument(
170
+ ctx: Context,
171
+ param: Extract<HandlerParamData, { kind: 'custom' }>,
172
+ m: GenNode,
173
+ helpers: Helpers,
174
+ resolvers: Resolvers,
175
+ ): GenNode {
176
+ const data = param.data === undefined ? ctx.lit(undefined) : dataNode(ctx, param.data.value)
177
+ let node: GenNode
178
+ if (param.resolver === undefined) {
179
+ const decorator = ctx.importValue(param.identity)
180
+ const type = ctx.code`${helpers.paramFunctionOf()}<typeof ${decorator}, 'messaging'>`
181
+ node = ctx.call(ctx.code`${ctx.member(decorator, 'resolvers', 'messaging')} as ${type}`, [m, data])
182
+ } else {
183
+ const position = resolvers.index.get(param.resolver.token) as number
184
+ if (param.arity === undefined) {
185
+ // a `resolve` the compiler cannot read (inherited from a mixin...): the instance is cast to a
186
+ // method taking both arguments (method parameters are bivariant), the value with `as never`
187
+ const instance = ctx.code`${resolvers.x as GenNode}[${position}] as { resolve(message: unknown, data: unknown): unknown }`
188
+ return ctx.code`(await (${instance}).resolve(${m}, ${data})) as never`
189
+ }
190
+ // a `resolve(message)` that ignores the data gets no data argument (TypeScript checks the arity)
191
+ const arity = Math.min(param.arity, 2)
192
+ let args = ctx.code``
193
+ if (arity === 1) args = m
194
+ else if (arity === 2) args = ctx.code`${m}, ${data}`
195
+ node = ctx.code`${resolvers.x as GenNode}[${position}].resolve(${args})`
196
+ }
197
+ return param.cast ? ctx.code`(await ${node}) as never` : ctx.code`await ${node}`
198
+ }
199
+
200
+ /** The data argument of a custom parameter: a literal (references imported) or a reference. */
201
+ function dataNode(ctx: Context, value: StaticValue): GenNode {
202
+ return isValueRef(value) ? ctx.ref(value as ValueRef) : ctx.lit(value as never)
203
+ }
204
+
205
+ /** `true` for a {@link ValueRef} (the only non-plain object a static value can hold). */
206
+ function isValueRef(value: unknown): boolean {
207
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
208
+ const prototype = Object.getPrototypeOf(value)
209
+ return prototype !== Object.prototype && prototype !== null && (value as { kind?: unknown }).kind === 'ref'
210
+ }
211
+
116
212
  function lazyHelpers(ctx: Context): Helpers {
117
213
  const cache = new Map<string, GenNode>()
118
214
  const get = (name: string, type: boolean): GenNode => {
@@ -127,5 +223,13 @@ function lazyHelpers(ctx: Context): Helpers {
127
223
  inboundMessage: () => get('InboundMessage', true),
128
224
  validateMessage: () => get('validateMessage', false),
129
225
  messageHeader: () => get('messageHeader', false),
226
+ paramFunctionOf: () => {
227
+ let node = cache.get('@znight/core#ParamFunctionOf')
228
+ if (node === undefined) {
229
+ node = ctx.importFrom('@znight/core', 'ParamFunctionOf', { type: true })
230
+ cache.set('@znight/core#ParamFunctionOf', node)
231
+ }
232
+ return node
233
+ },
130
234
  }
131
235
  }
package/src/consumers.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import type { Container, Token } from '@znight/core'
5
5
  import type { MessagingDriver } from './driver.ts'
6
+ import type { InboundMessage } from './message.ts'
6
7
  import type { ConsumerDefinition, MessageHandlerDefinition, MessagingMetadata } from './metadata.ts'
7
8
  import type { RetryOptions, RetryPolicy } from './retry.ts'
8
9
 
@@ -22,6 +23,11 @@ import {
22
23
  /** A handler ready to be dispatched to. */
23
24
  export interface PreparedHandler {
24
25
  readonly definition: MessageHandlerDefinition
26
+ /**
27
+ * Calls the handler: `definition.invoke`, or for a handler with parameter resolvers (RFC 0010) a
28
+ * function passing their instances, resolved in the delivery scope.
29
+ */
30
+ readonly invoke: (consumer: unknown, message: InboundMessage) => unknown
25
31
  readonly method: string
26
32
  readonly policy: RetryPolicy
27
33
  }
@@ -140,7 +146,7 @@ function prepareConsumer(
140
146
  let maxAttempts = 1
141
147
  const nativeDelays = new Set<number>()
142
148
  for (const handlerDefinition of handlers) {
143
- const handler = prepareHandler(handlerDefinition, name, driver, defaults.retry)
149
+ const handler = prepareHandler(handlerDefinition, name, driver, defaults.retry, container)
144
150
  maxAttempts = Math.max(maxAttempts, handler.policy.attempts)
145
151
  for (const delay of retryDelays(handler.policy)) {
146
152
  if (!isInProcessDelay(delay, driver.capabilities)) nativeDelays.add(delay)
@@ -204,6 +210,7 @@ function prepareHandler(
204
210
  consumer: string,
205
211
  driver: MessagingDriver,
206
212
  defaultPolicy: RetryPolicy,
213
+ container: Container,
207
214
  ): PreparedHandler {
208
215
  if (
209
216
  definition === null ||
@@ -227,7 +234,29 @@ function prepareHandler(
227
234
  driver.name,
228
235
  definition.retry === undefined ? `${where} (default retry policy)` : `@Retry on ${where}`,
229
236
  )
230
- return { definition, method: definition.method, policy }
237
+ return { definition, invoke: handlerInvoke(definition, container, where), method: definition.method, policy }
238
+ }
239
+
240
+ /** The generated invoke, or a function passing the parameter resolver instances (resolved per delivery). */
241
+ function handlerInvoke(
242
+ definition: MessageHandlerDefinition,
243
+ container: Container,
244
+ where: string,
245
+ ): PreparedHandler['invoke'] {
246
+ const invoke = definition.invoke
247
+ const tokens = definition.paramResolvers
248
+ if (tokens === undefined || tokens.length === 0) return invoke
249
+ for (const token of tokens) {
250
+ if (!container.has(token)) {
251
+ throw consumerError(`${where}: the parameter resolver ${token} is not a provider`, ['Regenerate the metadata'])
252
+ }
253
+ }
254
+ return (consumer, message) =>
255
+ invoke(
256
+ consumer,
257
+ message,
258
+ tokens.map(token => container.get(token)),
259
+ )
231
260
  }
232
261
 
233
262
  function duplicateType(consumer: string, type: string): Error {
package/src/message.ts CHANGED
@@ -1,3 +1,10 @@
1
+ declare module '@znight/core' {
2
+ /** Custom parameter decorators (`createParamDecorator`) resolve handler parameters from the {@link InboundMessage}. */
3
+ interface ParamContexts {
4
+ messaging: InboundMessage
5
+ }
6
+ }
7
+
1
8
  /**
2
9
  * A message being handled by a consumer. It is the value of the request-scoped external
3
10
  * `@znight/messaging#InboundMessage` during a delivery (inject it into request-scoped providers, or
package/src/metadata.ts CHANGED
@@ -49,13 +49,19 @@ export interface MessageHandlerDefinition {
49
49
  readonly type?: string
50
50
  /** `@Retry` policy, normalized. Default: the plugin's `defaults.retry`. */
51
51
  readonly retry?: RetryPolicy
52
+ /**
53
+ * Provider classes resolving custom parameters (`ParamResolver`, RFC 0010), in order of first use:
54
+ * the runtime resolves them in the delivery scope and passes the instances to `invoke` (`x`).
55
+ * Absent when there is none.
56
+ */
57
+ readonly paramResolvers?: readonly Token[]
52
58
  /**
53
59
  * Calls the handler: extracts and validates the parameters from the message
54
60
  * (`@Message(schema)` → `validateMessage(schema, m.payload)`, `@Headers(name)` →
55
- * `messageHeader(m, name)`, `@Ctx()` → `m`).
61
+ * `messageHeader(m, name)`, `@Ctx()` → `m`, a custom parameter → its resolver, awaited).
56
62
  */
57
- // biome-ignore lint/suspicious/noExplicitAny: generated invokers receive the concrete consumer class
58
- readonly invoke: (consumer: any, message: InboundMessage) => unknown
63
+ // biome-ignore lint/suspicious/noExplicitAny: generated invokers receive the concrete consumer class and resolver tuple
64
+ readonly invoke: (consumer: any, message: InboundMessage, resolvers?: any) => unknown
59
65
  }
60
66
 
61
67
  declare module '@znight/core' {
package/src/runtime.ts CHANGED
@@ -251,7 +251,7 @@ export class ConsumerRuntime {
251
251
  }
252
252
  try {
253
253
  const instance = this.#services.container.get(this.consumer.token)
254
- await handler.definition.invoke(instance, message)
254
+ await handler.invoke(instance, message)
255
255
  } catch (error) {
256
256
  return this.#failure(context, error, true)
257
257
  }