@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.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +622 -2
  3. package/dist/async-CFh_bvex.mjs +216 -0
  4. package/dist/async-CFh_bvex.mjs.map +1 -0
  5. package/dist/codec-Dku-NXXU.mjs +83 -0
  6. package/dist/codec-Dku-NXXU.mjs.map +1 -0
  7. package/dist/compiler.d.mts +75 -0
  8. package/dist/compiler.mjs +1023 -0
  9. package/dist/compiler.mjs.map +1 -0
  10. package/dist/driver-ZxLqy_Ap.d.mts +216 -0
  11. package/dist/index.d.mts +459 -0
  12. package/dist/index.mjs +870 -0
  13. package/dist/index.mjs.map +1 -0
  14. package/dist/kafka.d.mts +51 -0
  15. package/dist/kafka.mjs +517 -0
  16. package/dist/kafka.mjs.map +1 -0
  17. package/dist/memory.d.mts +124 -0
  18. package/dist/memory.mjs +429 -0
  19. package/dist/memory.mjs.map +1 -0
  20. package/dist/rabbitmq.d.mts +65 -0
  21. package/dist/rabbitmq.mjs +686 -0
  22. package/dist/rabbitmq.mjs.map +1 -0
  23. package/dist/shared-BJBpLS8V.mjs +52 -0
  24. package/dist/shared-BJBpLS8V.mjs.map +1 -0
  25. package/dist/sqs.d.mts +38 -0
  26. package/dist/sqs.mjs +421 -0
  27. package/dist/sqs.mjs.map +1 -0
  28. package/package.json +123 -4
  29. package/src/broker.ts +156 -0
  30. package/src/codec.ts +98 -0
  31. package/src/compiler/analyze.ts +800 -0
  32. package/src/compiler/constants.ts +73 -0
  33. package/src/compiler/data.ts +75 -0
  34. package/src/compiler/generate.ts +131 -0
  35. package/src/compiler/index.ts +52 -0
  36. package/src/compiler/patterns.ts +54 -0
  37. package/src/compiler/retry.ts +136 -0
  38. package/src/compiler/validate.ts +57 -0
  39. package/src/compiler/values.ts +76 -0
  40. package/src/consumers.ts +260 -0
  41. package/src/decorators.ts +131 -0
  42. package/src/driver.ts +142 -0
  43. package/src/drivers/kafka.ts +765 -0
  44. package/src/drivers/memory.ts +609 -0
  45. package/src/drivers/rabbitmq.ts +944 -0
  46. package/src/drivers/shared.ts +81 -0
  47. package/src/drivers/sqs.ts +583 -0
  48. package/src/errors.ts +95 -0
  49. package/src/headers.ts +70 -0
  50. package/src/hooks.ts +2 -0
  51. package/src/index.ts +89 -0
  52. package/src/internal/async.ts +115 -0
  53. package/src/message.ts +96 -0
  54. package/src/metadata.ts +65 -0
  55. package/src/plugin.ts +236 -0
  56. package/src/retry.ts +129 -0
  57. package/src/runtime.ts +424 -0
  58. package/src/standard-schema.ts +68 -0
  59. package/src/tokens.ts +14 -0
  60. package/src/validation.ts +61 -0
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
- # Temporary Holding Version
1
+ # @znight/messaging
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.