@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 +42 -21
- package/cjs/index.cjs +97 -44
- package/cjs/index.cjs.map +1 -1
- package/esm/drivers/rabbitmq/rabbitmq-channel.d.mts +22 -0
- package/esm/drivers/rabbitmq/rabbitmq-channel.d.mts.map +1 -1
- package/esm/drivers/rabbitmq/rabbitmq-channel.mjs +63 -20
- package/esm/drivers/rabbitmq/rabbitmq-channel.mjs.map +1 -1
- package/esm/drivers/rabbitmq/rabbitmq-driver.d.mts.map +1 -1
- package/esm/drivers/rabbitmq/rabbitmq-driver.mjs +33 -23
- package/esm/drivers/rabbitmq/rabbitmq-driver.mjs.map +1 -1
- package/esm/message-managers/prepare-consumer-subscription.mjs +1 -1
- package/esm/message-managers/prepare-consumer-subscription.mjs.map +1 -1
- package/llms-full.txt +6 -3
- package/package.json +20 -15
- package/skills/consume-message/SKILL.md +5 -3
- package/skills/herald-basics/SKILL.md +1 -0
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.
|
|
8
|
-
|
|
9
|
-
###
|
|
10
|
-
|
|
11
|
-
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
###
|
|
16
|
-
|
|
17
|
-
- Bumped `@mongez/reinforcements` to
|
|
18
|
-
|
|
19
|
-
## 4.
|
|
20
|
-
|
|
21
|
-
|
|
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.
|
|
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
|
-
|
|
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)
|
|
555
|
-
|
|
556
|
-
|
|
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
|
-
*
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
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
|
-
|
|
832
|
-
if (
|
|
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
|
|
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
|
-
|
|
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
|