Adapters & transports
Last updated:
The @babelqueue/core codec is framework-agnostic. Thin packages wire it into common
Node stacks: @babelqueue/bullmq (BullMQ jobs) and @babelqueue/nestjs (a NestJS
module built on the BullMQ adapter), plus broker transports — @babelqueue/redis (Redis),
@babelqueue/rabbitmq (RabbitMQ), @babelqueue/sqs (Amazon SQS),
@babelqueue/azure-service-bus (Azure Service Bus), @babelqueue/pulsar
(Apache Pulsar), @babelqueue/kafka (Apache Kafka) and @babelqueue/artemis (Apache
ActiveMQ Artemis).
BullMQ — @babelqueue/bullmq
npm install @babelqueue/bullmq bullmq
bullmq ^5 is a peer dependency; @babelqueue/core is pulled in for you.
It exports two main functions, plus header helpers (headersOf, encodeMetadata, decodeMetadata):
publish(queue, urn, data, options?) → Promise<string>— adds a BullMQ job whose name is the URN and whose data is the canonical envelope; returnsmeta.id.optionsis{ traceId?, jobsOptions?, headers? }(jobsOptionsis BullMQ’s own — delay, attempts, backoff…;headersis an out-of-band carrier such as a W3Ctraceparent, stored in the job’stelemetry.metadatabeside the untouched envelope).processor(handlers, options?) → (job) => Promise— a BullMQ processor function that validates each envelope, resolves its URN and routes tohandlers[urn].options.onUnknownUrn(envelope, job)handles URNs with no mapped handler.
Produce
import { Queue } from "bullmq";
import { publish } from "@babelqueue/bullmq";
const queue = new Queue("orders", { connection: { host: "localhost", port: 6379 } });
const id = await publish(queue, "urn:babel:orders:created", { order_id: 1042 });
Consume
import { Worker } from "bullmq";
import { processor } from "@babelqueue/bullmq";
new Worker(
"orders",
processor(
{
"urn:babel:orders:created": async (env, job) => {
console.log(env.data.order_id, env.trace_id);
},
},
{ onUnknownUrn: (env, job) => console.warn("no handler for", job.name) },
),
{ connection: { host: "localhost", port: 6379 } },
);
A handler is (envelope, job, headers) => unknown | Promise<unknown> (headers is the
out-of-band carrier published with the job, empty when there is none; headersOf(job) reads it
elsewhere). A non-conformant
envelope is poison: it fails the job with BullMQ’s UnrecoverableError, so BullMQ does not
retry it whatever the job’s attempts say, and the job data stays in the queue’s failed set. An
unmapped URN throws unless onUnknownUrn is supplied, and a throwing handler is retried per the job’s
options, as before.
Behaviour change (ADR-0037, since
@babelqueue/bullmq1.2.0). A poison job used to throw aBabelQueueError, which BullMQ retried as an ordinary failure up to the job’sattempts. Code that caughtBabelQueueErrorfrom the processor for this case must now handleUnrecoverableError.
To keep a copy of poison jobs where a polyglot reader can find them, give the processor a
deadLetterQueue:
import { Queue, Worker } from "bullmq";
import { processor } from "@babelqueue/bullmq";
new Worker(
"orders",
processor(handlers, {
deadLetterQueue: true, // adds the poison record to "orders.dlq" before the job fails
}),
{ connection },
);
deadLetterQueue is opt-in and takes a Queue (used as given), a queue name (opened on the
worker’s connection) or true (<queue>.dlq, <queue> being the worker’s queue; set the
processor’s queue option if the name should differ). The poison record has reason: "poison"
and its body is the job data exactly as BullMQ delivered it, already parsed, serialised as JSON.
A failed add is reported through onError and the job still fails as unrecoverable. The
processor has a close() that closes a Queue the adapter created.
NestJS — @babelqueue/nestjs
npm install @babelqueue/nestjs @nestjs/common bullmq
@nestjs/common ^10 || ^11 and bullmq ^5 are peers; it builds on
@babelqueue/bullmq.
Register the module and inject the publisher:
import { Module } from "@nestjs/common";
import { BabelQueueModule } from "@babelqueue/nestjs";
@Module({
imports: [
BabelQueueModule.forRoot({
queue: "orders",
connection: { host: "localhost", port: 6379 },
}),
],
})
export class AppModule {}
import { Injectable } from "@nestjs/common";
import { BabelQueuePublisher } from "@babelqueue/nestjs";
@Injectable()
export class Orders {
constructor(private readonly babelQueue: BabelQueuePublisher) {}
create() {
return this.babelQueue.publish("urn:babel:orders:created", { order_id: 1042 });
}
}
forRoot({ queue, connection, queueOptions? }) provides an injectable
BabelQueuePublisher (publish(urn, data, { traceId?, headers? }) → Promise<string>) over the
BullMQ queue. For consuming, build a plain BullMQ Worker with the processor
re-exported from @babelqueue/nestjs:
import { Worker } from "bullmq";
import { processor } from "@babelqueue/nestjs";
new Worker(
"orders",
processor({ "urn:babel:orders:created": async (env) => { /* ... */ } }),
{ connection: { host: "localhost", port: 6379 } },
);
Redis — @babelqueue/redis
npm install @babelqueue/redis ioredis
ioredis is an optional peer — you provide the client (an ioredis instance satisfies the
adapter structurally). It implements the §1 reliable-queue pattern:
the list element is the canonical envelope JSON, byte-for-byte, with no wrapping by default (unlike
@babelqueue/bullmq, which uses BullMQ’s own job layout) and no property projection — Redis
lists carry no native metadata, so routing and tracing read the body directly. Produce is RPUSH;
consume reserves the head into a <queue>:processing list (LMOVE <queue> <queue>:processing LEFT RIGHT inside a Lua script, so an in-flight message
survives a crash and the queue is FIFO), routes by URN, and LREMs it on success.
Out-of-band headers (e.g. traceparent) are opt-in: passing { headers } to publish stores a
__bq_frame wrapper around the envelope; without headers the element is the bare envelope. The
handler receives the carried headers as a third argument.
Produce
import Redis from "ioredis";
import { RedisPublisher } from "@babelqueue/redis";
const client = new Redis("redis://localhost:6379/0");
const id = await RedisPublisher.create(client, "orders")
.publish("urn:babel:orders:created", { order_id: 1042 });
publish(urn, data, { traceId?, headers? }) returns the message meta.id.
Consume
import { RedisConsumer, type BabelHandlers } from "@babelqueue/redis";
const handlers: BabelHandlers = {
"urn:babel:orders:created": (envelope, raw) => {
console.log(envelope.data.order_id, envelope.trace_id);
},
};
const consumer = new RedisConsumer(client, "orders", handlers, {
maxTries: 3, // requeue with attempts+1, then <queue>.dlq
onError: (err) => console.error(err),
});
await consumer.run(() => true); // reserve → process → LREM, until you stop it
A successful handler LREMs the element from <queue>:processing. A throwing handler requeues the
envelope with attempts + 1 (at-least-once) up to maxTries, then dead-letters to <queue>.dlq
with a dead_letter block — the body owns the attempt count. dead_letter.attempts is the
counted value, the delivered attempts plus one for the current delivery, so with maxTries: 3
it is 3 (the same value the Go and Python runtimes write; before, this consumer wrote the
delivered value, one lower). Unknown-URN strategy is one of fail / delete / release /
dead_letter. A body that does not decode, or a JSON object with no valid job / urn, is
poison: it never reaches a handler, whatever the strategy is, and with the default
<queue>.dlq it is dead-lettered as a poison record (reason: "poison", original body kept).
deadLetterQueue: null turns off only the dead-letter of an exhausted message and of an
unknown URN (they are acknowledged and dropped, and the dead_letter strategy degrades to
delete). It no longer drops a poison body: that is still dead-lettered as the poison record, to
the configured name, and with null to the default <queue>.dlq. This matches the Go and Python
transports and the Redis binding.
Behaviour change (ADR-0037, since
@babelqueue/redis1.4.0). Until nowdeadLetterQueue: nullacknowledged and dropped a poison body without a record. If you setnullto keep poison bodies out of Redis, read or trim<queue>.dlqfrom now on.
This is a Node-owned reliable queue. Full parity with Laravel’s reserved-sorted-set reservation on a shared Redis queue is a separate task — for a mixed PHP+Node fleet, prefer a queue this consumer owns end-to-end. See the Redis binding.
RabbitMQ — @babelqueue/rabbitmq
npm install @babelqueue/rabbitmq amqplib
amqplib is an optional peer — you provide the channel (an amqplib Channel satisfies the
adapter structurally). It implements §2 of the broker-bindings contract:
the envelope JSON is the message body, and the contract fields are projected onto native AMQP
0-9-1 properties so a consumer routes without decoding the body — type = URN, correlation_id =
trace_id, message_id = meta.id, app_id = babelqueue, plus the native-typed
x-schema-version / x-source-lang / x-attempts headers (AMQP field-tables carry typed values,
so integers stay integers). Consume is basic.get + manual ack (at-least-once).
Produce
import amqp from "amqplib";
import { RabbitMQPublisher } from "@babelqueue/rabbitmq";
const conn = await amqp.connect("amqp://guest:guest@localhost:5672/");
const channel = await conn.createChannel();
await channel.assertQueue("orders", { durable: true });
const id = await RabbitMQPublisher.create(channel, "orders")
.publish("urn:babel:orders:created", { order_id: 1042 });
publish(urn, data, { traceId? }) returns the message meta.id. Messages are persistent
(delivery_mode = 2).
Consume
import { RabbitMQConsumer, type BabelHandlers } from "@babelqueue/rabbitmq";
const handlers: BabelHandlers = {
"urn:babel:orders:created": (envelope, message) => {
console.log(envelope.data.order_id, envelope.trace_id);
},
};
const consumer = new RabbitMQConsumer(channel, "orders", handlers, {
maxTries: 3,
onError: (err) => console.error(err),
});
await consumer.run(() => true); // basic.get → process → ack, until you stop it
A successful handler acks the message. A throwing handler republishes the envelope with
attempts + 1 (at-least-once) up to maxTries, then dead-letters to <queue>.dlq with a
dead_letter block. The consumer routes on properties.type (falling back to the body URN).
Unknown-URN strategy is one of fail / delete / release / dead_letter. See the
RabbitMQ binding.
Amazon SQS — @babelqueue/sqs
npm install @babelqueue/sqs @aws-sdk/client-sqs
@aws-sdk/client-sqs is an optional peer — you provide the SQS client (the aggregated
SQS class satisfies the transport structurally). It sends the canonical envelope as the
MessageBody with the §3 MessageAttributes,
and consumes by routing each message to a handler by URN.
Produce
import { SQS } from "@aws-sdk/client-sqs";
import { SqsPublisher } from "@babelqueue/sqs";
const sqs = new SQS({ region: "eu-central-1" });
const url = "https://sqs.eu-central-1.amazonaws.com/123456789012/orders";
const id = await new SqsPublisher(sqs, url).publish("urn:babel:orders:created", { order_id: 1042 });
publish(urn, data, { traceId? }) returns the message meta.id. FIFO queues:
new SqsPublisher(sqs, url, { fifo: true }) (the queue URL must end in .fifo).
Consume
import { SqsConsumer } from "@babelqueue/sqs";
const consumer = new SqsConsumer(
sqs,
url,
{
"urn:babel:orders:created": async (env, message) => {
console.log(env.data.order_id, env.trace_id);
},
},
{ onUnknownUrn: (env, msg) => {}, onError: (err, env, msg) => {} },
);
await consumer.poll(); // receive one batch, route, delete handled; loop this
A throwing handler’s message is released, never deleted: the consumer calls
ChangeMessageVisibility with releaseDelay (default 0 s, so SQS redelivers it at once,
at-least-once). Give the queue a native RedrivePolicy (maxReceiveCount → a DLQ) so a
message that always fails cannot loop forever; attempts is reconciled to ApproximateReceiveCount − 1. Point the
client’s endpoint at LocalStack/ElasticMQ for local testing.
A poison body (not JSON, not an envelope, no valid job or urn, or an unsupported
meta.schema_version) and a version-gated message can never succeed, so the consumer settles them
on their first delivery, never hands them to a handler and never retries them
(ADR-0037, since @babelqueue/sqs 1.4.0).
Before decoding, the consumer version-gates on the bq-schema-version message attribute
(§3.7). A missing or blank value (the empty string, or only ASCII whitespace: space, tab, LF,
VT, FF, CR) changes nothing: the body is decoded as usual. Any other value is compared with
"1" exactly as sent, with no trimming, so "2", "x", "01" and " 1" are all rejected
without decoding the body, and onError is called. A "1" value does not skip the body check:
the decoded envelope still goes through the schema_version validation, and a body that fails it
is a poison body.
What happens next depends on whether you set deadLetterQueueUrl:
const consumer = new SqsConsumer(sqs, url, handlers, {
deadLetterQueueUrl: "https://sqs.eu-central-1.amazonaws.com/123456789012/orders-dlq",
});
| Configuration | Poison body | Version-gated message |
|---|---|---|
deadLetterQueueUrl set |
sent to <queue>-dlq as the poison record (reason: "poison": an object body spliced with a dead_letter block, anything else wrapped under raw), then deleted |
the raw body, byte for byte and with no dead_letter block, sent to <queue>.dlq, then deleted |
unknownUrn: "delete" |
deleted, no record | deleted, no record (dropped) |
no deadLetterQueueUrl |
reported and left to SQS: not deleted, so visibility expiry redelivers it and the queue’s RedrivePolicy moves it to its DLQ |
the same |
The original is deleted only after the send to the DLQ succeeded; a failed send leaves it undeleted,
so the write is tried again when the visibility timeout lapses. Name the DLQ <queue>-dlq (SQS does not accept
a dot in a queue name, so not <queue>.dlq). For a .fifo DLQ (<queue>-dlq.fifo) the consumer uses
the SQS message id as the deduplication id. deadLetterQueueUrl is opt-in: with none, nothing changes
and the native RedrivePolicy stays your safety net; with one set, leaving a poison or version-gated
message to the RedrivePolicy is no longer a conformant settle. Only an explicitly set unknownUrn
strategy is read here: an onUnknownUrn hook (and the delete it implies for an unknown URN) never
applies, so a hook-only consumer behaves as fail and never drops one silently.
Azure Service Bus — @babelqueue/azure-service-bus
npm install @babelqueue/azure-service-bus @azure/service-bus
@azure/service-bus is an optional peer — you provide the sender/receiver (a
ServiceBusSender / ServiceBusReceiver satisfies the adapter structurally). It sends the
canonical envelope as the message body with the native §4 projection (subject = URN,
correlationId = trace_id, messageId = meta.id, plus the bq- application
properties), and consumes by routing each message to a handler by URN.
import { ServiceBusClient } from "@azure/service-bus";
import { AsbPublisher, AsbConsumer } from "@babelqueue/azure-service-bus";
const client = new ServiceBusClient(connectionString); // or (namespace, credential)
// produce
const id = await new AsbPublisher(client.createSender("orders"))
.publish("urn:babel:orders:created", { order_id: 1042 });
// consume (PeekLock)
const consumer = new AsbConsumer(
client.createReceiver("orders"),
{
"urn:babel:orders:created": async (env, message) => {
console.log(env.data.order_id, env.trace_id);
},
},
{ onError: (err) => console.error(err) },
);
await consumer.run();
A poison body (not JSON, not an envelope, no valid job or urn, or an unsupported
meta.schema_version) and a version-gated message can never succeed, so the consumer settles them
on their first delivery, never hands them to a handler and never retries them
(ADR-0037, since @babelqueue/azure-service-bus 1.3.0).
Before decoding, the consumer version-gates on the bq-schema-version application property
(§4.7). A string "1" or the number or bigint 1 passes; any other type is rejected. The
spec rejects a float 1.0, but the AMQP client hands JavaScript a 1.0 as the number 1, so
this consumer cannot tell them apart and accepts it (the one exception §4.7 allows). A
missing or blank value (the empty string, or only ASCII whitespace: space, tab, LF, VT, FF, CR)
changes nothing: the body is decoded as usual. A property present with a null value counts
as missing too. Any other value is compared with "1" exactly as sent, with no trimming, so
"2", "x", "01" and " 1" are all rejected without decoding the body, and onError is
called. A "1" value does not skip the body check: the decoded envelope still goes through the
schema_version validation, and a body that fails it is a poison body.
What happens next depends on whether you gave the consumer a deadLetterSender, a sender on the
<queue>.dlq entity (an ordinary Service Bus queue that must exist):
const consumer = new AsbConsumer(client.createReceiver("orders"), handlers, {
deadLetterSender: client.createSender("orders.dlq"),
queue: "orders", // dead_letter.original_queue; defaults to the receiver's entityPath
});
| Configuration | Poison body | Version-gated message |
|---|---|---|
deadLetterSender set |
sent to <queue>.dlq as the poison record (reason: "poison": an object body spliced with a dead_letter block, anything else wrapped under raw), then completed |
the raw body, byte for byte and with no dead_letter block, sent to <queue>.dlq, then completed |
no deadLetterSender |
abandoned, so MaxDeliveryCount moves it to the native sub-queue |
explicitly dead-lettered (deadLetterMessage) into the native $DeadLetterQueue; if the receiver offers no deadLetterMessage it is abandoned and the error text says so |
The original is completed only after the send succeeded; a failed send is reported and abandons it,
so the write is tried again. deadLetterSender is opt-in: with none, nothing changes and the
native sub-queue stays your safety net. The <queue>.dlq copy is sent as bytes: a delivery whose
body the SDK already parsed (the default receiver parses a JSON body) has no exact bytes, so it is
reported and abandoned rather than completed into a lossy record; use a receiver created with
skipParsingBodyAsJson: true when you configure a deadLetterSender. The version gate never reads
an onUnknownUrn hook.
Fixed (wire behaviour, GR-5).
AsbPublisherused to put the envelope JSON inbodyas a string, and@azure/service-busJSON-encodes every body that is not aBuffer, so the data section held a quoted string literal instead of the envelope bytes the .NET, Java, Go and Python SDKs write and read. The body is now aBufferof the envelope’s UTF-8 bytes. Upgrade the Node consumers first, then the publishers. A new publisher against an old consumer (<= 1.2.0) makes every message look like poison, so work stops. A new consumer still opens an old, quoted-string body once (a migration step, only when the quoted string holds a valid envelope; any other quoted string stays poison), on both the default receiver andskipParsingBodyAsJson: true, so messages already on the queue drain. The step may be dropped in a later major.
Delayed delivery: publish(urn, data, { delayMs: 300000 }) → native
scheduledEnqueueTimeUtc. A throwing handler abandons the message (the broker redelivers,
incrementing deliveryCount); attempts is reconciled to
max(body.attempts, deliveryCount − 1). See the
Azure Service Bus binding.
Apache Pulsar — @babelqueue/pulsar
npm install @babelqueue/pulsar pulsar-client
pulsar-client is an optional peer — you provide the producer/consumer (a Producer /
Consumer satisfies the adapter structurally). It sends the canonical envelope as the
message payload with the §5 property projection (bq-job = URN, bq-trace-id = trace_id,
bq-message-id = meta.id, plus bq-schema-version / bq-source-lang / bq-attempts,
all string→string), and consumes by routing each message to a handler by URN.
import Pulsar from "pulsar-client";
import { PulsarPublisher, PulsarConsumer } from "@babelqueue/pulsar";
const client = new Pulsar.Client({ serviceUrl: "pulsar://localhost:6650" });
// produce
const producer = await client.createProducer({ topic: "orders" });
const id = await new PulsarPublisher(producer)
.publish("urn:babel:orders:created", { order_id: 1042 });
// consume
const sub = await client.subscribe({
topic: "orders",
subscription: "babelqueue",
subscriptionType: "Shared",
});
// optional: a producer on the dead-letter topic turns dead-lettering on
const dlq = await client.createProducer({ topic: "orders.dlq" });
const consumer = new PulsarConsumer(
sub,
{
"urn:babel:orders:created": async (env, message) => {
console.log(env.data.order_id, env.trace_id);
},
},
{
client, // the consumer builds its own retry producer from it...
topic: "orders", // ...on this topic (required with client: pulsar-client's Consumer has no getTopic())
maxAttempts: 3, // the default: 3 deliveries in total, the first included
deadLetterProducer: dlq,
onError: (err) => console.error(err),
},
);
await consumer.run();
Delayed delivery: publish(urn, data, { delayMs: 300000 }) → native deliverAfter.
attempts is reconciled to max(body.attempts, redeliveryCount) — the redelivery count is
0-based, so no −1.
Retry, max attempts and the DLQ
Redelivery is bounded by the SDK. Pulsar has no broker-side redelivery limit and the consumer
sets no native DeadLetterPolicy (one you add yourself is a second, uncoordinated limit), so it
counts the attempts itself. maxAttempts is the total number of deliveries a message gets, the
first included; the default is 3 and 1 means no retry. A value below 1 (or a non-integer)
throws BabelQueueError.
- Counted attempt. On every delivery the consumer takes the reconciled attempts and adds one
for the current delivery. A throwing handler, or an unmapped URN without an
onUnknownUrnhook, is retried while that counted attempt is belowmaxAttempts. - A retry is a re-publish. The consumer re-publishes the message to its own topic with
bq-attemptsset to the counted value (the body’sattemptsin sync, out-of-band headers carried) and then acknowledges the original, but only after the send succeeded. A failed re-publish or producer creation warns throughonWarnand negatively acknowledges the original, so nothing is lost. A negative acknowledgement advancesgetRedeliveryCount()only onSharedandKey_Sharedsubscriptions, so the re-publish is what makes the cap hold on every subscription type. - Giving the consumer a producer. The consumer builds the retry producer from
client(thepulsar-clientClient) only when you also passtopic, the topic the subscription reads: it creates the producer on that topic, lazily on the first retry.topicmust be given because thepulsar-clientConsumercannot report its own topic (it has nogetTopic()).retryProducersupplies your own producer instead and wins overclient. Withclientalone, or with neither, a retry would be a negative acknowledgement, which only terminates onSharedandKey_Shared; so onExclusive,Failoverand an undeclaredsubscriptionType(which counts asExclusive, the fail-closed choice) the constructor throwsBabelQueueError, asking fortopic. The way out is eitherclientplustopic(or aretryProducer), or declaringsubscriptionType: "Shared"(or"Key_Shared") when the subscription really is one. - At the cap. With a
deadLetterProducer(a producer on<queue>.dlq) the message is written there as the canonical envelope plus thedead_letterblock (reasonfailedorunknown_urn,attemptsanddead_letter.attempts= the counted value), keeping thebq-jobroute the delivery arrived on. Without one it is acknowledged and dropped, and the drop is reported throughonWarn. The original is acknowledged only after the DLQ write succeeded; a failed write leaves it negatively acknowledged, so it is redelivered and the write is tried again. - Poison. A body that does not decode into an envelope (not JSON, not an object, an object
with no valid
job/urn, or any other non-conformant envelope) never reaches a handler oronUnknownUrn. With adeadLetterProducerit is written to<queue>.dlqat once, without retry, as a poison record (reason: "poison", the original bytes kept; invalid UTF-8 as base64 underrawwithraw_encoding). Without one it takes the bounded retry and is then dropped. onUnknownUrnis unchanged. When the hook is set it runs and the message is acknowledged.
Behaviour change. Before this release a throwing handler was negatively acknowledged without a limit. With the defaults, a message that keeps failing is now dropped (or dead-lettered) at its third delivery; raise
maxAttemptsto keep retrying longer. A consumer on anExclusive,Failoveror undeclared subscription that is given neither aretryProducernorclienttogether withtopicnow throws at construction; passclientandtopic, or declaresubscriptionType: "Shared"/"Key_Shared".
Version gate. A message whose bq-schema-version property is present, not blank and not
"1" is rejected without decoding the body and settled on its first delivery, outside
maxAttempts (ADR-0037, since @babelqueue/pulsar 1.4.0): onError is called with the version error, no handler and no
onUnknownUrn hook runs, and it is never retried or re-published. With a deadLetterProducer the
raw body, byte for byte, with no dead_letter block, is written to <queue>.dlq and the
message is acknowledged after the write. Without one it is acknowledged and dropped, and the drop
is reported through onWarn. A failed write leaves it negatively acknowledged, so the write is
tried again on the redelivery.
Behaviour change (ADR-0037, since
@babelqueue/pulsar1.4.0). A version-gated message used to be negatively acknowledged on every delivery, without limit.
See the Apache Pulsar binding.
Apache Kafka — @babelqueue/kafka
npm install @babelqueue/kafka kafkajs
kafkajs is an optional peer — you provide the producer/consumer. Kafka has no native
ack/delay/DLQ/delivery-counter, so the adapter absorbs all four: the record value is the
envelope, the contract fields go to bq- headers (bq-job routes), the record timestamp
mirrors meta.created_at, and bq-attempts is the authoritative attempt counter. Consume
is process-then-commit (manual commit).
import { Kafka } from "kafkajs";
import { KafkaPublisher, KafkaConsumer, RetryTopics } from "@babelqueue/kafka";
const kafka = new Kafka({ brokers: ["localhost:9092"] });
// produce
const producer = kafka.producer();
await producer.connect();
const id = await KafkaPublisher.create(producer, "orders")
.publish("urn:babel:orders:created", { order_id: 1042 });
// consume (manual commit)
const consumer = kafka.consumer({ groupId: "orders-workers" });
await consumer.connect();
await consumer.subscribe({ topic: "orders" });
const retry = new RetryTopics("orders", [5_000, 60_000]); // orders.retry.1/.2 + orders.dlq
const babel = new KafkaConsumer(
consumer,
{ "urn:babel:orders:created": async (env, message) => { console.log(env.data.order_id, env.trace_id); } },
{ producer, retryTopics: retry, maxTries: 3, onError: (err) => console.error(err) },
);
await babel.run();
Before decoding, the consumer version-gates on the bq-schema-version record header (§6.7). A
missing or blank value (the empty string, or only ASCII whitespace: space, tab, LF, VT, FF, CR)
changes nothing: the body is decoded as usual. A header present with a null value counts as
missing too (it is skipped, and dropped from the raw record forwarded to <topic>.dlq). Any
other value is compared with "1" exactly as sent, with no trimming, so "2", "x",
"01" and " 1" are all rejected without decoding the body: onError is called, the raw
record is written to <topic>.dlq, and only then is the offset committed. With no DLQ
configured the record is committed and dropped. A "1" value does not skip the body check:
the decoded envelope still goes through the schema_version validation.
An undecodable (poison) body is never retried and never reaches a handler. It is dead-lettered
wrapped, like the Go reference (§6.5): a body that is exactly one JSON object keeps its keys,
anything else becomes {"raw": "<body>"}, and a dead_letter block with reason: "poison",
error, exception, original_queue, attempts (from bq-attempts) and lang: "node" replaces
any existing one. The key is kept, the record carries bq-attempts, bq-original-topic and the
bq-* headers projected from the body, and the offset is committed once the DLQ write is
confirmed. The schema-version gate rejection above still forwards its record raw. A body holding a
number a JS double cannot carry (an integer beyond 2^53, 1e400) is carried verbatim under raw
instead of being altered.
A body is poison when, and only when, the Go reference’s typed decode would reject it: invalid
JSON, a BOM, trailing data, an empty body or a non-object root; data, meta or dead_letter not
an object; job, trace_id or a string member of meta / dead_letter not a string;
meta.schema_version, meta.created_at, attempts or dead_letter.failed_at|attempts not an
int64 integer literal ("2026", 1.5, 1e2 and true are poison); or a non-finite number inside
data. A dedicated scanner decides this, because JSON.parse would lose duplicate keys. Member
names are matched exactly as spelled (JOB or Meta is an unknown member, kept as it is) and every
duplicate key is checked. A bare null body is not poison (an empty envelope on the unknown-URN
path), and a well-typed but non-conformant envelope (a missing data, an empty trace_id, a
schema_version other than 1) is routed by its URN like any other record. No decode failure can
wedge a partition. On a poison record dead_letter.attempts is the bq-attempts header (Go writes
0) and original_queue is the bq-original-topic header when present; a float literal such as
1.0 is re-formatted where the body is re-encoded.
A throwing handler republishes the envelope to the next <topic>.retry.<n> tier with
bq-attempts + 1, then commits; at maxTries it goes to <topic>.dlq with a dead_letter
block. A delay with no retry topics throws (Kafka has no native delay). The retry republish and the
envelope <topic>.dlq write carry the consumed record’s key unchanged (the raw DLQ record
already did): a null key stays null, an empty key stays empty, and a key is never invented.
Partition equality across SDKs is not guaranteed. See the
Apache Kafka binding.
onError never blocks settlement. On every failure path the consumer settles first (the retry
republish or the <topic>.dlq write, then the commit) and calls onError afterwards, even when
settling itself failed. A callback that throws, or returns a promise that rejects, is swallowed,
so it cannot skip the DLQ write or the commit and the consumer keeps running.
To replay <topic>.dlq, pass a KafkaRedriveIO to redrive() from @babelqueue/core. You
supply the pop (a bounded fetch with manual commit whose ack commits the offset) and the
record’s key; the adapter carries that key byte for byte on the write back to the work topic
and on every restore write back to the DLQ (a null key stays null, an empty key stays empty):
import { redrive } from "@babelqueue/core";
import { KafkaRedriveIO } from "@babelqueue/kafka";
const io = new KafkaRedriveIO({ producer, consumer, pop: (queue) => fetchNextDlqRecord(queue) });
await redrive(io, "orders.dlq");
A restore writes the consumed record back verbatim: its original value bytes, headers and key, with
a fresh timestamp, so the restored copy keeps its provenance (bq-original-topic, …) and does not
age out of the DLQ early. For that, have pop return the record’s value and headers along with
its body and key, and set topic: a write is treated as a restore when its queue is the
record’s own topic and its body is unchanged. A record without value and headers still
restores, with the body as the value and the bq-* headers projected from it. The write back to the
work topic is unchanged (bq-* headers projected from the body, meta.created_at as the timestamp).
A header the consumer decoded as null is not re-written on a restore.
Kafka commits are cumulative, so a run that stops on a failed message must not leave the group past
it. Pass the same kafkajs consumer (anything with seek) and have pop return each record’s
topic, partition and offset: the IO then offers the core’s optional RedriveIO.rewind, which
seeks every affected partition back to its first unacknowledged record (redrive pops the whole
batch before processing it). A record whose restore failed is never acked, and if the rewind fails
too both errors are thrown together (AggregateError). A record that was acknowledged is skipped by
the rewind. Without consumer nothing is rewound. pop returns a tracked copy and does not mutate
the message you give it. Rewinding needs the @babelqueue/core release that adds
RedriveIO.rewind; older cores never call it.
KafkaRedriveIO does not offer publishWithHeaders, so bypass has no effect on this
transport.
Apache ActiveMQ Artemis — @babelqueue/artemis
npm install @babelqueue/artemis rhea
rhea is an optional peer — you provide the sender/receiver. Artemis speaks AMQP 1.0
(not RabbitMQ’s 0-9-1), with native settlement, scheduled delivery, a delivery counter and a
dead-letter address. The envelope is the message body; the contract fields ride the slots a
JMS peer reads — x-opt-jms-type = URN (routes), correlation-id = trace_id, creation-time
= meta.created_at — plus the string bq_ application properties (underscored, since JMS property names must be valid Java identifiers).
import { Container } from "rhea";
import { ArtemisPublisher, ArtemisConsumer } from "@babelqueue/artemis";
const connection = new Container().connect({ host: "localhost", port: 5672 });
// produce
const sender = connection.open_sender("orders");
const id = await ArtemisPublisher.create(sender, "orders")
.publish("urn:babel:orders:created", { order_id: 1042 });
// consume (the consumer owns the disposition — autoaccept off)
const receiver = connection.open_receiver({ source: "orders", autoaccept: false, credit_window: 10 });
const dlqSender = connection.open_sender("orders.dlq");
const babel = new ArtemisConsumer(
{ "urn:babel:orders:created": (env, message) => { console.log(env.data.order_id, env.trace_id); } },
{ deadLetterSender: dlqSender, maxTries: 3, onError: (err) => console.error(err) },
);
babel.listen(receiver); // wires receiver.on("message") → accept / release / dead-letter
A poison body (not JSON, not an envelope, no valid job or urn, or an unsupported
meta.schema_version) and a version-gated message can never succeed, so the consumer settles them
on their first delivery, never hands them to a handler and never retries them
(ADR-0037, since @babelqueue/artemis 1.3.0).
Before decoding, the consumer version-gates on the bq_schema_version message property (§7.7).
A missing or blank value (the empty string, or only ASCII whitespace: space, tab, LF, VT,
FF, CR) changes nothing: the body is decoded as usual. A property present with a null value
counts as missing too. Any other value is compared with "1" exactly as sent, with no
trimming, so "2", "x", "01" and " 1" are all rejected without decoding the body, and
onError is called. A "1" value does not skip the body check: the decoded envelope still goes
through the schema_version validation, and a body that fails it is a poison body.
| Configuration | Poison body | Version-gated message |
|---|---|---|
deadLetterSender set |
written to <queue>.dlq as the poison record (reason: "poison": an object body spliced with a dead_letter block, anything else wrapped under raw; the exact bytes are kept, so invalid UTF-8 is not corrupted), then accepted once the broker confirmed the write |
the raw body, as received and with no dead_letter block, written to <queue>.dlq, then accepted once the write is confirmed |
unknownUrn: "delete" |
accepted, no record |
accepted, no record (dropped) |
no deadLetterSender |
accepted and dropped, reported to onError |
accepted and dropped |
| the DLQ write fails | released after a backoff, redelivered and written again |
same |
dead_letter.original_queue of a poison record is the consumer’s queue option, else the
source.address of the receiver given to listen(). Only an explicitly set unknownUrn strategy
is read here.
Behaviour change (ADR-0037, since
@babelqueue/artemis1.3.0). A poison body used to be forwarded to the DLQ as its raw bytes, without adead_letterblock, so a DLQ reader written against that form must now handle the poison record. UnderunknownUrn: "delete"a version-gated message is now dropped without a record; its raw body used to be written even then.
A successful handler accepts the message; a throwing handler releases it (the broker
redelivers and bumps delivery-count); at maxTries the envelope goes to <queue>.dlq with a
dead_letter block. attempts reconciles to max(body, delivery-count) — the AMQP counter is
0-based, so no −1.
The same confirm-then-accept rule covers every other dead-letter path: a handler that has run
out of tries, an unknown URN with the dead-letter strategy, and a poison body. The consumer
waits for the broker to confirm the <queue>.dlq write (an accepted verdict) before it
accepts the message. If the write is refused, fails or times out, onError is called and the
message is released after deadLetterRetryDelayMs, so it is redelivered and the handler may
run again; keep handlers idempotent. With no deadLetterSender the message is accepted and
dropped, as before. See the
Apache ActiveMQ Artemis binding.
Whatever a Node service produces is the canonical envelope, so it is consumed natively by any other BabelQueue SDK — see the wire contract.