@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.
Files changed (67) hide show
  1. package/CHANGELOG.md +15 -2
  2. package/README.md +17 -16
  3. package/cjs/index.cjs +4 -3
  4. package/cjs/index.cjs.map +1 -1
  5. package/esm/communicators/broker-registry.d.mts +4 -3
  6. package/esm/communicators/broker-registry.d.mts.map +1 -1
  7. package/esm/communicators/broker.d.mts +5 -3
  8. package/esm/communicators/broker.d.mts.map +1 -1
  9. package/esm/communicators/index.d.mts +2 -1
  10. package/esm/communicators/index.mjs +1 -1
  11. package/esm/contracts/broker-driver.contract.d.mts +2 -2
  12. package/esm/contracts/broker-driver.contract.d.mts.map +1 -1
  13. package/esm/contracts/channel.contract.d.mts +2 -2
  14. package/esm/contracts/channel.contract.d.mts.map +1 -1
  15. package/esm/contracts/index.d.mts +2 -1
  16. package/esm/decorators/consumable.d.mts +3 -5
  17. package/esm/decorators/consumable.d.mts.map +1 -1
  18. package/esm/decorators/index.d.mts +2 -1
  19. package/esm/decorators/index.mjs +1 -1
  20. package/esm/drivers/index.d.mts +3 -1
  21. package/esm/drivers/index.mjs +1 -1
  22. package/esm/drivers/rabbitmq/index.d.mts +2 -1
  23. package/esm/drivers/rabbitmq/index.mjs +1 -1
  24. package/esm/drivers/rabbitmq/rabbitmq-channel.d.mts +3 -2
  25. package/esm/drivers/rabbitmq/rabbitmq-channel.d.mts.map +1 -1
  26. package/esm/drivers/rabbitmq/rabbitmq-channel.mjs +4 -3
  27. package/esm/drivers/rabbitmq/rabbitmq-channel.mjs.map +1 -1
  28. package/esm/drivers/rabbitmq/rabbitmq-driver.d.mts +3 -2
  29. package/esm/drivers/rabbitmq/rabbitmq-driver.d.mts.map +1 -1
  30. package/esm/drivers/rabbitmq/rabbitmq-driver.mjs.map +1 -1
  31. package/esm/index.d.mts +7 -0
  32. package/esm/message-managers/event-consumer.d.mts +3 -4
  33. package/esm/message-managers/event-consumer.d.mts.map +1 -1
  34. package/esm/message-managers/event-message.d.mts +3 -4
  35. package/esm/message-managers/event-message.d.mts.map +1 -1
  36. package/esm/message-managers/index.d.mts +2 -1
  37. package/esm/message-managers/index.mjs +1 -1
  38. package/esm/message-managers/types.d.mts +3 -3
  39. package/esm/message-managers/types.d.mts.map +1 -1
  40. package/esm/types/channel.types.d.mts +2 -4
  41. package/esm/types/channel.types.d.mts.map +1 -1
  42. package/esm/types/connection.types.d.mts +8 -9
  43. package/esm/types/connection.types.d.mts.map +1 -1
  44. package/esm/types/driver.types.d.mts +4 -5
  45. package/esm/types/driver.types.d.mts.map +1 -1
  46. package/esm/types/index.d.mts +2 -1
  47. package/esm/types/message.types.d.mts +6 -7
  48. package/esm/types/message.types.d.mts.map +1 -1
  49. package/esm/types/publish.types.d.mts +2 -3
  50. package/esm/types/publish.types.d.mts.map +1 -1
  51. package/esm/types/registry.types.d.mts +2 -4
  52. package/esm/types/registry.types.d.mts.map +1 -1
  53. package/esm/types/subscribe.types.d.mts +3 -4
  54. package/esm/types/subscribe.types.d.mts.map +1 -1
  55. package/esm/use-case-broadcast.d.mts +2 -3
  56. package/esm/use-case-broadcast.d.mts.map +1 -1
  57. package/esm/utils/connect-to-broker.d.mts +9 -6
  58. package/esm/utils/connect-to-broker.d.mts.map +1 -1
  59. package/esm/utils/index.d.mts +2 -1
  60. package/esm/utils/index.mjs +1 -1
  61. package/llms-full.txt +62 -57
  62. package/package.json +3 -3
  63. package/skills/consume-message/SKILL.md +30 -28
  64. package/skills/herald-basics/SKILL.md +8 -8
  65. package/skills/index.md +4 -1
  66. package/skills/publish-message/SKILL.md +19 -16
  67. 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(); // success — message removed from queue
20
+ await ctx.ack(); // success — message removed from queue
21
21
  } catch (error) {
22
- await ctx.nack(true); // failure — requeue for redelivery
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 *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.
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 | 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 |
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", // 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?
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, // redelivery ceiling on handler throw (delay not auto-applied — see below)
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().channel("user.created.failed").subscribe(async (message, ctx) => {
105
- await alerts.notify(`Failed: ${JSON.stringify(message.payload)}`);
106
- await ctx.ack();
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() // or @Consumable({ broker: "analytics" }) to target a non-default broker
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, // optional seal validation
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; // the consumer id (string)
187
- subscription.isActive(); // boolean
188
+ subscription.id; // the consumer id (string)
189
+ subscription.isActive(); // boolean
188
190
 
189
- await subscription.unsubscribe(); // cancel the consumer on the broker
190
- await subscription.pause(); // also cancels the consumer (RabbitMQ has no native 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(); // drop all pending messages, returns the count
202
- const ok = await channel.exists(); // does the queue exist on the broker?
203
- await channel.assert(); // create the queue with its options (idempotent)
204
- await channel.delete(); // remove the queue entirely
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({ /* ... */ }); // → notifications
77
- herald("analytics").channel("events").publish({ /* ... */ }); // → analytics
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… | 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 |
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: "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."
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" }); // ✅ typed
31
- await channel.publish({ userId: "1" } as never); // ❌ compile error
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" }); // Throws — fails .email()
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, // 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)
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
- { 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
- });
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", // → publishes to channel "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 | Use request/respond |
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 | Internal service-to-service |
43
- | Frontend consumption | Backend orchestration |
44
- | Synchronous user-facing flow | Async-but-needs-result patterns |
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