@warlock.js/herald 4.14.0 → 4.16.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 CHANGED
@@ -1,21 +1,42 @@
1
- # Changelog — @warlock.js/herald
2
-
3
- All notable changes to `@warlock.js/herald` are documented in this file.
4
-
5
- The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). `@warlock.js/*` packages are released in lockstep — every package shares the same version number, so a version below may list only the changes that affected this package.
6
-
7
- ## 4.12.0
8
-
9
- ### Changed
10
-
11
- - Declares its own test runner and pins it to an exact version (`vitest@4.1.10`). The package is its own repository, so a runner resolved from a workspace root it may not be cloned with is a runner it cannot rely on. The pin is exact rather than a range because the version moved underneath the suite mid-development on an unrelated install a suite whose runner can change without anyone choosing it proves less than it appears to
12
-
13
- ## 4.2.11
14
-
15
- ### Changed
16
-
17
- - Bumped `@mongez/reinforcements` to 3.3.0
18
-
19
- ## 4.1.15
20
-
21
- - Baseline — per-package changelog tracking starts at this version.
1
+ # Changelog — @warlock.js/herald
2
+
3
+ All notable changes to `@warlock.js/herald` are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). `@warlock.js/*` packages are released in lockstep — every package shares the same version number, so a version below may list only the changes that affected this package.
6
+
7
+ ## 4.16.0 - 2026-08-18
8
+
9
+ ### Security
10
+
11
+ - **Poison-message DoS: an `EventConsumer.handle()` that reliably throws was nack+requeued forever**, with no retry cap and no dead-letter escape hatch a single bad message could pin a consumer in a hot ack/nack loop indefinitely, starving every other message behind it (worse with low prefetch). `prepareConsumerSubscription`'s catch now calls the channel's bounded `ctx.retry()` instead of an unconditional `ctx.nack(true)`, so redelivery is capped and the message is dead-lettered (if configured) or dropped with a loud `log.error` once the cap is hit never silently, and never forever
12
+ - **Fixed the retry counter never advancing on the automatic (non-explicit) nack path** (`RabbitMQChannel.subscribe`'s catch, used by any direct `channel.subscribe(handler, { retry, deadLetter })` caller whose handler throws instead of calling `ctx.retry()` itself). It read `x-retry-count` from the *original* message's headers and then plain-`nack(msg, false, true)`'d — which redelivers that same original message, so the header a developer's `retry.maxRetries`/`deadLetter` depended on never changed and the configured cap was silently never reached. Both the automatic path and the explicit `ctx.retry()` path now share one bounded-retry routine that republishes with an incremented header, so `maxRetries`/`deadLetter` are honored regardless of which path a handler takes
13
+ - **Broker credentials no longer leak into thrown/logged connection errors.** `username`/`password` are now URI-encoded when building the `amqp://` URL (a reserved character like `@`/`:`/`/` in a generated secret previously produced a malformed URL whose parser error echoed the raw credential back), and any error surfaced from `connect()` — including one that embeds a caller-supplied `uri` with credentials — has `user:pass@` redacted before it's re-thrown, so a connection failure can no longer put a plaintext broker password in front of `console.error`/structured logging/an error tracker
14
+
15
+ ### Dependencies
16
+
17
+ - Bumped `@mongez/events` to `^2.2.7` (no breaking changes) and `@mongez/reinforcements` to `^4.0.1`. The reinforcements major makes `Random.string/nanoid/id/token/uuid` CSPRNG-backed (WebCrypto) and removes `Random.seed()` support — audited this package's source and tests for `Random.seed(` and for seeded/reproducible use of `Random.*`; none found, so no code changes were needed.
18
+
19
+ ## 4.15.0 - 2026-08-16
20
+
21
+ ### Fixed
22
+
23
+ - **The RabbitMQ driver's lazy `amqplib` loader was not idempotent under concurrent callers.** It cached the resolved module in a module-level binding but nothing guarded the load itself, so two loads could be in flight at the same time and the last one to settle won the binding. The eager, unawaited `loadAmqplibModule()` call at module scope was one of those callers by construction — it started a load nobody was waiting on, which then raced the awaited call from `connect()`. That eager call has been removed: `connect()` already awaits the loader, so it bought nothing but the race. The loader now memoizes the in-flight promise itself, so the first caller starts the `import()` and every later caller awaits that same one
24
+ - **No user-visible misbehaviour is known in production** — both racing paths resolve the same real `amqplib`, so whichever won, callers got the module they expected. The observable damage was in test isolation: when a test was aborted mid-`await import(...)`, the racing loads could leave the binding holding the real `amqplib` while the test file's `vi.mock("amqplib")` was still active, so every later test in that file silently bypassed the mock and opened a real socket. Proven by instrumentation — the driver held a live `ChannelModel` on `::1:5672` while `import("amqplib")` inside the same test still returned the mock, which is how a green test could be green for the wrong reason
25
+ - **Verified by a timeout sweep, not by a passing suite.** The full suite passed both before and after (13 files / 137 tests), because the fault only surfaces when the first test is starved of time. Running `tests/connect-to-broker.test.ts` at `--testTimeout=3000` and `4000` previously timed out the first test *and* took `wraps a connection failure with the driver name` down with it, failing in ~50 ms with `promise resolved "Broker{…}" instead of rejecting` — the mock was gone. With the loader fixed, that test passes at every timeout even while the first test still times out: starving one test can no longer poison the next
26
+ - **The first test also paid a cold-transform cost inside its own timed body**, since `connectToBroker` dynamically imports the driver, which pulls in `@warlock.js/seal` and `@warlock.js/logger` as raw TS source. That work moved to a `beforeAll` warm-up. This is a test-timing change only and carries none of the correctness weight above — the loader fix stands on its own without it
27
+
28
+ ## 4.12.0
29
+
30
+ ### Changed
31
+
32
+ - Declares its own test runner and pins it to an exact version (`vitest@4.1.10`). The package is its own repository, so a runner resolved from a workspace root it may not be cloned with is a runner it cannot rely on. The pin is exact rather than a range because the version moved underneath the suite mid-development on an unrelated install — a suite whose runner can change without anyone choosing it proves less than it appears to
33
+
34
+ ## 4.2.11
35
+
36
+ ### Changed
37
+
38
+ - Bumped `@mongez/reinforcements` to 3.3.0
39
+
40
+ ## 4.1.15
41
+
42
+ - Baseline — per-package changelog tracking starts at this version.
package/cjs/index.cjs CHANGED
@@ -17,6 +17,7 @@ var __exportAll = (all, no_symbols) => {
17
17
 
18
18
  //#endregion
19
19
  let node_events = require("node:events");
20
+ let _warlock_js_logger = require("@warlock.js/logger");
20
21
  let _warlock_js_seal = require("@warlock.js/seal");
21
22
  let node_crypto = require("node:crypto");
22
23
  let crypto = require("crypto");
@@ -354,7 +355,7 @@ function prepareConsumerSubscription(Consumer, onError) {
354
355
  });
355
356
  ctx.ack();
356
357
  } catch (error) {
357
- ctx.nack(true);
358
+ await ctx.retry();
358
359
  if (onError) onError(error, Consumer.eventName);
359
360
  }
