@warlock.js/herald 5.30.0 → 5.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -2
- package/README.md +17 -16
- package/cjs/index.cjs +4 -3
- package/cjs/index.cjs.map +1 -1
- package/esm/communicators/broker-registry.d.mts +4 -3
- package/esm/communicators/broker-registry.d.mts.map +1 -1
- package/esm/communicators/broker.d.mts +5 -3
- package/esm/communicators/broker.d.mts.map +1 -1
- package/esm/communicators/index.d.mts +2 -1
- package/esm/communicators/index.mjs +1 -1
- package/esm/contracts/broker-driver.contract.d.mts +2 -2
- package/esm/contracts/broker-driver.contract.d.mts.map +1 -1
- package/esm/contracts/channel.contract.d.mts +2 -2
- package/esm/contracts/channel.contract.d.mts.map +1 -1
- package/esm/contracts/index.d.mts +2 -1
- package/esm/decorators/consumable.d.mts +3 -5
- package/esm/decorators/consumable.d.mts.map +1 -1
- package/esm/decorators/index.d.mts +2 -1
- package/esm/decorators/index.mjs +1 -1
- package/esm/drivers/index.d.mts +3 -1
- package/esm/drivers/index.mjs +1 -1
- package/esm/drivers/rabbitmq/index.d.mts +2 -1
- package/esm/drivers/rabbitmq/index.mjs +1 -1
- package/esm/drivers/rabbitmq/rabbitmq-channel.d.mts +3 -2
- package/esm/drivers/rabbitmq/rabbitmq-channel.d.mts.map +1 -1
- package/esm/drivers/rabbitmq/rabbitmq-channel.mjs +4 -3
- package/esm/drivers/rabbitmq/rabbitmq-channel.mjs.map +1 -1
- package/esm/drivers/rabbitmq/rabbitmq-driver.d.mts +3 -2
- package/esm/drivers/rabbitmq/rabbitmq-driver.d.mts.map +1 -1
- package/esm/drivers/rabbitmq/rabbitmq-driver.mjs.map +1 -1
- package/esm/index.d.mts +7 -0
- package/esm/message-managers/event-consumer.d.mts +3 -4
- package/esm/message-managers/event-consumer.d.mts.map +1 -1
- package/esm/message-managers/event-message.d.mts +3 -4
- package/esm/message-managers/event-message.d.mts.map +1 -1
- package/esm/message-managers/index.d.mts +2 -1
- package/esm/message-managers/index.mjs +1 -1
- package/esm/message-managers/types.d.mts +3 -3
- package/esm/message-managers/types.d.mts.map +1 -1
- package/esm/types/channel.types.d.mts +2 -4
- package/esm/types/channel.types.d.mts.map +1 -1
- package/esm/types/connection.types.d.mts +8 -9
- package/esm/types/connection.types.d.mts.map +1 -1
- package/esm/types/driver.types.d.mts +4 -5
- package/esm/types/driver.types.d.mts.map +1 -1
- package/esm/types/index.d.mts +2 -1
- package/esm/types/message.types.d.mts +6 -7
- package/esm/types/message.types.d.mts.map +1 -1
- package/esm/types/publish.types.d.mts +2 -3
- package/esm/types/publish.types.d.mts.map +1 -1
- package/esm/types/registry.types.d.mts +2 -4
- package/esm/types/registry.types.d.mts.map +1 -1
- package/esm/types/subscribe.types.d.mts +3 -4
- package/esm/types/subscribe.types.d.mts.map +1 -1
- package/esm/use-case-broadcast.d.mts +2 -3
- package/esm/use-case-broadcast.d.mts.map +1 -1
- package/esm/utils/connect-to-broker.d.mts +9 -6
- package/esm/utils/connect-to-broker.d.mts.map +1 -1
- package/esm/utils/index.d.mts +2 -1
- package/esm/utils/index.mjs +1 -1
- package/llms-full.txt +62 -57
- package/package.json +3 -3
- package/skills/consume-message/SKILL.md +30 -28
- package/skills/herald-basics/SKILL.md +8 -8
- package/skills/index.md +4 -1
- package/skills/publish-message/SKILL.md +19 -16
- package/skills/request-and-respond/SKILL.md +5 -5
|
@@ -17,9 +17,9 @@ herald()
|
|
|
17
17
|
.subscribe(async (message, ctx) => {
|
|
18
18
|
try {
|
|
19
19
|
await sendWelcomeEmail(message.payload.email);
|
|
20
|
-
await ctx.ack();
|
|
20
|
+
await ctx.ack(); // success — message removed from queue
|
|
21
21
|
} catch (error) {
|
|
22
|
-
await ctx.nack(true);
|
|
22
|
+
await ctx.nack(true); // failure — requeue for redelivery
|
|
23
23
|
}
|
|
24
24
|
});
|
|
25
25
|
```
|
|
@@ -30,7 +30,7 @@ herald()
|
|
|
30
30
|
- Handler **throws** → herald **auto-nacks** (requeue, or DLQ/reject once retries are exhausted — see below).
|
|
31
31
|
- Handler calls `ctx.ack()` / `ctx.nack()` / `ctx.reject()` / `ctx.retry()` → herald respects that and does nothing further.
|
|
32
32
|
|
|
33
|
-
So the `try/catch` above is optional — letting the error throw produces the same auto-nack. Call `ctx` methods explicitly only when you want a
|
|
33
|
+
So the `try/catch` above is optional — letting the error throw produces the same auto-nack. Call `ctx` methods explicitly only when you want a _non-default_ outcome (reject without requeue, route to DLQ, delayed retry). The one mode that loses messages is `autoAck: true`, where the broker acks on delivery before your handler runs.
|
|
34
34
|
|
|
35
35
|
## Message context — flow control
|
|
36
36
|
|
|
@@ -44,23 +44,23 @@ ctx.reply(payload); // for request-response pattern (see request-and-respond
|
|
|
44
44
|
|
|
45
45
|
Pick by intent:
|
|
46
46
|
|
|
47
|
-
| Intent
|
|
48
|
-
|
|
|
49
|
-
| Processed cleanly
|
|
50
|
-
| Transient failure, try again
|
|
51
|
-
| Permanent failure, send to DLQ
|
|
52
|
-
| Validation failure (bad message) | `ctx.reject()` — don't requeue
|
|
47
|
+
| Intent | Use |
|
|
48
|
+
| -------------------------------- | ------------------------------------- |
|
|
49
|
+
| Processed cleanly | `ctx.ack()` |
|
|
50
|
+
| Transient failure, try again | `ctx.nack(true)` or `ctx.retry(5000)` |
|
|
51
|
+
| Permanent failure, send to DLQ | `ctx.nack(false)` or `ctx.reject()` |
|
|
52
|
+
| Validation failure (bad message) | `ctx.reject()` — don't requeue |
|
|
53
53
|
|
|
54
54
|
## Subscribe options
|
|
55
55
|
|
|
56
56
|
```ts
|
|
57
57
|
await channel.subscribe(handler, {
|
|
58
|
-
group: "email-workers",
|
|
59
|
-
prefetch: 10,
|
|
60
|
-
autoAck: false,
|
|
61
|
-
exclusive: false,
|
|
58
|
+
group: "email-workers", // consumer group / tag — multiple consumers share work
|
|
59
|
+
prefetch: 10, // concurrency — how many in-flight messages this consumer holds
|
|
60
|
+
autoAck: false, // default; keep it false in production
|
|
61
|
+
exclusive: false, // single consumer only on this channel?
|
|
62
62
|
retry: {
|
|
63
|
-
maxRetries: 3,
|
|
63
|
+
maxRetries: 3, // redelivery ceiling on handler throw (delay not auto-applied — see below)
|
|
64
64
|
delay: 1000,
|
|
65
65
|
},
|
|
66
66
|
deadLetter: {
|
|
@@ -101,10 +101,12 @@ The DLQ always forwards the full original payload plus the message metadata, so
|
|
|
101
101
|
Messages that exhausted retries land in `user.created.failed`. Subscribe to it separately for alerting / manual inspection:
|
|
102
102
|
|
|
103
103
|
```ts
|
|
104
|
-
herald()
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
});
|
|
104
|
+
herald()
|
|
105
|
+
.channel("user.created.failed")
|
|
106
|
+
.subscribe(async (message, ctx) => {
|
|
107
|
+
await alerts.notify(`Failed: ${JSON.stringify(message.payload)}`);
|
|
108
|
+
await ctx.ack();
|
|
109
|
+
});
|
|
108
110
|
```
|
|
109
111
|
|
|
110
112
|
## Decorator-style consumers — `@Consumable` + `EventConsumer`
|
|
@@ -115,7 +117,7 @@ For class-based consumers that pair with the `EventMessage` (the `publish-messag
|
|
|
115
117
|
import { Consumable, EventConsumer } from "@warlock.js/herald";
|
|
116
118
|
import type { ConsumedEventMessage } from "@warlock.js/herald";
|
|
117
119
|
|
|
118
|
-
@Consumable()
|
|
120
|
+
@Consumable() // or @Consumable({ broker: "analytics" }) to target a non-default broker
|
|
119
121
|
export class UserCreatedConsumer extends EventConsumer<{ id: number; email: string }> {
|
|
120
122
|
public static eventName = "user.created";
|
|
121
123
|
|
|
@@ -167,7 +169,7 @@ export class UserCreatedConsumer extends EventConsumer {
|
|
|
167
169
|
import { defineConsumer } from "@warlock.js/herald";
|
|
168
170
|
|
|
169
171
|
export const userCreatedConsumer = defineConsumer<{ id: number; email: string }>("user.created", {
|
|
170
|
-
schema: userCreatedSchema,
|
|
172
|
+
schema: userCreatedSchema, // optional seal validation
|
|
171
173
|
handle: async (payload, event) => {
|
|
172
174
|
await sendWelcomeEmail(payload.email);
|
|
173
175
|
},
|
|
@@ -183,11 +185,11 @@ export const userCreatedConsumer = defineConsumer<{ id: number; email: string }>
|
|
|
183
185
|
```ts
|
|
184
186
|
const subscription = await channel.subscribe(handler);
|
|
185
187
|
|
|
186
|
-
subscription.id;
|
|
187
|
-
subscription.isActive();
|
|
188
|
+
subscription.id; // the consumer id (string)
|
|
189
|
+
subscription.isActive(); // boolean
|
|
188
190
|
|
|
189
|
-
await subscription.unsubscribe();
|
|
190
|
-
await subscription.pause();
|
|
191
|
+
await subscription.unsubscribe(); // cancel the consumer on the broker
|
|
192
|
+
await subscription.pause(); // also cancels the consumer (RabbitMQ has no native pause)
|
|
191
193
|
```
|
|
192
194
|
|
|
193
195
|
On the RabbitMQ driver, `pause()` and `unsubscribe()` both cancel the consumer; `resume()` is **not supported and throws** — create a fresh subscription to start consuming again. You can also cancel by id from the channel: `await channel.unsubscribeById(consumerId)`, or drop every consumer on the channel with `await channel.stopConsuming()`.
|
|
@@ -198,10 +200,10 @@ The channel exposes queue-management helpers beyond pub/sub:
|
|
|
198
200
|
|
|
199
201
|
```ts
|
|
200
202
|
const { messageCount, consumerCount } = await channel.stats(); // queue depth + live consumers
|
|
201
|
-
const purged = await channel.purge();
|
|
202
|
-
const ok = await channel.exists();
|
|
203
|
-
await channel.assert();
|
|
204
|
-
await channel.delete();
|
|
203
|
+
const purged = await channel.purge(); // drop all pending messages, returns the count
|
|
204
|
+
const ok = await channel.exists(); // does the queue exist on the broker?
|
|
205
|
+
await channel.assert(); // create the queue with its options (idempotent)
|
|
206
|
+
await channel.delete(); // remove the queue entirely
|
|
205
207
|
```
|
|
206
208
|
|
|
207
209
|
`assert()` runs lazily on the first `publish` / `subscribe`, so you rarely call it directly — reach for it only to pre-create a queue before any traffic.
|
|
@@ -73,20 +73,20 @@ await connectToBroker({
|
|
|
73
73
|
host: process.env.ANALYTICS_HOST,
|
|
74
74
|
});
|
|
75
75
|
|
|
76
|
-
herald().channel("emails").publish({
|
|
77
|
-
herald("analytics").channel("events").publish({
|
|
76
|
+
herald().channel("emails").publish({/* ... */}); // → notifications
|
|
77
|
+
herald("analytics").channel("events").publish({/* ... */}); // → analytics
|
|
78
78
|
```
|
|
79
79
|
|
|
80
80
|
`connectToBroker` defaults `isDefault` to `true`, and the registry promotes the most-recently-registered default. So when you register more than one broker, mark the secondaries `isDefault: false` — otherwise the last one wins and `herald()` returns the wrong broker.
|
|
81
81
|
|
|
82
82
|
## Pick a skill
|
|
83
83
|
|
|
84
|
-
| If the task is about…
|
|
85
|
-
|
|
|
86
|
-
| Publishing messages — single + batch + publish options (priority, ttl, delay, persistent, headers)
|
|
87
|
-
| Subscribing to messages — handler signature, message context (ack/nack/retry), prefetch, retry policy, dead-letter
|
|
88
|
-
| Request/response (RPC) — `channel.request(...)` + `channel.respond(...)` for synchronous-style calls over a message bus | the `request-and-respond` topic
|
|
89
|
-
| Schema-validated channels — `.channel(name, { schema })` with `@warlock.js/seal`
|
|
84
|
+
| If the task is about… | Load |
|
|
85
|
+
| ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
|
|
86
|
+
| Publishing messages — single + batch + publish options (priority, ttl, delay, persistent, headers) | the `publish-message` topic |
|
|
87
|
+
| Subscribing to messages — handler signature, message context (ack/nack/retry), prefetch, retry policy, dead-letter | the `consume-message` topic |
|
|
88
|
+
| Request/response (RPC) — `channel.request(...)` + `channel.respond(...)` for synchronous-style calls over a message bus | the `request-and-respond` topic |
|
|
89
|
+
| Schema-validated channels — `.channel(name, { schema })` with `@warlock.js/seal` | See the `seal-basics` topic of the `warlock-js-seal` skill + skills below |
|
|
90
90
|
|
|
91
91
|
## Things NOT to do
|
|
92
92
|
|
package/skills/index.md
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
---
|
|
2
|
-
description:
|
|
2
|
+
description: 'Message bus over RabbitMQ (Kafka planned): publish, subscribe, and request-response between services. Exports `connectToBroker`, `herald`, `heraldChannel`, `Broker`, `defineEvent`, `defineConsumer`, `EventMessage`, `EventConsumer`, `Consumable`, `brokerRegistry`. Use for: "publish an event to RabbitMQ", "subscribe to a channel", "ack or nack a message", "request and reply over the bus", "cross-service messaging". Not this package: durable retried background jobs on Redis → @warlock.js/queue; cron/recurring work → @warlock.js/scheduler.'
|
|
3
3
|
---
|
|
4
|
+
|
|
4
5
|
# @warlock.js/herald
|
|
5
6
|
|
|
6
7
|
Herald is the broker-agnostic message bus. `connectToBroker(config)` boots a broker once; `herald()` returns it (`herald("name")` for a named one); `.channel(name)` is the queue/topic you publish into and subscribe from. Channels are typed with generics and can validate with a seal schema. Server-only.
|
|
7
8
|
|
|
8
9
|
## The 80% path
|
|
10
|
+
|
|
9
11
|
1. Orient with `herald-basics.md` (the map and foundations).
|
|
10
12
|
2. Connect at startup, then publish: `publish-message.md`.
|
|
11
13
|
3. Consume with `subscribe` handlers or consumer classes: `consume-message.md`.
|
|
12
14
|
4. Need a reply? `request-and-respond.md`.
|
|
13
15
|
|
|
14
16
|
## Conventions and pitfalls
|
|
17
|
+
|
|
15
18
|
- Call `connectToBroker` once before any publish or subscribe.
|
|
16
19
|
- Smart auto-ack is the default: a clean return acks, a throw nacks with requeue. `autoAck: true` loses messages on a crash; avoid it.
|
|
17
20
|
- Use `ctx.reject()` / `ctx.retry(ms)` only for a non-default outcome.
|
|
@@ -27,8 +27,8 @@ type UserPayload = { userId: number; email: string };
|
|
|
27
27
|
|
|
28
28
|
const channel = herald().channel<UserPayload>("user.created");
|
|
29
29
|
|
|
30
|
-
await channel.publish({ userId: 1, email: "test@example.com" });
|
|
31
|
-
await channel.publish({ userId: "1" } as never);
|
|
30
|
+
await channel.publish({ userId: 1, email: "test@example.com" }); // ✅ typed
|
|
31
|
+
await channel.publish({ userId: "1" } as never); // ❌ compile error
|
|
32
32
|
```
|
|
33
33
|
|
|
34
34
|
Share the payload type between producer and consumer via a common `types/` file.
|
|
@@ -45,7 +45,7 @@ const userSchema = v.object({
|
|
|
45
45
|
|
|
46
46
|
const channel = herald().channel("user.created", { schema: userSchema });
|
|
47
47
|
|
|
48
|
-
await channel.publish({ userId: 1, email: "invalid" });
|
|
48
|
+
await channel.publish({ userId: 1, email: "invalid" }); // Throws — fails .email()
|
|
49
49
|
```
|
|
50
50
|
|
|
51
51
|
The schema runs `validate()` before the message hits the broker. Invalid payloads never leave the producer. See the `seal-basics` topic of the `warlock-js-seal` skill.
|
|
@@ -54,10 +54,10 @@ The schema runs `validate()` before the message hits the broker. Invalid payload
|
|
|
54
54
|
|
|
55
55
|
```ts
|
|
56
56
|
await channel.publish(payload, {
|
|
57
|
-
priority: 5,
|
|
58
|
-
expiration: 60_000,
|
|
59
|
-
delay: 5_000,
|
|
60
|
-
persistent: true,
|
|
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
61
|
correlationId: "uuid", // for tracking across services
|
|
62
62
|
headers: {
|
|
63
63
|
tenantId: "42",
|
|
@@ -71,14 +71,17 @@ The headers field is free-form — consumers read `message.metadata.headers?.ten
|
|
|
71
71
|
## Batch publishing
|
|
72
72
|
|
|
73
73
|
```ts
|
|
74
|
-
await channel.publishBatch(
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
74
|
+
await channel.publishBatch(
|
|
75
|
+
[
|
|
76
|
+
{ userId: 1, email: "a@example.com" },
|
|
77
|
+
{ userId: 2, email: "b@example.com" },
|
|
78
|
+
{ userId: 3, email: "c@example.com" },
|
|
79
|
+
],
|
|
80
|
+
{
|
|
81
|
+
// Options apply to every message in the batch
|
|
82
|
+
persistent: true,
|
|
83
|
+
},
|
|
84
|
+
);
|
|
82
85
|
```
|
|
83
86
|
|
|
84
87
|
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.
|
|
@@ -128,7 +131,7 @@ export default {
|
|
|
128
131
|
```ts
|
|
129
132
|
// The use case opts in — channel name defaults to the use case name.
|
|
130
133
|
export const createUserUseCase = useCase({
|
|
131
|
-
name: "users.create",
|
|
134
|
+
name: "users.create", // → publishes to channel "users.create"
|
|
132
135
|
schema: createUserSchema,
|
|
133
136
|
handler: async (data) => User.create(data),
|
|
134
137
|
broadcast: true,
|
|
@@ -36,12 +36,12 @@ The promise rejects if no reply arrives within `timeout` ms. Pick a sane number
|
|
|
36
36
|
|
|
37
37
|
## When to use it vs HTTP
|
|
38
38
|
|
|
39
|
-
| Use HTTP
|
|
40
|
-
|
|
|
39
|
+
| Use HTTP | Use request/respond |
|
|
40
|
+
| ------------------------------- | ------------------------------------------- |
|
|
41
41
|
| Stateless, fast, idempotent ops | Slow / queued ops where the caller can wait |
|
|
42
|
-
| Public API surface
|
|
43
|
-
| Frontend consumption
|
|
44
|
-
| Synchronous user-facing flow
|
|
42
|
+
| Public API surface | Internal service-to-service |
|
|
43
|
+
| Frontend consumption | Backend orchestration |
|
|
44
|
+
| Synchronous user-facing flow | Async-but-needs-result patterns |
|
|
45
45
|
|
|
46
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
47
|
|