@znight/messaging 0.0.0-stage → 0.1.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.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +622 -2
  3. package/dist/async-CFh_bvex.mjs +216 -0
  4. package/dist/async-CFh_bvex.mjs.map +1 -0
  5. package/dist/codec-Dku-NXXU.mjs +83 -0
  6. package/dist/codec-Dku-NXXU.mjs.map +1 -0
  7. package/dist/compiler.d.mts +75 -0
  8. package/dist/compiler.mjs +1023 -0
  9. package/dist/compiler.mjs.map +1 -0
  10. package/dist/driver-ZxLqy_Ap.d.mts +216 -0
  11. package/dist/index.d.mts +459 -0
  12. package/dist/index.mjs +870 -0
  13. package/dist/index.mjs.map +1 -0
  14. package/dist/kafka.d.mts +51 -0
  15. package/dist/kafka.mjs +517 -0
  16. package/dist/kafka.mjs.map +1 -0
  17. package/dist/memory.d.mts +124 -0
  18. package/dist/memory.mjs +429 -0
  19. package/dist/memory.mjs.map +1 -0
  20. package/dist/rabbitmq.d.mts +65 -0
  21. package/dist/rabbitmq.mjs +686 -0
  22. package/dist/rabbitmq.mjs.map +1 -0
  23. package/dist/shared-BJBpLS8V.mjs +52 -0
  24. package/dist/shared-BJBpLS8V.mjs.map +1 -0
  25. package/dist/sqs.d.mts +38 -0
  26. package/dist/sqs.mjs +421 -0
  27. package/dist/sqs.mjs.map +1 -0
  28. package/package.json +123 -4
  29. package/src/broker.ts +156 -0
  30. package/src/codec.ts +98 -0
  31. package/src/compiler/analyze.ts +800 -0
  32. package/src/compiler/constants.ts +73 -0
  33. package/src/compiler/data.ts +75 -0
  34. package/src/compiler/generate.ts +131 -0
  35. package/src/compiler/index.ts +52 -0
  36. package/src/compiler/patterns.ts +54 -0
  37. package/src/compiler/retry.ts +136 -0
  38. package/src/compiler/validate.ts +57 -0
  39. package/src/compiler/values.ts +76 -0
  40. package/src/consumers.ts +260 -0
  41. package/src/decorators.ts +131 -0
  42. package/src/driver.ts +142 -0
  43. package/src/drivers/kafka.ts +765 -0
  44. package/src/drivers/memory.ts +609 -0
  45. package/src/drivers/rabbitmq.ts +944 -0
  46. package/src/drivers/shared.ts +81 -0
  47. package/src/drivers/sqs.ts +583 -0
  48. package/src/errors.ts +95 -0
  49. package/src/headers.ts +70 -0
  50. package/src/hooks.ts +2 -0
  51. package/src/index.ts +89 -0
  52. package/src/internal/async.ts +115 -0
  53. package/src/message.ts +96 -0
  54. package/src/metadata.ts +65 -0
  55. package/src/plugin.ts +236 -0
  56. package/src/retry.ts +129 -0
  57. package/src/runtime.ts +424 -0
  58. package/src/standard-schema.ts +68 -0
  59. package/src/tokens.ts +14 -0
  60. package/src/validation.ts +61 -0
