https://github.com/softwarity/nestjs-amqp
https://github.com/softwarity/nestjs-amqp
Last synced: about 1 month ago
JSON representation
- Host: GitHub
- URL: https://github.com/softwarity/nestjs-amqp
- Owner: softwarity
- License: mit
- Created: 2026-05-27T11:27:50.000Z (about 1 month ago)
- Default Branch: main
- Last Pushed: 2026-05-28T11:14:51.000Z (about 1 month ago)
- Last Synced: 2026-05-28T11:18:31.221Z (about 1 month ago)
- Language: TypeScript
- Homepage: https://softwarity.github.io/nestjs-amqp/
- Size: 464 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- Contributing: CONTRIBUTING.md
- License: LICENSE
Awesome Lists containing this project
README
# @softwarity/nestjs-amqp
[](https://www.npmjs.com/package/@softwarity/nestjs-amqp)
[](https://opensource.org/licenses/MIT)
[](https://nodejs.org)
**AMQP 1.0 integration for NestJS, powered by [rhea](https://github.com/amqp/rhea).** A thin, RxJS-friendly wrapper that exposes decorator-based publishers and consumers β designed for RabbitMQ 4.x (native AMQP 1.0), Apache ActiveMQ Artemis, Apache Qpid, and Azure Service Bus.
π **Full documentation:** [softwarity.github.io/nestjs-amqp](https://softwarity.github.io/nestjs-amqp/)
---
> ## β Read this before your first deploy
>
> **This library does NOT create topology at runtime.** It opens senders and receivers on destinations that **must already exist** on the broker β queues, streams, exchanges, DLX bindings, the lot. Missing topology = silent failure (the AMQP link is rejected with `amqp:not-found`; the rest of the connection stays up and the app looks healthy).
>
> Declare everything broker-side via a definitions file or an IaC script. Full examples for **RabbitMQ 4.x** (`definitions.json` + docker-compose), **ActiveMQ Artemis** (`broker.xml`), **Azure Service Bus** (Azure CLI), and **Apache Qpid** live on the [doc site](https://softwarity.github.io/nestjs-amqp/#/broker-topology).
---
## Why?
`@nestjs/microservices` only covers AMQP 0.9.1 (via `amqplib`). When you want **AMQP 1.0** features β long-lived sessions, link credit, source filters, message annotations, stream consumers β `rhea` is the canonical Node.js client. This library wraps rhea so the rest of your codebase only sees `@AmqpQueue`, `@Consume`, and Observables.
## Features
- π― **Decorator-based** publishers (`@AmqpQueue`, `@AmqpTopic`) and consumers (`@Consume`, `@Subscribe`)
- π **Multi-broker** β speak to several brokers from one service; one connection / reply stream / DLQ per broker
- π **Request/Reply** via per-process correlation prefix on a shared reply stream (opt-in)
- π‘ **Broadcast/PubSub** via RabbitMQ streams (`@Subscribe`)
- π **Built-in retry policy** (`maxDelivery`, `dlq`) on work-queue consumers (opt-in)
- π **Optional DLQ browser** β paginate, replay, drop dead-lettered messages
- 𧬠**Pluggable wire codec** β JSON by default with `Date` round-trip + ObjectId auto-rehydration; bring your own per broker (msgpack, protobuf, β¦)
- π§ **`forRoot` / `forRootAsync`** configuration
- βοΈ **RxJS-native** β no Promise wrapper, no axios-style imperative shapes
## Installation
```bash
npm install @softwarity/nestjs-amqp rhea
# peer deps you probably already have
npm install @nestjs/common @nestjs/core rxjs reflect-metadata
```
---
# Getting started β the 90% case
The simplest, most common setup: **one broker, fire-and-forget publish, basic consume β no DLQ, no request/reply**. Declare as many queues and topics as you need; the simplification here is the feature surface, not the quantity. Reply/DLQ are opt-in features documented further down.
### 1. Declare your queues and topics broker-side
The library never declares topology β only opens senders/receivers on destinations that already exist. Declare whatever your service needs (one queue, ten queues, mixed work-queues and broadcast streams β same exercise). With RabbitMQ 4.x via `definitions.json`:
```json
{
"queues": [
{
"name": "orders.create",
"vhost": "/",
"durable": true,
"auto_delete": false,
"arguments": { "x-queue-type": "quorum" }
},
{
"name": "orders.ship",
"vhost": "/",
"durable": true,
"auto_delete": false,
"arguments": { "x-queue-type": "quorum" }
},
{
"name": "changes.bulletin",
"vhost": "/",
"durable": true,
"auto_delete": false,
"arguments": { "x-queue-type": "stream", "x-max-age": "1h" }
}
]
}
```
Quorum queues for work-queue semantics (one consumer per message), stream queues for broadcast (every consumer sees every message). The library makes no assumption about how many you declare.
### 2. Register the module
```ts
import { Module } from '@nestjs/common';
import { AmqpModule } from '@softwarity/nestjs-amqp';
@Module({
imports: [
AmqpModule.forRoot({
url: 'amqp://localhost:5672',
username: 'guest',
password: 'guest',
}),
],
})
export class AppModule {}
```
A single broker (the name is implicit β internally `'default'`). Because only one broker is configured, the `brokerName` argument is optional on every decorator and on the locator β the library resolves the lone broker automatically. If you want a custom name (visible as the AMQP container ID on the broker management UI), wrap in an array even with one entry: `AmqpModule.forRoot([{ name: 'my-svc', url, ... }])`.
### 3. Publish β fire and forget
```ts
import { Injectable } from '@nestjs/common';
import { AmqpQueue, AmqpTopic } from '@softwarity/nestjs-amqp';
@Injectable()
export class OrdersService {
@AmqpQueue('orders.create')
private readonly create!: AmqpQueue;
@AmqpQueue('orders.ship')
private readonly ship!: AmqpQueue;
@AmqpTopic('changes.bulletin')
private readonly changes!: AmqpTopic;
newOrder(body: OrderBody): void {
this.create.emit(body); // fire-and-forget
}
notifyShipped(body: OrderShipped): void {
this.ship.emit(body);
this.changes.emit({ type: 'shipped', orderId: body.id, when: new Date().toISOString() });
}
}
```
`@AmqpQueue` for work-queues (point-to-point) and `@AmqpTopic` for broadcast. `emit()` returns synchronously a `boolean` β `true` if the message was handed off to the sender, `false` if the broker is disabled or not connected. The boolean lets the caller fall back (e.g. NestJS `EventEmitter2` for in-process delivery, a local outbox, β¦):
```ts
if (!this.orders.emit(body)) {
this.bus.emit('orders.create', body); // in-process fallback
}
```
Each handle is generic on the payload type β every call site is type-checked at compile time.
### 4. Consume
```ts
import { Injectable } from '@nestjs/common';
import { Consume, Subscribe } from '@softwarity/nestjs-amqp';
@Injectable()
export class OrdersListener {
// The single un-annotated argument is bound to the JSON-decoded body.
// Equivalent to writing @AmqpBody() explicitly.
@Consume('orders.create')
onCreate(order: OrderBody): void {
this.svc.handle(order);
}
@Consume('orders.ship')
onShip(shipped: OrderShipped): void {
this.svc.markShipped(shipped);
}
@Subscribe('changes.bulletin')
onChange(change: BulletinChange): void {
this.realtime.publish(change);
}
}
```
Start the app β you'll see a boot log section like `broker 'default': 3 consumer(s)` followed by one line per binding (each tagged `@Consume` or `@Subscribe`). You're done.
### What's NOT in the 90% case
The bootstrap above intentionally skips three optional features. Add them Γ la carte:
| Feature | What you gain | What you have to do |
|---|---|---|
| [Request / reply (`send()`)](#request--reply--opt-in) | Wait for a reply Observable β RPC-style. | Declare a stream queue broker-side, add `replyStreamAddress` to the broker config. |
| [Retry & DLQ](#retry--dlq--opt-in) | Auto-retry on handler error, then route the failed message to a DLQ. | Declare a DLX + DLQ broker-side, set `{ maxDelivery, dlq: true }` on the decorator. |
| [Multiple brokers](#multi-broker) | Speak to several brokers from one service. | Pass an array to `forRoot`, pass `brokerName` on each decorator. |
---
# Request / reply β opt-in
`AmqpQueue.send()` returns an `Observable` that resolves with the peer's reply. It needs three things:
### 1. Declare a stream queue broker-side
```json
{
"queues": [{
"name": "my-service.replies",
"vhost": "/",
"durable": true,
"auto_delete": false,
"arguments": { "x-queue-type": "stream", "x-max-age": "5m" }
}]
}
```
### 2. Set `replyStreamAddress` on the broker options
```ts
AmqpModule.forRoot({
url: 'amqp://localhost:5672',
username: 'guest', password: 'guest',
replyStreamAddress: 'my-service.replies', // β REQUIRED for send()
});
```
### 3. Call `send()` on the publisher side
```ts
createOrder(body: OrderBody): Observable {
return this.orders.send(body, { timeoutMs: 5000 });
}
```
### 4. Return a value from the consumer to auto-reply
```ts
@Consume('orders.create')
onCreate(body: OrderBody): Observable {
return this.svc.create(body); // resolved value -> auto-shipped on reply_to
}
```
The library generates a per-process correlation prefix at boot and filters incoming replies on the shared reply stream β every instance sees every reply but only routes its own. Trade-off: NΓ bandwidth per reply (negligible for low-volume RPC on a LAN).
Without `replyStreamAddress` set on the broker, `send()` throws `AmqpConnectionError` at the call site. `emit()` and `@Consume` continue to work unchanged.
π Full details: [doc site β Request / reply](https://softwarity.github.io/nestjs-amqp/#/request-reply)
---
# Retry & DLQ β opt-in
Retry and DLQ are off by default (`maxDelivery: 1`, `dlq: false`) β handler errors silently drop the message.
> **The lib never publishes to a DLQ itself.** On terminal failure with `dlq: true`, it calls `delivery.reject()` and the **broker** routes the message via its own DLX configuration. If the queue has no DLX broker-side, `dlq: true` is silently ignored (the broker discards rejected messages).
### Setup with RabbitMQ 4.x
**1. Declare DLX + DLQ broker-side:**
```json
{
"exchanges": [{
"name": "my-service.dlx",
"vhost": "/",
"type": "direct",
"durable": true,
"auto_delete": false
}],
"queues": [
{
"name": "payments.process",
"vhost": "/",
"durable": true,
"arguments": {
"x-queue-type": "quorum",
"x-dead-letter-exchange": "my-service.dlx",
"x-dead-letter-routing-key": "payments.process"
}
},
{
"name": "my-service.dlq",
"vhost": "/",
"durable": true,
"arguments": { "x-queue-type": "quorum" }
}
],
"bindings": [{
"source": "my-service.dlx",
"vhost": "/",
"destination": "my-service.dlq",
"destination_type": "queue",
"routing_key": "payments.process",
"arguments": {}
}]
}
```
**2. Set `defaultDlqAddress` on the broker options** (used by the DLQ admin UI as a pre-fill):
```ts
AmqpModule.forRoot({
url: 'amqp://localhost:5672',
username: 'guest', password: 'guest',
defaultDlqAddress: 'my-service.dlq',
});
```
**3. Enable the policy on the decorator:**
```ts
@Consume('payments.process', { maxDelivery: 5, dlq: true })
onPayment(body: Payment): Observable {
return this.svc.process(body);
}
```
Run-time behaviour: handler throws β `modified(delivery_failed:true)` Γ 4 retries β on the 5th failure β `reject()` β broker routes to `my-service.dlx` with routing key `payments.process` β `my-service.dlq`.
### `retryPolicy` β delayed retries (in 0.3.x)
The decorator accepts a `retryPolicy` option that defines the timing between retries. **In 0.2.x only `'immediate'` is functional** β `fixed` / `exponential` shapes are accepted by the type system for forward-compatibility (runtime falls back to immediate with a boot warning). Client-side scheduled republish is planned for 0.3.x.
```ts
type RetryPolicy =
| 'immediate'
| { kind: 'fixed'; delayMs: number }
| { kind: 'exponential'; initialMs: number; multiplier: number; maxMs: number };
```
π Full details: [doc site β Retry & DLQ](https://softwarity.github.io/nestjs-amqp/#/retry-and-dlq)
---
# Multi-broker
Pass an array to `forRoot` and pass the broker name on each decorator. Each broker is independent β its own connection, reply stream, DLQ, body codec, enabled flag.
```ts
AmqpModule.forRoot([
{
name: 'primary',
url: 'amqp://broker-a:5672',
username: 'svc', password: '...',
replyStreamAddress: 'my-svc.replies',
defaultDlqAddress: 'my-svc.dlq',
},
{
name: 'analytics',
url: 'amqp://broker-b:5672',
username: 'svc', password: '...',
enabled: false, // per-broker kill switch
// No reply stream / DLQ β analytics is emit-only.
},
]);
@Injectable()
export class MixedService {
@AmqpQueue('orders.create', 'primary') private orders!: AmqpQueue;
@AmqpTopic('metrics.collected', 'analytics') private metrics!: AmqpTopic;
}
@Injectable()
export class MixedListener {
@Consume('orders.create', 'primary', { dlq: true })
onOrder(o: OrderBody): void { ... }
@Subscribe('events.tick', 'analytics')
onTick(e: TickEvent): void { ... }
}
```
The 2nd argument on `@Consume` / `@Subscribe` is detected at runtime β string = broker name, object = options. The forms `(addr)`, `(addr, options)`, `(addr, brokerName)`, `(addr, brokerName, options)` are all valid.
Forgetting the broker name in a multi-broker setup throws clearly at boot.
π Full details: [doc site β Multi-broker](https://softwarity.github.io/nestjs-amqp/#/multi-broker)
---
## Quick reference
### Decorators
```ts
@AmqpQueue(address, brokerName?) // Property β AmqpQueue (emit + send)
@AmqpTopic(address, brokerName?) // Property β AmqpTopic (emit only)
@Consume(address, brokerName?, options?) // Method, work-queue consumer
@Subscribe(address, brokerName?, options?) // Method, stream/topic consumer
```
`brokerName` is optional when a single broker is configured. With several brokers, omitting it throws at boot.
### Parameter decorators
```ts
@AmqpBody() // T β decoded body (also: a single un-annotated param is implicit @AmqpBody())
@AmqpAddress() // string β the @Subscribe address
@AmqpDeliveryCount() // number β 1-based attempt count
@AmqpHeader() // MessageHeader β durable, priority, ttl, delivery_count
@AmqpProperties() // MessageProperties β full standard properties
@AmqpProperty(name) // one field of message.properties
@AmqpAppProperties() // Record β full application_properties
@AmqpAppProperty(name) // one field of application_properties
@AmqpSettler() // AmqpSettler β manual accept/release/reject
@AmqpContext() // AmqpContext β full envelope + settle helpers
```
### Runtime resolution β `AmqpDestinations`
Inject `AmqpDestinations` to resolve a publish handle dynamically (tenant-scoped queues, dispatchers):
```ts
@Injectable()
export class DynamicPublisher {
constructor(private readonly amqp: AmqpDestinations) {}
publish(tenantId: string, body: OrderBody): void {
this.amqp.queue(`orders.${tenantId}`).emit(body);
}
}
```
### DLQ browser β `DlqAdminModule` (opt-in)
```ts
@Module({
imports: [
AmqpModule.forRoot({ url: '...', /* ... */ }),
DlqAdminModule, // adds /admin/dlq/... routes
],
})
export class AppModule {}
```
Routes (single-broker shortcut):
```
POST /admin/dlq/sessions { dlqAddress, pageSize? }
GET /admin/dlq/sessions/:token
POST /admin/dlq/sessions/:token/next-page
POST /admin/dlq/sessions/:token/messages/:idx/replay
POST /admin/dlq/sessions/:token/messages/:idx/drop
POST /admin/dlq/sessions/:token/close
```
Multi-broker variant: `POST /admin/dlq/:broker/sessions { ... }` to scope the open-session to a specific broker. Other routes work off the session token (the session knows its broker).
**β οΈ Auth not included.** The controller is unguarded β wrap with your own `Guard`, or sub-class and redeclare with your decorators. `openedBy` is read from `req.user.username ?? req.user.id ?? 'anonymous'`.
### Serialization / Deserialization β per broker
```ts
AmqpModule.forRoot([
{ name: 'primary', url: '...', /* default JSON codec */ },
{ name: 'analytics', url: '...', bodyCodec: new MsgpackCodec() },
]);
```
Default `JsonBodyCodec`:
- UTF-8 JSON
- Round-trips `Date` via `{ "$date": "" }`
- Encodes ObjectId-like values as `{ "$oid": "" }`; **decode auto-detects mongoose / bson and returns a real ObjectId instance** if installed, else the marker object
### Errors
| Class | Where it surfaces |
|---|---|
| `AmqpConnectionError` | Connection-level issues, `send()` when AMQP is disabled or no reply stream is configured on the broker |
| `AmqpTimeoutError` | `send()` Observable when no reply arrives in time. Carries `address`, `correlationId`, `timeoutMs` |
| `AmqpHandlerError` | Reserved for future use |
| `AmqpError` | Abstract base β `if (err instanceof AmqpError) β¦` |
## Known limitations
- **In-flight `send()` across reconnects** β if a reconnect happens between sending and receiving the reply, the reply is lost (we re-subscribe with `streamOffset: 'next'`). The pending call times out.
- **`topic.send()` (scatter-gather RPC)** β not supported. Build aggregation in user code on top of `emit()` if needed.
- **`@Subscribe` replay** β hardcoded to `streamOffset: 'next'`. PR welcome for a dedicated `@SubscribeStream` exposing the option.
- **Delayed retry (`retryPolicy`)** β only `'immediate'` is functional in 0.2.x. `fixed` / `exponential` shapes accepted by the type system; runtime falls back to immediate with a boot warning.
## License
MIT Β© FranΓ§ois ACHACHE
## Contributing
PRs welcome. Run `npm test && npm run lint && npm run build` before submitting.