@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/runtime.ts
ADDED
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The delivery state machine (docs/internals/architecture.md §9.2), one {@link ConsumerRuntime} per
|
|
3
|
+
* `@Consumer` class.
|
|
4
|
+
*
|
|
5
|
+
* For each delivery: concurrency slot → request scope seeded with the `InboundMessage` →
|
|
6
|
+
* `hooks.run('messaging:message')` → decode → select the handler by type → invoke →
|
|
7
|
+
* ack | retry (in-process wait or native redelivery) | `@OnError` → dead-letter (publish then ack;
|
|
8
|
+
* on failure the message is released for a later redelivery). Nothing is ever dropped: every path
|
|
9
|
+
* ends with the broker owning the message again.
|
|
10
|
+
*/
|
|
11
|
+
import type { Container, CoreLogger, Hooks, MessageDeliveryEvent } from '@znight/core'
|
|
12
|
+
import type { MessageCodec } from './codec.ts'
|
|
13
|
+
import type { PreparedConsumer, PreparedHandler } from './consumers.ts'
|
|
14
|
+
import type { DriverDelivery, MessagingDriver, Subscription } from './driver.ts'
|
|
15
|
+
import type { DeadLetterReason, InboundMessage } from './message.ts'
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
AttemptsExhaustedError,
|
|
19
|
+
isNonRetryable,
|
|
20
|
+
MessageDecodeError,
|
|
21
|
+
MessageValidationError,
|
|
22
|
+
NoHandlerError,
|
|
23
|
+
} from './errors.ts'
|
|
24
|
+
import { errorMessage, Semaphore, sleep } from './internal/async.ts'
|
|
25
|
+
import { computeRetryDelay, isInProcessDelay } from './retry.ts'
|
|
26
|
+
import { MessagingTokens } from './tokens.ts'
|
|
27
|
+
|
|
28
|
+
/** Services shared by every consumer runtime. */
|
|
29
|
+
export interface RuntimeServices {
|
|
30
|
+
readonly container: Container
|
|
31
|
+
readonly hooks: Hooks
|
|
32
|
+
readonly logger: CoreLogger
|
|
33
|
+
readonly codec: MessageCodec
|
|
34
|
+
readonly driver: MessagingDriver
|
|
35
|
+
/** Pause before releasing a message whose settlement failed (avoids hot redelivery loops). */
|
|
36
|
+
readonly requeueDelayMs: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** State of a {@link ConsumerRuntime}. */
|
|
40
|
+
export type ConsumerState = 'created' | 'starting' | 'running' | 'stopping' | 'stopped'
|
|
41
|
+
|
|
42
|
+
/** What remains to do once the scope of a delivery closed. */
|
|
43
|
+
type Followup =
|
|
44
|
+
| { readonly kind: 'retry'; readonly delayMs: number; readonly error: unknown }
|
|
45
|
+
| { readonly kind: 'requeue' }
|
|
46
|
+
| undefined
|
|
47
|
+
|
|
48
|
+
/** Per-delivery working state. */
|
|
49
|
+
interface DeliveryContext {
|
|
50
|
+
readonly delivery: DriverDelivery
|
|
51
|
+
readonly message: InboundMessage
|
|
52
|
+
readonly handler: PreparedHandler | undefined
|
|
53
|
+
readonly event: MessageDeliveryEvent
|
|
54
|
+
readonly decodeError: unknown
|
|
55
|
+
executed: boolean
|
|
56
|
+
followup: Followup
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Runs the deliveries of one consumer. */
|
|
60
|
+
export class ConsumerRuntime {
|
|
61
|
+
readonly consumer: PreparedConsumer
|
|
62
|
+
readonly #services: RuntimeServices
|
|
63
|
+
readonly #slots: Semaphore
|
|
64
|
+
readonly #abort = new AbortController()
|
|
65
|
+
readonly #inflight = new Set<Promise<void>>()
|
|
66
|
+
#subscription: Subscription | undefined
|
|
67
|
+
#state: ConsumerState = 'created'
|
|
68
|
+
|
|
69
|
+
constructor(consumer: PreparedConsumer, services: RuntimeServices) {
|
|
70
|
+
this.consumer = consumer
|
|
71
|
+
this.#services = services
|
|
72
|
+
this.#slots = new Semaphore(consumer.concurrency)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
get state(): ConsumerState {
|
|
76
|
+
return this.#state
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Deliveries currently handled (including those waiting for a slot or an in-process retry delay). */
|
|
80
|
+
get inFlight(): number {
|
|
81
|
+
return this.#inflight.size
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Subscribes to the queue. */
|
|
85
|
+
async start(): Promise<void> {
|
|
86
|
+
if (this.#state !== 'created') throw new Error(`[znight] Consumer ${this.consumer.name} was already started`)
|
|
87
|
+
this.#state = 'starting'
|
|
88
|
+
const { consumer } = this
|
|
89
|
+
try {
|
|
90
|
+
this.#subscription = await this.#services.driver.subscribe(consumer.queue, delivery => this.handle(delivery), {
|
|
91
|
+
consumer: consumer.name,
|
|
92
|
+
concurrency: consumer.concurrency,
|
|
93
|
+
deadLetterQueue: consumer.deadLetterQueue,
|
|
94
|
+
maxAttempts: consumer.maxAttempts,
|
|
95
|
+
retryDelays: consumer.retryDelays,
|
|
96
|
+
options: consumer.driverOptions,
|
|
97
|
+
})
|
|
98
|
+
} catch (error) {
|
|
99
|
+
this.#state = 'stopped'
|
|
100
|
+
throw error
|
|
101
|
+
}
|
|
102
|
+
this.#state = 'running'
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Graceful stop: stops receiving, aborts in-process retry delays (those messages are released to
|
|
107
|
+
* the broker), awaits the in-flight handlers, then closes the subscription. Never throws.
|
|
108
|
+
*/
|
|
109
|
+
async stop(): Promise<void> {
|
|
110
|
+
if (this.#state === 'stopping' || this.#state === 'stopped') return
|
|
111
|
+
this.#state = 'stopping'
|
|
112
|
+
this.#abort.abort()
|
|
113
|
+
const subscription = this.#subscription
|
|
114
|
+
if (subscription !== undefined) {
|
|
115
|
+
try {
|
|
116
|
+
await subscription.cancel()
|
|
117
|
+
} catch (error) {
|
|
118
|
+
this.#services.logger.error(`Consumer ${this.consumer.name}: failed to cancel the subscription`, { err: error })
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
while (this.#inflight.size > 0) await Promise.allSettled([...this.#inflight])
|
|
122
|
+
if (subscription !== undefined) {
|
|
123
|
+
try {
|
|
124
|
+
await subscription.close()
|
|
125
|
+
} catch (error) {
|
|
126
|
+
this.#services.logger.error(`Consumer ${this.consumer.name}: failed to close the subscription`, { err: error })
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
this.#state = 'stopped'
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Handles one delivery (the driver's `DeliveryHandler`). Never rejects. */
|
|
133
|
+
handle(delivery: DriverDelivery): Promise<void> {
|
|
134
|
+
const promise = this.#handle(delivery)
|
|
135
|
+
this.#inflight.add(promise)
|
|
136
|
+
const done = (): void => {
|
|
137
|
+
this.#inflight.delete(promise)
|
|
138
|
+
}
|
|
139
|
+
promise.then(done, done)
|
|
140
|
+
return promise
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async #handle(delivery: DriverDelivery): Promise<void> {
|
|
144
|
+
const signal = this.#abort.signal
|
|
145
|
+
if (signal.aborted) return this.#release(delivery)
|
|
146
|
+
try {
|
|
147
|
+
await this.#slots.acquire(signal)
|
|
148
|
+
} catch {
|
|
149
|
+
return this.#release(delivery)
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
await this.#process(delivery)
|
|
153
|
+
} catch (error) {
|
|
154
|
+
// defensive: #process handles its failures; never leave a message unsettled
|
|
155
|
+
this.#services.logger.error(`Consumer ${this.consumer.name}: unexpected delivery failure`, { err: error })
|
|
156
|
+
await this.#release(delivery)
|
|
157
|
+
} finally {
|
|
158
|
+
this.#slots.release()
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async #process(delivery: DriverDelivery): Promise<void> {
|
|
163
|
+
const { container, hooks, codec, driver } = this.#services
|
|
164
|
+
const { consumer } = this
|
|
165
|
+
const handler = consumer.select(delivery.type)
|
|
166
|
+
let payload: unknown
|
|
167
|
+
let decodeError: unknown
|
|
168
|
+
try {
|
|
169
|
+
payload = codec.decode(delivery.body, delivery.contentType)
|
|
170
|
+
} catch (error) {
|
|
171
|
+
decodeError = isNonRetryable(error)
|
|
172
|
+
? error
|
|
173
|
+
: new MessageDecodeError(`Cannot decode the message body: ${errorMessage(error)}`, { cause: error })
|
|
174
|
+
}
|
|
175
|
+
const message: InboundMessage = {
|
|
176
|
+
id: delivery.id,
|
|
177
|
+
queue: consumer.queue,
|
|
178
|
+
type: delivery.type,
|
|
179
|
+
payload,
|
|
180
|
+
headers: delivery.headers,
|
|
181
|
+
key: delivery.key,
|
|
182
|
+
attempt: delivery.attempt,
|
|
183
|
+
maxAttempts: handler?.policy.attempts ?? 1,
|
|
184
|
+
redelivered: delivery.redelivered,
|
|
185
|
+
timestamp: delivery.timestamp,
|
|
186
|
+
receivedAt: Date.now(),
|
|
187
|
+
contentType: delivery.contentType,
|
|
188
|
+
body: delivery.body,
|
|
189
|
+
driver: driver.name,
|
|
190
|
+
raw: delivery.raw,
|
|
191
|
+
signal: this.#abort.signal,
|
|
192
|
+
}
|
|
193
|
+
const event: MessageDeliveryEvent = {
|
|
194
|
+
queue: consumer.queue,
|
|
195
|
+
type: delivery.type,
|
|
196
|
+
messageId: delivery.id,
|
|
197
|
+
attempt: delivery.attempt,
|
|
198
|
+
headers: delivery.headers,
|
|
199
|
+
consumer: consumer.name,
|
|
200
|
+
handler: handler?.method,
|
|
201
|
+
message,
|
|
202
|
+
startTime: performance.now(),
|
|
203
|
+
}
|
|
204
|
+
const context: DeliveryContext = {
|
|
205
|
+
delivery,
|
|
206
|
+
message,
|
|
207
|
+
handler,
|
|
208
|
+
event,
|
|
209
|
+
decodeError,
|
|
210
|
+
executed: false,
|
|
211
|
+
followup: undefined,
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
let failure: unknown = new Error('A messaging:message hook wrapper did not call next()')
|
|
215
|
+
try {
|
|
216
|
+
await container.runInScope(
|
|
217
|
+
() => hooks.run('messaging:message', event, () => this.#execute(context)),
|
|
218
|
+
[[MessagingTokens.InboundMessage, message]],
|
|
219
|
+
)
|
|
220
|
+
} catch (error) {
|
|
221
|
+
failure = error
|
|
222
|
+
if (context.executed) {
|
|
223
|
+
// the delivery was settled: a wrapper failed afterwards, or a request-scoped instance failed to dispose
|
|
224
|
+
this.#services.logger.error(`Consumer ${consumer.name}: error after handling message ${delivery.id}`, {
|
|
225
|
+
err: error,
|
|
226
|
+
queue: consumer.queue,
|
|
227
|
+
})
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
// the handler never ran (a hooks wrapper threw or did not call next()): count it as a failed attempt
|
|
231
|
+
if (!context.executed) await this.#failure(context, failure, false)
|
|
232
|
+
if (hooks.hasListeners('messaging:message')) hooks.emit('messaging:message', event)
|
|
233
|
+
await this.#followup(context)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Runs inside the request scope and the hooks: handler, then settlement. Never throws. */
|
|
237
|
+
async #execute(context: DeliveryContext): Promise<void> {
|
|
238
|
+
if (context.executed) return // a wrapper called next() twice
|
|
239
|
+
context.executed = true
|
|
240
|
+
const { handler, message } = context
|
|
241
|
+
if (context.decodeError !== undefined) return this.#failure(context, context.decodeError, true)
|
|
242
|
+
if (handler === undefined)
|
|
243
|
+
return this.#terminal(context, new NoHandlerError(this.consumer.name, message.type), 'no-handler', true)
|
|
244
|
+
if (message.attempt > message.maxAttempts) {
|
|
245
|
+
return this.#terminal(
|
|
246
|
+
context,
|
|
247
|
+
new AttemptsExhaustedError(message.attempt, message.maxAttempts),
|
|
248
|
+
'max-attempts',
|
|
249
|
+
false,
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
const instance = this.#services.container.get(this.consumer.token)
|
|
254
|
+
await handler.definition.invoke(instance, message)
|
|
255
|
+
} catch (error) {
|
|
256
|
+
return this.#failure(context, error, true)
|
|
257
|
+
}
|
|
258
|
+
return this.#ack(context)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** A failed attempt: retry while attempts remain, dead-letter otherwise. */
|
|
262
|
+
#failure(context: DeliveryContext, error: unknown, withErrorHandler: boolean): Promise<void> {
|
|
263
|
+
if (isNonRetryable(error)) return this.#terminal(context, error, reasonOf(error), withErrorHandler)
|
|
264
|
+
if (context.message.attempt < context.message.maxAttempts) return this.#retry(context, error)
|
|
265
|
+
return this.#terminal(context, error, 'max-attempts', withErrorHandler)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async #retry(context: DeliveryContext, error: unknown): Promise<void> {
|
|
269
|
+
const { message, delivery, event } = context
|
|
270
|
+
const policy = context.handler?.policy
|
|
271
|
+
const delayMs = policy === undefined ? 0 : computeRetryDelay(policy, message.attempt)
|
|
272
|
+
event.outcome = 'retry'
|
|
273
|
+
event.error = error
|
|
274
|
+
this.#services.logger.warn(
|
|
275
|
+
`Consumer ${this.consumer.name}: attempt ${message.attempt}/${message.maxAttempts} of message ${message.id} failed, retrying in ${delayMs} ms`,
|
|
276
|
+
{ queue: message.queue, messageId: message.id, type: message.type, ...this.#errorContext(error) },
|
|
277
|
+
)
|
|
278
|
+
if (isInProcessDelay(delayMs, this.#services.driver.capabilities)) {
|
|
279
|
+
context.followup = { kind: 'retry', delayMs, error }
|
|
280
|
+
return
|
|
281
|
+
}
|
|
282
|
+
try {
|
|
283
|
+
await delivery.retry({ attempt: message.attempt + 1, delayMs, error })
|
|
284
|
+
} catch (retryError) {
|
|
285
|
+
this.#services.logger.error(
|
|
286
|
+
`Consumer ${this.consumer.name}: failed to schedule the retry of message ${message.id}`,
|
|
287
|
+
{
|
|
288
|
+
err: retryError,
|
|
289
|
+
queue: message.queue,
|
|
290
|
+
},
|
|
291
|
+
)
|
|
292
|
+
context.followup = { kind: 'requeue' }
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** No more attempts: `@OnError` may acknowledge, otherwise the message is dead-lettered. */
|
|
297
|
+
async #terminal(
|
|
298
|
+
context: DeliveryContext,
|
|
299
|
+
error: unknown,
|
|
300
|
+
reason: DeadLetterReason,
|
|
301
|
+
withErrorHandler: boolean,
|
|
302
|
+
): Promise<void> {
|
|
303
|
+
context.event.error = error
|
|
304
|
+
const method = this.consumer.errorHandler
|
|
305
|
+
if (withErrorHandler && method !== undefined) {
|
|
306
|
+
try {
|
|
307
|
+
const instance = this.#services.container.get(this.consumer.token) as Record<
|
|
308
|
+
string,
|
|
309
|
+
(...args: unknown[]) => unknown
|
|
310
|
+
>
|
|
311
|
+
const result = await instance[method]?.(error, context.message)
|
|
312
|
+
if (result === 'ack') return this.#ack(context)
|
|
313
|
+
} catch (handlerError) {
|
|
314
|
+
this.#services.logger.error(`Consumer ${this.consumer.name}: @OnError ${method}() failed`, {
|
|
315
|
+
err: handlerError,
|
|
316
|
+
queue: context.message.queue,
|
|
317
|
+
messageId: context.message.id,
|
|
318
|
+
})
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return this.#deadLetter(context, error, reason)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async #deadLetter(context: DeliveryContext, error: unknown, reason: DeadLetterReason): Promise<void> {
|
|
325
|
+
const { message, delivery, event } = context
|
|
326
|
+
const queue = this.consumer.deadLetterQueue
|
|
327
|
+
event.outcome = 'dead-letter'
|
|
328
|
+
try {
|
|
329
|
+
await delivery.deadLetter({ queue, reason, error, attempt: message.attempt })
|
|
330
|
+
} catch (deadLetterError) {
|
|
331
|
+
event.outcome = 'retry'
|
|
332
|
+
event.error = deadLetterError
|
|
333
|
+
this.#services.logger.error(
|
|
334
|
+
`Consumer ${this.consumer.name}: failed to dead-letter message ${message.id}, it will be redelivered`,
|
|
335
|
+
{ err: deadLetterError, cause: error, queue: message.queue, deadLetterQueue: queue },
|
|
336
|
+
)
|
|
337
|
+
context.followup = { kind: 'requeue' }
|
|
338
|
+
return
|
|
339
|
+
}
|
|
340
|
+
this.#services.logger.error(
|
|
341
|
+
queue === undefined
|
|
342
|
+
? `Consumer ${this.consumer.name}: message ${message.id} rejected (${reason})`
|
|
343
|
+
: `Consumer ${this.consumer.name}: message ${message.id} dead-lettered to ${queue} (${reason})`,
|
|
344
|
+
{
|
|
345
|
+
queue: message.queue,
|
|
346
|
+
messageId: message.id,
|
|
347
|
+
type: message.type,
|
|
348
|
+
attempt: message.attempt,
|
|
349
|
+
reason,
|
|
350
|
+
...this.#errorContext(error),
|
|
351
|
+
},
|
|
352
|
+
)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* The error of a failed delivery in the retry / dead-letter log entries: the error itself (with its
|
|
357
|
+
* stack), or only its message when a `messaging:message` wrapper is installed. Such a wrapper (the
|
|
358
|
+
* ObservabilityPlugin delivery log) writes its own entry for the delivery with the error: the stack
|
|
359
|
+
* is logged once. Settlement failures always log their error.
|
|
360
|
+
*/
|
|
361
|
+
#errorContext(error: unknown): { readonly err: unknown } | { readonly error: string } {
|
|
362
|
+
return this.#services.hooks.hasWrappers('messaging:message') ? { error: errorMessage(error) } : { err: error }
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async #ack(context: DeliveryContext): Promise<void> {
|
|
366
|
+
context.event.outcome = 'ack'
|
|
367
|
+
try {
|
|
368
|
+
await context.delivery.ack()
|
|
369
|
+
} catch (error) {
|
|
370
|
+
this.#services.logger.warn(
|
|
371
|
+
`Consumer ${this.consumer.name}: failed to acknowledge message ${context.message.id}, the broker will redeliver it`,
|
|
372
|
+
{ err: error, queue: context.message.queue },
|
|
373
|
+
)
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Work done after the scope closed: in-process retry delays and requeues. */
|
|
378
|
+
async #followup(context: DeliveryContext): Promise<void> {
|
|
379
|
+
const followup = context.followup
|
|
380
|
+
if (followup === undefined) return
|
|
381
|
+
const { delivery, message } = context
|
|
382
|
+
if (followup.kind === 'retry') {
|
|
383
|
+
try {
|
|
384
|
+
await sleep(followup.delayMs, this.#abort.signal)
|
|
385
|
+
} catch {
|
|
386
|
+
return this.#release(delivery) // stopping: the broker redelivers it later
|
|
387
|
+
}
|
|
388
|
+
try {
|
|
389
|
+
await delivery.retry({ attempt: message.attempt + 1, delayMs: 0, error: followup.error })
|
|
390
|
+
return
|
|
391
|
+
} catch (error) {
|
|
392
|
+
this.#services.logger.error(`Consumer ${this.consumer.name}: failed to redeliver message ${message.id}`, {
|
|
393
|
+
err: error,
|
|
394
|
+
queue: message.queue,
|
|
395
|
+
})
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
try {
|
|
399
|
+
await sleep(this.#services.requeueDelayMs, this.#abort.signal)
|
|
400
|
+
} catch {
|
|
401
|
+
// stopping: release right away
|
|
402
|
+
}
|
|
403
|
+
return this.#release(delivery)
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async #release(delivery: DriverDelivery): Promise<void> {
|
|
407
|
+
try {
|
|
408
|
+
await delivery.release()
|
|
409
|
+
} catch (error) {
|
|
410
|
+
this.#services.logger.error(
|
|
411
|
+
`Consumer ${this.consumer.name}: failed to release message ${delivery.id}, the broker will redeliver it`,
|
|
412
|
+
{ err: error, queue: this.consumer.queue },
|
|
413
|
+
)
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function reasonOf(error: unknown): DeadLetterReason {
|
|
419
|
+
if (error instanceof MessageValidationError) return 'validation'
|
|
420
|
+
if (error instanceof MessageDecodeError) return 'decode'
|
|
421
|
+
if (error instanceof NoHandlerError) return 'no-handler'
|
|
422
|
+
if (error instanceof AttemptsExhaustedError) return 'max-attempts'
|
|
423
|
+
return 'non-retryable'
|
|
424
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copy of the [Standard Schema v1](https://standardschema.dev) interface (MIT). Zod, Valibot, ArkType,
|
|
3
|
+
* Effect Schema... implement it: `@Message(schema)` accepts any of them without depending on one.
|
|
4
|
+
*
|
|
5
|
+
* The upstream declaration uses a namespace; the types are flattened here (`erasableSyntaxOnly`) but
|
|
6
|
+
* remain structurally identical, so any Standard Schema implementation is assignable.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** A schema implementing the Standard Schema v1 interface. */
|
|
10
|
+
export interface StandardSchemaV1<Input = unknown, Output = Input> {
|
|
11
|
+
readonly '~standard': StandardSchemaV1Props<Input, Output>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The `~standard` property of a {@link StandardSchemaV1}. */
|
|
15
|
+
export interface StandardSchemaV1Props<Input = unknown, Output = Input> {
|
|
16
|
+
/** Version of the Standard Schema specification. */
|
|
17
|
+
readonly version: 1
|
|
18
|
+
/** Library implementing the schema (`zod`, `valibot`...). */
|
|
19
|
+
readonly vendor: string
|
|
20
|
+
/** Validates an unknown value, synchronously or asynchronously. */
|
|
21
|
+
readonly validate: (value: unknown) => StandardSchemaV1Result<Output> | Promise<StandardSchemaV1Result<Output>>
|
|
22
|
+
/** Inferred types (type-level only). */
|
|
23
|
+
readonly types?: StandardSchemaV1Types<Input, Output> | undefined
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Result of {@link StandardSchemaV1Props.validate}. */
|
|
27
|
+
export type StandardSchemaV1Result<Output> = StandardSchemaV1SuccessResult<Output> | StandardSchemaV1FailureResult
|
|
28
|
+
|
|
29
|
+
/** Successful validation: `value` is the schema output (defaults, transforms applied). */
|
|
30
|
+
export interface StandardSchemaV1SuccessResult<Output> {
|
|
31
|
+
readonly value: Output
|
|
32
|
+
readonly issues?: undefined
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Failed validation. */
|
|
36
|
+
export interface StandardSchemaV1FailureResult {
|
|
37
|
+
readonly issues: readonly StandardSchemaV1Issue[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** One validation issue. */
|
|
41
|
+
export interface StandardSchemaV1Issue {
|
|
42
|
+
readonly message: string
|
|
43
|
+
readonly path?: readonly (PropertyKey | StandardSchemaV1PathSegment)[] | undefined
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A segment of an issue path. */
|
|
47
|
+
export interface StandardSchemaV1PathSegment {
|
|
48
|
+
readonly key: PropertyKey
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Input and output types of a schema (type-level only). */
|
|
52
|
+
export interface StandardSchemaV1Types<Input = unknown, Output = Input> {
|
|
53
|
+
readonly input: Input
|
|
54
|
+
readonly output: Output
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Input type of a Standard Schema. */
|
|
58
|
+
export type InferSchemaInput<Schema extends StandardSchemaV1> = NonNullable<Schema['~standard']['types']>['input']
|
|
59
|
+
|
|
60
|
+
/** Output type of a Standard Schema (what handlers receive). */
|
|
61
|
+
export type InferSchemaOutput<Schema extends StandardSchemaV1> = NonNullable<Schema['~standard']['types']>['output']
|
|
62
|
+
|
|
63
|
+
/** Whether `value` looks like a Standard Schema v1 (has a `~standard.validate` function). */
|
|
64
|
+
export function isStandardSchema(value: unknown): value is StandardSchemaV1 {
|
|
65
|
+
if (value === null || (typeof value !== 'object' && typeof value !== 'function')) return false
|
|
66
|
+
const props = (value as { '~standard'?: unknown })['~standard']
|
|
67
|
+
return props !== null && typeof props === 'object' && typeof (props as { validate?: unknown }).validate === 'function'
|
|
68
|
+
}
|
package/src/tokens.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { TypedToken } from '@znight/core'
|
|
2
|
+
import type { MessageBroker } from './broker.ts'
|
|
3
|
+
import type { InboundMessage } from './message.ts'
|
|
4
|
+
|
|
5
|
+
/** Tokens of the externals provided by `MessagingPlugin` (declared in the package manifest). */
|
|
6
|
+
export const MessagingTokens: {
|
|
7
|
+
/** The {@link MessageBroker} singleton. */
|
|
8
|
+
readonly MessageBroker: TypedToken<MessageBroker>
|
|
9
|
+
/** The {@link InboundMessage} of the current delivery (request-scoped). */
|
|
10
|
+
readonly InboundMessage: TypedToken<InboundMessage>
|
|
11
|
+
} = Object.freeze({
|
|
12
|
+
MessageBroker: '@znight/messaging#MessageBroker',
|
|
13
|
+
InboundMessage: '@znight/messaging#InboundMessage',
|
|
14
|
+
})
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
InferSchemaOutput,
|
|
3
|
+
StandardSchemaV1,
|
|
4
|
+
StandardSchemaV1Issue,
|
|
5
|
+
StandardSchemaV1PathSegment,
|
|
6
|
+
StandardSchemaV1Result,
|
|
7
|
+
} from './standard-schema.ts'
|
|
8
|
+
|
|
9
|
+
import { MessageValidationError, type MessageValidationIssue } from './errors.ts'
|
|
10
|
+
import { isStandardSchema } from './standard-schema.ts'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Validates `value` with a Standard Schema (Zod, Valibot, ArkType...) and returns the schema
|
|
14
|
+
* **output** (defaults and transforms applied). On failure it throws a {@link MessageValidationError},
|
|
15
|
+
* a `NonRetryableError`: the message is dead-lettered without further attempts.
|
|
16
|
+
*
|
|
17
|
+
* Returns synchronously when the schema validates synchronously, a promise otherwise. Called by the
|
|
18
|
+
* code generated for `@Message(schema)` parameters:
|
|
19
|
+
*
|
|
20
|
+
* ```ts
|
|
21
|
+
* invoke: async (c, m) => c.handle(await validateMessage(OrderCreated, m.payload))
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* @param label Describes the value in the error message. Default: `'message payload'`.
|
|
25
|
+
*/
|
|
26
|
+
export function validateMessage<S extends StandardSchemaV1>(
|
|
27
|
+
schema: S,
|
|
28
|
+
value: unknown,
|
|
29
|
+
label = 'message payload',
|
|
30
|
+
): InferSchemaOutput<S> | Promise<InferSchemaOutput<S>> {
|
|
31
|
+
if (!isStandardSchema(schema)) {
|
|
32
|
+
throw new TypeError(`[znight] validateMessage(): the schema of the ${label} is not a Standard Schema v1`)
|
|
33
|
+
}
|
|
34
|
+
const result = schema['~standard'].validate(value) as
|
|
35
|
+
| StandardSchemaV1Result<InferSchemaOutput<S>>
|
|
36
|
+
| Promise<StandardSchemaV1Result<InferSchemaOutput<S>>>
|
|
37
|
+
if (isPromiseLike(result)) return Promise.resolve(result).then(settled => unwrap(settled, label))
|
|
38
|
+
return unwrap(result, label)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function unwrap<T>(result: StandardSchemaV1Result<T>, label: string): T {
|
|
42
|
+
if (result.issues === undefined) return result.value
|
|
43
|
+
throw new MessageValidationError(label, result.issues.map(toIssue))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function toIssue(issue: StandardSchemaV1Issue): MessageValidationIssue {
|
|
47
|
+
return { path: formatPath(issue.path), message: String(issue.message) }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function formatPath(path: StandardSchemaV1Issue['path']): string {
|
|
51
|
+
if (path === undefined || path.length === 0) return ''
|
|
52
|
+
return path.map(segment => String(isSegment(segment) ? segment.key : segment)).join('.')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isPromiseLike<T>(value: T | PromiseLike<T>): value is PromiseLike<T> {
|
|
56
|
+
return typeof (value as { then?: unknown } | null)?.then === 'function'
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isSegment(segment: PropertyKey | StandardSchemaV1PathSegment): segment is StandardSchemaV1PathSegment {
|
|
60
|
+
return typeof segment === 'object' && segment !== null && 'key' in segment
|
|
61
|
+
}
|