@@ -0,0 +1,800 @@
1
+ /**
2
+ * `analyzeClass` of the messaging extension: reads `@Consumer` classes, their `@OnMessage` handlers
3
+ * (`@Retry`, parameters), their `@OnError` method, registers the consumer provider and reports every
4
+ * problem that can be detected on one class (ZNIGHT005, ZNIGHT011, ZNIGHT017).
5
+ *
6
+ * Handlers may be declared on base classes: the consumer inherits the messaging methods of its
7
+ * analysable base classes (the nearest declaration of a method carrying messaging decorators wins, an
8
+ * override without decorators keeps the inherited handler).
9
+ */
10
+ import type {
11
+ AnalyzeContext,
12
+ ClassInfo,
13
+ DecoratorInfo,
14
+ Location,
15
+ MethodInfo,
16
+ ParameterInfo,
17
+ StaticValue,
18
+ TypeHelpers,
19
+ TypeInfo,
20
+ ValueRef,
21
+ } from '@znight/compiler'
22
+ import type { Scope } from '@znight/core'
23
+ import type { HandlerData, HandlerParamData, MessagingData, RetryPolicyData } from './data.ts'
24
+
25
+ import {
26
+ CONSUMER_SCOPES,
27
+ EXTENSION_NAME,
28
+ INBOUND_MESSAGE_IDENTITY,
29
+ INVALID_CONSUMER,
30
+ INVALID_DECORATOR,
31
+ METHOD_DECORATORS,
32
+ MessagingDecorator,
33
+ PARAMETER_DECORATORS,
34
+ RUNTIME_MODULE,
35
+ UNVALIDATED_INPUT,
36
+ } from './constants.ts'
37
+ import { isPattern, patternCovers } from './patterns.ts'
38
+ import { normalizeRetryDecorator } from './retry.ts'
39
+ import { describeValue, isRecord, propertyLocation } from './values.ts'
40
+
41
+ type Context = AnalyzeContext<MessagingData>
42
+
43
+ /** The messaging declarations of one method. */
44
+ interface MemberAnalysis {
45
+ readonly method: string
46
+ readonly declaredBy: string
47
+ /** Number of `@OnMessage` decorators (valid or not). */
48
+ readonly onMessage: number
49
+ /** One entry per valid `@OnMessage` decorator. */
50
+ readonly handlers: readonly HandlerData[]
51
+ /** Location of the `@OnError` decorator when the method is the error handler. */
52
+ readonly errorHandler: Location | undefined
53
+ }
54
+
55
+ /** Types injected as is by `@Message()` without being "unvalidated input" (binary payloads). */
56
+ const BINARY_TYPES: ReadonlySet<string> = new Set([
57
+ 'globalThis#Uint8Array',
58
+ 'globalThis#ArrayBuffer',
59
+ 'globalThis#Buffer',
60
+ 'node:buffer#Buffer',
61
+ 'buffer#Buffer',
62
+ ])
63
+
64
+ /** Maximum depth of the base class chain walked for inherited handlers. */
65
+ const MAX_CHAIN = 32
66
+
67
+ /** Analyses a class carrying at least one decorator of the extension. */
68
+ export function analyzeMessagingClass(cls: ClassInfo, ctx: Context): void {
69
+ const own = analyzeMembers(cls, ctx)
70
+ const consumers = cls.decorators.filter(decorator => decorator.identity === MessagingDecorator.Consumer)
71
+ const consumer = consumers[0]
72
+ if (consumer === undefined) {
73
+ const decorators = cls.methods.flatMap(method =>
74
+ method.decorators
75
+ .filter(decorator => decorator.identity !== undefined && METHOD_DECORATORS.has(decorator.identity))
76
+ .map(decorator => ({ name: decorator.name, location: decorator.location })),
77
+ )
78
+ if (decorators.length > 0) {
79
+ ctx.store({
80
+ kind: 'members',
81
+ identity: cls.identity,
82
+ displayName: cls.displayName,
83
+ abstract: cls.abstract,
84
+ decorators,
85
+ })
86
+ }
87
+ return
88
+ }
89
+ for (const duplicate of consumers.slice(1)) {
90
+ ctx.report({
91
+ code: INVALID_DECORATOR,
92
+ severity: 'error',
93
+ message: `@${duplicate.name} is applied several times on ${cls.displayName}`,
94
+ location: duplicate.location,
95
+ hints: [`Keep a single @${duplicate.name}: a class consumes one queue`],
96
+ })
97
+ }
98
+
99
+ const options = readConsumerOptions(consumer, cls, ctx)
100
+ const token = ctx.registerProvider(cls, { scope: options.scope })
101
+ const chain = classChain(cls, ctx)
102
+
103
+ // messaging methods of the class and its bases: the nearest declaration wins, at the position of
104
+ // the first declaration (base classes first)
105
+ const merged = new Map<string, MemberAnalysis>()
106
+ for (let index = chain.length - 1; index >= 0; index--) {
107
+ const owner = chain[index] as ClassInfo
108
+ for (const [name, member] of index === 0 ? own : analyzeMembers(owner, ctx)) merged.set(name, member)
109
+ }
110
+ const members = [...merged.values()]
111
+ const handlers = members.flatMap(member => member.handlers)
112
+
113
+ if (!members.some(member => member.onMessage > 0)) {
114
+ ctx.report({
115
+ code: INVALID_CONSUMER,
116
+ severity: 'error',
117
+ message: `@Consumer ${cls.displayName} has no @OnMessage handler`,
118
+ location: consumer.location,
119
+ hints: ['Decorate at least one public method with @OnMessage() (default handler) or @OnMessage("type")'],
120
+ })
121
+ }
122
+ checkHandlerTypes(cls.displayName, handlers, ctx)
123
+
124
+ const errorHandlers = members.filter(member => member.errorHandler !== undefined)
125
+ const errorHandler = errorHandlers[0]
126
+ for (const extra of errorHandlers.slice(1)) {
127
+ ctx.report({
128
+ code: INVALID_CONSUMER,
129
+ severity: 'error',
130
+ message: `@Consumer ${cls.displayName} has several @OnError methods: ${errorHandlers.map(member => `${member.declaredBy}.${member.method}()`).join(', ')}`,
131
+ location: extra.errorHandler as Location,
132
+ hints: ['Keep a single @OnError method per consumer'],
133
+ related: [{ message: 'First @OnError method', location: errorHandler?.errorHandler as Location }],
134
+ })
135
+ }
136
+
137
+ ctx.store({
138
+ kind: 'consumer',
139
+ token,
140
+ identity: cls.identity,
141
+ displayName: cls.displayName,
142
+ queue: options.queue,
143
+ options: options.options,
144
+ handlers,
145
+ errorHandler: errorHandler?.method,
146
+ chain: chain.map(owner => owner.identity),
147
+ location: consumer.location,
148
+ })
149
+ }
150
+
151
+ // ------------------------------------------------------------------------------------------ consumer
152
+
153
+ interface ConsumerOptions {
154
+ /** Empty when invalid (reported). */
155
+ readonly queue: string
156
+ readonly scope: Scope
157
+ readonly options: Readonly<Record<string, StaticValue>> | undefined
158
+ }
159
+
160
+ /** `@Consumer(queue)` / `@Consumer({ queue, scope?, concurrency?, deadLetter?, ...driver options })`. */
161
+ function readConsumerOptions(decorator: DecoratorInfo, cls: ClassInfo, ctx: Context): ConsumerOptions {
162
+ const invalid: ConsumerOptions = { queue: '', scope: 'singleton', options: undefined }
163
+ const prefix = `@Consumer ${cls.displayName}`
164
+ if (!decorator.called) return invalid // `@Consumer` without call: already reported
165
+ const arg = decorator.args[0]
166
+ if (arg === undefined) {
167
+ ctx.report({
168
+ code: INVALID_DECORATOR,
169
+ severity: 'error',
170
+ message: `${prefix}: the queue to consume is required`,
171
+ location: decorator.location,
172
+ hints: ["Write @Consumer('orders') or @Consumer({ queue: 'orders', concurrency: 5 })"],
173
+ })
174
+ return invalid
175
+ }
176
+ if (!arg.evaluated) return invalid // not statically evaluable: already reported
177
+ const value = arg.value
178
+ if (typeof value === 'string') {
179
+ if (value === '') {
180
+ ctx.report({
181
+ code: INVALID_CONSUMER,
182
+ severity: 'error',
183
+ message: `${prefix}: the queue must be a non-empty string`,
184
+ location: arg.location,
185
+ })
186
+ }
187
+ return { queue: value, scope: 'singleton', options: undefined }
188
+ }
189
+ if (!isRecord(value)) {
190
+ ctx.report({
191
+ code: INVALID_DECORATOR,
192
+ severity: 'error',
193
+ message: `${prefix}: expected a queue name or an options object ({ queue, scope?, concurrency?, deadLetter? }), got ${describeValue(value)}`,
194
+ location: arg.location,
195
+ })
196
+ return invalid
197
+ }
198
+
199
+ const fail = (key: string, message: string, hints?: readonly string[]): void => {
200
+ ctx.report({
201
+ code: INVALID_CONSUMER,
202
+ severity: 'error',
203
+ message: `${prefix}: ${message}`,
204
+ location: propertyLocation(ctx.project, arg, key),
205
+ ...(hints === undefined ? {} : { hints }),
206
+ })
207
+ }
208
+
209
+ let queue = ''
210
+ const rawQueue = value['queue']
211
+ if (rawQueue === undefined) {
212
+ fail('queue', 'the "queue" option is required', ["Write @Consumer({ queue: 'orders', ... })"])
213
+ } else if (typeof rawQueue !== 'string' || rawQueue === '') {
214
+ fail('queue', `"queue" must be a non-empty string, got ${describeValue(rawQueue)}`)
215
+ } else {
216
+ queue = rawQueue
217
+ }
218
+
219
+ let scope: Scope = 'singleton'
220
+ const rawScope = value['scope']
221
+ if (rawScope !== undefined) {
222
+ if (typeof rawScope === 'string' && CONSUMER_SCOPES.includes(rawScope)) {
223
+ scope = rawScope as Scope
224
+ } else {
225
+ fail('scope', `"scope" must be 'singleton', 'request' or 'transient', got ${describeValue(rawScope)}`)
226
+ }
227
+ }
228
+
229
+ const concurrency = value['concurrency']
230
+ if (
231
+ concurrency !== undefined &&
232
+ !(typeof concurrency === 'number' && Number.isSafeInteger(concurrency) && concurrency > 0)
233
+ ) {
234
+ fail('concurrency', `"concurrency" must be a positive integer, got ${describeValue(concurrency)}`)
235
+ }
236
+
237
+ const deadLetter = value['deadLetter']
238
+ if (deadLetter !== undefined && deadLetter !== false && (typeof deadLetter !== 'string' || deadLetter === '')) {
239
+ fail('deadLetter', `"deadLetter" must be a queue name or false, got ${describeValue(deadLetter)}`)
240
+ } else if (deadLetter === queue && queue !== '') {
241
+ fail('deadLetter', `the dead-letter queue cannot be the consumed queue "${queue}"`, [
242
+ 'Use another queue (default: the driver naming, e.g. "orders.dlq"), or false to disable dead-lettering',
243
+ ])
244
+ }
245
+
246
+ const options: Record<string, StaticValue> = {}
247
+ for (const [key, option] of Object.entries(value)) {
248
+ if (key === 'queue' || key === 'scope' || option === undefined) continue
249
+ options[key] = option
250
+ }
251
+ return { queue, scope, options: Object.keys(options).length === 0 ? undefined : Object.freeze(options) }
252
+ }
253
+
254
+ /** The class and its analysable base classes (nearest first). */
255
+ function classChain(cls: ClassInfo, ctx: Context): ClassInfo[] {
256
+ const chain = [cls]
257
+ const seen = new Set([cls.identity])
258
+ let current = cls
259
+ while (current.superClass !== undefined && chain.length < MAX_CHAIN) {
260
+ const base = ctx.classInfo(current.superClass.identity)
261
+ if (base === undefined || seen.has(base.identity)) break
262
+ chain.push(base)
263
+ seen.add(base.identity)
264
+ current = base
265
+ }
266
+ return chain
267
+ }
268
+
269
+ /**
270
+ * Handler selection checks, mirroring the runtime (`consumers.ts`): exact types first, then patterns
271
+ * in declaration order, then the single default handler.
272
+ */
273
+ function checkHandlerTypes(consumer: string, handlers: readonly HandlerData[], ctx: Context): void {
274
+ let fallback: HandlerData | undefined
275
+ const exact = new Map<string, HandlerData>()
276
+ const patterns: (HandlerData & { readonly type: string })[] = []
277
+ const label = (handler: HandlerData): string => `${handler.declaredBy}.${handler.method}()`
278
+ for (const handler of handlers) {
279
+ const type = handler.type
280
+ if (type === undefined) {
281
+ if (fallback === undefined) {
282
+ fallback = handler
283
+ continue
284
+ }
285
+ ctx.report({
286
+ code: INVALID_CONSUMER,
287
+ severity: 'error',
288
+ message: `@Consumer ${consumer}: ${label(fallback)} and ${label(handler)} are both default @OnMessage() handlers`,
289
+ location: handler.location,
290
+ hints: ['Give one of them a message type: @OnMessage("order.created")'],
291
+ related: [{ message: 'First default handler', location: fallback.location }],
292
+ })
293
+ continue
294
+ }
295
+ const previous = isPattern(type) ? patterns.find(pattern => pattern.type === type) : exact.get(type)
296
+ if (previous !== undefined) {
297
+ ctx.report({
298
+ code: INVALID_CONSUMER,
299
+ severity: 'error',
300
+ message: `@Consumer ${consumer}: several @OnMessage handlers for type "${type}" (${label(previous)} and ${label(handler)})`,
301
+ location: handler.location,
302
+ hints: ['A message type is handled by a single method: merge the handlers'],
303
+ related: [{ message: `First handler of "${type}"`, location: previous.location }],
304
+ })
305
+ continue
306
+ }
307
+ if (!isPattern(type)) {
308
+ exact.set(type, handler)
309
+ continue
310
+ }
311
+ const covering = patterns.find(pattern => patternCovers(pattern.type, type))
312
+ if (covering !== undefined) {
313
+ ctx.report({
314
+ code: INVALID_CONSUMER,
315
+ severity: 'error',
316
+ message: `@Consumer ${consumer}: the pattern "${type}" of ${label(handler)} never matches: every type it matches is matched first by "${covering.type}" (${label(covering)})`,
317
+ location: handler.location,
318
+ hints: ['Patterns are tried in declaration order (after exact types): declare the more specific pattern first'],
319
+ related: [{ message: `Pattern "${covering.type}"`, location: covering.location }],
320
+ })
321
+ }
322
+ patterns.push({ ...handler, type })
323
+ }
324
+ }
325
+
326
+ // ------------------------------------------------------------------------------------------- members
327
+
328
+ /** Messaging methods declared by `cls` itself, by name, in declaration order. */
329
+ function analyzeMembers(cls: ClassInfo, ctx: Context): Map<string, MemberAnalysis> {
330
+ const members = new Map<string, MemberAnalysis>()
331
+ for (const method of cls.methods) {
332
+ const member = analyzeMethod(cls, method, ctx)
333
+ if (member !== undefined) members.set(method.name, member)
334
+ }
335
+ return members
336
+ }
337
+
338
+ function analyzeMethod(cls: ClassInfo, method: MethodInfo, ctx: Context): MemberAnalysis | undefined {
339
+ const onMessage = decoratorsOf(method.decorators, MessagingDecorator.OnMessage)
340
+ const retry = decoratorsOf(method.decorators, MessagingDecorator.Retry)
341
+ const onError = decoratorsOf(method.decorators, MessagingDecorator.OnError)
342
+ const decoratedParams = method.params.some(param => param.decorators.some(isParameterDecorator))
343
+ if (onMessage.length === 0 && retry.length === 0 && onError.length === 0 && !decoratedParams) return undefined
344
+
345
+ const where = `${cls.displayName}.${method.name}()`
346
+ const primary = onMessage[0] ?? onError[0]
347
+ if (primary !== undefined) checkMethodShape(method, primary, where, ctx)
348
+ for (const duplicate of [...retry.slice(1), ...onError.slice(1)]) {
349
+ ctx.report({
350
+ code: INVALID_DECORATOR,
351
+ severity: 'error',
352
+ message: `@${duplicate.name} is applied several times on ${where}`,
353
+ location: duplicate.location,
354
+ hints: [`Keep a single @${duplicate.name}`],
355
+ })
356
+ }
357
+ if (onMessage.length > 0 && onError[0] !== undefined) {
358
+ ctx.report({
359
+ code: INVALID_DECORATOR,
360
+ severity: 'error',
361
+ message: `${where} cannot be both an @OnMessage handler and the @OnError method`,
362
+ location: onError[0].location,
363
+ hints: ['Declare the error handler in a separate method'],
364
+ })
365
+ }
366
+ if (onMessage.length === 0 && retry[0] !== undefined) {
367
+ ctx.report({
368
+ code: INVALID_DECORATOR,
369
+ severity: 'error',
370
+ message: `@Retry has no effect on ${where}: it is not an @OnMessage handler`,
371
+ location: retry[0].location,
372
+ hints: ['Add @OnMessage() to the method, or remove @Retry'],
373
+ })
374
+ }
375
+
376
+ let handlers: HandlerData[] = []
377
+ if (onMessage.length > 0) {
378
+ const policy: RetryPolicyData | undefined =
379
+ retry[0] === undefined ? undefined : normalizeRetryDecorator(retry[0], where, ctx)
380
+ const params = analyzeHandlerParams(method, where, ctx)
381
+ handlers = onMessage.flatMap(decorator => {
382
+ const type = messageType(decorator, where, ctx)
383
+ if (type === false) return []
384
+ return [
385
+ Object.freeze({
386
+ method: method.name,
387
+ type,
388
+ retry: policy,
389
+ params,
390
+ declaredBy: cls.displayName,
391
+ location: decorator.location,
392
+ }),
393
+ ]
394
+ })
395
+ } else {
396
+ reportStrayParameterDecorators(method, where, onError.length > 0, ctx)
397
+ }
398
+
399
+ const errorHandler = onMessage.length === 0 ? onError[0] : undefined
400
+ if (errorHandler !== undefined) checkErrorHandler(method, errorHandler, where, ctx)
401
+ return {
402
+ method: method.name,
403
+ declaredBy: cls.displayName,
404
+ onMessage: onMessage.length,
405
+ handlers,
406
+ errorHandler: errorHandler?.location,
407
+ }
408
+ }
409
+
410
+ /** Handlers are called by the generated code: `consumer.method(...)`. */
411
+ function checkMethodShape(method: MethodInfo, decorator: DecoratorInfo, where: string, ctx: Context): void {
412
+ const name = `@${decorator.name}`
413
+ let problem: { message: string; hint: string } | undefined
414
+ if (method.kind !== 'method') {
415
+ problem = { message: `${name} cannot be applied to the accessor ${where}`, hint: 'Use a regular method' }
416
+ } else if (method.static) {
417
+ problem = {
418
+ message: `${name} cannot be applied to the static method ${where}`,
419
+ hint: 'Handlers are called on the consumer instance: remove `static`',
420
+ }
421
+ } else if (method.privateName) {
422
+ problem = {
423
+ message: `${name} cannot be applied to the #private method ${where}`,
424
+ hint: 'Use a public method: the generated code calls it',
425
+ }
426
+ } else if (method.visibility !== 'public') {
427
+ problem = {
428
+ message: `${where} must be public: ${name} methods are called by the generated code`,
429
+ hint: `Remove the '${method.visibility}' modifier`,
430
+ }
431
+ } else if (method.computed) {
432
+ problem = {
433
+ message: `${name} cannot be applied to ${where}: computed method names are not supported`,
434
+ hint: 'Use a plain method name',
435
+ }
436
+ } else if (method.generator) {
437
+ problem = {
438
+ message: `${name} cannot be applied to the generator method ${where}`,
439
+ hint: 'Use a regular (or async) method',
440
+ }
441
+ }
442
+ if (problem !== undefined) {
443
+ ctx.report({
444
+ code: INVALID_DECORATOR,
445
+ severity: 'error',
446
+ message: problem.message,
447
+ location: decorator.location,
448
+ hints: [problem.hint],
449
+ })
450
+ }
451
+ }
452
+
453
+ /** Type of an `@OnMessage` decorator: a string, `undefined` (default handler) or `false` (invalid). */
454
+ function messageType(decorator: DecoratorInfo, where: string, ctx: Context): string | undefined | false {
455
+ const arg = decorator.args[0]
456
+ if (arg === undefined || (arg.evaluated && arg.value === undefined)) return undefined
457
+ if (!arg.evaluated) return false // not statically evaluable: already reported
458
+ if (typeof arg.value !== 'string') {
459
+ ctx.report({
460
+ code: INVALID_DECORATOR,
461
+ severity: 'error',
462
+ message: `@OnMessage on ${where} expects a message type (a string), got ${describeValue(arg.value)}`,
463
+ location: arg.location,
464
+ hints: ['Use @OnMessage() for the default handler'],
465
+ })
466
+ return false
467
+ }
468
+ if (arg.value === '') {
469
+ ctx.report({
470
+ code: INVALID_CONSUMER,
471
+ severity: 'error',
472
+ message: `The @OnMessage type of ${where} must be a non-empty string`,
473
+ location: arg.location,
474
+ hints: ['Use @OnMessage() for the default handler'],
475
+ })
476
+ return false
477
+ }
478
+ return arg.value
479
+ }
480
+
481
+ /** The `@OnError` method is called as `consumer[method](error, message)`. */
482
+ function checkErrorHandler(method: MethodInfo, decorator: DecoratorInfo, where: string, ctx: Context): void {
483
+ const required = method.params.filter(param => !param.optional && !param.hasDefault && !param.rest).length
484
+ if (required > 2) {
485
+ ctx.report({
486
+ code: INVALID_CONSUMER,
487
+ severity: 'error',
488
+ message: `The @OnError method ${where} has ${required} required parameters: it is called with (error, message)`,
489
+ location: method.location,
490
+ hints: ['Declare onError(error: unknown, message: InboundMessage)'],
491
+ })
492
+ }
493
+ if (method.returnType !== undefined && !isErrorHandlerResult(ctx.types.awaited(method.returnType))) {
494
+ ctx.report({
495
+ code: INVALID_CONSUMER,
496
+ severity: 'error',
497
+ message: `The @OnError method ${where} returns '${ctx.types.format(method.returnType)}': return 'ack' to acknowledge the message, or nothing to dead-letter it`,
498
+ location: decorator.location,
499
+ hints: ["Declare the return type `'ack' | undefined` (or `void`)"],
500
+ })
501
+ }
502
+ }
503
+
504
+ // ---------------------------------------------------------------------------------------- parameters
505
+
506
+ function analyzeHandlerParams(method: MethodInfo, where: string, ctx: Context): readonly HandlerParamData[] {
507
+ return Object.freeze(method.params.map(param => analyzeHandlerParam(param, where, ctx)))
508
+ }
509
+
510
+ function analyzeHandlerParam(param: ParameterInfo, where: string, ctx: Context): HandlerParamData {
511
+ const decorators = parameterDecorators(param)
512
+ const decorator = decorators[0]
513
+ if (decorator === undefined) {
514
+ const foreign = foreignParameterDecorator(param)
515
+ if (foreign !== undefined) {
516
+ // e.g. `Headers` auto-imported from '@znight/http': one precise error instead of "no decorator"
517
+ ctx.report({
518
+ code: INVALID_DECORATOR,
519
+ severity: 'error',
520
+ message: `@${foreign.decorator.name} on parameter '${param.name}' of ${where} is imported from '${foreign.module}': @OnMessage handler parameters use ${foreign.name} from '${RUNTIME_MODULE}'`,
521
+ location: foreign.decorator.location,
522
+ hints: [`Import it from '${RUNTIME_MODULE}': import { ${foreign.name} } from '${RUNTIME_MODULE}'`],
523
+ })
524
+ return { kind: 'undefined' }
525
+ }
526
+ if (param.optional || param.hasDefault || param.rest) return { kind: 'undefined' }
527
+ ctx.report({
528
+ code: INVALID_CONSUMER,
529
+ severity: 'error',
530
+ message: `Parameter '${param.name}' of ${where} has no @Message(), @Headers() or @Ctx() decorator`,
531
+ location: param.location,
532
+ hints: [
533
+ 'Every handler parameter is extracted from the message: @Message(schema?) for the payload, @Headers(name?) for the headers, @Ctx() for the InboundMessage',
534
+ ],
535
+ })
536
+ return { kind: 'undefined' }
537
+ }
538
+ if (decorators.length > 1) {
539
+ ctx.report({
540
+ code: INVALID_DECORATOR,
541
+ severity: 'error',
542
+ message: `Parameter '${param.name}' of ${where} has several messaging decorators (${decorators.map(found => `@${found.name}`).join(', ')})`,
543
+ location: (decorators[1] as DecoratorInfo).location,
544
+ hints: ['Keep a single one'],
545
+ })
546
+ }
547
+ if (param.rest) {
548
+ ctx.report({
549
+ code: INVALID_DECORATOR,
550
+ severity: 'error',
551
+ message: `@${decorator.name} cannot be applied to the rest parameter '...${param.name}' of ${where}`,
552
+ location: decorator.location,
553
+ })
554
+ return { kind: 'undefined' }
555
+ }
556
+ switch (decorator.identity) {
557
+ case MessagingDecorator.Message:
558
+ return messageParam(param, decorator, where, ctx)
559
+ case MessagingDecorator.Headers:
560
+ return headersParam(param, decorator, where, ctx)
561
+ default:
562
+ return { kind: 'message', narrow: narrowsPayload(param.type, ctx.types) }
563
+ }
564
+ }
565
+
566
+ /** `@Message()` / `@Message(schema)`. */
567
+ function messageParam(param: ParameterInfo, decorator: DecoratorInfo, where: string, ctx: Context): HandlerParamData {
568
+ const arg = decorator.args[0]
569
+ if (arg === undefined || (arg.evaluated && arg.value === undefined)) {
570
+ checkUnvalidated(param, decorator, where, ctx)
571
+ return { kind: 'payload' }
572
+ }
573
+ if (arg.ref !== undefined) {
574
+ if (isClassReference(arg.ref)) {
575
+ ctx.report({
576
+ code: INVALID_DECORATOR,
577
+ severity: 'error',
578
+ message: `@Message(${arg.text}) on ${where}: '${arg.ref.name}' is a class, not a Standard Schema`,
579
+ location: arg.location,
580
+ hints: [
581
+ 'Pass a Standard Schema (Zod, Valibot, ArkType...): export const OrderPlaced = z.object({ ... })',
582
+ 'Or inject the raw payload with @Message() and validate it in the handler',
583
+ ],
584
+ })
585
+ return { kind: 'undefined' }
586
+ }
587
+ return { kind: 'validated', schema: arg.ref }
588
+ }
589
+ if (!arg.evaluated) return { kind: 'undefined' } // unexported or invalid reference: already reported
590
+ ctx.report({
591
+ code: INVALID_DECORATOR,
592
+ severity: 'error',
593
+ message: `@Message on ${where} expects a Standard Schema (Zod, Valibot, ArkType...), got ${describeValue(arg.value)}`,
594
+ location: arg.location,
595
+ hints: [
596
+ 'Declare the schema with `export const OrderPlaced = z.object({ ... })` and pass it: @Message(OrderPlaced)',
597
+ ],
598
+ })
599
+ return { kind: 'undefined' }
600
+ }
601
+
602
+ /** `@Headers()` / `@Headers(name)`. */
603
+ function headersParam(param: ParameterInfo, decorator: DecoratorInfo, where: string, ctx: Context): HandlerParamData {
604
+ const arg = decorator.args[0]
605
+ if (arg === undefined || (arg.evaluated && arg.value === undefined)) return { kind: 'headers' }
606
+ if (!arg.evaluated) return { kind: 'undefined' } // not statically evaluable: already reported
607
+ const name = arg.value
608
+ if (typeof name !== 'string' || name === '') {
609
+ ctx.report({
610
+ code: INVALID_DECORATOR,
611
+ severity: 'error',
612
+ message: `@Headers on ${where} expects a header name (a non-empty string), got ${describeValue(name)}`,
613
+ location: arg.location,
614
+ hints: ['Use @Headers() to inject every header'],
615
+ })
616
+ return { kind: 'undefined' }
617
+ }
618
+ if (!param.optional && !param.hasDefault && !acceptsUndefined(param.type)) {
619
+ ctx.report({
620
+ code: INVALID_DECORATOR,
621
+ severity: 'warning',
622
+ message: `@Headers(${JSON.stringify(name)}) injects 'string | undefined' (the header may be missing) but parameter '${param.name}' of ${where} is typed '${ctx.types.format(param.type)}'`,
623
+ location: param.typeLocation,
624
+ hints: [`Declare it '${param.name}?: string' or '${param.name}: string | undefined', or give it a default value`],
625
+ })
626
+ }
627
+ return { kind: 'header', name }
628
+ }
629
+
630
+ /** ZNIGHT017: a structured payload injected without schema is trusted blindly. */
631
+ function checkUnvalidated(param: ParameterInfo, decorator: DecoratorInfo, where: string, ctx: Context): void {
632
+ const { type } = ctx.types.stripNullish(param.type)
633
+ if (isSafePayloadType(type)) return
634
+ const typed =
635
+ type.kind === 'opaque' && type.reason === 'missing-annotation'
636
+ ? 'has no type annotation'
637
+ : `is typed '${ctx.types.format(param.type)}'`
638
+ ctx.report({
639
+ code: UNVALIDATED_INPUT,
640
+ severity: ctx.options.strictValidation ? 'error' : 'warning',
641
+ message: `Unvalidated message payload: parameter '${param.name}' of ${where} ${typed} but @Message() has no schema`,
642
+ location: decorator.location,
643
+ hints: [
644
+ 'Validate it with a Standard Schema (Zod, Valibot, ArkType...): @Message(OrderPlaced)',
645
+ "Or declare the parameter as 'unknown' and check it in the handler",
646
+ ],
647
+ })
648
+ }
649
+
650
+ /** Methods whose parameters carry messaging decorators without being `@OnMessage` handlers. */
651
+ function reportStrayParameterDecorators(method: MethodInfo, where: string, errorHandler: boolean, ctx: Context): void {
652
+ // a method handled by another extension (an HTTP route...): the decorator comes from the wrong package
653
+ const owner = errorHandler ? undefined : foreignHandler(method)
654
+ for (const param of method.params) {
655
+ for (const decorator of parameterDecorators(param)) {
656
+ let hint = 'Add @OnMessage() to the method, or remove the decorator'
657
+ if (errorHandler) hint = '@OnError methods are called with (error, message): remove the decorator'
658
+ else if (owner !== undefined) {
659
+ hint = `${where} is a @${owner.decorator.name} handler: use the parameter decorators of '${owner.module}'`
660
+ }
661
+ ctx.report({
662
+ code: INVALID_DECORATOR,
663
+ severity: 'error',
664
+ message:
665
+ owner === undefined
666
+ ? `@${decorator.name} has no effect on parameter '${param.name}' of ${where}: it is not an @OnMessage handler`
667
+ : `@${decorator.name} (from '${RUNTIME_MODULE}') has no effect on parameter '${param.name}' of ${where}: it is not an @OnMessage handler`,
668
+ location: decorator.location,
669
+ hints: [hint],
670
+ })
671
+ }
672
+ }
673
+ }
674
+
675
+ /** A decorator, with the module and the export name of its identity (`'@znight/http#Get'`). */
676
+ interface ForeignDecorator {
677
+ readonly decorator: DecoratorInfo
678
+ readonly module: string
679
+ readonly name: string
680
+ }
681
+
682
+ function splitIdentity(decorator: DecoratorInfo): ForeignDecorator | undefined {
683
+ const identity = decorator.identity
684
+ const separator = identity?.lastIndexOf('#') ?? -1
685
+ if (identity === undefined || separator <= 0) return undefined
686
+ return { decorator, module: identity.slice(0, separator), name: identity.slice(separator + 1) }
687
+ }
688
+
689
+ /**
690
+ * A parameter decorator named like a messaging one (`Headers`, `Ctx`, `Message`) but imported from
691
+ * another package (`@znight/http` has `Headers` and `Ctx` too).
692
+ */
693
+ function foreignParameterDecorator(param: ParameterInfo): ForeignDecorator | undefined {
694
+ for (const decorator of param.decorators) {
695
+ const split = splitIdentity(decorator)
696
+ if (split === undefined || split.module === RUNTIME_MODULE) continue
697
+ if (PARAMETER_DECORATORS.has(`${RUNTIME_MODULE}#${split.name}`)) return split
698
+ }
699
+ return undefined
700
+ }
701
+
702
+ /** The decorator through which another extension handles the method (`@Get` of `@znight/http`...). */
703
+ function foreignHandler(method: MethodInfo): ForeignDecorator | undefined {
704
+ for (const decorator of method.decorators) {
705
+ const owner = decorator.owner
706
+ if (owner === undefined || owner === EXTENSION_NAME || owner === 'core' || owner === 'manifest') continue
707
+ const split = splitIdentity(decorator)
708
+ if (split !== undefined) return split
709
+ }
710
+ return undefined
711
+ }
712
+
713
+ // ------------------------------------------------------------------------------------------- helpers
714
+
715
+ function decoratorsOf(decorators: readonly DecoratorInfo[], identity: string): DecoratorInfo[] {
716
+ return decorators.filter(decorator => decorator.identity === identity)
717
+ }
718
+
719
+ /**
720
+ * Messaging decorators of a parameter. De-duplicated by position: the compiler (0.1) reports the
721
+ * decorators of a parameter with a default value (`@Headers('x') x = 'a'`) twice.
722
+ */
723
+ function parameterDecorators(param: ParameterInfo): DecoratorInfo[] {
724
+ const seen = new Set<number>()
725
+ return param.decorators.filter(decorator => {
726
+ if (!isParameterDecorator(decorator) || seen.has(decorator.location.start)) return false
727
+ seen.add(decorator.location.start)
728
+ return true
729
+ })
730
+ }
731
+
732
+ function isParameterDecorator(decorator: DecoratorInfo): boolean {
733
+ return decorator.identity !== undefined && PARAMETER_DECORATORS.has(decorator.identity)
734
+ }
735
+
736
+ /** A reference to a class declaration (`@Message(OrderDto)`). */
737
+ function isClassReference(ref: ValueRef): boolean {
738
+ const symbol = ref.symbol
739
+ return symbol.kind === 'declaration' && symbol.declaration.kind === 'class'
740
+ }
741
+
742
+ /** Primitive and binary payload types need no schema (`unknown` is honest, `any` is not). */
743
+ function isSafePayloadType(type: TypeInfo): boolean {
744
+ switch (type.kind) {
745
+ case 'primitive':
746
+ return type.name !== 'any' && type.name !== 'object'
747
+ case 'literal':
748
+ return true
749
+ case 'union':
750
+ return type.types.every(isSafePayloadType)
751
+ case 'reference':
752
+ return BINARY_TYPES.has(type.identity)
753
+ default:
754
+ return false
755
+ }
756
+ }
757
+
758
+ /** Whether `undefined` is assignable to the type (unknown types are given the benefit of the doubt). */
759
+ function acceptsUndefined(type: TypeInfo): boolean {
760
+ switch (type.kind) {
761
+ case 'primitive':
762
+ return type.name === 'any' || type.name === 'unknown' || type.name === 'undefined' || type.name === 'void'
763
+ case 'union':
764
+ return type.types.some(acceptsUndefined)
765
+ case 'opaque':
766
+ case 'typeParameter':
767
+ return true
768
+ default:
769
+ return false
770
+ }
771
+ }
772
+
773
+ /** Valid results of an `@OnError` method: `'ack'`, nothing (`void`, `undefined`), or unknown types. */
774
+ function isErrorHandlerResult(type: TypeInfo): boolean {
775
+ switch (type.kind) {
776
+ case 'primitive':
777
+ return ['void', 'undefined', 'any', 'unknown', 'never'].includes(type.name)
778
+ case 'literal':
779
+ return type.value === 'ack'
780
+ case 'union':
781
+ return type.types.every(isErrorHandlerResult)
782
+ case 'promise':
783
+ return isErrorHandlerResult(type.inner)
784
+ case 'opaque':
785
+ case 'typeParameter':
786
+ return true
787
+ default:
788
+ return false
789
+ }
790
+ }
791
+
792
+ /** `@Ctx() message: InboundMessage<Order>` narrows the payload: the invoker casts `m`. */
793
+ function narrowsPayload(type: TypeInfo, types: TypeHelpers): boolean {
794
+ const { type: inner } = types.stripNullish(type)
795
+ if (inner.kind !== 'reference' || inner.identity !== INBOUND_MESSAGE_IDENTITY) return false
796
+ const payload = inner.typeArgs[0]
797
+ return (
798
+ payload !== undefined && !(payload.kind === 'primitive' && (payload.name === 'unknown' || payload.name === 'any'))
799
+ )
800
+ }