@znight/messaging 0.0.0-stage → 0.1.1
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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Ugho STEPHAN
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,623 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @znight/messaging
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Message consumers and publishing for [Znight](../../README.md), checked at build time and run on
|
|
4
|
+
RabbitMQ, Amazon SQS, Kafka or in memory.
|
|
5
|
+
|
|
6
|
+
Retries with backoff, dead-letter queues, per-consumer concurrency and graceful shutdown, the same
|
|
7
|
+
on **RabbitMQ**, **Amazon SQS** and **Kafka**; the in-memory broker serves tests and local
|
|
8
|
+
development.
|
|
9
|
+
|
|
10
|
+
Like the rest of Znight, consumers are discovered **at build time**: `@znight/compiler` (with the
|
|
11
|
+
`@znight/messaging/compiler` extension) reads the decorators and generates the consumer metadata. At
|
|
12
|
+
runtime `MessagingPlugin` only executes it.
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
# bun
|
|
16
|
+
bun add @znight/core @znight/messaging
|
|
17
|
+
bun add -d @znight/compiler vite
|
|
18
|
+
bun add amqplib # optional: RabbitMQ driver
|
|
19
|
+
bun add @aws-sdk/client-sqs # optional: SQS driver
|
|
20
|
+
bun add @platformatic/kafka # optional: Kafka driver
|
|
21
|
+
|
|
22
|
+
# npm
|
|
23
|
+
npm install @znight/core @znight/messaging
|
|
24
|
+
npm install -D @znight/compiler vite
|
|
25
|
+
npm install amqplib # optional: RabbitMQ driver
|
|
26
|
+
npm install @aws-sdk/client-sqs # optional: SQS driver
|
|
27
|
+
npm install @platformatic/kafka # optional: Kafka driver
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Runs on Node.js >= 24 and Bun >= 1.3. Broker clients are optional peer dependencies: install only the
|
|
31
|
+
one you use (the in-memory driver needs none).
|
|
32
|
+
|
|
33
|
+
**Documentation:** [`@znight/messaging`](https://github.com/znight-org/znight/blob/main/docs/packages/messaging.md).
|
|
34
|
+
**Examples:** [`microservice`](https://github.com/znight-org/znight/tree/main/examples/microservice)
|
|
35
|
+
(publishing from an HTTP API, retries, dead letters, traces across HTTP and messaging),
|
|
36
|
+
[`worker`](https://github.com/znight-org/znight/tree/main/examples/worker) (a worker without HTTP
|
|
37
|
+
server), [`monorepo`](https://github.com/znight-org/znight/tree/main/examples/monorepo) (a consumer
|
|
38
|
+
service whose queue and message contracts come from a shared library package).
|
|
39
|
+
|
|
40
|
+
## Quick start
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
// src/orders/order.consumer.ts
|
|
44
|
+
import { Consumer, Message, NonRetryableError, OnError, OnMessage, Retry, type InboundMessage } from '@znight/messaging'
|
|
45
|
+
import { z } from 'zod'
|
|
46
|
+
|
|
47
|
+
export const OrderPlaced = z.object({ id: z.string(), total: z.number() })
|
|
48
|
+
|
|
49
|
+
@Consumer('orders')
|
|
50
|
+
export class OrderConsumer {
|
|
51
|
+
constructor(private readonly billing: BillingService) {}
|
|
52
|
+
|
|
53
|
+
@OnMessage('order.placed')
|
|
54
|
+
@Retry({ attempts: 5, backoff: 'exponential', delay: 500, maxDelay: 30_000 })
|
|
55
|
+
async placed(@Message(OrderPlaced) order: z.output<typeof OrderPlaced>) {
|
|
56
|
+
if (order.total < 0) throw new NonRetryableError('Negative total') // dead-lettered right away
|
|
57
|
+
await this.billing.charge(order) // throws → retried
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
@OnError()
|
|
61
|
+
onError(error: unknown, message: InboundMessage) {
|
|
62
|
+
if (message.type === 'order.legacy') return 'ack' // swallow instead of dead-lettering
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
// src/orders/order.service.ts
|
|
69
|
+
import { Injectable } from '@znight/core'
|
|
70
|
+
import { MessageBroker } from '@znight/messaging'
|
|
71
|
+
|
|
72
|
+
@Injectable()
|
|
73
|
+
export class OrderService {
|
|
74
|
+
constructor(private readonly broker: MessageBroker) {}
|
|
75
|
+
|
|
76
|
+
async place(order: Order) {
|
|
77
|
+
await this.broker.publish('orders', order, { type: 'order.placed', key: order.customerId })
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
// src/main.ts
|
|
84
|
+
import { Application } from '@znight/core'
|
|
85
|
+
import { MessagingPlugin } from '@znight/messaging'
|
|
86
|
+
import { rabbitmq } from '@znight/messaging/rabbitmq'
|
|
87
|
+
import { __ZNIGHT_METADATA__ } from './.znight/container.generated.js'
|
|
88
|
+
|
|
89
|
+
const app = await Application.create({
|
|
90
|
+
metadata: __ZNIGHT_METADATA__,
|
|
91
|
+
plugins: [MessagingPlugin.configure({ driver: rabbitmq({ url: process.env.AMQP_URL! }) })],
|
|
92
|
+
})
|
|
93
|
+
await app.start() // consumers subscribe; SIGTERM drains them gracefully
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Decorators
|
|
97
|
+
|
|
98
|
+
Decorators are compile-time markers (no-ops at runtime). _Static_ arguments must be statically
|
|
99
|
+
evaluable (literals, `const` references); _value_ arguments (schemas) may reference exported bindings.
|
|
100
|
+
|
|
101
|
+
| Decorator | Target | Description |
|
|
102
|
+
| ------------------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------ |
|
|
103
|
+
| `@Consumer(queue)` / `@Consumer({ queue, scope?, concurrency?, deadLetter?, ...driver options })` | class | A provider (singleton by default, `scope: 'request'` for one instance per message) consuming `queue` (a Kafka topic). |
|
|
104
|
+
| `@OnMessage(type?)` | method | Handles messages of `type` (exact, or a pattern where `*` matches anything: `'order.*'`). Without type: default handler. |
|
|
105
|
+
| `@Retry({ attempts, backoff?, delay?, maxDelay? })` | method | Retry policy of the handler (see [Retries](#retries)). |
|
|
106
|
+
| `@OnError()` | method | `(error, message) => void \| 'ack'`, called when a message will not be retried anymore. |
|
|
107
|
+
| `@Message(schema?)` | param | The payload; validated with a [Standard Schema](https://standardschema.dev) (Zod, Valibot, ArkType...) when given. |
|
|
108
|
+
| `@Headers(name?)` | param | All headers (`Record<string, string>`), or one header (`string \| undefined`, exact then case-insensitive match). |
|
|
109
|
+
| `@Ctx()` | param | The `InboundMessage`. |
|
|
110
|
+
|
|
111
|
+
Handler selection: exact type, then patterns (declaration order), then the default handler. A
|
|
112
|
+
message no handler matches is dead-lettered with reason `no-handler` (or acknowledged by `@OnError`).
|
|
113
|
+
A queue is consumed by a single `@Consumer` class per application.
|
|
114
|
+
|
|
115
|
+
## Delivery semantics
|
|
116
|
+
|
|
117
|
+
Delivery is **at-least-once**. A message can be handled more than once: redelivery after a crash, a
|
|
118
|
+
lost acknowledgement, a rebalance, a dead-letter queue outage... **Handlers must be idempotent** —
|
|
119
|
+
deduplicate on `message.id` (stable across redeliveries; `publish()` generates a UUID, or pass your
|
|
120
|
+
own `id`) or make the side effects idempotent.
|
|
121
|
+
|
|
122
|
+
Each delivery goes through this state machine:
|
|
123
|
+
|
|
124
|
+
```
|
|
125
|
+
receive ─► wait for a concurrency slot ─► request scope (InboundMessage) ─► hooks.run('messaging:message')
|
|
126
|
+
└─► decode ─► select handler by type ─► invoke (validate → handler)
|
|
127
|
+
├─ success ───────────────────────────────────────────────► ack
|
|
128
|
+
├─ failure, attempts left ─► wait in-process (≤ driver budget) ─► redeliver as attempt + 1
|
|
129
|
+
│ └─ or native delayed redelivery (SQS visibility, RabbitMQ delay queue, Kafka retry topic)
|
|
130
|
+
└─ NonRetryableError / invalid payload / undecodable body / no handler / last attempt
|
|
131
|
+
└─► @OnError ─ 'ack' ─► ack
|
|
132
|
+
└─ otherwise (or throws) ─► dead-letter: publish (confirmed) then ack
|
|
133
|
+
└─ publish failed ─► release (redelivered later)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
- **Nothing is dropped**: every path ends with the broker owning the message (acknowledged,
|
|
137
|
+
republished, dead-lettered or released). A settlement failure (connection loss) leaves the message
|
|
138
|
+
unacknowledged, so the broker redelivers it.
|
|
139
|
+
- **Attempts** are counted by the broker (SQS `ApproximateReceiveCount`, RabbitMQ quorum
|
|
140
|
+
`x-delivery-count`) and/or the `x-znight-attempt` header of republished retries
|
|
141
|
+
(`message.attempt`, 1-based). A redelivery beyond the last attempt is dead-lettered without calling
|
|
142
|
+
the handler.
|
|
143
|
+
- **Ordering** is not guaranteed across retries. Kafka keeps the order of a partition (records of a
|
|
144
|
+
partition are handled one at a time); SQS FIFO keeps the order of a message group.
|
|
145
|
+
|
|
146
|
+
| Outcome (`event.outcome`) | When |
|
|
147
|
+
| ------------------------- | ------------------------------------------------------------------------------------------ |
|
|
148
|
+
| `ack` | The handler succeeded, or `@OnError` returned `'ack'`. |
|
|
149
|
+
| `retry` | A retryable failure with attempts left, or a failed dead-lettering (message released). |
|
|
150
|
+
| `dead-letter` | No more attempts: moved to the dead-letter queue (or rejected when dead-lettering is off). |
|
|
151
|
+
|
|
152
|
+
### Retries
|
|
153
|
+
|
|
154
|
+
`attempts` counts the first delivery: `{ attempts: 3 }` = 1 delivery + 2 retries.
|
|
155
|
+
|
|
156
|
+
| Option | Default | Description |
|
|
157
|
+
| ---------- | --------------- | ------------------------------------------------------------------ |
|
|
158
|
+
| `attempts` | — | Total attempts (`1`: no retry). |
|
|
159
|
+
| `backoff` | `'exponential'` | `fixed`: always `delay`; `exponential`: `delay × 2^(attempt − 1)`. |
|
|
160
|
+
| `delay` | `1000` | Base delay in milliseconds. |
|
|
161
|
+
| `maxDelay` | — | Cap of exponential delays. |
|
|
162
|
+
|
|
163
|
+
Handlers without `@Retry` use `defaults.retry` (3 attempts, exponential from 1 s, capped at 30 s).
|
|
164
|
+
|
|
165
|
+
A delay shorter than the driver's `maxInProcessDelayMs` is waited **in-process** (the message stays
|
|
166
|
+
locked: prefetch slot, SQS lease) then the message is redelivered; longer delays use the broker's
|
|
167
|
+
**native** delayed redelivery. **The application fails to start (ZNIGHT011)** if a policy needs a delay
|
|
168
|
+
the driver cannot honour (e.g. more than 12 h on SQS).
|
|
169
|
+
|
|
170
|
+
### Errors and validation
|
|
171
|
+
|
|
172
|
+
- Throw `NonRetryableError` (or a subclass) for permanent failures: dead-lettered immediately
|
|
173
|
+
(reason `non-retryable`). `isNonRetryable(error)` also recognises errors from other copies of the package.
|
|
174
|
+
- `@Message(schema)` validation failures throw a `MessageValidationError` (non-retryable, reason
|
|
175
|
+
`validation`, `error.issues: { path, message }[]`). Handlers receive the schema **output**
|
|
176
|
+
(defaults, transforms).
|
|
177
|
+
- An undecodable body (malformed JSON...) is dead-lettered with reason `decode`.
|
|
178
|
+
|
|
179
|
+
### Dead-letter queues
|
|
180
|
+
|
|
181
|
+
The dead-letter destination of a queue is, by priority: `@Consumer({ deadLetter })`,
|
|
182
|
+
`defaults.deadLetter(queue)`, the driver naming (`orders.dlq`; SQS `orders-dlq` or the target of the
|
|
183
|
+
queue's `RedrivePolicy`). The copy keeps the body, type, id and headers (without `x-znight-attempt`, so
|
|
184
|
+
that a redrive starts over) and adds:
|
|
185
|
+
|
|
186
|
+
| Header | Value |
|
|
187
|
+
| ----------------------------- | ----------------------------------------------------------------------- |
|
|
188
|
+
| `x-znight-dead-letter-reason` | `no-handler`, `decode`, `validation`, `non-retryable` or `max-attempts` |
|
|
189
|
+
| `x-znight-error` | Error message (truncated to 1024 characters) |
|
|
190
|
+
| `x-znight-original-queue` | Queue / topic the message was consumed from |
|
|
191
|
+
| `x-znight-attempts` | Attempts made |
|
|
192
|
+
| `x-znight-consumer-group` | Kafka only: consumer group |
|
|
193
|
+
|
|
194
|
+
The original is acknowledged only once the copy is confirmed; if the publication fails, the message
|
|
195
|
+
is released after `defaults.requeueDelay` and redelivered. `deadLetter: false` disables
|
|
196
|
+
dead-lettering: RabbitMQ rejects the message (a queue DLX policy applies), SQS and Kafka discard it.
|
|
197
|
+
|
|
198
|
+
### Concurrency and graceful shutdown
|
|
199
|
+
|
|
200
|
+
`concurrency` (default `defaults.concurrency`, 1) bounds the messages a consumer handles at once
|
|
201
|
+
(RabbitMQ prefetch, SQS receive batches, Kafka partitions handled in parallel).
|
|
202
|
+
|
|
203
|
+
`app.stop()` (or SIGTERM) stops receiving, aborts in-process retry delays (those messages are released
|
|
204
|
+
to the broker, never dropped), aborts `message.signal`, waits for the handlers in flight, then closes
|
|
205
|
+
the connections. Keep handlers shorter than `shutdown.hookTimeoutMs` (5 s by default) or raise it.
|
|
206
|
+
|
|
207
|
+
While consumers run (from `onStart` to the end of `onStop`), the plugin keeps the process alive: a
|
|
208
|
+
worker whose only ingress is a consumer does not exit after `app.start()`, whatever the driver (the
|
|
209
|
+
memory driver holds no socket), and stops only through `app.stop()` or a signal.
|
|
210
|
+
|
|
211
|
+
The runtime logs each retry (`warn`) and each dead letter (`error`) with the error. When a
|
|
212
|
+
`messaging:message` wrapper is installed (the `ObservabilityPlugin` delivery log writes one entry per
|
|
213
|
+
delivery with the error), these entries carry only the error message (`error`) instead of the error
|
|
214
|
+
and its stack (`err`), so that the stack is logged once.
|
|
215
|
+
|
|
216
|
+
### Request scope
|
|
217
|
+
|
|
218
|
+
Each delivery runs in its own request scope seeded with the `InboundMessage`
|
|
219
|
+
(`@znight/messaging#InboundMessage`, a request-scoped external): inject it into request-scoped
|
|
220
|
+
providers, or use `Lazy<InboundMessage>` from singletons. Request-scoped instances are disposed when
|
|
221
|
+
the delivery ends.
|
|
222
|
+
|
|
223
|
+
```ts
|
|
224
|
+
interface InboundMessage<T = unknown> {
|
|
225
|
+
id: string // stable across redeliveries
|
|
226
|
+
queue: string
|
|
227
|
+
type: string | undefined
|
|
228
|
+
payload: T
|
|
229
|
+
headers: Readonly<Record<string, string>>
|
|
230
|
+
key: string | undefined // Kafka key, SQS FIFO group
|
|
231
|
+
attempt: number // 1-based
|
|
232
|
+
maxAttempts: number
|
|
233
|
+
redelivered: boolean
|
|
234
|
+
timestamp: number | undefined // produced at (epoch ms)
|
|
235
|
+
receivedAt: number
|
|
236
|
+
contentType: string | undefined
|
|
237
|
+
body: Uint8Array | string // raw body
|
|
238
|
+
driver: string
|
|
239
|
+
raw: unknown // amqplib ConsumeMessage, SQS Message, Kafka Message...
|
|
240
|
+
signal: AbortSignal // aborted on shutdown
|
|
241
|
+
}
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
## Publishing
|
|
245
|
+
|
|
246
|
+
`MessageBroker` is a singleton external: inject it anywhere (or `app.get(MessageBroker)`).
|
|
247
|
+
|
|
248
|
+
```ts
|
|
249
|
+
const { id } = await broker.publish('orders', order, {
|
|
250
|
+
type: 'order.placed', // handler selection
|
|
251
|
+
headers: { 'x-tenant': 'acme' }, // string values
|
|
252
|
+
key: order.customerId, // Kafka partition key / SQS FIFO group
|
|
253
|
+
delay: 5_000, // ms (RabbitMQ, SQS ≤ 15 min, memory; not Kafka)
|
|
254
|
+
id: order.id, // default: random UUID
|
|
255
|
+
})
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
`publish()` resolves once the broker **confirmed** the message (publisher confirms, SQS response, Kafka
|
|
259
|
+
acks from all in-sync replicas). Payloads are JSON-encoded; `Uint8Array` is sent as
|
|
260
|
+
`application/octet-stream`; a string with a `contentType` is sent as is. It works from the plugin's
|
|
261
|
+
bootstrap until the application is disposed (providers' `onStart` and `onStop` included).
|
|
262
|
+
|
|
263
|
+
## Configuration
|
|
264
|
+
|
|
265
|
+
```ts
|
|
266
|
+
MessagingPlugin.configure({
|
|
267
|
+
driver: sqs({ clientConfig: { region: 'eu-west-1' } }),
|
|
268
|
+
defaults: { retry: { attempts: 5, delay: 200 }, concurrency: 10, deadLetter: queue => `${queue}-dead` },
|
|
269
|
+
})
|
|
270
|
+
// or computed from the container (evaluated at bootstrap)
|
|
271
|
+
MessagingPlugin.configure(c => ({ driver: rabbitmq({ url: c.get(AmqpConfig).url }) }))
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
| Option | Default | Description |
|
|
275
|
+
| ----------------------- | ---------------------------- | --------------------------------------------------------------------- |
|
|
276
|
+
| `driver` | — | `memory()`, `rabbitmq()`, `sqs()` or `kafka()` (from their subpaths). |
|
|
277
|
+
| `defaults.retry` | 3 attempts, 1 s exp., ≤ 30 s | Policy of handlers without `@Retry`; `false` disables retries. |
|
|
278
|
+
| `defaults.concurrency` | `1` | Concurrency of consumers without `concurrency`. |
|
|
279
|
+
| `defaults.deadLetter` | driver naming | `(queue) => name`, or `false` to disable dead-lettering. |
|
|
280
|
+
| `defaults.requeueDelay` | `1000` | Pause (ms) before releasing a message whose settlement failed. |
|
|
281
|
+
| `codec` | `jsonCodec` | `{ encode(payload, contentType), decode(body, contentType) }`. |
|
|
282
|
+
| `consume` | `true` | `false` for publish-only processes (consumers are not subscribed). |
|
|
283
|
+
|
|
284
|
+
The plugin (`name: 'messaging'`) exposes `broker`, `driver` and `consumers()` (state, in-flight count,
|
|
285
|
+
dead-letter queue of each consumer).
|
|
286
|
+
|
|
287
|
+
Options are validated in `onBootstrap`, so invalid options (a `driver` that is not a messaging driver,
|
|
288
|
+
a `defaults.concurrency` that is not a positive integer...) make `Application.create()` fail with
|
|
289
|
+
**ZNIGHT011**, the code of invalid consumer settings, like a `@Retry` policy the driver cannot honour.
|
|
290
|
+
|
|
291
|
+
## Drivers
|
|
292
|
+
|
|
293
|
+
| Capability | memory | RabbitMQ | SQS | Kafka |
|
|
294
|
+
| ---------------------- | --------- | --------------------------------------- | -------------------------------- | ------------------------------------ |
|
|
295
|
+
| Subpath | `/memory` | `/rabbitmq` | `/sqs` | `/kafka` |
|
|
296
|
+
| Client (optional peer) | — | `amqplib` ^2 | `@aws-sdk/client-sqs` ^3 | `@platformatic/kafka` ^2 |
|
|
297
|
+
| Attempt counting | native | `x-znight-attempt` + `x-delivery-count` | `ApproximateReceiveCount` | `x-znight-attempt` |
|
|
298
|
+
| Native retry delay | timers | delay queues (TTL + DLX), ≤ 49 days | visibility timeout, ≤ 12 h | retry topic, ≤ `maxRetryDelayMs` |
|
|
299
|
+
| In-process budget | 1 s | 5 s | 1 s | 0 (always the retry topic) |
|
|
300
|
+
| Delayed publish | yes | yes (32 shared delay levels), ≤ 49 days | ≤ 15 min (not FIFO) | no |
|
|
301
|
+
| Dead letters | `<q>.dlq` | `<q>.dlq` (publish confirmed + ack) | `<q>-dlq` / RedrivePolicy target | `<topic>.dlq` (acks all + commit) |
|
|
302
|
+
| Ordering | FIFO | none across retries | FIFO groups | per partition |
|
|
303
|
+
| Topology | implicit | declared (`assertTopology`) or verified | verified (queues must exist) | verified or created (`createTopics`) |
|
|
304
|
+
|
|
305
|
+
### Memory
|
|
306
|
+
|
|
307
|
+
Deterministic in-process broker: FIFO queues, round-robin between competing subscribers, delays (of
|
|
308
|
+
any length: timers longer than 2^31 - 1 ms are chained), native delivery counts, dead-letter queues.
|
|
309
|
+
Ideal for tests:
|
|
310
|
+
|
|
311
|
+
```ts
|
|
312
|
+
import { memory } from '@znight/messaging/memory'
|
|
313
|
+
|
|
314
|
+
const driver = memory({ maxInProcessDelayMs: 1000 })
|
|
315
|
+
const app = await Application.create({ metadata, plugins: [MessagingPlugin.configure({ driver })] })
|
|
316
|
+
await app.start()
|
|
317
|
+
|
|
318
|
+
driver.send('orders', { payload: { id: 1 }, type: 'order.placed' }) // as an external producer
|
|
319
|
+
driver.send('orders', { body: '{oops', contentType: 'application/json' }) // malformed body
|
|
320
|
+
await driver.idle() // nothing in flight, scheduled or deliverable
|
|
321
|
+
|
|
322
|
+
driver.settlements('orders') // [{ id, attempt, action: 'ack' | 'retry' | 'release' | 'dead-letter' | 'reject', delayMs?, reason?, error? }]
|
|
323
|
+
driver.deadLetters('orders') // [{ queue, reason, error, attempt, message }]
|
|
324
|
+
driver.published('orders') // messages published through MessageBroker
|
|
325
|
+
driver.pending('orders.dlq') // messages waiting in a queue
|
|
326
|
+
driver.fail('publish', { queue: 'orders.dlq', times: 1 }) // inject failures (publish, ack, retry, dead-letter, release, subscribe, connect)
|
|
327
|
+
await driver.waitFor(() => driver.settlements().length === 3)
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
### RabbitMQ
|
|
331
|
+
|
|
332
|
+
```ts
|
|
333
|
+
import { rabbitmq } from '@znight/messaging/rabbitmq'
|
|
334
|
+
|
|
335
|
+
rabbitmq({ url: 'amqp://guest:guest@localhost:5672' })
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
| Option | Default | Description |
|
|
339
|
+
| --------------------- | -------------------------------------------- | --------------------------------------------------------------------------- |
|
|
340
|
+
| `url` | — | AMQP URL or amqplib connection options. |
|
|
341
|
+
| `socketOptions` | — | TLS / socket options. |
|
|
342
|
+
| `assertTopology` | `true` | Declare consumed, dead-letter and delay queues; `false`: verify they exist. |
|
|
343
|
+
| `queueType` | `'quorum'` | Type of declared queues (`'classic'` for existing classic queues). |
|
|
344
|
+
| `queueArguments` | — | Extra arguments of declared queues (`x-delivery-limit` overrides `-1`). |
|
|
345
|
+
| `maxInProcessDelayMs` | `5000` | Longest retry delay waited in-process. |
|
|
346
|
+
| `delayQueueName` | `` `${q}.delay.${ms}` `` | Retry delay queue naming. |
|
|
347
|
+
| `delayLevelPrefix` | `'znight.delays'` | Names of the delay levels of delayed publications (`<prefix>.0` to `.31`). |
|
|
348
|
+
| `deadLetterName` | `` `${q}.dlq` `` | Default dead-letter queue. |
|
|
349
|
+
| `mandatory` | `true` | Fail `publish()` when no queue receives the message. |
|
|
350
|
+
| `persistent` | `true` | Persistent messages. |
|
|
351
|
+
| `typeFromRoutingKey` | `false` | Use the routing key as type for messages without type (topic exchanges). |
|
|
352
|
+
| `reconnect` | `{ initialDelayMs: 100, maxDelayMs: 30000 }` | Reconnection with exponential backoff; `false` disables. |
|
|
353
|
+
| `connectionName` | `'znight'` | Connection name in the management UI. |
|
|
354
|
+
|
|
355
|
+
Publishing goes through a confirm channel and the default exchange (routing key = queue); a returned
|
|
356
|
+
(unroutable) message fails its own `publish()` only, even when concurrent publications share its id.
|
|
357
|
+
Each consumer has its own channel with `prefetch = concurrency`. Retries are republished copies
|
|
358
|
+
carrying `x-znight-attempt` (to the queue, or to a per-delay queue `orders.delay.2000` whose TTL
|
|
359
|
+
dead-letters back into `orders`, so delays never block each other); quorum delay queues use
|
|
360
|
+
at-least-once dead-lettering. Retry delays come from the `@Retry` policies, so their delay queues are
|
|
361
|
+
a bounded set declared at subscribe time. The type is read from the `type` property (then
|
|
362
|
+
`x-znight-type`, then the routing key with `typeFromRoutingKey`), the id from `message-id`; retried
|
|
363
|
+
and dead-lettered copies carry the type that selected the handler (a type read from the routing key
|
|
364
|
+
included) but never the `CC` / `BCC` headers nor the broker's `x-death` headers.
|
|
365
|
+
|
|
366
|
+
Delayed publications (`publish(queue, payload, { delay })`, any delay up to 2^32 - 1 ms) do not create
|
|
367
|
+
one queue per delay: they go through 32 **delay levels** shared by every queue of the virtual host,
|
|
368
|
+
`znight.delays.0` to `znight.delays.31` (a topic exchange and a queue each, the queue of level n with a
|
|
369
|
+
TTL of 2^n ms). The delay, rounded up to an odd number of milliseconds, is written in binary in the
|
|
370
|
+
routing key: the message waits in the level of each set bit, then level 0 dead-letters it to the
|
|
371
|
+
default exchange, which delivers it to its queue (named in the `CC` header). A delay is never shortened;
|
|
372
|
+
each level crossed adds a few milliseconds. The levels are declared on the first delayed publication
|
|
373
|
+
of each connection, whatever `assertTopology`; quorum levels use at-least-once dead-lettering. Use
|
|
374
|
+
`delayLevelPrefix` when applications sharing a virtual host use different `queueType`s.
|
|
375
|
+
|
|
376
|
+
On RabbitMQ 4 and later, declared quorum queues (consumed and dead-letter queues) get
|
|
377
|
+
`x-delivery-limit: -1`: Znight counts the attempts itself, whereas the broker's default limit (20
|
|
378
|
+
deliveries, releases included) would silently drop a message released repeatedly, for instance while
|
|
379
|
+
its dead-letter queue is unavailable. A queue that already exists without this argument cannot be
|
|
380
|
+
changed by a declaration: the driver declares it as it is and logs a warning; apply a policy with
|
|
381
|
+
`"delivery-limit": -1` (or re-create the queue). With `assertTopology: false`, set that policy on your
|
|
382
|
+
quorum queues.
|
|
383
|
+
|
|
384
|
+
After a connection loss the driver reconnects (exponential backoff) and resumes the consumers;
|
|
385
|
+
messages in flight on the lost connection are redelivered by RabbitMQ. A consumer channel closed by a
|
|
386
|
+
channel error, or a consumer cancelled by the broker (queue deleted, node down: the queues are then
|
|
387
|
+
declared again with `assertTopology`), is resumed the same way. The recovery retries until every
|
|
388
|
+
consumer consumes again, including when the connection is lost again while it runs.
|
|
389
|
+
|
|
390
|
+
### Amazon SQS
|
|
391
|
+
|
|
392
|
+
```ts
|
|
393
|
+
import { sqs } from '@znight/messaging/sqs'
|
|
394
|
+
|
|
395
|
+
sqs({ clientConfig: { region: 'eu-west-1' } })
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
| Option | Default | Description |
|
|
399
|
+
| --------------------- | --------------- | ---------------------------------------------------------------- |
|
|
400
|
+
| `client` | — | An existing `SQSClient` (not destroyed on close). |
|
|
401
|
+
| `clientConfig` | `{}` | Configuration of the client created by the driver. |
|
|
402
|
+
| `queueUrls` | — | `{ name: url }` to skip `GetQueueUrl` (queues may also be URLs). |
|
|
403
|
+
| `waitTimeSeconds` | `20` | Long polling duration. |
|
|
404
|
+
| `visibilityTimeout` | queue attribute | Visibility timeout of received messages (seconds). |
|
|
405
|
+
| `extendVisibility` | `true` | Extend the visibility while handlers run (every half timeout). |
|
|
406
|
+
| `maxInProcessDelayMs` | `1000` | Longest retry delay waited in-process. |
|
|
407
|
+
| `deadLetterName` | `<q>-dlq` | Default dead-letter queue (`<name>-dlq.fifo` for FIFO queues). |
|
|
408
|
+
|
|
409
|
+
Queues are not created by the driver (manage them as infrastructure). At subscribe time it verifies
|
|
410
|
+
the queue and its dead-letter queue exist and that a `RedrivePolicy` `maxReceiveCount` is at least the
|
|
411
|
+
consumer's largest `attempts` (otherwise SQS would move messages before Znight retried them; the boot
|
|
412
|
+
fails). With a redrive policy and the default naming, Znight dead-letters to the policy's target.
|
|
413
|
+
Headers are message attributes: SQS allows 10 per message and Znight uses up to 4 (`x-znight-id`,
|
|
414
|
+
`x-znight-type`, `content-type`, `x-znight-encoding`). FIFO queues use `key` as `MessageGroupId`
|
|
415
|
+
(default `'default'`), the message id as deduplication id, and messages of a group are handled in
|
|
416
|
+
order. The lease extension of a message stops (once any extension in flight completed) before its
|
|
417
|
+
settlement is sent, so that it never overrides the visibility set by a retry or a release. IAM
|
|
418
|
+
permissions: `sqs:ReceiveMessage`, `sqs:DeleteMessage`, `sqs:ChangeMessageVisibility`,
|
|
419
|
+
`sqs:SendMessage`, `sqs:GetQueueUrl`, `sqs:GetQueueAttributes`.
|
|
420
|
+
|
|
421
|
+
### Kafka
|
|
422
|
+
|
|
423
|
+
```ts
|
|
424
|
+
import { kafka } from '@znight/messaging/kafka'
|
|
425
|
+
|
|
426
|
+
kafka({ bootstrapBrokers: ['localhost:9092'], groupId: 'billing' })
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
| Option | Default | Description |
|
|
430
|
+
| --------------------- | ---------------------- | ------------------------------------------------------------------------------- |
|
|
431
|
+
| `bootstrapBrokers` | — | `host:port` list. |
|
|
432
|
+
| `clientId` | `'znight'` | Client id. |
|
|
433
|
+
| `groupId` | `'znight'` | Prefix of consumer groups: each consumer uses `<groupId>.<topic>`. |
|
|
434
|
+
| `connection` | — | Options shared by producer, consumers and admin (tls, sasl, timeouts). |
|
|
435
|
+
| `consumer` | — | Extra consumer options (`sessionTimeout`, `heartbeatInterval`...). |
|
|
436
|
+
| `fromBeginning` | `false` | Where new consumer groups start (`earliest` or `latest`). |
|
|
437
|
+
| `createTopics` | `false` | Create missing topics (`true` or `{ partitions, replicas }`); otherwise verify. |
|
|
438
|
+
| `retryTopicName` | `` `${group}.retry` `` | Retry topic of a consumer group. |
|
|
439
|
+
| `deadLetterName` | `` `${topic}.dlq` `` | Default dead-letter topic. |
|
|
440
|
+
| `maxInProcessDelayMs` | `0` | Longest retry delay waited in-process (blocks the partition). |
|
|
441
|
+
| `maxRetryDelayMs` | 15 min | Longest wait of a retry record. |
|
|
442
|
+
|
|
443
|
+
`@Consumer({ queue: 'orders', groupId: 'billing-orders' })` overrides the group of one consumer (two
|
|
444
|
+
consumers cannot share a group). Offsets are committed manually, once a record is settled; records of
|
|
445
|
+
a partition are handled sequentially, partitions concurrently (`concurrency`). Retries are
|
|
446
|
+
non-blocking: the record goes to the group's retry topic with `x-znight-not-before`; a dedicated
|
|
447
|
+
consumer (group `<group>.retry`, always from the beginning) waits until then without holding a
|
|
448
|
+
concurrency slot, and the main and retry streams have their own back-pressure (a backlog of retries
|
|
449
|
+
waiting for their time never pauses the main topic). After a rebalance, the records of a partition this
|
|
450
|
+
member kept are fetched again from the committed offset: those already buffered or handled are
|
|
451
|
+
skipped (committed offsets never move back); the buffered records of the partitions it lost are
|
|
452
|
+
dropped, their new owner handles them. On shutdown, unsettled records are not committed and are
|
|
453
|
+
consumed again later.
|
|
454
|
+
|
|
455
|
+
With `createTopics`, a topic created concurrently (by another consumer, such as the collector of a
|
|
456
|
+
dead-letter topic, or another instance) counts as created. Transient broker errors are retried by the
|
|
457
|
+
client (`connection.retries`), and by the driver for the topics still missing when an existing topic
|
|
458
|
+
stopped those retries; startup then fails with the error code and message of each topic.
|
|
459
|
+
|
|
460
|
+
## Hooks
|
|
461
|
+
|
|
462
|
+
```ts
|
|
463
|
+
// one delivery (observability uses it for logs, metrics, spans)
|
|
464
|
+
app.hooks.wrap('messaging:message', async (event, next) => {
|
|
465
|
+
await next()
|
|
466
|
+
metrics.count(event.queue, event.outcome) // 'ack' | 'retry' | 'dead-letter'; event.error on failure
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
// one publication: add propagation headers before next()
|
|
470
|
+
app.hooks.wrap('messaging:publish', (event, next) => {
|
|
471
|
+
event.headers.traceparent = currentTraceParent()
|
|
472
|
+
return next()
|
|
473
|
+
})
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
`MessageDeliveryEvent` (from `@znight/core`): `queue`, `type`, `messageId`, `attempt`, `headers`,
|
|
477
|
+
`consumer`, `handler`, `message`, `startTime`, `outcome`, `error`. A wrapper that throws before
|
|
478
|
+
`next()` (or never calls it) counts as a failed attempt. Listeners (`hooks.on`) receive the completed
|
|
479
|
+
events of both hooks (`outcome` / `error` set).
|
|
480
|
+
|
|
481
|
+
## Writing a driver
|
|
482
|
+
|
|
483
|
+
Implement `MessagingDriver` (`name`, `capabilities`, `connect`, `subscribe`, `publish`, `close`,
|
|
484
|
+
`deadLetterName`). `subscribe` receives a `DeliveryHandler`: call it for each message with a
|
|
485
|
+
`DriverDelivery` exposing `ack`, `retry({ attempt, delayMs })`, `deadLetter({ queue, reason, error,
|
|
486
|
+
attempt })` and `release()`; never have more pending handler promises than `options.concurrency`.
|
|
487
|
+
The runtime calls exactly one settlement (then possibly `release` when it failed), so operations must
|
|
488
|
+
only be marked done once the broker confirmed them. The `drivers/shared.ts` helpers
|
|
489
|
+
(`deadLetterHeaders`...) and the integration conformance suite (`test/integration/conformance.ts`)
|
|
490
|
+
document the expected behaviour.
|
|
491
|
+
|
|
492
|
+
## Compiler
|
|
493
|
+
|
|
494
|
+
`@znight/compiler` loads the `@znight/messaging/compiler` extension automatically (it is declared in the
|
|
495
|
+
package manifest) as soon as your code imports `@znight/messaging`. At build time it:
|
|
496
|
+
|
|
497
|
+
- registers every `@Consumer` class as a provider (singleton unless `scope` says otherwise); consumers
|
|
498
|
+
are roots of the application, so neither they nor their dependencies are reported as unused;
|
|
499
|
+
- collects the `@OnMessage` handlers and the `@OnError` method of the class **and of its base
|
|
500
|
+
classes**: an abstract base class can hold shared handlers. The nearest declaration of a method
|
|
501
|
+
wins; an override without messaging decorators keeps the inherited handler (which calls the
|
|
502
|
+
override);
|
|
503
|
+
- creates one handler per `@OnMessage` decorator: stack them to handle several types with one method;
|
|
504
|
+
- generates a **typed** invoker per handler, so `tsc` checks the handler signature against what is
|
|
505
|
+
injected: the schema output for `@Message(schema)`, `string | undefined` for `@Headers('name')`,
|
|
506
|
+
`Readonly<Record<string, string>>` for `@Headers()`, the `InboundMessage` for `@Ctx()`.
|
|
507
|
+
|
|
508
|
+
Every parameter of a handler needs `@Message()`, `@Headers()` or `@Ctx()`; optional parameters and
|
|
509
|
+
parameters with a default value may have none (they receive `undefined`).
|
|
510
|
+
|
|
511
|
+
```ts
|
|
512
|
+
@Consumer('orders')
|
|
513
|
+
export class OrderConsumer extends AuditedConsumer {
|
|
514
|
+
@OnMessage('order.created')
|
|
515
|
+
@OnMessage('order.updated') // same method, two types
|
|
516
|
+
upsert(@Message(Order) order: Order, @Headers('x-tenant') tenant?: string, @Ctx() message?: InboundMessage) {}
|
|
517
|
+
}
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
Invalid consumers fail the build with a precise location instead of failing at boot:
|
|
521
|
+
|
|
522
|
+
- **ZNIGHT005** (invalid decorator usage): decorator on the wrong target; `@Consumer` without queue;
|
|
523
|
+
`@Consumer`, `@Retry` or `@OnError` applied twice; handler the generated code cannot call (static,
|
|
524
|
+
private, protected, `#private`, accessor, computed name, generator); `@OnMessage` and `@OnError` on
|
|
525
|
+
one method; `@Retry` or a parameter decorator outside of an `@OnMessage` handler; several decorators
|
|
526
|
+
on one parameter; a handler parameter decorator imported from another package (`Headers` or `Ctx`
|
|
527
|
+
of `@znight/http`), or a messaging parameter decorator on another extension's handler (an HTTP
|
|
528
|
+
route); rest parameter; `@Message(...)` argument that is not an exported schema (a class,
|
|
529
|
+
an object, an inline expression); invalid `@Headers` name or `@OnMessage` type; messaging methods on
|
|
530
|
+
a class that is neither a consumer nor a base class of one (abstract classes are fine). Warning:
|
|
531
|
+
`@Headers('name')` parameter whose type does not accept `undefined`.
|
|
532
|
+
- **ZNIGHT011** (invalid consumer): no handler; several default handlers; two handlers for the same
|
|
533
|
+
type or pattern; a pattern that can never be selected because an earlier pattern matches every type
|
|
534
|
+
it matches (`order.*` declared before `order.created.*`); empty type; invalid `@Retry` options (the
|
|
535
|
+
runtime rules, plus unknown options); invalid `queue`, `scope`, `concurrency` or `deadLetter`;
|
|
536
|
+
handler parameter without decorator; `@OnError` method with more than two required parameters or
|
|
537
|
+
returning something else than `'ack'` or nothing; several `@OnError` methods; several consumers of
|
|
538
|
+
the same queue. Warning: `maxDelay` with `backoff: 'fixed'` (no effect).
|
|
539
|
+
- **ZNIGHT017** (unvalidated input, a warning, an error with `strictValidation`): `@Message()` without
|
|
540
|
+
schema on a parameter that is not a primitive (`string`, `number`, literal types...), `unknown` or
|
|
541
|
+
binary (`Uint8Array`, `Buffer`).
|
|
542
|
+
|
|
543
|
+
```
|
|
544
|
+
error ZNIGHT011 src/orders/order.consumer.ts:14:3: @Consumer OrderConsumer: the pattern "order.created.*" of OrderConsumer.created() never matches: every type it matches is matched first by "order.*" (OrderConsumer.any())
|
|
545
|
+
= hint: Patterns are tried in declaration order (after exact types): declare the more specific pattern first
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+
Patterns that only partially overlap (`order.*` and `*.created`) are legal: the first declared wins.
|
|
549
|
+
Checks that depend on the driver (retry delays longer than its `maxInProcessDelayMs` / native delay
|
|
550
|
+
limits, SQS redrive policies) run when the application starts. Scope rules are checked by the core
|
|
551
|
+
compiler: a singleton consumer cannot inject the request-scoped `InboundMessage` (ZNIGHT003), use
|
|
552
|
+
`@Ctx()`, `Lazy<InboundMessage>` or `scope: 'request'`.
|
|
553
|
+
|
|
554
|
+
## Compiler contract
|
|
555
|
+
|
|
556
|
+
The `@znight/messaging/compiler` extension (spec §5, §9.2) generates
|
|
557
|
+
`metadata.extensions.messaging: MessagingMetadata`:
|
|
558
|
+
|
|
559
|
+
```ts
|
|
560
|
+
interface MessagingMetadata {
|
|
561
|
+
consumers: {
|
|
562
|
+
token: Token // the consumer provider (registered with the @Consumer scope)
|
|
563
|
+
queue: string
|
|
564
|
+
options?: { concurrency?: number; deadLetter?: string | false; [driverOption: string]: unknown } // @Consumer options minus queue/scope
|
|
565
|
+
handlers: {
|
|
566
|
+
method: string
|
|
567
|
+
type?: string // @OnMessage(type)
|
|
568
|
+
retry?: { attempts: number; backoff: 'fixed' | 'exponential'; delay: number; maxDelay?: number } // @Retry, defaults filled
|
|
569
|
+
invoke: (consumer: any, message: InboundMessage) => unknown
|
|
570
|
+
}[]
|
|
571
|
+
errorHandler?: string // @OnError method name, called as consumer[errorHandler](error, message)
|
|
572
|
+
}[]
|
|
573
|
+
}
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
Helpers generated code may call (exported from `@znight/messaging`, stable):
|
|
577
|
+
|
|
578
|
+
| Helper | Used for |
|
|
579
|
+
| ----------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
|
|
580
|
+
| `validateMessage<S extends StandardSchemaV1>(schema: S, value: unknown, label?: string): InferSchemaOutput<S> \| Promise<InferSchemaOutput<S>>` | `@Message(schema)`: throws `MessageValidationError` (non-retryable). Always `await` it. |
|
|
581
|
+
| `messageHeader(message: { headers: Readonly<Record<string, string>> }, name: string): string \| undefined` | `@Headers('name')` |
|
|
582
|
+
| `DEFAULT_BACKOFF` (`'exponential'`), `DEFAULT_RETRY_DELAY_MS` (`1000`) | Defaults to fill in `retry` (the runtime also normalizes missing fields). |
|
|
583
|
+
|
|
584
|
+
Parameter extraction in `invoke`: `@Message()` → `m.payload as never` (no schema, ZNIGHT017 when the
|
|
585
|
+
type is not primitive); `@Message(S)` → `await validateMessage(S, m.payload)` (the invoker is then
|
|
586
|
+
`async`); `@Headers()` → `m.headers`; `@Headers('x')` → `messageHeader(m, 'x')`; `@Ctx()` → `m`
|
|
587
|
+
(`m as InboundMessage<never>` when the parameter narrows the payload type); an optional parameter
|
|
588
|
+
without decorator → `undefined` (trailing ones are omitted). The invoker is `async` only when it
|
|
589
|
+
awaits a validation, and its `m` parameter is omitted when no argument uses it (`noUnusedParameters`).
|
|
590
|
+
Consumers are sorted by token, handlers keep their declaration order (inherited handlers first), and
|
|
591
|
+
`options` / `errorHandler` / `type` / `retry` are omitted when absent. Example:
|
|
592
|
+
|
|
593
|
+
```ts
|
|
594
|
+
import type { InboundMessage } from '@znight/messaging'
|
|
595
|
+
import { messageHeader, validateMessage } from '@znight/messaging'
|
|
596
|
+
import { OrderConsumer, OrderPlaced } from '../orders/order.consumer.js'
|
|
597
|
+
|
|
598
|
+
extensions: {
|
|
599
|
+
messaging: {
|
|
600
|
+
consumers: [
|
|
601
|
+
{
|
|
602
|
+
token: 'src/orders/order.consumer.ts#OrderConsumer',
|
|
603
|
+
queue: 'orders',
|
|
604
|
+
handlers: [
|
|
605
|
+
{
|
|
606
|
+
method: 'placed',
|
|
607
|
+
type: 'order.placed',
|
|
608
|
+
retry: { attempts: 5, backoff: 'exponential', delay: 500, maxDelay: 30000 },
|
|
609
|
+
invoke: async (c: OrderConsumer, m: InboundMessage) =>
|
|
610
|
+
c.placed(await validateMessage(OrderPlaced, m.payload), messageHeader(m, 'x-tenant')),
|
|
611
|
+
},
|
|
612
|
+
],
|
|
613
|
+
errorHandler: 'onError',
|
|
614
|
+
},
|
|
615
|
+
],
|
|
616
|
+
},
|
|
617
|
+
}
|
|
618
|
+
```
|
|
619
|
+
|
|
620
|
+
The package manifest declares the externals `@znight/messaging#MessageBroker` (singleton) and
|
|
621
|
+
`@znight/messaging#InboundMessage` (request), both provided by `MessagingPlugin`. Compile-time checks
|
|
622
|
+
(ZNIGHT011: no handler, duplicate types, overlapping patterns, invalid retry options) mirror the
|
|
623
|
+
boot-time checks of the plugin, which also validates retry delays against the driver capabilities.
|