@warlock.js/herald 5.19.1 → 5.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,172 +1,172 @@
1
- ---
2
- name: publish-message
3
- description: "Publish messages in @warlock.js/herald; use when you need to publish message."
4
- ---
5
-
6
- # Publish messages
7
-
8
- Push messages into a channel. The consumer side picks them up via [`@warlock.js/herald/consume-message/SKILL.md`](@warlock.js/herald/consume-message/SKILL.md).
9
-
10
- ## Single publish
11
-
12
- ```ts
13
- import { herald } from "@warlock.js/herald";
14
-
15
- await herald().channel("user.created").publish({
16
- userId: 1,
17
- email: "ada@example.com",
18
- });
19
- ```
20
-
21
- Returns when the broker has accepted the message (not when a consumer has handled it — that's `.request()` territory).
22
-
23
- ## Typed channels
24
-
25
- ```ts
26
- type UserPayload = { userId: number; email: string };
27
-
28
- const channel = herald().channel<UserPayload>("user.created");
29
-
30
- await channel.publish({ userId: 1, email: "test@example.com" }); // ✅ typed
31
- await channel.publish({ userId: "1" } as never); // ❌ compile error
32
- ```
33
-
34
- Share the payload type between producer and consumer via a common `types/` file.
35
-
36
- ## Schema-validated publish
37
-
38
- ```ts
39
- import { v } from "@warlock.js/seal";
40
-
41
- const userSchema = v.object({
42
- userId: v.int(),
43
- email: v.string().email(),
44
- });
45
-
46
- const channel = herald().channel("user.created", { schema: userSchema });
47
-
48
- await channel.publish({ userId: 1, email: "invalid" }); // Throws — fails .email()
49
- ```
50
-
51
- The schema runs `validate()` before the message hits the broker. Invalid payloads never leave the producer. See [`@warlock.js/seal/seal-basics/SKILL.md`](@warlock.js/seal/seal-basics/SKILL.md).
52
-
53
- ## Publish options
54
-
55
- ```ts
56
- await channel.publish(payload, {
57
- priority: 5, // 0-9, higher = served first
58
- expiration: 60_000, // ms — message expires if not consumed (RabbitMQ uses `expiration`, not `ttl`)
59
- delay: 5_000, // ms — delayed delivery (requires RabbitMQ delay plugin)
60
- persistent: true, // survive broker restart (default true)
61
- correlationId: "uuid", // for tracking across services
62
- headers: {
63
- tenantId: "42",
64
- source: "billing-service",
65
- },
66
- });
67
- ```
68
-
69
- The headers field is free-form — consumers read `message.metadata.headers?.tenantId` to route or filter (headers live under `message.metadata`, alongside `messageId`, `correlationId`, `timestamp`, and `retryCount`).
70
-
71
- ## Batch publishing
72
-
73
- ```ts
74
- await channel.publishBatch([
75
- { userId: 1, email: "a@example.com" },
76
- { userId: 2, email: "b@example.com" },
77
- { userId: 3, email: "c@example.com" },
78
- ], {
79
- // Options apply to every message in the batch
80
- persistent: true,
81
- });
82
- ```
83
-
84
- Under the hood `publishBatch` iterates and calls `.publish()` per message — there's no AMQP-level batching today. It's an ergonomic shape for "emit these N items" (bulk import, fanout to many recipients), not a throughput optimization. The same `options` apply to every message.
85
-
86
- ## Publish + transaction (outbox pattern)
87
-
88
- Don't publish inside a database transaction — if the transaction rolls back but the publish already happened, you've emitted an event for a state that never persisted.
89
-
90
- The outbox pattern: write to an `outbox` table inside the transaction, dispatch from the outbox via a worker after commit:
91
-
92
- ```ts
93
- await transaction(async () => {
94
- await Order.create(orderData);
95
- await Outbox.create({
96
- channel: "order.created",
97
- payload: { orderId: order.id, ... },
98
- status: "pending",
99
- });
100
- });
101
-
102
- // In a separate worker:
103
- const pending = await Outbox.where("status", "pending").get();
104
- for (const row of pending) {
105
- await herald().channel(row.get("channel")).publish(row.get("payload"));
106
- await row.merge({ status: "sent", sent_at: new Date() }).save();
107
- }
108
- ```
109
-
110
- Full recipe at `domains/cascade/docs/recipes/outbox-pattern.md`.
111
-
112
- ## Broadcasting use-case results
113
-
114
- `@warlock.js/herald` ships `heraldBroadcast()` — a channel adapter that lets a `@warlock.js/core` `useCase()` publish its result to the bus automatically on success. Register it once in the use-cases config; each use case opts in with `broadcast: true`.
115
-
116
- ```ts title="src/config/use-cases.ts"
117
- import { type UseCaseConfigurations } from "@warlock.js/core";
118
- import { heraldBroadcast } from "@warlock.js/herald";
119
-
120
- export default {
121
- broadcast: {
122
- enabled: true,
123
- channels: [heraldBroadcast({ broker: "default" })], // omit broker for the default
124
- },
125
- } satisfies UseCaseConfigurations;
126
- ```
127
-
128
- ```ts
129
- // The use case opts in — channel name defaults to the use case name.
130
- export const createUserUseCase = useCase({
131
- name: "users.create", // → publishes to channel "users.create"
132
- schema: createUserSchema,
133
- handler: async (data) => User.create(data),
134
- broadcast: true,
135
- });
136
- ```
137
-
138
- The adapter publishes the broadcast envelope's `payload` to `channel(event.event)`. It's structurally typed, so herald keeps no dependency on core. See [`@warlock.js/core/write-use-case/SKILL.md`](@warlock.js/core/write-use-case/SKILL.md) for the `broadcast` option (payload projection, custom event name, payload-safety rules).
139
-
140
- ## Event classes (`EventMessage`) — the typed event layer
141
-
142
- Raw `channel(name).publish(payload)` is the low-level path. For domain events that travel between modules, herald ships a higher-level pair: define the event once as a class, publish instances of it, and consume them with an [`EventConsumer`](@warlock.js/herald/consume-message/SKILL.md). The channel name comes from the event's `eventName` — no string to keep in sync across producer and consumer.
143
-
144
- ```ts
145
- import { defineEvent, publishEvent } from "@warlock.js/herald";
146
-
147
- // `defineEvent<IncomingData, OutgoingData>` — `toJSON` projects the wire payload.
148
- const UserCreatedEvent = defineEvent<User, { id: number; email: string }>("user.created", {
149
- toJSON: (user) => ({ id: user.id, email: user.email }),
150
- // schema?: an @warlock.js/seal ObjectValidator (held on the instance; the consumer side validates)
151
- });
152
-
153
- // `publishEvent` serializes the instance and publishes to channel "user.created" on the default broker.
154
- await publishEvent(new UserCreatedEvent(user));
155
- ```
156
-
157
- `publishEvent(event)` is shorthand for `herald().publish(event)` — it calls `event.serialize()` and sends an **envelope**: `{ payload, metadata, messageId, eventName, version, occurredAt }`. That nesting is why an `EventConsumer` reads `envelope.payload`, not the raw body — pair this only with the consumer side, not a bare `.subscribe()`.
158
-
159
- Set an optional `version` on the event (instance field) to drive the consumer's `minVersion` / `maxVersion` acceptance gate. The class form (`class X extends EventMessage`) is equivalent — override `toJSON()` and set `eventName`, `version`, `schema` as fields.
160
-
161
- ## Things NOT to do
162
-
163
- - Don't publish inside a transaction. Use the outbox pattern.
164
- - Don't pass non-JSON-serializable values (functions, `BigInt`, class instances with methods). Serialization happens at the broker boundary; functions go silent, BigInt throws.
165
- - Don't `expiration: 0` — set a real expiry or omit. `0` means "expire immediately."
166
- - Don't omit `persistent: true` for messages where loss matters. Default is `true` in this lib, but worth being explicit when the message represents money / customer state.
167
- - Don't put secrets in headers. Headers travel in plaintext (encrypted only by TLS in transit, not at rest in the broker).
168
-
169
- ## See also
170
-
171
- - [`@warlock.js/herald/consume-message/SKILL.md`](@warlock.js/herald/consume-message/SKILL.md) — the receiving side
172
- - [`@warlock.js/herald/request-and-respond/SKILL.md`](@warlock.js/herald/request-and-respond/SKILL.md) — when you need a reply
1
+ ---
2
+ name: publish-message
3
+ description: 'Publish messages to a channel — .publish(payload, options?) for single, .publishBatch([...], options?) for batch, with priority / ttl / delay / persistent / correlationId / headers options. Optional schema validation via .channel(name, {schema}). Triggers: `publish`, `publishBatch`, `channel`, `priority`, `ttl`, `delay`, `persistent`, `correlationId`, `headers`, `schema`; "publish a message", "emit an event after a domain change", "fan out a notification", "schedule delayed work", "batch publish"; typical import `import { herald } from "@warlock.js/herald"`. Skip: setup — `@warlock.js/herald/herald-basics/SKILL.md`; consuming — `@warlock.js/herald/consume-message/SKILL.md`; RPC — `@warlock.js/herald/request-and-respond/SKILL.md`; competing libs `amqplib`, `kafkajs`, `bullmq`; NestJS `ClientProxy.emit`.'
4
+ ---
5
+
6
+ # Publish messages
7
+
8
+ Push messages into a channel. The consumer side picks them up via [`@warlock.js/herald/consume-message/SKILL.md`](@warlock.js/herald/consume-message/SKILL.md).
9
+
10
+ ## Single publish
11
+
12
+ ```ts
13
+ import { herald } from "@warlock.js/herald";
14
+
15
+ await herald().channel("user.created").publish({
16
+ userId: 1,
17
+ email: "ada@example.com",
18
+ });
19
+ ```
20
+
21
+ Returns when the broker has accepted the message (not when a consumer has handled it — that's `.request()` territory).
22
+
23
+ ## Typed channels
24
+
25
+ ```ts
26
+ type UserPayload = { userId: number; email: string };
27
+
28
+ const channel = herald().channel<UserPayload>("user.created");
29
+
30
+ await channel.publish({ userId: 1, email: "test@example.com" }); // ✅ typed
31
+ await channel.publish({ userId: "1" } as never); // ❌ compile error
32
+ ```
33
+
34
+ Share the payload type between producer and consumer via a common `types/` file.
35
+
36
+ ## Schema-validated publish
37
+
38
+ ```ts
39
+ import { v } from "@warlock.js/seal";
40
+
41
+ const userSchema = v.object({
42
+ userId: v.int(),
43
+ email: v.string().email(),
44
+ });
45
+
46
+ const channel = herald().channel("user.created", { schema: userSchema });
47
+
48
+ await channel.publish({ userId: 1, email: "invalid" }); // Throws — fails .email()
49
+ ```
50
+
51
+ The schema runs `validate()` before the message hits the broker. Invalid payloads never leave the producer. See [`@warlock.js/seal/seal-basics/SKILL.md`](@warlock.js/seal/seal-basics/SKILL.md).
52
+
53
+ ## Publish options
54
+
55
+ ```ts
56
+ await channel.publish(payload, {
57
+ priority: 5, // 0-9, higher = served first
58
+ expiration: 60_000, // ms — message expires if not consumed (RabbitMQ uses `expiration`, not `ttl`)
59
+ delay: 5_000, // ms — delayed delivery (requires RabbitMQ delay plugin)
60
+ persistent: true, // survive broker restart (default true)
61
+ correlationId: "uuid", // for tracking across services
62
+ headers: {
63
+ tenantId: "42",
64
+ source: "billing-service",
65
+ },
66
+ });
67
+ ```
68
+
69
+ The headers field is free-form — consumers read `message.metadata.headers?.tenantId` to route or filter (headers live under `message.metadata`, alongside `messageId`, `correlationId`, `timestamp`, and `retryCount`).
70
+
71
+ ## Batch publishing
72
+
73
+ ```ts
74
+ await channel.publishBatch([
75
+ { userId: 1, email: "a@example.com" },
76
+ { userId: 2, email: "b@example.com" },
77
+ { userId: 3, email: "c@example.com" },
78
+ ], {
79
+ // Options apply to every message in the batch
80
+ persistent: true,
81
+ });
82
+ ```
83
+
84
+ Under the hood `publishBatch` iterates and calls `.publish()` per message — there's no AMQP-level batching today. It's an ergonomic shape for "emit these N items" (bulk import, fanout to many recipients), not a throughput optimization. The same `options` apply to every message.
85
+
86
+ ## Publish + transaction (outbox pattern)
87
+
88
+ Don't publish inside a database transaction — if the transaction rolls back but the publish already happened, you've emitted an event for a state that never persisted.
89
+
90
+ The outbox pattern: write to an `outbox` table inside the transaction, dispatch from the outbox via a worker after commit:
91
+
92
+ ```ts
93
+ await transaction(async () => {
94
+ await Order.create(orderData);
95
+ await Outbox.create({
96
+ channel: "order.created",
97
+ payload: { orderId: order.id, ... },
98
+ status: "pending",
99
+ });
100
+ });
101
+
102
+ // In a separate worker:
103
+ const pending = await Outbox.where("status", "pending").get();
104
+ for (const row of pending) {
105
+ await herald().channel(row.get("channel")).publish(row.get("payload"));
106
+ await row.merge({ status: "sent", sent_at: new Date() }).save();
107
+ }
108
+ ```
109
+
110
+ Full recipe at `domains/cascade/docs/recipes/outbox-pattern.md`.
111
+
112
+ ## Broadcasting use-case results
113
+
114
+ `@warlock.js/herald` ships `heraldBroadcast()` — a channel adapter that lets a `@warlock.js/core` `useCase()` publish its result to the bus automatically on success. Register it once in the use-cases config; each use case opts in with `broadcast: true`.
115
+
116
+ ```ts title="src/config/use-cases.ts"
117
+ import { type UseCaseConfigurations } from "@warlock.js/core";
118
+ import { heraldBroadcast } from "@warlock.js/herald";
119
+
120
+ export default {
121
+ broadcast: {
122
+ enabled: true,
123
+ channels: [heraldBroadcast({ broker: "default" })], // omit broker for the default
124
+ },
125
+ } satisfies UseCaseConfigurations;
126
+ ```
127
+
128
+ ```ts
129
+ // The use case opts in — channel name defaults to the use case name.
130
+ export const createUserUseCase = useCase({
131
+ name: "users.create", // → publishes to channel "users.create"
132
+ schema: createUserSchema,
133
+ handler: async (data) => User.create(data),
134
+ broadcast: true,
135
+ });
136
+ ```
137
+
138
+ The adapter publishes the broadcast envelope's `payload` to `channel(event.event)`. It's structurally typed, so herald keeps no dependency on core. See [`@warlock.js/core/write-use-case/SKILL.md`](@warlock.js/core/write-use-case/SKILL.md) for the `broadcast` option (payload projection, custom event name, payload-safety rules).
139
+
140
+ ## Event classes (`EventMessage`) — the typed event layer
141
+
142
+ Raw `channel(name).publish(payload)` is the low-level path. For domain events that travel between modules, herald ships a higher-level pair: define the event once as a class, publish instances of it, and consume them with an [`EventConsumer`](@warlock.js/herald/consume-message/SKILL.md). The channel name comes from the event's `eventName` — no string to keep in sync across producer and consumer.
143
+
144
+ ```ts
145
+ import { defineEvent, publishEvent } from "@warlock.js/herald";
146
+
147
+ // `defineEvent<IncomingData, OutgoingData>` — `toJSON` projects the wire payload.
148
+ const UserCreatedEvent = defineEvent<User, { id: number; email: string }>("user.created", {
149
+ toJSON: (user) => ({ id: user.id, email: user.email }),
150
+ // schema?: an @warlock.js/seal ObjectValidator (held on the instance; the consumer side validates)
151
+ });
152
+
153
+ // `publishEvent` serializes the instance and publishes to channel "user.created" on the default broker.
154
+ await publishEvent(new UserCreatedEvent(user));
155
+ ```
156
+
157
+ `publishEvent(event)` is shorthand for `herald().publish(event)` — it calls `event.serialize()` and sends an **envelope**: `{ payload, metadata, messageId, eventName, version, occurredAt }`. That nesting is why an `EventConsumer` reads `envelope.payload`, not the raw body — pair this only with the consumer side, not a bare `.subscribe()`.
158
+
159
+ Set an optional `version` on the event (instance field) to drive the consumer's `minVersion` / `maxVersion` acceptance gate. The class form (`class X extends EventMessage`) is equivalent — override `toJSON()` and set `eventName`, `version`, `schema` as fields.
160
+
161
+ ## Things NOT to do
162
+
163
+ - Don't publish inside a transaction. Use the outbox pattern.
164
+ - Don't pass non-JSON-serializable values (functions, `BigInt`, class instances with methods). Serialization happens at the broker boundary; functions go silent, BigInt throws.
165
+ - Don't `expiration: 0` — set a real expiry or omit. `0` means "expire immediately."
166
+ - Don't omit `persistent: true` for messages where loss matters. Default is `true` in this lib, but worth being explicit when the message represents money / customer state.
167
+ - Don't put secrets in headers. Headers travel in plaintext (encrypted only by TLS in transit, not at rest in the broker).
168
+
169
+ ## See also
170
+
171
+ - [`@warlock.js/herald/consume-message/SKILL.md`](@warlock.js/herald/consume-message/SKILL.md) — the receiving side
172
+ - [`@warlock.js/herald/request-and-respond/SKILL.md`](@warlock.js/herald/request-and-respond/SKILL.md) — when you need a reply
@@ -1,74 +1,74 @@
1
- ---
2
- name: request-and-respond
3
- description: "Request-response over the bus in @warlock.js/herald; use when you need to request and respond."
4
- ---
5
-
6
- # Request-response over the bus
7
-
8
- Most messaging is fire-and-forget. When you need a reply, use the request/respond pair — a reply-queue + correlation-id pattern under the hood, surfaced as a typed promise.
9
-
10
- ## Shape
11
-
12
- ```ts
13
- // Caller (client)
14
- const response = await herald()
15
- .channel<RequestPayload>("compute.tax")
16
- .request<TaxResponse>({ amount: 1000, country: "US" }, { timeout: 30_000 });
17
-
18
- // Responder (server)
19
- herald()
20
- .channel<RequestPayload>("compute.tax")
21
- .respond(async (message, ctx) => {
22
- const tax = await computeTax(message.payload);
23
- return { tax, currency: "USD" }; // ← the return value IS the reply
24
- });
25
- ```
26
-
27
- `request` returns a promise that resolves with the responder's return value. `respond` registers a handler and **automatically replies with whatever the handler returns** (then acks) — so just `return` the response; you don't call `ctx.reply()` yourself inside a `respond` handler. (`ctx.reply` is the lower-level primitive used when you wire a plain `.subscribe()` as a responder by hand.)
28
-
29
- ## Timeout
30
-
31
- ```ts
32
- await channel.request(payload, { timeout: 30_000 });
33
- ```
34
-
35
- The promise rejects if no reply arrives within `timeout` ms. Pick a sane number — too short rejects mid-work; too long lets a hung responder block the caller.
36
-
37
- ## When to use it vs HTTP
38
-
39
- | Use HTTP | Use request/respond |
40
- | --- | --- |
41
- | Stateless, fast, idempotent ops | Slow / queued ops where the caller can wait |
42
- | Public API surface | Internal service-to-service |
43
- | Frontend consumption | Backend orchestration |
44
- | Synchronous user-facing flow | Async-but-needs-result patterns |
45
-
46
- The bus adds queue persistence and retry. HTTP is faster for sub-100ms ops; the bus shines when the operation has variable duration or needs a queue's backpressure.
47
-
48
- ## When NOT to use it
49
-
50
- - "I need a result in under 50ms" — too much overhead on the bus.
51
- - "The responder might be down for hours" — request will time out repeatedly; consider `publish` + write the result somewhere the caller polls.
52
- - "Many callers, the response is the same for all" — cache the result and use `publish` for invalidation.
53
-
54
- ## Multiple responders
55
-
56
- `respond(handler)` takes no options — there's no `group` knob here. If multiple consumers `respond()` to the same channel, they all sit on the same queue, so RabbitMQ round-robins between them: **one responder handles each request, and the caller gets exactly one reply.** That's the "share work across responders" pattern — a synchronous worker pool. Make every responder functionally identical, since the caller has no say in which one answers.
57
-
58
- For "multiple replies expected" patterns, use a regular `subscribe` + a request id, then the caller listens on a result channel.
59
-
60
- ## Correlation across replies
61
-
62
- Each `request()` generates its own correlation ID and an exclusive reply queue under the hood. The reply carries the same ID; the client matches it to the awaiting promise and resolves. You don't manage correlation IDs manually — and you shouldn't pass your own `correlationId` on a `request()`, since herald overwrites it with the one it uses to match the reply. To thread your own trace id (e.g. a transaction id for logs) through to the responder, put it in `headers` instead — the responder reads it from `message.metadata.headers`.
63
-
64
- ## Things NOT to do
65
-
66
- - Don't use request/respond for high-throughput, low-latency ops. The reply-queue round-trip adds at least one broker hop. HTTP is the right tool.
67
- - Don't return huge payloads from `respond`. Reply messages travel through the broker; a multi-MB response is a load on the bus and on memory. For big results, write to S3 / cache and reply with a reference.
68
- - Don't forget to `respond` in long-lived consumers. If the responder crashes, every caller's request will time out — they don't know why.
69
- - Don't set timeout to `Infinity`. A stuck request becomes a leaked promise — set a real timeout and handle the rejection.
70
-
71
- ## See also
72
-
73
- - [`@warlock.js/herald/publish-message/SKILL.md`](@warlock.js/herald/publish-message/SKILL.md) — fire-and-forget pattern
74
- - [`@warlock.js/herald/consume-message/SKILL.md`](@warlock.js/herald/consume-message/SKILL.md) — subscribe + ctx flow control (including `ctx.reply`)
1
+ ---
2
+ name: request-and-respond
3
+ description: 'Synchronous-style RPC over the message bus — channel.request<R>(payload, {timeout}) waits for a reply, channel.respond(handler) registers the responder, ctx.reply(response) sends the answer. Triggers: `channel.request`, `channel.respond`, `ctx.reply`, `timeout`, `correlationId`, `headers.correlationId`; "RPC over message bus", "request-response across services", "wait for a reply", "internal service-to-service call instead of HTTP"; typical import `import { herald } from "@warlock.js/herald"`. Skip: setup — `@warlock.js/herald/herald-basics/SKILL.md`; fire-and-forget — `@warlock.js/herald/publish-message/SKILL.md`; consumer ctx flow — `@warlock.js/herald/consume-message/SKILL.md`; competing libs `amqplib` RPC, `nats` request/reply; NestJS `ClientProxy.send`; gRPC; HTTP.'
4
+ ---
5
+
6
+ # Request-response over the bus
7
+
8
+ Most messaging is fire-and-forget. When you need a reply, use the request/respond pair — a reply-queue + correlation-id pattern under the hood, surfaced as a typed promise.
9
+
10
+ ## Shape
11
+
12
+ ```ts
13
+ // Caller (client)
14
+ const response = await herald()
15
+ .channel<RequestPayload>("compute.tax")
16
+ .request<TaxResponse>({ amount: 1000, country: "US" }, { timeout: 30_000 });
17
+
18
+ // Responder (server)
19
+ herald()
20
+ .channel<RequestPayload>("compute.tax")
21
+ .respond(async (message, ctx) => {
22
+ const tax = await computeTax(message.payload);
23
+ return { tax, currency: "USD" }; // ← the return value IS the reply
24
+ });
25
+ ```
26
+
27
+ `request` returns a promise that resolves with the responder's return value. `respond` registers a handler and **automatically replies with whatever the handler returns** (then acks) — so just `return` the response; you don't call `ctx.reply()` yourself inside a `respond` handler. (`ctx.reply` is the lower-level primitive used when you wire a plain `.subscribe()` as a responder by hand.)
28
+
29
+ ## Timeout
30
+
31
+ ```ts
32
+ await channel.request(payload, { timeout: 30_000 });
33
+ ```
34
+
35
+ The promise rejects if no reply arrives within `timeout` ms. Pick a sane number — too short rejects mid-work; too long lets a hung responder block the caller.
36
+
37
+ ## When to use it vs HTTP
38
+
39
+ | Use HTTP | Use request/respond |
40
+ | --- | --- |
41
+ | Stateless, fast, idempotent ops | Slow / queued ops where the caller can wait |
42
+ | Public API surface | Internal service-to-service |
43
+ | Frontend consumption | Backend orchestration |
44
+ | Synchronous user-facing flow | Async-but-needs-result patterns |
45
+
46
+ The bus adds queue persistence and retry. HTTP is faster for sub-100ms ops; the bus shines when the operation has variable duration or needs a queue's backpressure.
47
+
48
+ ## When NOT to use it
49
+
50
+ - "I need a result in under 50ms" — too much overhead on the bus.
51
+ - "The responder might be down for hours" — request will time out repeatedly; consider `publish` + write the result somewhere the caller polls.
52
+ - "Many callers, the response is the same for all" — cache the result and use `publish` for invalidation.
53
+
54
+ ## Multiple responders
55
+
56
+ `respond(handler)` takes no options — there's no `group` knob here. If multiple consumers `respond()` to the same channel, they all sit on the same queue, so RabbitMQ round-robins between them: **one responder handles each request, and the caller gets exactly one reply.** That's the "share work across responders" pattern — a synchronous worker pool. Make every responder functionally identical, since the caller has no say in which one answers.
57
+
58
+ For "multiple replies expected" patterns, use a regular `subscribe` + a request id, then the caller listens on a result channel.
59
+
60
+ ## Correlation across replies
61
+
62
+ Each `request()` generates its own correlation ID and an exclusive reply queue under the hood. The reply carries the same ID; the client matches it to the awaiting promise and resolves. You don't manage correlation IDs manually — and you shouldn't pass your own `correlationId` on a `request()`, since herald overwrites it with the one it uses to match the reply. To thread your own trace id (e.g. a transaction id for logs) through to the responder, put it in `headers` instead — the responder reads it from `message.metadata.headers`.
63
+
64
+ ## Things NOT to do
65
+
66
+ - Don't use request/respond for high-throughput, low-latency ops. The reply-queue round-trip adds at least one broker hop. HTTP is the right tool.
67
+ - Don't return huge payloads from `respond`. Reply messages travel through the broker; a multi-MB response is a load on the bus and on memory. For big results, write to S3 / cache and reply with a reference.
68
+ - Don't forget to `respond` in long-lived consumers. If the responder crashes, every caller's request will time out — they don't know why.
69
+ - Don't set timeout to `Infinity`. A stuck request becomes a leaked promise — set a real timeout and handle the rejection.
70
+
71
+ ## See also
72
+
73
+ - [`@warlock.js/herald/publish-message/SKILL.md`](@warlock.js/herald/publish-message/SKILL.md) — fire-and-forget pattern
74
+ - [`@warlock.js/herald/consume-message/SKILL.md`](@warlock.js/herald/consume-message/SKILL.md) — subscribe + ctx flow control (including `ctx.reply`)