@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.
- package/LICENSE +21 -0
- package/README.md +622 -2
- package/dist/async-CFh_bvex.mjs +216 -0
- package/dist/async-CFh_bvex.mjs.map +1 -0
- package/dist/codec-Dku-NXXU.mjs +83 -0
- package/dist/codec-Dku-NXXU.mjs.map +1 -0
- package/dist/compiler.d.mts +75 -0
- package/dist/compiler.mjs +1023 -0
- package/dist/compiler.mjs.map +1 -0
- package/dist/driver-ZxLqy_Ap.d.mts +216 -0
- package/dist/index.d.mts +459 -0
- package/dist/index.mjs +870 -0
- package/dist/index.mjs.map +1 -0
- package/dist/kafka.d.mts +51 -0
- package/dist/kafka.mjs +517 -0
- package/dist/kafka.mjs.map +1 -0
- package/dist/memory.d.mts +124 -0
- package/dist/memory.mjs +429 -0
- package/dist/memory.mjs.map +1 -0
- package/dist/rabbitmq.d.mts +65 -0
- package/dist/rabbitmq.mjs +686 -0
- package/dist/rabbitmq.mjs.map +1 -0
- package/dist/shared-BJBpLS8V.mjs +52 -0
- package/dist/shared-BJBpLS8V.mjs.map +1 -0
- package/dist/sqs.d.mts +38 -0
- package/dist/sqs.mjs +421 -0
- package/dist/sqs.mjs.map +1 -0
- package/package.json +123 -4
- package/src/broker.ts +156 -0
- package/src/codec.ts +98 -0
- package/src/compiler/analyze.ts +800 -0
- package/src/compiler/constants.ts +73 -0
- package/src/compiler/data.ts +75 -0
- package/src/compiler/generate.ts +131 -0
- package/src/compiler/index.ts +52 -0
- package/src/compiler/patterns.ts +54 -0
- package/src/compiler/retry.ts +136 -0
- package/src/compiler/validate.ts +57 -0
- package/src/compiler/values.ts +76 -0
- package/src/consumers.ts +260 -0
- package/src/decorators.ts +131 -0
- package/src/driver.ts +142 -0
- package/src/drivers/kafka.ts +765 -0
- package/src/drivers/memory.ts +609 -0
- package/src/drivers/rabbitmq.ts +944 -0
- package/src/drivers/shared.ts +81 -0
- package/src/drivers/sqs.ts +583 -0
- package/src/errors.ts +95 -0
- package/src/headers.ts +70 -0
- package/src/hooks.ts +2 -0
- package/src/index.ts +89 -0
- package/src/internal/async.ts +115 -0
- package/src/message.ts +96 -0
- package/src/metadata.ts +65 -0
- package/src/plugin.ts +236 -0
- package/src/retry.ts +129 -0
- package/src/runtime.ts +424 -0
- package/src/standard-schema.ts +68 -0
- package/src/tokens.ts +14 -0
- package/src/validation.ts +61 -0
package/src/consumers.ts
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preparation and validation of the consumers declared in the metadata (boot time, ZNIGHT011).
|
|
3
|
+
*/
|
|
4
|
+
import type { Container, Token } from '@znight/core'
|
|
5
|
+
import type { MessagingDriver } from './driver.ts'
|
|
6
|
+
import type { ConsumerDefinition, MessageHandlerDefinition, MessagingMetadata } from './metadata.ts'
|
|
7
|
+
import type { RetryOptions, RetryPolicy } from './retry.ts'
|
|
8
|
+
|
|
9
|
+
import { displayName } from '@znight/core'
|
|
10
|
+
|
|
11
|
+
import { consumerError } from './errors.ts'
|
|
12
|
+
import { isPositiveInteger } from './internal/async.ts'
|
|
13
|
+
import {
|
|
14
|
+
assertRetrySupported,
|
|
15
|
+
DEFAULT_RETRY_POLICY,
|
|
16
|
+
isInProcessDelay,
|
|
17
|
+
NO_RETRY_POLICY,
|
|
18
|
+
normalizeRetry,
|
|
19
|
+
retryDelays,
|
|
20
|
+
} from './retry.ts'
|
|
21
|
+
|
|
22
|
+
/** A handler ready to be dispatched to. */
|
|
23
|
+
export interface PreparedHandler {
|
|
24
|
+
readonly definition: MessageHandlerDefinition
|
|
25
|
+
readonly method: string
|
|
26
|
+
readonly policy: RetryPolicy
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A validated consumer. */
|
|
30
|
+
export interface PreparedConsumer {
|
|
31
|
+
readonly definition: ConsumerDefinition
|
|
32
|
+
readonly token: Token
|
|
33
|
+
/** Display name of the consumer class. */
|
|
34
|
+
readonly name: string
|
|
35
|
+
readonly queue: string
|
|
36
|
+
readonly concurrency: number
|
|
37
|
+
readonly deadLetterQueue: string | undefined
|
|
38
|
+
readonly errorHandler: string | undefined
|
|
39
|
+
readonly maxAttempts: number
|
|
40
|
+
/** Native delays the runtime may request (sorted, unique). */
|
|
41
|
+
readonly retryDelays: readonly number[]
|
|
42
|
+
readonly driverOptions: Readonly<Record<string, unknown>>
|
|
43
|
+
/** Selects the handler of a message type (exact, then patterns, then the default handler). */
|
|
44
|
+
select(type: string | undefined): PreparedHandler | undefined
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Plugin-level defaults applied to every consumer. */
|
|
48
|
+
export interface ConsumerDefaults {
|
|
49
|
+
readonly retry: RetryPolicy
|
|
50
|
+
readonly concurrency: number
|
|
51
|
+
readonly deadLetter: ((queue: string) => string) | false | undefined
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Normalizes `defaults.retry` of the plugin. */
|
|
55
|
+
export function defaultRetryPolicy(retry: RetryOptions | false | undefined): RetryPolicy {
|
|
56
|
+
if (retry === undefined) return DEFAULT_RETRY_POLICY
|
|
57
|
+
if (retry === false) return NO_RETRY_POLICY
|
|
58
|
+
return normalizeRetry(retry, 'MessagingPlugin defaults.retry')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Validates `metadata.extensions.messaging` against the container and the driver capabilities.
|
|
63
|
+
*
|
|
64
|
+
* @throws ZnightError ZNIGHT011 describing the first invalid consumer.
|
|
65
|
+
*/
|
|
66
|
+
export function prepareConsumers(
|
|
67
|
+
metadata: MessagingMetadata | undefined,
|
|
68
|
+
container: Container,
|
|
69
|
+
driver: MessagingDriver,
|
|
70
|
+
defaults: ConsumerDefaults,
|
|
71
|
+
): PreparedConsumer[] {
|
|
72
|
+
if (metadata === undefined) return []
|
|
73
|
+
if (metadata === null || typeof metadata !== 'object' || !Array.isArray(metadata.consumers)) {
|
|
74
|
+
throw consumerError('Invalid messaging metadata: "consumers" must be an array', [
|
|
75
|
+
'Regenerate the metadata with the @znight/messaging compiler extension',
|
|
76
|
+
])
|
|
77
|
+
}
|
|
78
|
+
const byQueue = new Map<string, string>()
|
|
79
|
+
const prepared: PreparedConsumer[] = []
|
|
80
|
+
for (const definition of metadata.consumers) {
|
|
81
|
+
const consumer = prepareConsumer(definition, container, driver, defaults)
|
|
82
|
+
const previous = byQueue.get(consumer.queue)
|
|
83
|
+
if (previous !== undefined) {
|
|
84
|
+
throw consumerError(`${previous} and ${consumer.name} both consume "${consumer.queue}"`, [
|
|
85
|
+
'A queue is consumed by a single @Consumer class: merge the handlers, or route by type with @OnMessage(type)',
|
|
86
|
+
])
|
|
87
|
+
}
|
|
88
|
+
byQueue.set(consumer.queue, consumer.name)
|
|
89
|
+
prepared.push(consumer)
|
|
90
|
+
}
|
|
91
|
+
return prepared
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function prepareConsumer(
|
|
95
|
+
definition: ConsumerDefinition,
|
|
96
|
+
container: Container,
|
|
97
|
+
driver: MessagingDriver,
|
|
98
|
+
defaults: ConsumerDefaults,
|
|
99
|
+
): PreparedConsumer {
|
|
100
|
+
if (
|
|
101
|
+
definition === null ||
|
|
102
|
+
typeof definition !== 'object' ||
|
|
103
|
+
typeof definition.token !== 'string' ||
|
|
104
|
+
definition.token === ''
|
|
105
|
+
) {
|
|
106
|
+
throw consumerError('Invalid consumer definition: missing provider token')
|
|
107
|
+
}
|
|
108
|
+
const token = definition.token
|
|
109
|
+
const name = displayName(token)
|
|
110
|
+
if (typeof definition.queue !== 'string' || definition.queue === '') {
|
|
111
|
+
throw consumerError(`@Consumer ${name}: the queue must be a non-empty string`)
|
|
112
|
+
}
|
|
113
|
+
if (!container.has(token)) {
|
|
114
|
+
throw consumerError(`@Consumer ${name}: no provider is registered for ${token}`, [
|
|
115
|
+
'Regenerate the metadata: the consumer class must be a compiled provider',
|
|
116
|
+
])
|
|
117
|
+
}
|
|
118
|
+
const options = definition.options ?? {}
|
|
119
|
+
const { concurrency: rawConcurrency, deadLetter, ...driverOptions } = options
|
|
120
|
+
const concurrency = rawConcurrency ?? defaults.concurrency
|
|
121
|
+
if (!isPositiveInteger(concurrency)) {
|
|
122
|
+
throw consumerError(`@Consumer ${name}: "concurrency" must be a positive integer, got ${String(concurrency)}`)
|
|
123
|
+
}
|
|
124
|
+
if (deadLetter !== undefined && deadLetter !== false && (typeof deadLetter !== 'string' || deadLetter === '')) {
|
|
125
|
+
throw consumerError(`@Consumer ${name}: "deadLetter" must be a queue name or false, got ${String(deadLetter)}`)
|
|
126
|
+
}
|
|
127
|
+
if (deadLetter === definition.queue) {
|
|
128
|
+
throw consumerError(`@Consumer ${name}: the dead-letter queue cannot be the consumed queue "${definition.queue}"`)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const handlers = definition.handlers
|
|
132
|
+
if (!Array.isArray(handlers) || handlers.length === 0) {
|
|
133
|
+
throw consumerError(`@Consumer ${name} has no @OnMessage handler`, [
|
|
134
|
+
'Decorate at least one method with @OnMessage()',
|
|
135
|
+
])
|
|
136
|
+
}
|
|
137
|
+
const exact = new Map<string, PreparedHandler>()
|
|
138
|
+
const patterns: { readonly regex: RegExp; readonly handler: PreparedHandler }[] = []
|
|
139
|
+
let fallback: PreparedHandler | undefined
|
|
140
|
+
let maxAttempts = 1
|
|
141
|
+
const nativeDelays = new Set<number>()
|
|
142
|
+
for (const handlerDefinition of handlers) {
|
|
143
|
+
const handler = prepareHandler(handlerDefinition, name, driver, defaults.retry)
|
|
144
|
+
maxAttempts = Math.max(maxAttempts, handler.policy.attempts)
|
|
145
|
+
for (const delay of retryDelays(handler.policy)) {
|
|
146
|
+
if (!isInProcessDelay(delay, driver.capabilities)) nativeDelays.add(delay)
|
|
147
|
+
}
|
|
148
|
+
const type = handlerDefinition.type
|
|
149
|
+
if (type === undefined) {
|
|
150
|
+
if (fallback !== undefined) {
|
|
151
|
+
throw consumerError(
|
|
152
|
+
`@Consumer ${name}: ${fallback.method}() and ${handler.method}() are both default @OnMessage() handlers`,
|
|
153
|
+
['Give one of them a message type: @OnMessage("order.created")'],
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
fallback = handler
|
|
157
|
+
} else if (type.includes('*')) {
|
|
158
|
+
if (patterns.some(entry => entry.handler.definition.type === type)) {
|
|
159
|
+
throw duplicateType(name, type)
|
|
160
|
+
}
|
|
161
|
+
patterns.push({ regex: patternToRegExp(type), handler })
|
|
162
|
+
} else {
|
|
163
|
+
if (exact.has(type)) throw duplicateType(name, type)
|
|
164
|
+
exact.set(type, handler)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const errorHandler = definition.errorHandler
|
|
169
|
+
if (errorHandler !== undefined) {
|
|
170
|
+
if (typeof errorHandler !== 'string' || errorHandler === '') {
|
|
171
|
+
throw consumerError(`@Consumer ${name}: invalid @OnError method name ${String(errorHandler)}`)
|
|
172
|
+
}
|
|
173
|
+
const providers = container.metadata.providers
|
|
174
|
+
const provider = Object.hasOwn(providers, token) ? providers[token] : undefined
|
|
175
|
+
const prototype = provider?.class?.prototype as Record<string, unknown> | undefined
|
|
176
|
+
if (prototype !== undefined && typeof prototype[errorHandler] !== 'function') {
|
|
177
|
+
throw consumerError(`@Consumer ${name}: the @OnError method ${errorHandler}() does not exist`)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
definition,
|
|
183
|
+
token,
|
|
184
|
+
name,
|
|
185
|
+
queue: definition.queue,
|
|
186
|
+
concurrency,
|
|
187
|
+
deadLetterQueue: resolveDeadLetter(definition.queue, deadLetter, defaults.deadLetter, driver),
|
|
188
|
+
errorHandler,
|
|
189
|
+
maxAttempts,
|
|
190
|
+
retryDelays: [...nativeDelays].sort((a, b) => a - b),
|
|
191
|
+
driverOptions,
|
|
192
|
+
select(type: string | undefined): PreparedHandler | undefined {
|
|
193
|
+
if (type === undefined) return fallback
|
|
194
|
+
const handler = exact.get(type)
|
|
195
|
+
if (handler !== undefined) return handler
|
|
196
|
+
for (const entry of patterns) if (entry.regex.test(type)) return entry.handler
|
|
197
|
+
return fallback
|
|
198
|
+
},
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function prepareHandler(
|
|
203
|
+
definition: MessageHandlerDefinition,
|
|
204
|
+
consumer: string,
|
|
205
|
+
driver: MessagingDriver,
|
|
206
|
+
defaultPolicy: RetryPolicy,
|
|
207
|
+
): PreparedHandler {
|
|
208
|
+
if (
|
|
209
|
+
definition === null ||
|
|
210
|
+
typeof definition !== 'object' ||
|
|
211
|
+
typeof definition.method !== 'string' ||
|
|
212
|
+
definition.method === ''
|
|
213
|
+
) {
|
|
214
|
+
throw consumerError(`@Consumer ${consumer}: invalid @OnMessage handler definition`)
|
|
215
|
+
}
|
|
216
|
+
const where = `${consumer}.${definition.method}()`
|
|
217
|
+
if (typeof definition.invoke !== 'function') {
|
|
218
|
+
throw consumerError(`${where}: the handler has no invoker`, ['Regenerate the metadata'])
|
|
219
|
+
}
|
|
220
|
+
if (definition.type !== undefined && (typeof definition.type !== 'string' || definition.type === '')) {
|
|
221
|
+
throw consumerError(`${where}: the @OnMessage type must be a non-empty string`)
|
|
222
|
+
}
|
|
223
|
+
const policy = definition.retry === undefined ? defaultPolicy : normalizeRetry(definition.retry, `@Retry on ${where}`)
|
|
224
|
+
assertRetrySupported(
|
|
225
|
+
policy,
|
|
226
|
+
driver.capabilities,
|
|
227
|
+
driver.name,
|
|
228
|
+
definition.retry === undefined ? `${where} (default retry policy)` : `@Retry on ${where}`,
|
|
229
|
+
)
|
|
230
|
+
return { definition, method: definition.method, policy }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function duplicateType(consumer: string, type: string): Error {
|
|
234
|
+
return consumerError(`@Consumer ${consumer}: several @OnMessage handlers for type "${type}"`)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** `order.*` → `/^order\..*$/`: `*` matches any sequence of characters. */
|
|
238
|
+
function patternToRegExp(pattern: string): RegExp {
|
|
239
|
+
const source = pattern
|
|
240
|
+
.split('*')
|
|
241
|
+
.map(part => part.replace(/[.+?^${}()|[\]\\]/g, '\\$&'))
|
|
242
|
+
.join('.*')
|
|
243
|
+
return new RegExp(`^${source}$`, 's')
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function resolveDeadLetter(
|
|
247
|
+
queue: string,
|
|
248
|
+
option: string | false | undefined,
|
|
249
|
+
defaults: ((queue: string) => string) | false | undefined,
|
|
250
|
+
driver: MessagingDriver,
|
|
251
|
+
): string | undefined {
|
|
252
|
+
if (option === false) return undefined
|
|
253
|
+
if (typeof option === 'string') return option
|
|
254
|
+
if (defaults === false) return undefined
|
|
255
|
+
const name = defaults === undefined ? driver.deadLetterName(queue) : defaults(queue)
|
|
256
|
+
if (typeof name !== 'string' || name === '' || name === queue) {
|
|
257
|
+
throw consumerError(`Invalid dead-letter queue name for "${queue}": ${String(name)}`)
|
|
258
|
+
}
|
|
259
|
+
return name
|
|
260
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Marker decorators of `@znight/messaging`. Like every Znight decorator they do nothing at runtime:
|
|
3
|
+
* the compiler extension (`@znight/messaging/compiler`) reads them from the source code and generates
|
|
4
|
+
* `metadata.extensions.messaging`. Arguments marked _static_ must be statically evaluable (literals,
|
|
5
|
+
* `const` references); _value_ arguments (schemas) may also be references to exported bindings.
|
|
6
|
+
*/
|
|
7
|
+
import type { ClassMarker, MethodMarker, ParameterMarker, Scope } from '@znight/core'
|
|
8
|
+
import type { InboundMessage } from './message.ts'
|
|
9
|
+
import type { RetryOptions } from './retry.ts'
|
|
10
|
+
import type { StandardSchemaV1 } from './standard-schema.ts'
|
|
11
|
+
|
|
12
|
+
/** Options of {@link Consumer} (static). */
|
|
13
|
+
export interface ConsumerOptions {
|
|
14
|
+
/** Queue (RabbitMQ, SQS) or topic (Kafka) to consume. */
|
|
15
|
+
readonly queue: string
|
|
16
|
+
/** Scope of the consumer provider. Default: `singleton`; `request` creates one instance per message. */
|
|
17
|
+
readonly scope?: Scope
|
|
18
|
+
/** Maximum number of messages handled concurrently. Default: plugin `defaults.concurrency` (1). */
|
|
19
|
+
readonly concurrency?: number
|
|
20
|
+
/** Dead-letter queue/topic, or `false` to disable dead-lettering. Default: driver naming (`<queue>.dlq`). */
|
|
21
|
+
readonly deadLetter?: string | false
|
|
22
|
+
/** Driver-specific options (e.g. Kafka `groupId`). */
|
|
23
|
+
readonly [driverOption: string]: unknown
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Result of an `@OnError` method: `'ack'` acknowledges (swallows) the message, anything else dead-letters it. */
|
|
27
|
+
// biome-ignore lint/suspicious/noConfusingVoidType: lets error handlers without return statement type-check
|
|
28
|
+
export type ErrorHandlerResult = 'ack' | undefined | void
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Signature of an `@OnError` method. Declared as a method signature so that parameters may be
|
|
32
|
+
* narrowed (`onError(error: Error, message: InboundMessage<Order>)`).
|
|
33
|
+
*/
|
|
34
|
+
export type ErrorHandlerMethod = ErrorHandlerSignature['onError']
|
|
35
|
+
|
|
36
|
+
interface ErrorHandlerSignature {
|
|
37
|
+
onError(error: unknown, message: InboundMessage): ErrorHandlerResult | Promise<ErrorHandlerResult>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Marker usable on an `@OnError` method: checks its signature. */
|
|
41
|
+
export type ErrorHandlerMarker = <F extends ErrorHandlerMethod>(
|
|
42
|
+
target: object,
|
|
43
|
+
propertyKeyOrContext: string | symbol | ClassMethodDecoratorContext<unknown, F>,
|
|
44
|
+
descriptor?: TypedPropertyDescriptor<F>,
|
|
45
|
+
) => void
|
|
46
|
+
|
|
47
|
+
function marker(): void {
|
|
48
|
+
// intentionally empty: decorators are compile-time markers
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const classMarker: ClassMarker = marker
|
|
52
|
+
const methodMarker: MethodMarker = marker
|
|
53
|
+
const parameterMarker: ParameterMarker = marker
|
|
54
|
+
const errorHandlerMarker: ErrorHandlerMarker = marker
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Declares a message consumer: a provider (singleton by default) whose `@OnMessage` methods handle
|
|
58
|
+
* the messages of `queue`. Arguments are static.
|
|
59
|
+
*
|
|
60
|
+
* ```ts
|
|
61
|
+
* @Consumer('orders')
|
|
62
|
+
* export class OrderConsumer {
|
|
63
|
+
* constructor(private readonly orders: OrderService) {}
|
|
64
|
+
*
|
|
65
|
+
* @OnMessage('order.created')
|
|
66
|
+
* @Retry({ attempts: 5, backoff: 'exponential', delay: 500 })
|
|
67
|
+
* async created(@Message(OrderCreated) event: OrderCreated) {
|
|
68
|
+
* await this.orders.process(event)
|
|
69
|
+
* }
|
|
70
|
+
* }
|
|
71
|
+
* ```
|
|
72
|
+
*/
|
|
73
|
+
export function Consumer(queue: string): ClassMarker
|
|
74
|
+
export function Consumer(options: ConsumerOptions): ClassMarker
|
|
75
|
+
export function Consumer(_queueOrOptions: string | ConsumerOptions): ClassMarker {
|
|
76
|
+
return classMarker
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Declares a message handler. With `type` (static), it handles the messages of that type (exact
|
|
81
|
+
* match, or a pattern where `*` matches any sequence: `'order.*'`); without type it is the consumer's
|
|
82
|
+
* default handler (messages without type, or whose type no other handler matches). A message no
|
|
83
|
+
* handler matches is dead-lettered (reason `no-handler`).
|
|
84
|
+
*/
|
|
85
|
+
export function OnMessage(_type?: string): MethodMarker {
|
|
86
|
+
return methodMarker
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Retry policy of a handler (static options). `attempts` counts the first delivery:
|
|
91
|
+
* `{ attempts: 3 }` means one delivery plus two retries. Without `@Retry`, the plugin's
|
|
92
|
+
* `defaults.retry` applies (3 attempts, exponential from 1 s).
|
|
93
|
+
*/
|
|
94
|
+
export function Retry(_options: RetryOptions): MethodMarker {
|
|
95
|
+
return methodMarker
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Declares the consumer's error handler, called once a message will not be retried anymore (last
|
|
100
|
+
* attempt failed, `NonRetryableError`, invalid payload, no matching handler). Returning `'ack'`
|
|
101
|
+
* acknowledges the message; returning anything else (or throwing) dead-letters it.
|
|
102
|
+
*
|
|
103
|
+
* ```ts
|
|
104
|
+
* @OnError()
|
|
105
|
+
* onError(error: unknown, message: InboundMessage) {
|
|
106
|
+
* if (message.type?.startsWith('legacy.')) return 'ack'
|
|
107
|
+
* }
|
|
108
|
+
* ```
|
|
109
|
+
*/
|
|
110
|
+
export function OnError(): ErrorHandlerMarker {
|
|
111
|
+
return errorHandlerMarker
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Injects the message payload. With a Standard Schema (Zod, Valibot...), the payload is validated and
|
|
116
|
+
* the parameter receives the schema output; an invalid payload is dead-lettered without retry
|
|
117
|
+
* (reason `validation`). The schema is a _value_ argument (inline, or an exported binding).
|
|
118
|
+
*/
|
|
119
|
+
export function Message(_schema?: StandardSchemaV1): ParameterMarker {
|
|
120
|
+
return parameterMarker
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Injects all the message headers, or the value of one header (`string | undefined`). `name` is static. */
|
|
124
|
+
export function Headers(_name?: string): ParameterMarker {
|
|
125
|
+
return parameterMarker
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Injects the {@link InboundMessage} (id, type, attempt, headers, raw message, abort signal...). */
|
|
129
|
+
export function Ctx(): ParameterMarker {
|
|
130
|
+
return parameterMarker
|
|
131
|
+
}
|
package/src/driver.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import type { CoreLogger } from '@znight/core'
|
|
2
|
+
import type { DeadLetterReason, OutboundMessage } from './message.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What a driver can do. `MessagingPlugin` validates every `@Retry` policy against it at boot: a
|
|
6
|
+
* delay is either waited in-process (`<= maxInProcessDelayMs`) or delegated to the broker
|
|
7
|
+
* (`nativeDelay`, `<= maxNativeDelayMs`); otherwise the application fails to start (ZNIGHT011).
|
|
8
|
+
*/
|
|
9
|
+
export interface DriverCapabilities {
|
|
10
|
+
/** The broker can redeliver a message after a delay (SQS visibility, RabbitMQ TTL queue, Kafka retry topic). */
|
|
11
|
+
readonly nativeDelay: boolean
|
|
12
|
+
/** Longest native redelivery delay, in milliseconds (`0` without native delay). */
|
|
13
|
+
readonly maxNativeDelayMs: number
|
|
14
|
+
/**
|
|
15
|
+
* Longest delay the runtime may wait in-process while holding a delivery before asking the
|
|
16
|
+
* broker to redeliver it (the message stays locked: prefetch slot, SQS lease...).
|
|
17
|
+
*/
|
|
18
|
+
readonly maxInProcessDelayMs: number
|
|
19
|
+
/**
|
|
20
|
+
* How attempts are counted: `native` (broker delivery count: SQS `ApproximateReceiveCount`,
|
|
21
|
+
* RabbitMQ quorum `x-delivery-count`) or `header` (`x-znight-attempt` on republished retries).
|
|
22
|
+
*/
|
|
23
|
+
readonly deliveryCount: 'native' | 'header'
|
|
24
|
+
/** Longest `publish(..., { delay })`, in milliseconds (`0`: delayed publishing unsupported). */
|
|
25
|
+
readonly maxPublishDelayMs: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Services a driver receives when it connects. */
|
|
29
|
+
export interface DriverContext {
|
|
30
|
+
/** Logger for background failures (polling errors, connection loss...). */
|
|
31
|
+
readonly logger: CoreLogger
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Redelivery request of a failed delivery. */
|
|
35
|
+
export interface RetryRequest {
|
|
36
|
+
/** Attempt number of the redelivery (failed attempt + 1). */
|
|
37
|
+
readonly attempt: number
|
|
38
|
+
/** Delay before the redelivery, in milliseconds (`0`: as soon as possible). */
|
|
39
|
+
readonly delayMs: number
|
|
40
|
+
/** Error of the failed attempt. */
|
|
41
|
+
readonly error: unknown
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Dead-letter request of a delivery. */
|
|
45
|
+
export interface DeadLetterRequest {
|
|
46
|
+
/**
|
|
47
|
+
* Destination queue/topic, `undefined` when dead-lettering is disabled for the consumer: the driver
|
|
48
|
+
* then rejects the message natively (RabbitMQ `nack` without requeue, so a queue DLX applies) or
|
|
49
|
+
* discards it.
|
|
50
|
+
*/
|
|
51
|
+
readonly queue: string | undefined
|
|
52
|
+
readonly reason: DeadLetterReason
|
|
53
|
+
readonly error: unknown
|
|
54
|
+
/** Attempts made. */
|
|
55
|
+
readonly attempt: number
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A message received by a driver, with the operations settling it. The runtime calls exactly one
|
|
60
|
+
* settlement operation (`ack`, `retry`, `deadLetter` or `release`), possibly followed by `release`
|
|
61
|
+
* when the first one failed. Each operation resolves once the broker acknowledged it.
|
|
62
|
+
*/
|
|
63
|
+
export interface DriverDelivery {
|
|
64
|
+
readonly id: string
|
|
65
|
+
readonly body: Uint8Array | string
|
|
66
|
+
readonly contentType: string | undefined
|
|
67
|
+
/** Headers as strings (non-string values converted). */
|
|
68
|
+
readonly headers: Readonly<Record<string, string>>
|
|
69
|
+
readonly type: string | undefined
|
|
70
|
+
readonly key: string | undefined
|
|
71
|
+
/** 1-based attempt of this delivery. */
|
|
72
|
+
readonly attempt: number
|
|
73
|
+
readonly redelivered: boolean
|
|
74
|
+
/** Production time (epoch ms), when known. */
|
|
75
|
+
readonly timestamp: number | undefined
|
|
76
|
+
/** Driver-native message. */
|
|
77
|
+
readonly raw: unknown
|
|
78
|
+
/** The message was handled: remove it from the queue / commit it. */
|
|
79
|
+
ack(): Promise<void>
|
|
80
|
+
/**
|
|
81
|
+
* Redeliver the message as attempt `request.attempt` after `request.delayMs`. The redelivery must be
|
|
82
|
+
* durable (confirmed) before the original is acknowledged.
|
|
83
|
+
*/
|
|
84
|
+
retry(request: RetryRequest): Promise<void>
|
|
85
|
+
/** Move the message to the dead-letter destination (publish with confirmation, then acknowledge). */
|
|
86
|
+
deadLetter(request: DeadLetterRequest): Promise<void>
|
|
87
|
+
/**
|
|
88
|
+
* Give the message back to the broker for a later redelivery without settling it (shutdown,
|
|
89
|
+
* failed dead-lettering). Must never lose the message.
|
|
90
|
+
*/
|
|
91
|
+
release(): Promise<void>
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Handles one delivery. The returned promise settles once the delivery was settled: drivers use it
|
|
96
|
+
* for back-pressure (never more than `concurrency` pending promises per subscription).
|
|
97
|
+
*/
|
|
98
|
+
export type DeliveryHandler = (delivery: DriverDelivery) => Promise<void>
|
|
99
|
+
|
|
100
|
+
/** Options of {@link MessagingDriver.subscribe}. */
|
|
101
|
+
export interface SubscribeOptions {
|
|
102
|
+
/** Display name of the consumer class (logs, errors). */
|
|
103
|
+
readonly consumer: string
|
|
104
|
+
/** Maximum number of deliveries handled concurrently (prefetch, receive batch size...). */
|
|
105
|
+
readonly concurrency: number
|
|
106
|
+
/** Dead-letter destination, `undefined` when disabled. Drivers verify it exists at subscribe time. */
|
|
107
|
+
readonly deadLetterQueue: string | undefined
|
|
108
|
+
/** Largest number of attempts among the consumer's handlers (e.g. checked against SQS `maxReceiveCount`). */
|
|
109
|
+
readonly maxAttempts: number
|
|
110
|
+
/** Native retry delays (ms) the runtime may request: lets drivers prepare delay queues. Sorted, unique. */
|
|
111
|
+
readonly retryDelays: readonly number[]
|
|
112
|
+
/** Driver-specific options of `@Consumer({ queue, ...options })`. */
|
|
113
|
+
readonly options: Readonly<Record<string, unknown>>
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** An active subscription. */
|
|
117
|
+
export interface Subscription {
|
|
118
|
+
/** Stops receiving new deliveries. Deliveries already handed to the runtime can still be settled. */
|
|
119
|
+
cancel(): Promise<void>
|
|
120
|
+
/** Releases the subscription's resources, once in-flight deliveries settled. Idempotent. */
|
|
121
|
+
close(): Promise<void>
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* A broker integration. Drivers live in subpath exports (`@znight/messaging/rabbitmq`, `/sqs`,
|
|
126
|
+
* `/kafka`, `/memory`) so their client libraries stay optional peers.
|
|
127
|
+
*/
|
|
128
|
+
export interface MessagingDriver {
|
|
129
|
+
/** Short name (`rabbitmq`, `sqs`, `kafka`, `memory`), exposed as `InboundMessage.driver`. */
|
|
130
|
+
readonly name: string
|
|
131
|
+
readonly capabilities: DriverCapabilities
|
|
132
|
+
/** Connects to the broker (called in `onBootstrap`). */
|
|
133
|
+
connect(context: DriverContext): Promise<void>
|
|
134
|
+
/** Starts consuming `queue`. Verifies the destination and its dead-letter configuration. */
|
|
135
|
+
subscribe(queue: string, handler: DeliveryHandler, options: SubscribeOptions): Promise<Subscription>
|
|
136
|
+
/** Publishes a message; resolves once the broker confirmed it. */
|
|
137
|
+
publish(queue: string, message: OutboundMessage): Promise<void>
|
|
138
|
+
/** Closes every subscription and the connection. Idempotent. */
|
|
139
|
+
close(): Promise<void>
|
|
140
|
+
/** Default dead-letter destination of `queue` (`orders.dlq`, `orders-dlq`...). */
|
|
141
|
+
deadLetterName(queue: string): string
|
|
142
|
+
}
|