360
361
  };
@@ -465,6 +466,7 @@ var RabbitMQChannel = class {
465
466
  const { consumerTag } = await this.amqpChannel.consume(this.name, async (msg) => {
466
467
  if (!msg) return;
467
468
  let ackHandled = isFireAndForget;
469
+ let parsedMessage;
468
470
  try {
469
471
  const content = JSON.parse(msg.content.toString());
470
472
  let payload = content.payload;
@@ -495,6 +497,7 @@ var RabbitMQChannel = class {
495
497
  payload,
496
498
  raw: msg
497
499
  };
500
+ parsedMessage = message;
498
501
  await handler(message, {
499
502
  ack: async () => {
500
503
  if (!ackHandled) {
@@ -530,31 +533,16 @@ var RabbitMQChannel = class {
530
533
  retry: async (delay) => {
531
534
  if (ackHandled) return;
532
535
  ackHandled = true;
533
- const retryCount = (metadata.retryCount || 0) + 1;
534
- if (retryCount > (options?.retry?.maxRetries ?? 3)) {
535
- if (options?.deadLetter) await this.sendToDeadLetter(message, options.deadLetter.channel);
536
- this.amqpChannel.ack(msg);
537
- return;
538
- }
539
- const headers = {
540
- ...msg.properties.headers,
541
- "x-retry-count": retryCount
542
- };
543
- if (delay) headers["x-delay"] = delay;
544
- this.amqpChannel.sendToQueue(this.name, msg.content, {
545
- ...msg.properties,
546
- headers
547
- });
548
- this.amqpChannel.ack(msg);
536
+ await this.retryOrGiveUp(msg, metadata.retryCount || 0, options, message, delay);
549
537
  }
550
538
  });
551
539
  if (!ackHandled) this.amqpChannel.ack(msg);
552
540
  } catch (error) {
553
541
  if (ackHandled) return;
554
- if (options?.retry) if ((msg.properties.headers?.["x-retry-count"] || 0) < options.retry.maxRetries) this.amqpChannel.nack(msg, false, true);
555
- else if (options.deadLetter) this.amqpChannel.nack(msg, false, false);
556
- else this.amqpChannel.reject(msg, false);
557
- else this.amqpChannel.nack(msg, false, false);
542
+ if (options?.retry) {
543
+ const currentRetryCount = msg.properties.headers?.["x-retry-count"] || 0;
544
+ await this.retryOrGiveUp(msg, currentRetryCount, options, parsedMessage);
545
+ } else this.amqpChannel.nack(msg, false, false);
558
546
  }
559
547
  }, consumerOptions);
560
548
  const subscription = new RabbitMQSubscription(subscriptionId, this.name, consumerTag, this.amqpChannel);
@@ -593,6 +581,61 @@ var RabbitMQChannel = class {
593
581
  this.amqpChannel.sendToQueue(deadLetterChannel, Buffer.from(content), { persistent: true });
594
582
  }
595
583
  /**
584
+ * Dead-letter a message whose body couldn't be parsed into a {@link Message}
585
+ * (e.g. `JSON.parse` itself threw before an envelope existed). Forwards the
586
+ * raw AMQP bytes/properties as-is rather than re-deriving a payload, so a
587
+ * malformed message isn't lost.
588
+ */
589
+ sendToDeadLetterRaw(msg, deadLetterChannel) {
590
+ this.amqpChannel.sendToQueue(deadLetterChannel, msg.content, {
591
+ ...msg.properties,
592
+ persistent: true
593
+ });
594
+ }
595
+ /**
596
+ * Bounded retry shared by the explicit `ctx.retry()` call and the automatic
597
+ * catch when a handler throws without calling it itself — so both paths
598
+ * honor the same cap instead of the automatic path silently requeueing
599
+ * forever (see `subscribe()`'s catch block).
600
+ *
601
+ * Under the cap: republishes with an incremented `x-retry-count` header —
602
+ * NOT a plain `nack(msg, false, true)`, which redelivers the original
603
+ * message untouched and never advances the counter.
604
+ *
605
+ * At/over the cap: dead-letters if configured, otherwise drops the message
606
+ * with a loud `log.error` (never a silent drop) so an operator can see a
607
+ * poison message was discarded instead of it vanishing without a trace.
608
+ */
609
+ async retryOrGiveUp(msg, currentRetryCount, options, parsedMessage, delay) {
610
+ const retryCount = currentRetryCount + 1;
611
+ const maxRetries = options?.retry?.maxRetries ?? 3;
612
+ if (retryCount > maxRetries) {
613
+ if (options?.deadLetter) if (parsedMessage) await this.sendToDeadLetter(parsedMessage, options.deadLetter.channel);
614
+ else this.sendToDeadLetterRaw(msg, options.deadLetter.channel);
615
+ else {
616
+ _warlock_js_logger.log.error("herald", "poison-message", `Dropping message on channel "${this.name}" after ${retryCount - 1} failed ${retryCount - 1 === 1 ? "retry" : "retries"} (maxRetries: ${maxRetries}) with no dead-letter channel configured.`, {
617
+ channel: this.name,
618
+ retryCount: retryCount - 1,
619
+ maxRetries
620
+ });
621
+ this.amqpChannel.reject(msg, false);
622
+ return;
623
+ }
624
+ this.amqpChannel.ack(msg);
625
+ return;
626
+ }
627
+ const headers = {
628
+ ...msg.properties.headers,
629
+ "x-retry-count": retryCount
630
+ };
631
+ if (delay) headers["x-delay"] = delay;
632
+ this.amqpChannel.sendToQueue(this.name, msg.content, {
633
+ ...msg.properties,
634
+ headers
635
+ });
636
+ this.amqpChannel.ack(msg);
637
+ }
638
+ /**
596
639
  * Request-response pattern
597
640
  */
598
641
  async request(payload, options) {
@@ -712,13 +755,16 @@ var RabbitMQSubscription = class {
712
755
  //#region ../herald/src/drivers/rabbitmq/rabbitmq-driver.ts
713
756
  var rabbitmq_driver_exports = /* @__PURE__ */ __exportAll({ RabbitMQDriver: () => RabbitMQDriver });
714
757
  /**
715
- * Cached amqplib module (loaded once, reused)
716
- */
717
- let amqplibModule;
718
- /**
719
- * Module availability flag
758
+ * The single amqplib load, shared by every caller.
759
+ *
760
+ * Memoized as a promise rather than as a resolved value so the loader stays
761
+ * idempotent: without it, two callers arriving before the first `import()`
762
+ * settles would each start their own load and the last writer would win, so a
763
+ * caller could end up observing a module instance it never awaited.
764
+ *
765
+ * Resolves to `undefined` when amqplib is not installed.
720
766
  */
721
- let isModuleExists = null;
767
+ let amqplibModulePromise;
722
768
  /**
723
769
  * Installation instructions for amqplib
724
770
  */
@@ -735,17 +781,26 @@ Or manually:
735
781
  yarn add amqplib
736
782
  `.trim();
737
783
  /**
738
- * Load amqplib module
784
+ * Strip `user:password@` credentials from any `amqp(s)://` URL embedded in a
785
+ * string. The connection URL carries plaintext broker credentials, and
786
+ * amqplib/Node's URL parser commonly echoes the offending URL verbatim in a
787
+ * malformed-URL error (e.g. an unencoded `@`/`:` in the password) — applied
788
+ * to every error `connect()` surfaces so a credential never reaches whatever
789
+ * the host app does with a thrown connection error (console.error,
790
+ * structured logging, an error tracker).
739
791
  */
740
- async function loadAmqplibModule() {
741
- try {
742
- amqplibModule = await import("amqplib");
743
- isModuleExists = true;
744
- } catch {
745
- isModuleExists = false;
746
- }
792
+ function redactAmqpCredentials(message) {
793
+ return message.replace(/(amqps?:\/\/)[^/@\s]+@/gi, "$1****:****@");
794
+ }
795
+ /**
796
+ * Load amqplib, reusing the single shared load for every caller.
797
+ *
798
+ * @returns The amqplib module, or `undefined` when it is not installed.
799
+ */
800
+ function loadAmqplibModule() {
801
+ if (!amqplibModulePromise) amqplibModulePromise = import("amqplib").catch(() => void 0);
802
+ return amqplibModulePromise;
747
803
  }
748
- loadAmqplibModule();
749
804
  /**
750
805
  * RabbitMQ Driver
751
806
  *
@@ -828,11 +883,8 @@ var RabbitMQDriver = class {
828
883
  * Connect to RabbitMQ
829
884
  */
830
885
  async connect() {
831
- if (isModuleExists === false) throw new Error(`amqplib is not installed.\n\n${AMQPLIB_INSTALL_INSTRUCTIONS}`);
832
- if (isModuleExists === null) {
833
- await loadAmqplibModule();
834
- if (!isModuleExists) throw new Error(`amqplib is not installed.\n\n${AMQPLIB_INSTALL_INSTRUCTIONS}`);
835
- }
886
+ const amqplib = await loadAmqplibModule();
887
+ if (!amqplib) throw new Error(`amqplib is not installed.\n\n${AMQPLIB_INSTALL_INSTRUCTIONS}`);
836
888
  try {
837
889
  const url = this.buildConnectionUrl();
838
890
  const connectOptions = {
@@ -840,7 +892,7 @@ var RabbitMQDriver = class {
840
892
  timeout: this.options.connectionTimeout,
841
893
  ...this.options.clientOptions
842
894
  };
843
- this.connection = await amqplibModule.connect(url, connectOptions);
895
+ this.connection = await amqplib.connect(url, connectOptions);
844
896
  this.amqpChannel = await this.connection.createChannel();
845
897
  if (this.options.prefetch) await this.amqpChannel.prefetch(this.options.prefetch);
846
898
  this._isConnected = true;
@@ -857,7 +909,8 @@ var RabbitMQDriver = class {
857
909
  });
858
910
  } catch (error) {
859
911
  this._isConnected = false;
860
- throw new Error(`Failed to connect to RabbitMQ: ${error instanceof Error ? error.message : String(error)}`);
912
+ const message = error instanceof Error ? error.message : String(error);
913
+ throw new Error(`Failed to connect to RabbitMQ: ${redactAmqpCredentials(message)}`);
861
914
  }
862
915
  }
863
916
  /**
@@ -869,7 +922,7 @@ var RabbitMQDriver = class {
869
922
  const host = this.options.host ?? "localhost";
870
923
  const port = this.options.port ?? 5672;
871
924
  const vhost = this.options.vhost ?? "/";
872
- return `${protocol}://${this.options.username ?? "guest"}:${this.options.password ?? "guest"}@${host}:${port}/${encodeURIComponent(vhost)}`;
925
+ return `${protocol}://${encodeURIComponent(this.options.username ?? "guest")}:${encodeURIComponent(this.options.password ?? "guest")}@${host}:${port}/${encodeURIComponent(vhost)}`;
873
926
  }
874
927
  /**
875
928
  * Handle reconnection