@babelqueue/azure-service-bus 1.2.0 → 1.3.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/README.md +40 -4
- package/dist/index.cjs +133 -23
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +51 -4
- package/dist/index.d.ts +51 -4
- package/dist/index.js +134 -24
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -67,10 +67,46 @@ Service Bus is purely additive.
|
|
|
67
67
|
application property. A missing or blank value (empty or only ASCII whitespace) changes nothing;
|
|
68
68
|
the string `"1"` and the integral number `1` decode; any other value or type (`"2"`, `"x"`, `"01"`,
|
|
69
69
|
`2`, `1.5` — strings are never trimmed) rejects the message **undecoded**: it reaches `onError`
|
|
70
|
-
(with an empty envelope) and is **
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
70
|
+
(with an empty envelope) and is settled on its **first delivery** (ADR-0037 decision 1) — never decoded,
|
|
71
|
+
never given to a handler or `onUnknownUrn`. With a `deadLetterSender` (a sender on the cross-language
|
|
72
|
+
`<queue>.dlq` queue) the body is sent there exactly as received (no `dead_letter` block) and the message is
|
|
73
|
+
**completed only after the send succeeded**; a failed send is reported and abandons it, so the write is tried
|
|
74
|
+
again. Without a `deadLetterSender` it is left to the **native** mechanism: **dead-lettered explicitly**
|
|
75
|
+
(`deadLetterMessage`, reason `unsupported schema version`, into `$DeadLetterQueue`) — never abandoned into a
|
|
76
|
+
loop. The receiver must provide `deadLetterMessage` (a `ServiceBusReceiver` does); one without it degrades to
|
|
77
|
+
`abandonMessage` (`MaxDeliveryCount` then dead-letters it). This consumer has no unknown-URN strategy option
|
|
78
|
+
and an `onUnknownUrn` hook never applies to a version-gated delivery: it behaves as `fail`.
|
|
79
|
+
|
|
80
|
+
**Poison (ADR-0037 decision 2, ADR-0035).** A body that does not decode into a valid envelope is never given
|
|
81
|
+
to a handler. With a `deadLetterSender` the **ADR-0035 record** (`reason: "poison"`, built with the core's
|
|
82
|
+
`buildPoisonRecord`: an object body spliced byte for byte, anything else wrapped in `raw`, invalid UTF-8 as
|
|
83
|
+
base64) is sent to `<queue>.dlq` and the message completed only after the send; a failed send abandons it.
|
|
84
|
+
Without a `deadLetterSender` it is reported and abandoned, so `MaxDeliveryCount` moves it to the native
|
|
85
|
+
dead-letter sub-queue. `dead_letter.original_queue` is the `queue` option, else the receiver's `entityPath`,
|
|
86
|
+
else the sender's entity path without `.dlq`.
|
|
87
|
+
|
|
88
|
+
**Bodies, the default receiver and `skipParsingBodyAsJson`.** `@azure/service-bus` parses a data-section body that holds
|
|
89
|
+
valid JSON into an object unless the receiver is created with `skipParsingBodyAsJson: true`. The consumer does not
|
|
90
|
+
depend on that setting: it reads the exact bytes from the raw AMQP message the SDK keeps on every received message
|
|
91
|
+
(`_rawAmqpMessage.body`, rhea's decoded section: `{ typecode: 0x75, content: Buffer }` for a data section, an array of
|
|
92
|
+
Buffers for several, which are concatenated; an amqp-value body arrives unwrapped, as a plain string or Buffer, and is read as is). With a
|
|
93
|
+
`deadLetterSender`, a poison or version-gated delivery is therefore written to `<queue>.dlq` byte for byte even on a
|
|
94
|
+
default receiver. A shape the adapter does not recognise (an amqp-sequence, a non-text amqp-value, no raw message at all)
|
|
95
|
+
leaves the bytes unknown: no record is written and the delivery is reported and abandoned (the native mechanism keeps
|
|
96
|
+
it), never completed. `skipParsingBodyAsJson: true` always delivers exact bytes. DLQ records are always sent as bytes (the
|
|
97
|
+
SDK would otherwise JSON-encode a string into a quoted literal).
|
|
98
|
+
|
|
99
|
+
**Upgrading from a release before the publisher fix.** Upgrade every consumer first, then the publishers: an old
|
|
100
|
+
consumer (`<= 1.2.0`) cannot read the new wire body and would abandon every message to the native dead-letter queue.
|
|
101
|
+
A new consumer also reads the quoted JSON string literal that old publishers left in the queue (a body that is a
|
|
102
|
+
quoted JSON string whose content is a valid envelope is opened once, with either receiver setting); any other
|
|
103
|
+
quoted string is poison. With the default receiver the SDK's `JSON.parse` has already rounded integers above 2^53
|
|
104
|
+
and `1e400`-style numbers in the parsed view; the consumer decodes the envelope from the raw bytes when it can, but the
|
|
105
|
+
core codec's own decode follows `JSON.parse` semantics for `data`, so use `skipParsingBodyAsJson: true` where exact
|
|
106
|
+
number forms matter to your handler.
|
|
107
|
+
|
|
108
|
+
**Publisher wire format.** `AsbPublisher` / `toServiceBusMessage` send the envelope as bytes (a `Buffer`), so the data
|
|
109
|
+
section holds the envelope JSON itself, byte-identical to the other SDKs (GR-5).
|
|
74
110
|
|
|
75
111
|
The sender/receiver are injected, so the unit tests use fakes — no Azure, no broker. Dual
|
|
76
112
|
ESM + CJS.
|
package/dist/index.cjs
CHANGED
|
@@ -42,6 +42,7 @@ function sanitizeHeaders(headers) {
|
|
|
42
42
|
|
|
43
43
|
// src/index.ts
|
|
44
44
|
var BLANK_ASCII = /^[ \t\n\v\f\r]*$/;
|
|
45
|
+
var UNKNOWN_BODY_BYTES = "The message body was already parsed from JSON by the receiver and the message exposes no raw AMQP body, so its exact bytes are unknown and no DLQ record is written. Create the receiver with skipParsingBodyAsJson: true.";
|
|
45
46
|
var UNSUPPORTED_SCHEMA_VERSION_REASON = "unsupported schema version";
|
|
46
47
|
var MAX_REPORTED_SCHEMA_VERSION_LENGTH = 64;
|
|
47
48
|
function unsupportedSchemaVersion(message) {
|
|
@@ -66,7 +67,9 @@ function toServiceBusMessage(envelope, extra) {
|
|
|
66
67
|
applicationProperties["bq-created-at"] = envelope.meta.created_at;
|
|
67
68
|
}
|
|
68
69
|
const message = {
|
|
69
|
-
|
|
70
|
+
// Bytes, never a string: `@azure/service-bus` JSON-encodes every body that is not a Buffer / Uint8Array, so a
|
|
71
|
+
// string would reach the wire as a quoted JSON string literal and the other SDKs would not read an envelope (GR-5).
|
|
72
|
+
body: Buffer.from(import_core.EnvelopeCodec.encode(envelope), "utf8"),
|
|
70
73
|
contentType: "application/json"
|
|
71
74
|
};
|
|
72
75
|
if (envelope.job) message.subject = envelope.job;
|
|
@@ -114,6 +117,59 @@ var AsbPublisher = class {
|
|
|
114
117
|
return envelope.meta.id;
|
|
115
118
|
}
|
|
116
119
|
};
|
|
120
|
+
function rawAmqpBodyBytes(message) {
|
|
121
|
+
let raw;
|
|
122
|
+
try {
|
|
123
|
+
raw = message._rawAmqpMessage?.body;
|
|
124
|
+
} catch {
|
|
125
|
+
return void 0;
|
|
126
|
+
}
|
|
127
|
+
return sectionBytes(raw);
|
|
128
|
+
}
|
|
129
|
+
function sectionBytes(raw) {
|
|
130
|
+
if (raw instanceof Uint8Array) return raw;
|
|
131
|
+
if (typeof raw === "string") return Buffer.from(raw, "utf8");
|
|
132
|
+
if (raw === null || typeof raw !== "object") return void 0;
|
|
133
|
+
const { typecode, content } = raw;
|
|
134
|
+
if (typecode === 117) {
|
|
135
|
+
if (content instanceof Uint8Array) return content;
|
|
136
|
+
if (Array.isArray(content) && content.length > 0 && content.every((part) => part instanceof Uint8Array)) {
|
|
137
|
+
return Buffer.concat(content);
|
|
138
|
+
}
|
|
139
|
+
return void 0;
|
|
140
|
+
}
|
|
141
|
+
if (typecode === 119) {
|
|
142
|
+
if (typeof content === "string") return Buffer.from(content, "utf8");
|
|
143
|
+
if (content instanceof Uint8Array) return content;
|
|
144
|
+
}
|
|
145
|
+
return void 0;
|
|
146
|
+
}
|
|
147
|
+
function bodyBytes(message) {
|
|
148
|
+
const raw = rawAmqpBodyBytes(message);
|
|
149
|
+
if (raw !== void 0) return raw;
|
|
150
|
+
const body = message.body;
|
|
151
|
+
if (body == null) return new Uint8Array(0);
|
|
152
|
+
if (typeof body === "string") return Buffer.from(body, "utf8");
|
|
153
|
+
if (body instanceof Uint8Array) return body;
|
|
154
|
+
return void 0;
|
|
155
|
+
}
|
|
156
|
+
function unwrapLegacyQuotedEnvelope(text) {
|
|
157
|
+
if (!text.startsWith('"')) return text;
|
|
158
|
+
try {
|
|
159
|
+
const inner = JSON.parse(text);
|
|
160
|
+
if (typeof inner === "string" && import_core.EnvelopeCodec.accepts(import_core.EnvelopeCodec.decode(inner))) return inner;
|
|
161
|
+
} catch {
|
|
162
|
+
}
|
|
163
|
+
return text;
|
|
164
|
+
}
|
|
165
|
+
function bodyText(message) {
|
|
166
|
+
const bytes = bodyBytes(message);
|
|
167
|
+
if (bytes !== void 0) return unwrapLegacyQuotedEnvelope(Buffer.from(bytes).toString("utf8"));
|
|
168
|
+
return JSON.stringify(message.body) ?? "";
|
|
169
|
+
}
|
|
170
|
+
function stripDlqSuffix(entityPath) {
|
|
171
|
+
return entityPath.endsWith(".dlq") && entityPath.length > 4 ? entityPath.slice(0, -4) : entityPath;
|
|
172
|
+
}
|
|
117
173
|
var AsbConsumer = class {
|
|
118
174
|
constructor(receiver, handlers, options = {}) {
|
|
119
175
|
this.receiver = receiver;
|
|
@@ -145,26 +201,10 @@ var AsbConsumer = class {
|
|
|
145
201
|
if (rejectedVersion !== void 0) {
|
|
146
202
|
const reported = rejectedVersion.length > MAX_REPORTED_SCHEMA_VERSION_LENGTH ? `${rejectedVersion.slice(0, MAX_REPORTED_SCHEMA_VERSION_LENGTH)}...` : rejectedVersion;
|
|
147
203
|
const description = `Rejected a message from Azure Service Bus with unsupported bq-schema-version "${reported}" (supported: ${import_core.SCHEMA_VERSION}).`;
|
|
148
|
-
|
|
149
|
-
this.options.onError?.(
|
|
150
|
-
new import_core.BabelQueueError(
|
|
151
|
-
deadLetter ? description : `${description} The receiver has no deadLetterMessage; degraded to abandon.`
|
|
152
|
-
),
|
|
153
|
-
{},
|
|
154
|
-
message
|
|
155
|
-
);
|
|
156
|
-
if (deadLetter) {
|
|
157
|
-
await deadLetter(message, {
|
|
158
|
-
deadLetterReason: UNSUPPORTED_SCHEMA_VERSION_REASON,
|
|
159
|
-
deadLetterErrorDescription: description
|
|
160
|
-
});
|
|
161
|
-
} else {
|
|
162
|
-
await this.receiver.abandonMessage(message);
|
|
163
|
-
}
|
|
204
|
+
await this.settleVersionGated(message, description);
|
|
164
205
|
return;
|
|
165
206
|
}
|
|
166
|
-
const
|
|
167
|
-
const envelope = import_core.EnvelopeCodec.decode(raw);
|
|
207
|
+
const envelope = import_core.EnvelopeCodec.decode(bodyText(message));
|
|
168
208
|
const deliveryCount = typeof message.deliveryCount === "number" ? message.deliveryCount : 0;
|
|
169
209
|
const native = deliveryCount - 1;
|
|
170
210
|
const current = typeof envelope.attempts === "number" ? envelope.attempts : 0;
|
|
@@ -172,12 +212,11 @@ var AsbConsumer = class {
|
|
|
172
212
|
envelope.attempts = native;
|
|
173
213
|
}
|
|
174
214
|
if (!import_core.EnvelopeCodec.accepts(envelope)) {
|
|
175
|
-
this.
|
|
176
|
-
|
|
215
|
+
await this.settlePoison(
|
|
216
|
+
message,
|
|
177
217
|
envelope,
|
|
178
|
-
|
|
218
|
+
new import_core.BabelQueueError("Rejected a non-conformant BabelQueue envelope from Azure Service Bus.")
|
|
179
219
|
);
|
|
180
|
-
await this.receiver.abandonMessage(message);
|
|
181
220
|
return;
|
|
182
221
|
}
|
|
183
222
|
const urn = import_core.EnvelopeCodec.urn(envelope);
|
|
@@ -200,6 +239,77 @@ var AsbConsumer = class {
|
|
|
200
239
|
await this.receiver.abandonMessage(message);
|
|
201
240
|
}
|
|
202
241
|
}
|
|
242
|
+
/**
|
|
243
|
+
* Version-gated delivery (ADR-0037 decision 1): with a {@link AsbConsumerOptions.deadLetterSender} the raw body goes
|
|
244
|
+
* to `<queue>.dlq` and the message is completed only after the send (a failed send is reported and the message
|
|
245
|
+
* abandoned); without one it is reported and left to the native mechanism.
|
|
246
|
+
*/
|
|
247
|
+
async settleVersionGated(message, description) {
|
|
248
|
+
const sender = this.options.deadLetterSender;
|
|
249
|
+
if (sender === void 0) {
|
|
250
|
+
const deadLetter = this.receiver.deadLetterMessage?.bind(this.receiver);
|
|
251
|
+
this.options.onError?.(
|
|
252
|
+
new import_core.BabelQueueError(
|
|
253
|
+
deadLetter ? description : `${description} The receiver has no deadLetterMessage; degraded to abandon.`
|
|
254
|
+
),
|
|
255
|
+
{},
|
|
256
|
+
message
|
|
257
|
+
);
|
|
258
|
+
if (deadLetter) {
|
|
259
|
+
await deadLetter(message, {
|
|
260
|
+
deadLetterReason: UNSUPPORTED_SCHEMA_VERSION_REASON,
|
|
261
|
+
deadLetterErrorDescription: description
|
|
262
|
+
});
|
|
263
|
+
} else {
|
|
264
|
+
await this.receiver.abandonMessage(message);
|
|
265
|
+
}
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
this.options.onError?.(new import_core.BabelQueueError(description), {}, message);
|
|
269
|
+
try {
|
|
270
|
+
const bytes = bodyBytes(message);
|
|
271
|
+
if (bytes === void 0) throw new import_core.BabelQueueError(UNKNOWN_BODY_BYTES);
|
|
272
|
+
const record = { body: Buffer.from(bytes) };
|
|
273
|
+
if (message.contentType !== void 0) record.contentType = message.contentType;
|
|
274
|
+
await sender.sendMessages(record);
|
|
275
|
+
} catch (writeError) {
|
|
276
|
+
this.options.onError?.(writeError, {}, message);
|
|
277
|
+
await this.receiver.abandonMessage(message);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
await this.receiver.completeMessage(message);
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Poison body (ADR-0037 decision 2): with a {@link AsbConsumerOptions.deadLetterSender} the ADR-0035 record goes to
|
|
284
|
+
* `<queue>.dlq` and the message is completed only after the send (a failed send is reported and the message
|
|
285
|
+
* abandoned); without one it is reported and abandoned, so `MaxDeliveryCount` moves it to the native sub-queue.
|
|
286
|
+
*/
|
|
287
|
+
async settlePoison(message, envelope, error) {
|
|
288
|
+
this.options.onError?.(error, envelope, message);
|
|
289
|
+
const sender = this.options.deadLetterSender;
|
|
290
|
+
if (sender === void 0) {
|
|
291
|
+
await this.receiver.abandonMessage(message);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
try {
|
|
295
|
+
const delivered = Math.max(0, (typeof message.deliveryCount === "number" ? message.deliveryCount : 1) - 1);
|
|
296
|
+
const original = this.options.queue ?? this.receiver.entityPath ?? stripDlqSuffix(sender.entityPath);
|
|
297
|
+
const deadLetter = (0, import_core.annotate)({ attempts: delivered }, "poison", original, {
|
|
298
|
+
attempts: delivered,
|
|
299
|
+
error: error.message,
|
|
300
|
+
exception: error.name
|
|
301
|
+
}).dead_letter;
|
|
302
|
+
const bytes = bodyBytes(message);
|
|
303
|
+
if (bytes === void 0) throw new import_core.BabelQueueError(UNKNOWN_BODY_BYTES);
|
|
304
|
+
const record = (0, import_core.buildPoisonRecord)(bytes, JSON.stringify(deadLetter));
|
|
305
|
+
await sender.sendMessages({ body: Buffer.from(record, "utf8"), contentType: "application/json" });
|
|
306
|
+
} catch (writeError) {
|
|
307
|
+
this.options.onError?.(writeError, envelope, message);
|
|
308
|
+
await this.receiver.abandonMessage(message);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
await this.receiver.completeMessage(message);
|
|
312
|
+
}
|
|
203
313
|
};
|
|
204
314
|
// Annotate the CommonJS export names for ESM import in node:
|
|
205
315
|
0 && (module.exports = {
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts","../src/headers.ts"],"sourcesContent":["/**\n * Azure Service Bus adapter for BabelQueue.\n *\n * A canonical-envelope **publisher** and a URN-routed **consumer** over Azure Service\n * Bus, so an ASB-based Node service speaks the same contract (envelope shape, URN\n * identity, trace propagation) as the .NET, Java, Python and Go SDKs.\n *\n * import { ServiceBusClient } from \"@azure/service-bus\";\n * import { AsbPublisher, AsbConsumer } from \"@babelqueue/azure-service-bus\";\n *\n * const client = new ServiceBusClient(connectionString);\n * await new AsbPublisher(client.createSender(\"orders\"))\n * .publish(\"urn:babel:orders:created\", { order_id: 1042 });\n *\n * const consumer = new AsbConsumer(client.createReceiver(\"orders\"), {\n * \"urn:babel:orders:created\": async (env) => { ... },\n * });\n * await consumer.run();\n *\n * This implements §4 of the broker-bindings contract: the canonical envelope is the\n * message body, projected onto native Service Bus fields (`subject` = URN,\n * `correlationId` = trace_id, `messageId` = meta.id, plus the `bq-` application\n * properties). The envelope is unchanged (`schema_version` stays 1); ASB is purely\n * additive. Retry is broker-native — a failed handler `abandon`s the message, so it is\n * redelivered and `deliveryCount` is incremented; the authoritative attempt count is the\n * native `deliveryCount`, surfaced to handlers as `attempts = deliveryCount − 1`.\n *\n * **Version gate (§4.7).** Before the body is decoded the consumer reads the `bq-schema-version`\n * application property. A missing or blank value (empty or only ASCII whitespace) changes nothing;\n * the string `\"1\"` and the integral number `1` (what the producer writes) decode; any other value\n * or type (`\"2\"`, `\"x\"`, `\"01\"`, `2`, `1.5`, ...) rejects the message undecoded — `onError` (with an\n * empty envelope) and an explicit `deadLetterMessage` (reason `unsupported schema version`), never\n * `abandon`, so the message is not redelivered to the same consumer.\n *\n * **Out-of-band headers (ADR-0028).** A `headers` carrier (e.g. a W3C `traceparent` for cross-hop\n * span linkage) rides as additional native `applicationProperties` beside the contract `bq-*`\n * properties — the contract properties win a key collision (merge-not-clobber), blanks dropped. On\n * consume the inbound `applicationProperties` are surfaced as a `Record<string,string>` so the\n * core's `otel` extract sees the `traceparent` and links the consumer span as a child. A\n * header-less publish is byte-identical. GR-1: the wire envelope body is never touched.\n */\n\nimport { BabelQueueError, EnvelopeCodec, SCHEMA_VERSION, UnknownUrnError } from \"@babelqueue/core\";\nimport type { Envelope, HeaderCarrier, IncomingEnvelope } from \"@babelqueue/core\";\n\nimport { sanitizeHeaders } from \"./headers.js\";\n\n// --- Minimal Service Bus shapes (a structural subset of @azure/service-bus) -----\n\n/** A message to send (structural subset of @azure/service-bus `ServiceBusMessage`). */\nexport interface AsbMessage {\n body: unknown;\n subject?: string;\n correlationId?: string;\n messageId?: string;\n contentType?: string;\n applicationProperties?: { [key: string]: number | boolean | string | Date | null };\n scheduledEnqueueTimeUtc?: Date;\n}\n\n/** A received message (structural subset of @azure/service-bus `ServiceBusReceivedMessage`). */\nexport interface AsbReceivedMessage {\n body: unknown;\n subject?: string;\n correlationId?: string;\n messageId?: string;\n deliveryCount?: number;\n applicationProperties?: { [key: string]: unknown };\n}\n\n/**\n * The subset of the Service Bus sender this adapter calls. A `ServiceBusSender` from\n * `@azure/service-bus` satisfies it structurally; a fake satisfies it in tests.\n */\nexport interface AsbSender {\n entityPath: string;\n sendMessages(message: AsbMessage): Promise<void>;\n scheduleMessages(message: AsbMessage, scheduledEnqueueTimeUtc: Date): Promise<bigint[]>;\n}\n\n/**\n * The subset of the Service Bus receiver this adapter calls. A `ServiceBusReceiver`\n * from `@azure/service-bus` satisfies it structurally; a fake satisfies it in tests.\n */\nexport interface AsbReceiver {\n receiveMessages(maxMessageCount: number, options?: { maxWaitTimeInMs?: number }): Promise<AsbReceivedMessage[]>;\n completeMessage(message: AsbReceivedMessage): Promise<void>;\n abandonMessage(message: AsbReceivedMessage): Promise<void>;\n /**\n * Move a message to the entity's dead-letter sub-queue. A `ServiceBusReceiver` from\n * `@azure/service-bus` provides it. Optional so existing structural implementations keep\n * compiling; a receiver without it cannot dead-letter, so an unsupported-schema-version\n * message degrades to `abandonMessage` (the broker's `MaxDeliveryCount` then dead-letters it).\n */\n deadLetterMessage?(\n message: AsbReceivedMessage,\n options?: { deadLetterReason?: string; deadLetterErrorDescription?: string },\n ): Promise<void>;\n}\n\n/** Blank = empty or only ASCII whitespace (space, \\t, \\n, \\v, \\f, \\r) — not `String.prototype.trim()`'s wider set. */\nconst BLANK_ASCII = /^[ \\t\\n\\v\\f\\r]*$/;\n\n/** Reason recorded on a message dead-lettered by the version gate. */\nconst UNSUPPORTED_SCHEMA_VERSION_REASON = \"unsupported schema version\";\n\n/** Longest rejected value echoed into reports; Service Bus caps the dead-letter description at 4096 chars. */\nconst MAX_REPORTED_SCHEMA_VERSION_LENGTH = 64;\n\n/**\n * The `bq-schema-version` application property rendered for reporting when it is present, non-blank\n * and not the supported version (compared against the core's `SCHEMA_VERSION`). The string must be\n * exactly `\"1\"` (`\"01\"`, `\" 1\"`, `\"1.0\"` are rejected, never trimmed); blank means the empty string\n * or only ASCII whitespace (the same set in every SDK, GR-5), while NBSP, U+0085, U+FEFF, U+3000\n * and the like are not blank. AMQP-typed numbers are accepted when they are the integer 1 (a JS\n * `number`, or the `bigint` an int64 may surface as); every other number or type is rejected.\n * `undefined` when the message may be decoded.\n */\nfunction unsupportedSchemaVersion(message: AsbReceivedMessage): string | undefined {\n const raw = message.applicationProperties?.[\"bq-schema-version\"];\n if (raw == null) return undefined;\n if (typeof raw === \"string\") {\n if (BLANK_ASCII.test(raw)) return undefined;\n return raw === String(SCHEMA_VERSION) ? undefined : raw;\n }\n if (typeof raw === \"number\") return raw === SCHEMA_VERSION ? undefined : String(raw);\n if (typeof raw === \"bigint\") return raw === BigInt(SCHEMA_VERSION) ? undefined : String(raw);\n return `${typeof raw}:${String(raw)}`;\n}\n\n// --- Native projection (contract §4.2–§4.3) ------------------------------------\n\n/**\n * Project the envelope's contract fields onto a native Service Bus message — `subject`\n * = URN, `correlationId` = trace_id, `messageId` = meta.id, plus the `bq-` application\n * properties. The body stays authoritative.\n *\n * When `extra` is given, those out-of-band headers (e.g. a W3C `traceparent`, ADR-0028) are merged\n * into `applicationProperties` **first**, then the contract `bq-*` properties are written so they\n * always win a key collision (merge-not-clobber); blank keys/values are dropped. GR-1: the wire\n * envelope body is never touched.\n */\nexport function toServiceBusMessage(envelope: Envelope, extra?: HeaderCarrier | null): AsbMessage {\n const applicationProperties: { [key: string]: number | boolean | string } = {};\n // Out-of-band riders go in first so the contract bq-* properties below win any key collision.\n for (const [key, value] of Object.entries(sanitizeHeaders(extra))) applicationProperties[key] = value;\n if (envelope.meta.schema_version != null) {\n applicationProperties[\"bq-schema-version\"] = envelope.meta.schema_version;\n }\n if (envelope.meta.lang) applicationProperties[\"bq-source-lang\"] = envelope.meta.lang;\n if (envelope.meta.created_at != null) {\n applicationProperties[\"bq-created-at\"] = envelope.meta.created_at;\n }\n\n const message: AsbMessage = {\n body: EnvelopeCodec.encode(envelope),\n contentType: \"application/json\",\n };\n if (envelope.job) message.subject = envelope.job;\n if (envelope.trace_id) message.correlationId = envelope.trace_id;\n if (envelope.meta.id) message.messageId = envelope.meta.id;\n if (Object.keys(applicationProperties).length > 0) {\n message.applicationProperties = applicationProperties;\n }\n return message;\n}\n\n/**\n * Read a received message's `applicationProperties` back into a flat {@link HeaderCarrier},\n * stringifying values defensively. Returns an empty object when there are none. Both the contract\n * `bq-*` properties and any out-of-band rider (e.g. `traceparent`) surface — the core's `otel`\n * extract reads only the keys it knows.\n */\nexport function headersOf(message: AsbReceivedMessage): HeaderCarrier {\n const props = message.applicationProperties;\n const out: HeaderCarrier = {};\n if (!props) return out;\n for (const key of Object.keys(props)) {\n const value = props[key];\n if (value == null) continue;\n const s = String(value);\n if (s !== \"\") out[key] = s;\n }\n return out;\n}\n\n// --- Publisher -----------------------------------------------------------------\n\n/** Options for {@link AsbPublisher.publish}. */\nexport interface PublishOptions {\n /** Reuse an existing trace id (trace continuation). */\n traceId?: string;\n /** Schedule native delayed delivery this many milliseconds from now (`scheduledEnqueueTimeUtc`). */\n delayMs?: number;\n /**\n * Out-of-band transport headers carried as native `applicationProperties` beside the contract\n * `bq-*` properties (ADR-0028) — e.g. a W3C `traceparent` written by `@babelqueue/core/otel`'s\n * `publish`. The contract properties win a key collision; an empty/omitted carrier leaves the\n * message unchanged.\n */\n headers?: HeaderCarrier;\n}\n\n/** Sends canonical-envelope messages to one Service Bus entity with the §4 native projection. */\nexport class AsbPublisher {\n constructor(private readonly sender: AsbSender) {}\n\n /**\n * Build the canonical envelope for `(urn, data)`, send it with the native projection,\n * and return the message id (`meta.id`). A positive `delayMs` schedules native delayed\n * delivery.\n */\n async publish(\n urn: string,\n data: Record<string, unknown>,\n options: PublishOptions = {},\n ): Promise<string> {\n const envelope = EnvelopeCodec.make(urn, data, {\n queue: this.sender.entityPath,\n traceId: options.traceId,\n });\n const message = toServiceBusMessage(envelope, options.headers);\n\n if (options.delayMs != null && options.delayMs > 0) {\n message.applicationProperties = { ...message.applicationProperties, \"bq-delay\": options.delayMs };\n await this.sender.scheduleMessages(message, new Date(Date.now() + options.delayMs));\n } else {\n await this.sender.sendMessages(message);\n }\n return envelope.meta.id;\n }\n}\n\n// --- Consumer ------------------------------------------------------------------\n\n/**\n * A URN handler. Receives the validated envelope, the raw Service Bus message, and the out-of-band\n * {@link HeaderCarrier} read from the message's `applicationProperties` (empty when there are none).\n * Pass `headers` to `@babelqueue/core/otel`'s `wrapHandler` to link the consumer span as a child of\n * the producer span (ADR-0028).\n */\nexport type BabelHandler = (envelope: Envelope, message: AsbReceivedMessage, headers: HeaderCarrier) => unknown | Promise<unknown>;\n\n/** A map of URN → handler. */\nexport type BabelHandlers = Record<string, BabelHandler>;\n\n/** Options for {@link AsbConsumer}. */\nexport interface AsbConsumerOptions {\n /** Called instead of erroring when a message's URN has no handler (then the message is completed). */\n onUnknownUrn?: (envelope: IncomingEnvelope, message: AsbReceivedMessage) => unknown | Promise<unknown>;\n /** Called for a non-conformant envelope, an unmapped URN (no `onUnknownUrn`), or a throwing handler. The loop never stops. */\n onError?: (error: unknown, envelope: IncomingEnvelope, message: AsbReceivedMessage) => void;\n /** Max messages per receive (default 10). */\n maxMessages?: number;\n /** Max time to wait for a batch, in ms. */\n maxWaitTimeInMs?: number;\n}\n\n/**\n * Receives from a Service Bus entity (PeekLock), decodes + validates each message, routes\n * it to the handler for its URN, and completes it on success. A throwing handler abandons\n * the message — the broker redelivers it and increments `deliveryCount` (at-least-once);\n * `attempts` is reconciled to `deliveryCount − 1` for the handler.\n */\nexport class AsbConsumer {\n constructor(\n private readonly receiver: AsbReceiver,\n private readonly handlers: BabelHandlers,\n private readonly options: AsbConsumerOptions = {},\n ) {}\n\n /** Receive one batch, route each message, settle each. Returns the batch size. */\n async poll(): Promise<number> {\n const messages = await this.receiver.receiveMessages(\n this.options.maxMessages ?? 10,\n this.options.maxWaitTimeInMs != null ? { maxWaitTimeInMs: this.options.maxWaitTimeInMs } : undefined,\n );\n for (const message of messages) {\n await this.handle(message);\n }\n return messages.length;\n }\n\n /** Poll until `signal` aborts. */\n async run(signal?: AbortSignal): Promise<void> {\n while (signal?.aborted !== true) {\n await this.poll();\n }\n }\n\n private async handle(message: AsbReceivedMessage): Promise<void> {\n // §4.7: gate on bq-schema-version before the body is decoded. The message is dead-lettered\n // explicitly (never abandoned) so it does not loop back to the same consumer.\n const rejectedVersion = unsupportedSchemaVersion(message);\n if (rejectedVersion !== undefined) {\n const reported =\n rejectedVersion.length > MAX_REPORTED_SCHEMA_VERSION_LENGTH\n ? `${rejectedVersion.slice(0, MAX_REPORTED_SCHEMA_VERSION_LENGTH)}...`\n : rejectedVersion;\n const description = `Rejected a message from Azure Service Bus with unsupported bq-schema-version \"${reported}\" (supported: ${SCHEMA_VERSION}).`;\n const deadLetter = this.receiver.deadLetterMessage?.bind(this.receiver);\n this.options.onError?.(\n new BabelQueueError(\n deadLetter ? description : `${description} The receiver has no deadLetterMessage; degraded to abandon.`,\n ),\n {},\n message,\n );\n if (deadLetter) {\n await deadLetter(message, {\n deadLetterReason: UNSUPPORTED_SCHEMA_VERSION_REASON,\n deadLetterErrorDescription: description,\n });\n } else {\n await this.receiver.abandonMessage(message);\n }\n return;\n }\n\n const raw = typeof message.body === \"string\" ? message.body : String(message.body ?? \"\");\n const envelope = EnvelopeCodec.decode(raw);\n\n // attempts = max(current, deliveryCount − 1): deliveryCount (1-based) is the native\n // redelivery floor; the max never lowers a higher body count republished from another SDK.\n const deliveryCount = typeof message.deliveryCount === \"number\" ? message.deliveryCount : 0;\n const native = deliveryCount - 1;\n const current = typeof envelope.attempts === \"number\" ? envelope.attempts : 0;\n if (native > current) {\n envelope.attempts = native;\n }\n\n if (!EnvelopeCodec.accepts(envelope)) {\n this.options.onError?.(\n new BabelQueueError(\"Rejected a non-conformant BabelQueue envelope from Azure Service Bus.\"),\n envelope,\n message,\n );\n await this.receiver.abandonMessage(message);\n return;\n }\n\n const urn = EnvelopeCodec.urn(envelope);\n const handler = this.handlers[urn];\n if (!handler) {\n if (this.options.onUnknownUrn) {\n await this.options.onUnknownUrn(envelope, message);\n await this.receiver.completeMessage(message);\n } else {\n this.options.onError?.(new UnknownUrnError(urn), envelope, message);\n await this.receiver.abandonMessage(message);\n }\n return;\n }\n\n try {\n await handler(envelope, message, headersOf(message));\n await this.receiver.completeMessage(message);\n } catch (error) {\n // Abandon releases the lock — the broker redelivers and increments deliveryCount.\n this.options.onError?.(error, envelope, message);\n await this.receiver.abandonMessage(message);\n }\n }\n}\n","/**\n * Pure, dependency-free helpers for the out-of-band transport-header seam (ADR-0028) — the Node\n * adapter mirror of Go's `sanitizeHeaders` and PHP's `Support\\Headers`. They fold an injected\n * {@link HeaderCarrier} (e.g. a W3C `traceparent`) onto a transport's metadata channel **without\n * clobbering** the contract headers it already carries.\n *\n * Headers ride beside the frozen wire envelope (GR-1), never inside it.\n */\n\nimport type { HeaderCarrier } from \"@babelqueue/core\";\n\n/**\n * Copy `headers`, dropping blank keys and blank values. Returns an empty object when nothing\n * survives. A nullish input yields an empty object.\n */\nexport function sanitizeHeaders(headers: HeaderCarrier | null | undefined): HeaderCarrier {\n const out: HeaderCarrier = {};\n if (!headers) return out;\n for (const key of Object.keys(headers)) {\n const value = headers[key];\n if (key === \"\" || value == null || value === \"\") continue;\n out[key] = value;\n }\n return out;\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/headers.ts"],"sourcesContent":["/**\n * Azure Service Bus adapter for BabelQueue.\n *\n * A canonical-envelope **publisher** and a URN-routed **consumer** over Azure Service\n * Bus, so an ASB-based Node service speaks the same contract (envelope shape, URN\n * identity, trace propagation) as the .NET, Java, Python and Go SDKs.\n *\n * import { ServiceBusClient } from \"@azure/service-bus\";\n * import { AsbPublisher, AsbConsumer } from \"@babelqueue/azure-service-bus\";\n *\n * const client = new ServiceBusClient(connectionString);\n * await new AsbPublisher(client.createSender(\"orders\"))\n * .publish(\"urn:babel:orders:created\", { order_id: 1042 });\n *\n * const consumer = new AsbConsumer(client.createReceiver(\"orders\"), {\n * \"urn:babel:orders:created\": async (env) => { ... },\n * });\n * await consumer.run();\n *\n * This implements §4 of the broker-bindings contract: the canonical envelope is the\n * message body, projected onto native Service Bus fields (`subject` = URN,\n * `correlationId` = trace_id, `messageId` = meta.id, plus the `bq-` application\n * properties). The envelope is unchanged (`schema_version` stays 1); ASB is purely\n * additive. Retry is broker-native — a failed handler `abandon`s the message, so it is\n * redelivered and `deliveryCount` is incremented; the authoritative attempt count is the\n * native `deliveryCount`, surfaced to handlers as `attempts = deliveryCount − 1`.\n *\n * **Version gate (§4.7, ADR-0037 decision 1).** Before the body is decoded the consumer reads the `bq-schema-version`\n * application property. A missing or blank value (empty or only ASCII whitespace) changes nothing;\n * the string `\"1\"` and the integral number `1` (what the producer writes) decode; any other value\n * or type (`\"2\"`, `\"x\"`, `\"01\"`, `2`, `1.5`, ...) rejects the message undecoded (`onError` with an empty\n * envelope) and settles it on its **first delivery**: never decoded, never given to a handler or `onUnknownUrn`,\n * never retried. With a `deadLetterSender` (`<queue>.dlq`) the body, byte for byte and without a `dead_letter`\n * block, is sent there and the message completed only after the send succeeded (a failed send abandons it, so\n * the write is tried again); without one it is left to the **native** mechanism — an explicit `deadLetterMessage`\n * (reason `unsupported schema version`) into `$DeadLetterQueue`, or `abandon` when the receiver cannot dead-letter.\n * This consumer has no unknown-URN strategy option and an `onUnknownUrn` hook never applies here: it behaves as `fail`.\n *\n * **Poison (ADR-0037 decision 2, ADR-0035).** A body that does not decode into a valid envelope is never given\n * to a handler. With a `deadLetterSender` the ADR-0035 record (`reason: \"poison\"`, built with the core's\n * `buildPoisonRecord`) is sent to `<queue>.dlq` and the message completed only after the send; without one it is\n * abandoned, so `MaxDeliveryCount` moves it to the native dead-letter sub-queue.\n *\n * **Out-of-band headers (ADR-0028).** A `headers` carrier (e.g. a W3C `traceparent` for cross-hop\n * span linkage) rides as additional native `applicationProperties` beside the contract `bq-*`\n * properties — the contract properties win a key collision (merge-not-clobber), blanks dropped. On\n * consume the inbound `applicationProperties` are surfaced as a `Record<string,string>` so the\n * core's `otel` extract sees the `traceparent` and links the consumer span as a child. A\n * header-less publish is byte-identical. GR-1: the wire envelope body is never touched.\n */\n\nimport { BabelQueueError, EnvelopeCodec, SCHEMA_VERSION, UnknownUrnError, annotate, buildPoisonRecord } from \"@babelqueue/core\";\nimport type { Envelope, HeaderCarrier, IncomingEnvelope } from \"@babelqueue/core\";\n\nimport { sanitizeHeaders } from \"./headers.js\";\n\n// --- Minimal Service Bus shapes (a structural subset of @azure/service-bus) -----\n\n/** A message to send (structural subset of @azure/service-bus `ServiceBusMessage`). */\nexport interface AsbMessage {\n body: unknown;\n subject?: string;\n correlationId?: string;\n messageId?: string;\n contentType?: string;\n applicationProperties?: { [key: string]: number | boolean | string | Date | null };\n scheduledEnqueueTimeUtc?: Date;\n}\n\n/** A received message (structural subset of @azure/service-bus `ServiceBusReceivedMessage`). */\nexport interface AsbReceivedMessage {\n body: unknown;\n contentType?: string;\n subject?: string;\n correlationId?: string;\n messageId?: string;\n deliveryCount?: number;\n applicationProperties?: { [key: string]: unknown };\n /**\n * Optional (a `ServiceBusReceivedMessage` has it, `readonly _rawAmqpMessage: AmqpAnnotatedMessage`): the raw AMQP\n * message the SDK decoded this one from. Its `body` is rhea's undecoded view: for a data section a\n * `Section { typecode: 0x75, content: Buffer }` (`content` an array of Buffers when there are several sections), for an\n * amqp-value section the plain value itself (rhea does not wrap an amqp-value: a string or a Buffer), whereas {@link body} is the SDK's JSON-parsed view (unless the receiver\n * was created with `skipParsingBodyAsJson: true`). The consumer reads the exact bytes from here.\n */\n _rawAmqpMessage?: { body?: unknown; bodyType?: string };\n}\n\n/**\n * The subset of the Service Bus sender this adapter calls. A `ServiceBusSender` from\n * `@azure/service-bus` satisfies it structurally; a fake satisfies it in tests.\n */\nexport interface AsbSender {\n entityPath: string;\n sendMessages(message: AsbMessage): Promise<void>;\n scheduleMessages(message: AsbMessage, scheduledEnqueueTimeUtc: Date): Promise<bigint[]>;\n}\n\n/**\n * The subset of the Service Bus receiver this adapter calls. A `ServiceBusReceiver`\n * from `@azure/service-bus` satisfies it structurally; a fake satisfies it in tests.\n */\nexport interface AsbReceiver {\n /** Optional (a `ServiceBusReceiver` has it): the entity this receiver reads, the default `original_queue` of a poison record. */\n entityPath?: string;\n receiveMessages(maxMessageCount: number, options?: { maxWaitTimeInMs?: number }): Promise<AsbReceivedMessage[]>;\n completeMessage(message: AsbReceivedMessage): Promise<void>;\n abandonMessage(message: AsbReceivedMessage): Promise<void>;\n /**\n * Move a message to the entity's dead-letter sub-queue. A `ServiceBusReceiver` from\n * `@azure/service-bus` provides it. Optional so existing structural implementations keep\n * compiling; a receiver without it cannot dead-letter, so an unsupported-schema-version\n * message degrades to `abandonMessage` (the broker's `MaxDeliveryCount` then dead-letters it).\n */\n deadLetterMessage?(\n message: AsbReceivedMessage,\n options?: { deadLetterReason?: string; deadLetterErrorDescription?: string },\n ): Promise<void>;\n}\n\n/** Blank = empty or only ASCII whitespace (space, \\t, \\n, \\v, \\f, \\r) — not `String.prototype.trim()`'s wider set. */\nconst BLANK_ASCII = /^[ \\t\\n\\v\\f\\r]*$/;\n\n/**\n * Reported (and the message abandoned, never completed) when a DLQ record cannot be written byte-faithfully: the receiver\n * parsed the body and the message exposes no raw AMQP view to recover the bytes from. Only a receiver or SDK build that\n * hides both is affected; `skipParsingBodyAsJson: true` always delivers exact bytes.\n */\nconst UNKNOWN_BODY_BYTES =\n \"The message body was already parsed from JSON by the receiver and the message exposes no raw AMQP body, so its exact bytes are \" +\n \"unknown and no DLQ record is written. Create the receiver with skipParsingBodyAsJson: true.\";\n\n/** Reason recorded on a message dead-lettered by the version gate. */\nconst UNSUPPORTED_SCHEMA_VERSION_REASON = \"unsupported schema version\";\n\n/** Longest rejected value echoed into reports; Service Bus caps the dead-letter description at 4096 chars. */\nconst MAX_REPORTED_SCHEMA_VERSION_LENGTH = 64;\n\n/**\n * The `bq-schema-version` application property rendered for reporting when it is present, non-blank\n * and not the supported version (compared against the core's `SCHEMA_VERSION`). The string must be\n * exactly `\"1\"` (`\"01\"`, `\" 1\"`, `\"1.0\"` are rejected, never trimmed); blank means the empty string\n * or only ASCII whitespace (the same set in every SDK, GR-5), while NBSP, U+0085, U+FEFF, U+3000\n * and the like are not blank. AMQP-typed numbers are accepted when they are the integer 1 (a JS\n * `number`, or the `bigint` an int64 may surface as); every other number or type is rejected.\n * `undefined` when the message may be decoded.\n */\nfunction unsupportedSchemaVersion(message: AsbReceivedMessage): string | undefined {\n const raw = message.applicationProperties?.[\"bq-schema-version\"];\n if (raw == null) return undefined;\n if (typeof raw === \"string\") {\n if (BLANK_ASCII.test(raw)) return undefined;\n return raw === String(SCHEMA_VERSION) ? undefined : raw;\n }\n if (typeof raw === \"number\") return raw === SCHEMA_VERSION ? undefined : String(raw);\n if (typeof raw === \"bigint\") return raw === BigInt(SCHEMA_VERSION) ? undefined : String(raw);\n return `${typeof raw}:${String(raw)}`;\n}\n\n// --- Native projection (contract §4.2–§4.3) ------------------------------------\n\n/**\n * Project the envelope's contract fields onto a native Service Bus message — `subject`\n * = URN, `correlationId` = trace_id, `messageId` = meta.id, plus the `bq-` application\n * properties. The body stays authoritative.\n *\n * When `extra` is given, those out-of-band headers (e.g. a W3C `traceparent`, ADR-0028) are merged\n * into `applicationProperties` **first**, then the contract `bq-*` properties are written so they\n * always win a key collision (merge-not-clobber); blank keys/values are dropped. GR-1: the wire\n * envelope body is never touched.\n */\nexport function toServiceBusMessage(envelope: Envelope, extra?: HeaderCarrier | null): AsbMessage {\n const applicationProperties: { [key: string]: number | boolean | string } = {};\n // Out-of-band riders go in first so the contract bq-* properties below win any key collision.\n for (const [key, value] of Object.entries(sanitizeHeaders(extra))) applicationProperties[key] = value;\n if (envelope.meta.schema_version != null) {\n applicationProperties[\"bq-schema-version\"] = envelope.meta.schema_version;\n }\n if (envelope.meta.lang) applicationProperties[\"bq-source-lang\"] = envelope.meta.lang;\n if (envelope.meta.created_at != null) {\n applicationProperties[\"bq-created-at\"] = envelope.meta.created_at;\n }\n\n const message: AsbMessage = {\n // Bytes, never a string: `@azure/service-bus` JSON-encodes every body that is not a Buffer / Uint8Array, so a\n // string would reach the wire as a quoted JSON string literal and the other SDKs would not read an envelope (GR-5).\n body: Buffer.from(EnvelopeCodec.encode(envelope), \"utf8\"),\n contentType: \"application/json\",\n };\n if (envelope.job) message.subject = envelope.job;\n if (envelope.trace_id) message.correlationId = envelope.trace_id;\n if (envelope.meta.id) message.messageId = envelope.meta.id;\n if (Object.keys(applicationProperties).length > 0) {\n message.applicationProperties = applicationProperties;\n }\n return message;\n}\n\n/**\n * Read a received message's `applicationProperties` back into a flat {@link HeaderCarrier},\n * stringifying values defensively. Returns an empty object when there are none. Both the contract\n * `bq-*` properties and any out-of-band rider (e.g. `traceparent`) surface — the core's `otel`\n * extract reads only the keys it knows.\n */\nexport function headersOf(message: AsbReceivedMessage): HeaderCarrier {\n const props = message.applicationProperties;\n const out: HeaderCarrier = {};\n if (!props) return out;\n for (const key of Object.keys(props)) {\n const value = props[key];\n if (value == null) continue;\n const s = String(value);\n if (s !== \"\") out[key] = s;\n }\n return out;\n}\n\n// --- Publisher -----------------------------------------------------------------\n\n/** Options for {@link AsbPublisher.publish}. */\nexport interface PublishOptions {\n /** Reuse an existing trace id (trace continuation). */\n traceId?: string;\n /** Schedule native delayed delivery this many milliseconds from now (`scheduledEnqueueTimeUtc`). */\n delayMs?: number;\n /**\n * Out-of-band transport headers carried as native `applicationProperties` beside the contract\n * `bq-*` properties (ADR-0028) — e.g. a W3C `traceparent` written by `@babelqueue/core/otel`'s\n * `publish`. The contract properties win a key collision; an empty/omitted carrier leaves the\n * message unchanged.\n */\n headers?: HeaderCarrier;\n}\n\n/** Sends canonical-envelope messages to one Service Bus entity with the §4 native projection. */\nexport class AsbPublisher {\n constructor(private readonly sender: AsbSender) {}\n\n /**\n * Build the canonical envelope for `(urn, data)`, send it with the native projection,\n * and return the message id (`meta.id`). A positive `delayMs` schedules native delayed\n * delivery.\n */\n async publish(\n urn: string,\n data: Record<string, unknown>,\n options: PublishOptions = {},\n ): Promise<string> {\n const envelope = EnvelopeCodec.make(urn, data, {\n queue: this.sender.entityPath,\n traceId: options.traceId,\n });\n const message = toServiceBusMessage(envelope, options.headers);\n\n if (options.delayMs != null && options.delayMs > 0) {\n message.applicationProperties = { ...message.applicationProperties, \"bq-delay\": options.delayMs };\n await this.sender.scheduleMessages(message, new Date(Date.now() + options.delayMs));\n } else {\n await this.sender.sendMessages(message);\n }\n return envelope.meta.id;\n }\n}\n\n// --- Consumer ------------------------------------------------------------------\n\n/**\n * The exact bytes held by the raw AMQP body (`_rawAmqpMessage.body`), or `undefined` when it is absent or has a shape this\n * adapter does not read. rhea hands over a decoded section as `{ typecode, content }`:\n * - `0x75` (data): `content` is the `Buffer` received; several data sections arrive as an array of Buffers, which are\n * concatenated (the AMQP body is the sequence of the sections' bytes);\n * - an amqp-value is not wrapped by rhea: it arrives as the plain value, a string (its UTF-8 bytes) or a Buffer (its bytes);\n * - `0x77` `{ typecode, content }` (defensive only, not a shape rhea produces): a string or binary `content` is read the same way;\n * - `0x76` (amqp-sequence) and everything else are unknown.\n * Unknown shapes leave the bytes unknown, and\n * the caller falls back to `message.body` (then to the safe path: report and abandon).\n */\nfunction rawAmqpBodyBytes(message: AsbReceivedMessage): Uint8Array | undefined {\n let raw: unknown;\n try {\n raw = message._rawAmqpMessage?.body;\n } catch {\n return undefined;\n }\n return sectionBytes(raw);\n}\n\nfunction sectionBytes(raw: unknown): Uint8Array | undefined {\n if (raw instanceof Uint8Array) return raw;\n if (typeof raw === \"string\") return Buffer.from(raw, \"utf8\");\n if (raw === null || typeof raw !== \"object\") return undefined;\n const { typecode, content } = raw as { typecode?: unknown; content?: unknown };\n if (typecode === 0x75) {\n if (content instanceof Uint8Array) return content;\n if (Array.isArray(content) && content.length > 0 && content.every((part) => part instanceof Uint8Array)) {\n return Buffer.concat(content as Uint8Array[]);\n }\n return undefined;\n }\n if (typecode === 0x77) {\n if (typeof content === \"string\") return Buffer.from(content, \"utf8\");\n if (content instanceof Uint8Array) return content;\n }\n return undefined;\n}\n\n/**\n * The exact bytes of a received body, or `undefined` when they are not known. `@azure/service-bus` parses a data-section\n * body that holds valid JSON into an object / array / number unless the receiver is created with\n * `skipParsingBodyAsJson: true`, so `message.body` alone may have lost the original bytes (whitespace, number forms,\n * key order). The raw AMQP message the SDK keeps (`_rawAmqpMessage`) still holds them, so it is read first (see\n * {@link rawAmqpBodyBytes} for the rhea section shapes). Otherwise a\n * string `message.body` is its UTF-8 encoding, a `Buffer` / `Uint8Array` (a binary body, or any body with\n * `skipParsingBodyAsJson: true`) is returned as is, and an empty body is no bytes. A body that was parsed and has no raw\n * view has no known bytes.\n */\nfunction bodyBytes(message: AsbReceivedMessage): Uint8Array | undefined {\n const raw = rawAmqpBodyBytes(message);\n if (raw !== undefined) return raw;\n const body = message.body;\n if (body == null) return new Uint8Array(0);\n if (typeof body === \"string\") return Buffer.from(body, \"utf8\");\n if (body instanceof Uint8Array) return body;\n return undefined;\n}\n\n/**\n * Migration step for the double-encoded body that releases before this fix published: `@azure/service-bus` JSON-encoded\n * the envelope *string*, so the data section held a quoted JSON string literal (`\"{\\\"job\\\":...}\"`). Only a body that is\n * a quoted JSON string **and** whose content decodes into a valid envelope is opened (once); anything else, a quoted\n * string that is not an envelope included, is returned unchanged and takes the normal (poison) path.\n */\nfunction unwrapLegacyQuotedEnvelope(text: string): string {\n if (!text.startsWith('\"')) return text;\n try {\n const inner: unknown = JSON.parse(text);\n if (typeof inner === \"string\" && EnvelopeCodec.accepts(EnvelopeCodec.decode(inner))) return inner;\n } catch {\n // not a JSON string literal\n }\n return text;\n}\n\n/**\n * The text an envelope is decoded from: the exact bytes when known (the raw AMQP body, or a `skipParsingBodyAsJson: true`\n * receiver's `Buffer`), the string a default receiver parsed out of a legacy quoted literal, or the JSON of an\n * already-parsed body. A legacy double-encoded body is opened by {@link unwrapLegacyQuotedEnvelope} on every path.\n */\nfunction bodyText(message: AsbReceivedMessage): string {\n const bytes = bodyBytes(message);\n if (bytes !== undefined) return unwrapLegacyQuotedEnvelope(Buffer.from(bytes).toString(\"utf8\"));\n return JSON.stringify(message.body) ?? \"\";\n}\n\n/** `orders.dlq` -> `orders`: the queue a DLQ entity belongs to (a name without the suffix is kept). */\nfunction stripDlqSuffix(entityPath: string): string {\n return entityPath.endsWith(\".dlq\") && entityPath.length > 4 ? entityPath.slice(0, -4) : entityPath;\n}\n\n/**\n * A URN handler. Receives the validated envelope, the raw Service Bus message, and the out-of-band\n * {@link HeaderCarrier} read from the message's `applicationProperties` (empty when there are none).\n * Pass `headers` to `@babelqueue/core/otel`'s `wrapHandler` to link the consumer span as a child of\n * the producer span (ADR-0028).\n */\nexport type BabelHandler = (envelope: Envelope, message: AsbReceivedMessage, headers: HeaderCarrier) => unknown | Promise<unknown>;\n\n/** A map of URN → handler. */\nexport type BabelHandlers = Record<string, BabelHandler>;\n\n/** Options for {@link AsbConsumer}. */\nexport interface AsbConsumerOptions {\n /** Called instead of erroring when a message's URN has no handler (then the message is completed). */\n onUnknownUrn?: (envelope: IncomingEnvelope, message: AsbReceivedMessage) => unknown | Promise<unknown>;\n /** Called for a non-conformant envelope, an unmapped URN (no `onUnknownUrn`), or a throwing handler. The loop never stops. */\n onError?: (error: unknown, envelope: IncomingEnvelope, message: AsbReceivedMessage) => void;\n /** Max messages per receive (default 10). */\n maxMessages?: number;\n /** Max time to wait for a batch, in ms. */\n maxWaitTimeInMs?: number;\n /**\n * A sender on the cross-language `<queue>.dlq` entity (an ordinary Service Bus queue, §4.5); opt-in, default\n * none. With it a poison body (the ADR-0035 record) and a version-gated delivery (the raw body) are written\n * there and the source message is completed only after the send succeeded. Without it they are left to the\n * entity's native mechanism (`MaxDeliveryCount` / `$DeadLetterQueue`).\n */\n deadLetterSender?: AsbSender;\n /**\n * The queue this consumer reads, written to `dead_letter.original_queue` of a poison record. Defaults to the\n * receiver's `entityPath`, else the {@link deadLetterSender} entity path without its `.dlq` suffix.\n */\n queue?: string;\n}\n\n/**\n * Receives from a Service Bus entity (PeekLock), decodes + validates each message, routes\n * it to the handler for its URN, and completes it on success. A throwing handler abandons\n * the message — the broker redelivers it and increments `deliveryCount` (at-least-once);\n * `attempts` is reconciled to `deliveryCount − 1` for the handler.\n */\nexport class AsbConsumer {\n constructor(\n private readonly receiver: AsbReceiver,\n private readonly handlers: BabelHandlers,\n private readonly options: AsbConsumerOptions = {},\n ) {}\n\n /** Receive one batch, route each message, settle each. Returns the batch size. */\n async poll(): Promise<number> {\n const messages = await this.receiver.receiveMessages(\n this.options.maxMessages ?? 10,\n this.options.maxWaitTimeInMs != null ? { maxWaitTimeInMs: this.options.maxWaitTimeInMs } : undefined,\n );\n for (const message of messages) {\n await this.handle(message);\n }\n return messages.length;\n }\n\n /** Poll until `signal` aborts. */\n async run(signal?: AbortSignal): Promise<void> {\n while (signal?.aborted !== true) {\n await this.poll();\n }\n }\n\n private async handle(message: AsbReceivedMessage): Promise<void> {\n // §4.7: gate on bq-schema-version before the body is decoded; settled on the first delivery (ADR-0037).\n const rejectedVersion = unsupportedSchemaVersion(message);\n if (rejectedVersion !== undefined) {\n const reported =\n rejectedVersion.length > MAX_REPORTED_SCHEMA_VERSION_LENGTH\n ? `${rejectedVersion.slice(0, MAX_REPORTED_SCHEMA_VERSION_LENGTH)}...`\n : rejectedVersion;\n const description = `Rejected a message from Azure Service Bus with unsupported bq-schema-version \"${reported}\" (supported: ${SCHEMA_VERSION}).`;\n await this.settleVersionGated(message, description);\n return;\n }\n\n const envelope = EnvelopeCodec.decode(bodyText(message));\n\n // attempts = max(current, deliveryCount − 1): deliveryCount (1-based) is the native\n // redelivery floor; the max never lowers a higher body count republished from another SDK.\n const deliveryCount = typeof message.deliveryCount === \"number\" ? message.deliveryCount : 0;\n const native = deliveryCount - 1;\n const current = typeof envelope.attempts === \"number\" ? envelope.attempts : 0;\n if (native > current) {\n envelope.attempts = native;\n }\n\n if (!EnvelopeCodec.accepts(envelope)) {\n await this.settlePoison(\n message,\n envelope,\n new BabelQueueError(\"Rejected a non-conformant BabelQueue envelope from Azure Service Bus.\"),\n );\n return;\n }\n\n const urn = EnvelopeCodec.urn(envelope);\n const handler = this.handlers[urn];\n if (!handler) {\n if (this.options.onUnknownUrn) {\n await this.options.onUnknownUrn(envelope, message);\n await this.receiver.completeMessage(message);\n } else {\n this.options.onError?.(new UnknownUrnError(urn), envelope, message);\n await this.receiver.abandonMessage(message);\n }\n return;\n }\n\n try {\n await handler(envelope, message, headersOf(message));\n await this.receiver.completeMessage(message);\n } catch (error) {\n // Abandon releases the lock — the broker redelivers and increments deliveryCount.\n this.options.onError?.(error, envelope, message);\n await this.receiver.abandonMessage(message);\n }\n }\n\n /**\n * Version-gated delivery (ADR-0037 decision 1): with a {@link AsbConsumerOptions.deadLetterSender} the raw body goes\n * to `<queue>.dlq` and the message is completed only after the send (a failed send is reported and the message\n * abandoned); without one it is reported and left to the native mechanism.\n */\n private async settleVersionGated(message: AsbReceivedMessage, description: string): Promise<void> {\n const sender = this.options.deadLetterSender;\n if (sender === undefined) {\n const deadLetter = this.receiver.deadLetterMessage?.bind(this.receiver);\n this.options.onError?.(\n new BabelQueueError(\n deadLetter ? description : `${description} The receiver has no deadLetterMessage; degraded to abandon.`,\n ),\n {},\n message,\n );\n if (deadLetter) {\n await deadLetter(message, {\n deadLetterReason: UNSUPPORTED_SCHEMA_VERSION_REASON,\n deadLetterErrorDescription: description,\n });\n } else {\n await this.receiver.abandonMessage(message);\n }\n return;\n }\n this.options.onError?.(new BabelQueueError(description), {}, message);\n try {\n const bytes = bodyBytes(message);\n if (bytes === undefined) throw new BabelQueueError(UNKNOWN_BODY_BYTES);\n // Bytes, never a string: the SDK would JSON-encode a string into a quoted literal.\n const record: AsbMessage = { body: Buffer.from(bytes) };\n if (message.contentType !== undefined) record.contentType = message.contentType;\n await sender.sendMessages(record);\n } catch (writeError) {\n this.options.onError?.(writeError, {}, message);\n await this.receiver.abandonMessage(message); // unsettled: redelivered, the gate rejects it again, the write is retried\n return;\n }\n await this.receiver.completeMessage(message);\n }\n\n /**\n * Poison body (ADR-0037 decision 2): with a {@link AsbConsumerOptions.deadLetterSender} the ADR-0035 record goes to\n * `<queue>.dlq` and the message is completed only after the send (a failed send is reported and the message\n * abandoned); without one it is reported and abandoned, so `MaxDeliveryCount` moves it to the native sub-queue.\n */\n private async settlePoison(message: AsbReceivedMessage, envelope: IncomingEnvelope, error: BabelQueueError): Promise<void> {\n this.options.onError?.(error, envelope, message);\n const sender = this.options.deadLetterSender;\n if (sender === undefined) {\n await this.receiver.abandonMessage(message);\n return;\n }\n try {\n const delivered = Math.max(0, (typeof message.deliveryCount === \"number\" ? message.deliveryCount : 1) - 1); // informational\n const original = this.options.queue ?? this.receiver.entityPath ?? stripDlqSuffix(sender.entityPath);\n const deadLetter = annotate({ attempts: delivered } as unknown as Envelope, \"poison\", original, {\n attempts: delivered,\n error: error.message,\n exception: error.name,\n }).dead_letter;\n const bytes = bodyBytes(message);\n if (bytes === undefined) throw new BabelQueueError(UNKNOWN_BODY_BYTES);\n const record = buildPoisonRecord(bytes, JSON.stringify(deadLetter));\n // Bytes, never a string: the SDK would JSON-encode a string into a quoted literal.\n await sender.sendMessages({ body: Buffer.from(record, \"utf8\"), contentType: \"application/json\" });\n } catch (writeError) {\n this.options.onError?.(writeError, envelope, message);\n await this.receiver.abandonMessage(message);\n return;\n }\n await this.receiver.completeMessage(message);\n }\n}\n","/**\n * Pure, dependency-free helpers for the out-of-band transport-header seam (ADR-0028) — the Node\n * adapter mirror of Go's `sanitizeHeaders` and PHP's `Support\\Headers`. They fold an injected\n * {@link HeaderCarrier} (e.g. a W3C `traceparent`) onto a transport's metadata channel **without\n * clobbering** the contract headers it already carries.\n *\n * Headers ride beside the frozen wire envelope (GR-1), never inside it.\n */\n\nimport type { HeaderCarrier } from \"@babelqueue/core\";\n\n/**\n * Copy `headers`, dropping blank keys and blank values. Returns an empty object when nothing\n * survives. A nullish input yields an empty object.\n */\nexport function sanitizeHeaders(headers: HeaderCarrier | null | undefined): HeaderCarrier {\n const out: HeaderCarrier = {};\n if (!headers) return out;\n for (const key of Object.keys(headers)) {\n const value = headers[key];\n if (key === \"\" || value == null || value === \"\") continue;\n out[key] = value;\n }\n return out;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAmDA,kBAA6G;;;ACpCtG,SAAS,gBAAgB,SAA0D;AACxF,QAAM,MAAqB,CAAC;AAC5B,MAAI,CAAC,QAAS,QAAO;AACrB,aAAW,OAAO,OAAO,KAAK,OAAO,GAAG;AACtC,UAAM,QAAQ,QAAQ,GAAG;AACzB,QAAI,QAAQ,MAAM,SAAS,QAAQ,UAAU,GAAI;AACjD,QAAI,GAAG,IAAI;AAAA,EACb;AACA,SAAO;AACT;;;ADiGA,IAAM,cAAc;AAOpB,IAAM,qBACJ;AAIF,IAAM,oCAAoC;AAG1C,IAAM,qCAAqC;AAW3C,SAAS,yBAAyB,SAAiD;AACjF,QAAM,MAAM,QAAQ,wBAAwB,mBAAmB;AAC/D,MAAI,OAAO,KAAM,QAAO;AACxB,MAAI,OAAO,QAAQ,UAAU;AAC3B,QAAI,YAAY,KAAK,GAAG,EAAG,QAAO;AAClC,WAAO,QAAQ,OAAO,0BAAc,IAAI,SAAY;AAAA,EACtD;AACA,MAAI,OAAO,QAAQ,SAAU,QAAO,QAAQ,6BAAiB,SAAY,OAAO,GAAG;AACnF,MAAI,OAAO,QAAQ,SAAU,QAAO,QAAQ,OAAO,0BAAc,IAAI,SAAY,OAAO,GAAG;AAC3F,SAAO,GAAG,OAAO,GAAG,IAAI,OAAO,GAAG,CAAC;AACrC;AAcO,SAAS,oBAAoB,UAAoB,OAA0C;AAChG,QAAM,wBAAsE,CAAC;AAE7E,aAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,gBAAgB,KAAK,CAAC,EAAG,uBAAsB,GAAG,IAAI;AAChG,MAAI,SAAS,KAAK,kBAAkB,MAAM;AACxC,0BAAsB,mBAAmB,IAAI,SAAS,KAAK;AAAA,EAC7D;AACA,MAAI,SAAS,KAAK,KAAM,uBAAsB,gBAAgB,IAAI,SAAS,KAAK;AAChF,MAAI,SAAS,KAAK,cAAc,MAAM;AACpC,0BAAsB,eAAe,IAAI,SAAS,KAAK;AAAA,EACzD;AAEA,QAAM,UAAsB;AAAA;AAAA;AAAA,IAG1B,MAAM,OAAO,KAAK,0BAAc,OAAO,QAAQ,GAAG,MAAM;AAAA,IACxD,aAAa;AAAA,EACf;AACA,MAAI,SAAS,IAAK,SAAQ,UAAU,SAAS;AAC7C,MAAI,SAAS,SAAU,SAAQ,gBAAgB,SAAS;AACxD,MAAI,SAAS,KAAK,GAAI,SAAQ,YAAY,SAAS,KAAK;AACxD,MAAI,OAAO,KAAK,qBAAqB,EAAE,SAAS,GAAG;AACjD,YAAQ,wBAAwB;AAAA,EAClC;AACA,SAAO;AACT;AAQO,SAAS,UAAU,SAA4C;AACpE,QAAM,QAAQ,QAAQ;AACtB,QAAM,MAAqB,CAAC;AAC5B,MAAI,CAAC,MAAO,QAAO;AACnB,aAAW,OAAO,OAAO,KAAK,KAAK,GAAG;AACpC,UAAM,QAAQ,MAAM,GAAG;AACvB,QAAI,SAAS,KAAM;AACnB,UAAM,IAAI,OAAO,KAAK;AACtB,QAAI,MAAM,GAAI,KAAI,GAAG,IAAI;AAAA,EAC3B;AACA,SAAO;AACT;AAoBO,IAAM,eAAN,MAAmB;AAAA,EACxB,YAA6B,QAAmB;AAAnB;AAAA,EAAoB;AAAA,EAApB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAO7B,MAAM,QACJ,KACA,MACA,UAA0B,CAAC,GACV;AACjB,UAAM,WAAW,0BAAc,KAAK,KAAK,MAAM;AAAA,MAC7C,OAAO,KAAK,OAAO;AAAA,MACnB,SAAS,QAAQ;AAAA,IACnB,CAAC;AACD,UAAM,UAAU,oBAAoB,UAAU,QAAQ,OAAO;AAE7D,QAAI,QAAQ,WAAW,QAAQ,QAAQ,UAAU,GAAG;AAClD,cAAQ,wBAAwB,EAAE,GAAG,QAAQ,uBAAuB,YAAY,QAAQ,QAAQ;AAChG,YAAM,KAAK,OAAO,iBAAiB,SAAS,IAAI,KAAK,KAAK,IAAI,IAAI,QAAQ,OAAO,CAAC;AAAA,IACpF,OAAO;AACL,YAAM,KAAK,OAAO,aAAa,OAAO;AAAA,IACxC;AACA,WAAO,SAAS,KAAK;AAAA,EACvB;AACF;AAeA,SAAS,iBAAiB,SAAqD;AAC7E,MAAI;AACJ,MAAI;AACF,UAAM,QAAQ,iBAAiB;AAAA,EACjC,QAAQ;AACN,WAAO;AAAA,EACT;AACA,SAAO,aAAa,GAAG;AACzB;AAEA,SAAS,aAAa,KAAsC;AAC1D,MAAI,eAAe,WAAY,QAAO;AACtC,MAAI,OAAO,QAAQ,SAAU,QAAO,OAAO,KAAK,KAAK,MAAM;AAC3D,MAAI,QAAQ,QAAQ,OAAO,QAAQ,SAAU,QAAO;AACpD,QAAM,EAAE,UAAU,QAAQ,IAAI;AAC9B,MAAI,aAAa,KAAM;AACrB,QAAI,mBAAmB,WAAY,QAAO;AAC1C,QAAI,MAAM,QAAQ,OAAO,KAAK,QAAQ,SAAS,KAAK,QAAQ,MAAM,CAAC,SAAS,gBAAgB,UAAU,GAAG;AACvG,aAAO,OAAO,OAAO,OAAuB;AAAA,IAC9C;AACA,WAAO;AAAA,EACT;AACA,MAAI,aAAa,KAAM;AACrB,QAAI,OAAO,YAAY,SAAU,QAAO,OAAO,KAAK,SAAS,MAAM;AACnE,QAAI,mBAAmB,WAAY,QAAO;AAAA,EAC5C;AACA,SAAO;AACT;AAYA,SAAS,UAAU,SAAqD;AACtE,QAAM,MAAM,iBAAiB,OAAO;AACpC,MAAI,QAAQ,OAAW,QAAO;AAC9B,QAAM,OAAO,QAAQ;AACrB,MAAI,QAAQ,KAAM,QAAO,IAAI,WAAW,CAAC;AACzC,MAAI,OAAO,SAAS,SAAU,QAAO,OAAO,KAAK,MAAM,MAAM;AAC7D,MAAI,gBAAgB,WAAY,QAAO;AACvC,SAAO;AACT;AAQA,SAAS,2BAA2B,MAAsB;AACxD,MAAI,CAAC,KAAK,WAAW,GAAG,EAAG,QAAO;AAClC,MAAI;AACF,UAAM,QAAiB,KAAK,MAAM,IAAI;AACtC,QAAI,OAAO,UAAU,YAAY,0BAAc,QAAQ,0BAAc,OAAO,KAAK,CAAC,EAAG,QAAO;AAAA,EAC9F,QAAQ;AAAA,EAER;AACA,SAAO;AACT;AAOA,SAAS,SAAS,SAAqC;AACrD,QAAM,QAAQ,UAAU,OAAO;AAC/B,MAAI,UAAU,OAAW,QAAO,2BAA2B,OAAO,KAAK,KAAK,EAAE,SAAS,MAAM,CAAC;AAC9F,SAAO,KAAK,UAAU,QAAQ,IAAI,KAAK;AACzC;AAGA,SAAS,eAAe,YAA4B;AAClD,SAAO,WAAW,SAAS,MAAM,KAAK,WAAW,SAAS,IAAI,WAAW,MAAM,GAAG,EAAE,IAAI;AAC1F;AA2CO,IAAM,cAAN,MAAkB;AAAA,EACvB,YACmB,UACA,UACA,UAA8B,CAAC,GAChD;AAHiB;AACA;AACA;AAAA,EAChB;AAAA,EAHgB;AAAA,EACA;AAAA,EACA;AAAA;AAAA,EAInB,MAAM,OAAwB;AAC5B,UAAM,WAAW,MAAM,KAAK,SAAS;AAAA,MACnC,KAAK,QAAQ,eAAe;AAAA,MAC5B,KAAK,QAAQ,mBAAmB,OAAO,EAAE,iBAAiB,KAAK,QAAQ,gBAAgB,IAAI;AAAA,IAC7F;AACA,eAAW,WAAW,UAAU;AAC9B,YAAM,KAAK,OAAO,OAAO;AAAA,IAC3B;AACA,WAAO,SAAS;AAAA,EAClB;AAAA;AAAA,EAGA,MAAM,IAAI,QAAqC;AAC7C,WAAO,QAAQ,YAAY,MAAM;AAC/B,YAAM,KAAK,KAAK;AAAA,IAClB;AAAA,EACF;AAAA,EAEA,MAAc,OAAO,SAA4C;AAE/D,UAAM,kBAAkB,yBAAyB,OAAO;AACxD,QAAI,oBAAoB,QAAW;AACjC,YAAM,WACJ,gBAAgB,SAAS,qCACrB,GAAG,gBAAgB,MAAM,GAAG,kCAAkC,CAAC,QAC/D;AACN,YAAM,cAAc,iFAAiF,QAAQ,iBAAiB,0BAAc;AAC5I,YAAM,KAAK,mBAAmB,SAAS,WAAW;AAClD;AAAA,IACF;AAEA,UAAM,WAAW,0BAAc,OAAO,SAAS,OAAO,CAAC;AAIvD,UAAM,gBAAgB,OAAO,QAAQ,kBAAkB,WAAW,QAAQ,gBAAgB;AAC1F,UAAM,SAAS,gBAAgB;AAC/B,UAAM,UAAU,OAAO,SAAS,aAAa,WAAW,SAAS,WAAW;AAC5E,QAAI,SAAS,SAAS;AACpB,eAAS,WAAW;AAAA,IACtB;AAEA,QAAI,CAAC,0BAAc,QAAQ,QAAQ,GAAG;AACpC,YAAM,KAAK;AAAA,QACT;AAAA,QACA;AAAA,QACA,IAAI,4BAAgB,uEAAuE;AAAA,MAC7F;AACA;AAAA,IACF;AAEA,UAAM,MAAM,0BAAc,IAAI,QAAQ;AACtC,UAAM,UAAU,KAAK,SAAS,GAAG;AACjC,QAAI,CAAC,SAAS;AACZ,UAAI,KAAK,QAAQ,cAAc;AAC7B,cAAM,KAAK,QAAQ,aAAa,UAAU,OAAO;AACjD,cAAM,KAAK,SAAS,gBAAgB,OAAO;AAAA,MAC7C,OAAO;AACL,aAAK,QAAQ,UAAU,IAAI,4BAAgB,GAAG,GAAG,UAAU,OAAO;AAClE,cAAM,KAAK,SAAS,eAAe,OAAO;AAAA,MAC5C;AACA;AAAA,IACF;AAEA,QAAI;AACF,YAAM,QAAQ,UAAU,SAAS,UAAU,OAAO,CAAC;AACnD,YAAM,KAAK,SAAS,gBAAgB,OAAO;AAAA,IAC7C,SAAS,OAAO;AAEd,WAAK,QAAQ,UAAU,OAAO,UAAU,OAAO;AAC/C,YAAM,KAAK,SAAS,eAAe,OAAO;AAAA,IAC5C;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,MAAc,mBAAmB,SAA6B,aAAoC;AAChG,UAAM,SAAS,KAAK,QAAQ;AAC5B,QAAI,WAAW,QAAW;AACxB,YAAM,aAAa,KAAK,SAAS,mBAAmB,KAAK,KAAK,QAAQ;AACtE,WAAK,QAAQ;AAAA,QACX,IAAI;AAAA,UACF,aAAa,cAAc,GAAG,WAAW;AAAA,QAC3C;AAAA,QACA,CAAC;AAAA,QACD;AAAA,MACF;AACA,UAAI,YAAY;AACd,cAAM,WAAW,SAAS;AAAA,UACxB,kBAAkB;AAAA,UAClB,4BAA4B;AAAA,QAC9B,CAAC;AAAA,MACH,OAAO;AACL,cAAM,KAAK,SAAS,eAAe,OAAO;AAAA,MAC5C;AACA;AAAA,IACF;AACA,SAAK,QAAQ,UAAU,IAAI,4BAAgB,WAAW,GAAG,CAAC,GAAG,OAAO;AACpE,QAAI;AACF,YAAM,QAAQ,UAAU,OAAO;AAC/B,UAAI,UAAU,OAAW,OAAM,IAAI,4BAAgB,kBAAkB;AAErE,YAAM,SAAqB,EAAE,MAAM,OAAO,KAAK,KAAK,EAAE;AACtD,UAAI,QAAQ,gBAAgB,OAAW,QAAO,cAAc,QAAQ;AACpE,YAAM,OAAO,aAAa,MAAM;AAAA,IAClC,SAAS,YAAY;AACnB,WAAK,QAAQ,UAAU,YAAY,CAAC,GAAG,OAAO;AAC9C,YAAM,KAAK,SAAS,eAAe,OAAO;AAC1C;AAAA,IACF;AACA,UAAM,KAAK,SAAS,gBAAgB,OAAO;AAAA,EAC7C;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,MAAc,aAAa,SAA6B,UAA4B,OAAuC;AACzH,SAAK,QAAQ,UAAU,OAAO,UAAU,OAAO;AAC/C,UAAM,SAAS,KAAK,QAAQ;AAC5B,QAAI,WAAW,QAAW;AACxB,YAAM,KAAK,SAAS,eAAe,OAAO;AAC1C;AAAA,IACF;AACA,QAAI;AACF,YAAM,YAAY,KAAK,IAAI,IAAI,OAAO,QAAQ,kBAAkB,WAAW,QAAQ,gBAAgB,KAAK,CAAC;AACzG,YAAM,WAAW,KAAK,QAAQ,SAAS,KAAK,SAAS,cAAc,eAAe,OAAO,UAAU;AACnG,YAAM,iBAAa,sBAAS,EAAE,UAAU,UAAU,GAA0B,UAAU,UAAU;AAAA,QAC9F,UAAU;AAAA,QACV,OAAO,MAAM;AAAA,QACb,WAAW,MAAM;AAAA,MACnB,CAAC,EAAE;AACH,YAAM,QAAQ,UAAU,OAAO;AAC/B,UAAI,UAAU,OAAW,OAAM,IAAI,4BAAgB,kBAAkB;AACrE,YAAM,aAAS,+BAAkB,OAAO,KAAK,UAAU,UAAU,CAAC;AAElE,YAAM,OAAO,aAAa,EAAE,MAAM,OAAO,KAAK,QAAQ,MAAM,GAAG,aAAa,mBAAmB,CAAC;AAAA,IAClG,SAAS,YAAY;AACnB,WAAK,QAAQ,UAAU,YAAY,UAAU,OAAO;AACpD,YAAM,KAAK,SAAS,eAAe,OAAO;AAC1C;AAAA,IACF;AACA,UAAM,KAAK,SAAS,gBAAgB,OAAO;AAAA,EAC7C;AACF;","names":[]}
|
package/dist/index.d.cts
CHANGED
|
@@ -27,12 +27,21 @@ import { Envelope, HeaderCarrier, IncomingEnvelope } from '@babelqueue/core';
|
|
|
27
27
|
* redelivered and `deliveryCount` is incremented; the authoritative attempt count is the
|
|
28
28
|
* native `deliveryCount`, surfaced to handlers as `attempts = deliveryCount − 1`.
|
|
29
29
|
*
|
|
30
|
-
* **Version gate (§4.7).** Before the body is decoded the consumer reads the `bq-schema-version`
|
|
30
|
+
* **Version gate (§4.7, ADR-0037 decision 1).** Before the body is decoded the consumer reads the `bq-schema-version`
|
|
31
31
|
* application property. A missing or blank value (empty or only ASCII whitespace) changes nothing;
|
|
32
32
|
* the string `"1"` and the integral number `1` (what the producer writes) decode; any other value
|
|
33
|
-
* or type (`"2"`, `"x"`, `"01"`, `2`, `1.5`, ...) rejects the message undecoded
|
|
34
|
-
*
|
|
35
|
-
* `
|
|
33
|
+
* or type (`"2"`, `"x"`, `"01"`, `2`, `1.5`, ...) rejects the message undecoded (`onError` with an empty
|
|
34
|
+
* envelope) and settles it on its **first delivery**: never decoded, never given to a handler or `onUnknownUrn`,
|
|
35
|
+
* never retried. With a `deadLetterSender` (`<queue>.dlq`) the body, byte for byte and without a `dead_letter`
|
|
36
|
+
* block, is sent there and the message completed only after the send succeeded (a failed send abandons it, so
|
|
37
|
+
* the write is tried again); without one it is left to the **native** mechanism — an explicit `deadLetterMessage`
|
|
38
|
+
* (reason `unsupported schema version`) into `$DeadLetterQueue`, or `abandon` when the receiver cannot dead-letter.
|
|
39
|
+
* This consumer has no unknown-URN strategy option and an `onUnknownUrn` hook never applies here: it behaves as `fail`.
|
|
40
|
+
*
|
|
41
|
+
* **Poison (ADR-0037 decision 2, ADR-0035).** A body that does not decode into a valid envelope is never given
|
|
42
|
+
* to a handler. With a `deadLetterSender` the ADR-0035 record (`reason: "poison"`, built with the core's
|
|
43
|
+
* `buildPoisonRecord`) is sent to `<queue>.dlq` and the message completed only after the send; without one it is
|
|
44
|
+
* abandoned, so `MaxDeliveryCount` moves it to the native dead-letter sub-queue.
|
|
36
45
|
*
|
|
37
46
|
* **Out-of-band headers (ADR-0028).** A `headers` carrier (e.g. a W3C `traceparent` for cross-hop
|
|
38
47
|
* span linkage) rides as additional native `applicationProperties` beside the contract `bq-*`
|
|
@@ -57,6 +66,7 @@ interface AsbMessage {
|
|
|
57
66
|
/** A received message (structural subset of @azure/service-bus `ServiceBusReceivedMessage`). */
|
|
58
67
|
interface AsbReceivedMessage {
|
|
59
68
|
body: unknown;
|
|
69
|
+
contentType?: string;
|
|
60
70
|
subject?: string;
|
|
61
71
|
correlationId?: string;
|
|
62
72
|
messageId?: string;
|
|
@@ -64,6 +74,17 @@ interface AsbReceivedMessage {
|
|
|
64
74
|
applicationProperties?: {
|
|
65
75
|
[key: string]: unknown;
|
|
66
76
|
};
|
|
77
|
+
/**
|
|
78
|
+
* Optional (a `ServiceBusReceivedMessage` has it, `readonly _rawAmqpMessage: AmqpAnnotatedMessage`): the raw AMQP
|
|
79
|
+
* message the SDK decoded this one from. Its `body` is rhea's undecoded view: for a data section a
|
|
80
|
+
* `Section { typecode: 0x75, content: Buffer }` (`content` an array of Buffers when there are several sections), for an
|
|
81
|
+
* amqp-value section the plain value itself (rhea does not wrap an amqp-value: a string or a Buffer), whereas {@link body} is the SDK's JSON-parsed view (unless the receiver
|
|
82
|
+
* was created with `skipParsingBodyAsJson: true`). The consumer reads the exact bytes from here.
|
|
83
|
+
*/
|
|
84
|
+
_rawAmqpMessage?: {
|
|
85
|
+
body?: unknown;
|
|
86
|
+
bodyType?: string;
|
|
87
|
+
};
|
|
67
88
|
}
|
|
68
89
|
/**
|
|
69
90
|
* The subset of the Service Bus sender this adapter calls. A `ServiceBusSender` from
|
|
@@ -79,6 +100,8 @@ interface AsbSender {
|
|
|
79
100
|
* from `@azure/service-bus` satisfies it structurally; a fake satisfies it in tests.
|
|
80
101
|
*/
|
|
81
102
|
interface AsbReceiver {
|
|
103
|
+
/** Optional (a `ServiceBusReceiver` has it): the entity this receiver reads, the default `original_queue` of a poison record. */
|
|
104
|
+
entityPath?: string;
|
|
82
105
|
receiveMessages(maxMessageCount: number, options?: {
|
|
83
106
|
maxWaitTimeInMs?: number;
|
|
84
107
|
}): Promise<AsbReceivedMessage[]>;
|
|
@@ -157,6 +180,18 @@ interface AsbConsumerOptions {
|
|
|
157
180
|
maxMessages?: number;
|
|
158
181
|
/** Max time to wait for a batch, in ms. */
|
|
159
182
|
maxWaitTimeInMs?: number;
|
|
183
|
+
/**
|
|
184
|
+
* A sender on the cross-language `<queue>.dlq` entity (an ordinary Service Bus queue, §4.5); opt-in, default
|
|
185
|
+
* none. With it a poison body (the ADR-0035 record) and a version-gated delivery (the raw body) are written
|
|
186
|
+
* there and the source message is completed only after the send succeeded. Without it they are left to the
|
|
187
|
+
* entity's native mechanism (`MaxDeliveryCount` / `$DeadLetterQueue`).
|
|
188
|
+
*/
|
|
189
|
+
deadLetterSender?: AsbSender;
|
|
190
|
+
/**
|
|
191
|
+
* The queue this consumer reads, written to `dead_letter.original_queue` of a poison record. Defaults to the
|
|
192
|
+
* receiver's `entityPath`, else the {@link deadLetterSender} entity path without its `.dlq` suffix.
|
|
193
|
+
*/
|
|
194
|
+
queue?: string;
|
|
160
195
|
}
|
|
161
196
|
/**
|
|
162
197
|
* Receives from a Service Bus entity (PeekLock), decodes + validates each message, routes
|
|
@@ -174,6 +209,18 @@ declare class AsbConsumer {
|
|
|
174
209
|
/** Poll until `signal` aborts. */
|
|
175
210
|
run(signal?: AbortSignal): Promise<void>;
|
|
176
211
|
private handle;
|
|
212
|
+
/**
|
|
213
|
+
* Version-gated delivery (ADR-0037 decision 1): with a {@link AsbConsumerOptions.deadLetterSender} the raw body goes
|
|
214
|
+
* to `<queue>.dlq` and the message is completed only after the send (a failed send is reported and the message
|
|
215
|
+
* abandoned); without one it is reported and left to the native mechanism.
|
|
216
|
+
*/
|
|
217
|
+
private settleVersionGated;
|
|
218
|
+
/**
|
|
219
|
+
* Poison body (ADR-0037 decision 2): with a {@link AsbConsumerOptions.deadLetterSender} the ADR-0035 record goes to
|
|
220
|
+
* `<queue>.dlq` and the message is completed only after the send (a failed send is reported and the message
|
|
221
|
+
* abandoned); without one it is reported and abandoned, so `MaxDeliveryCount` moves it to the native sub-queue.
|
|
222
|
+
*/
|
|
223
|
+
private settlePoison;
|
|
177
224
|
}
|
|
178
225
|
|
|
179
226
|
export { AsbConsumer, type AsbConsumerOptions, type AsbMessage, AsbPublisher, type AsbReceivedMessage, type AsbReceiver, type AsbSender, type BabelHandler, type BabelHandlers, type PublishOptions, headersOf, toServiceBusMessage };
|
package/dist/index.d.ts
CHANGED
|
@@ -27,12 +27,21 @@ import { Envelope, HeaderCarrier, IncomingEnvelope } from '@babelqueue/core';
|
|
|
27
27
|
* redelivered and `deliveryCount` is incremented; the authoritative attempt count is the
|
|
28
28
|
* native `deliveryCount`, surfaced to handlers as `attempts = deliveryCount − 1`.
|
|
29
29
|
*
|
|
30
|
-
* **Version gate (§4.7).** Before the body is decoded the consumer reads the `bq-schema-version`
|
|
30
|
+
* **Version gate (§4.7, ADR-0037 decision 1).** Before the body is decoded the consumer reads the `bq-schema-version`
|
|
31
31
|
* application property. A missing or blank value (empty or only ASCII whitespace) changes nothing;
|
|
32
32
|
* the string `"1"` and the integral number `1` (what the producer writes) decode; any other value
|
|
33
|
-
* or type (`"2"`, `"x"`, `"01"`, `2`, `1.5`, ...) rejects the message undecoded
|
|
34
|
-
*
|
|
35
|
-
* `
|
|
33
|
+
* or type (`"2"`, `"x"`, `"01"`, `2`, `1.5`, ...) rejects the message undecoded (`onError` with an empty
|
|
34
|
+
* envelope) and settles it on its **first delivery**: never decoded, never given to a handler or `onUnknownUrn`,
|
|
35
|
+
* never retried. With a `deadLetterSender` (`<queue>.dlq`) the body, byte for byte and without a `dead_letter`
|
|
36
|
+
* block, is sent there and the message completed only after the send succeeded (a failed send abandons it, so
|
|
37
|
+
* the write is tried again); without one it is left to the **native** mechanism — an explicit `deadLetterMessage`
|
|
38
|
+
* (reason `unsupported schema version`) into `$DeadLetterQueue`, or `abandon` when the receiver cannot dead-letter.
|
|
39
|
+
* This consumer has no unknown-URN strategy option and an `onUnknownUrn` hook never applies here: it behaves as `fail`.
|
|
40
|
+
*
|
|
41
|
+
* **Poison (ADR-0037 decision 2, ADR-0035).** A body that does not decode into a valid envelope is never given
|
|
42
|
+
* to a handler. With a `deadLetterSender` the ADR-0035 record (`reason: "poison"`, built with the core's
|
|
43
|
+
* `buildPoisonRecord`) is sent to `<queue>.dlq` and the message completed only after the send; without one it is
|
|
44
|
+
* abandoned, so `MaxDeliveryCount` moves it to the native dead-letter sub-queue.
|
|
36
45
|
*
|
|
37
46
|
* **Out-of-band headers (ADR-0028).** A `headers` carrier (e.g. a W3C `traceparent` for cross-hop
|
|
38
47
|
* span linkage) rides as additional native `applicationProperties` beside the contract `bq-*`
|
|
@@ -57,6 +66,7 @@ interface AsbMessage {
|
|
|
57
66
|
/** A received message (structural subset of @azure/service-bus `ServiceBusReceivedMessage`). */
|
|
58
67
|
interface AsbReceivedMessage {
|
|
59
68
|
body: unknown;
|
|
69
|
+
contentType?: string;
|
|
60
70
|
subject?: string;
|
|
61
71
|
correlationId?: string;
|
|
62
72
|
messageId?: string;
|
|
@@ -64,6 +74,17 @@ interface AsbReceivedMessage {
|
|
|
64
74
|
applicationProperties?: {
|
|
65
75
|
[key: string]: unknown;
|
|
66
76
|
};
|
|
77
|
+
/**
|
|
78
|
+
* Optional (a `ServiceBusReceivedMessage` has it, `readonly _rawAmqpMessage: AmqpAnnotatedMessage`): the raw AMQP
|
|
79
|
+
* message the SDK decoded this one from. Its `body` is rhea's undecoded view: for a data section a
|
|
80
|
+
* `Section { typecode: 0x75, content: Buffer }` (`content` an array of Buffers when there are several sections), for an
|
|
81
|
+
* amqp-value section the plain value itself (rhea does not wrap an amqp-value: a string or a Buffer), whereas {@link body} is the SDK's JSON-parsed view (unless the receiver
|
|
82
|
+
* was created with `skipParsingBodyAsJson: true`). The consumer reads the exact bytes from here.
|
|
83
|
+
*/
|
|
84
|
+
_rawAmqpMessage?: {
|
|
85
|
+
body?: unknown;
|
|
86
|
+
bodyType?: string;
|
|
87
|
+
};
|
|
67
88
|
}
|
|
68
89
|
/**
|
|
69
90
|
* The subset of the Service Bus sender this adapter calls. A `ServiceBusSender` from
|
|
@@ -79,6 +100,8 @@ interface AsbSender {
|
|
|
79
100
|
* from `@azure/service-bus` satisfies it structurally; a fake satisfies it in tests.
|
|
80
101
|
*/
|
|
81
102
|
interface AsbReceiver {
|
|
103
|
+
/** Optional (a `ServiceBusReceiver` has it): the entity this receiver reads, the default `original_queue` of a poison record. */
|
|
104
|
+
entityPath?: string;
|
|
82
105
|
receiveMessages(maxMessageCount: number, options?: {
|
|
83
106
|
maxWaitTimeInMs?: number;
|
|
84
107
|
}): Promise<AsbReceivedMessage[]>;
|
|
@@ -157,6 +180,18 @@ interface AsbConsumerOptions {
|
|
|
157
180
|
maxMessages?: number;
|
|
158
181
|
/** Max time to wait for a batch, in ms. */
|
|
159
182
|
maxWaitTimeInMs?: number;
|
|
183
|
+
/**
|
|
184
|
+
* A sender on the cross-language `<queue>.dlq` entity (an ordinary Service Bus queue, §4.5); opt-in, default
|
|
185
|
+
* none. With it a poison body (the ADR-0035 record) and a version-gated delivery (the raw body) are written
|
|
186
|
+
* there and the source message is completed only after the send succeeded. Without it they are left to the
|
|
187
|
+
* entity's native mechanism (`MaxDeliveryCount` / `$DeadLetterQueue`).
|
|
188
|
+
*/
|
|
189
|
+
deadLetterSender?: AsbSender;
|
|
190
|
+
/**
|
|
191
|
+
* The queue this consumer reads, written to `dead_letter.original_queue` of a poison record. Defaults to the
|
|
192
|
+
* receiver's `entityPath`, else the {@link deadLetterSender} entity path without its `.dlq` suffix.
|
|
193
|
+
*/
|
|
194
|
+
queue?: string;
|
|
160
195
|
}
|
|
161
196
|
/**
|
|
162
197
|
* Receives from a Service Bus entity (PeekLock), decodes + validates each message, routes
|
|
@@ -174,6 +209,18 @@ declare class AsbConsumer {
|
|
|
174
209
|
/** Poll until `signal` aborts. */
|
|
175
210
|
run(signal?: AbortSignal): Promise<void>;
|
|
176
211
|
private handle;
|
|
212
|
+
/**
|
|
213
|
+
* Version-gated delivery (ADR-0037 decision 1): with a {@link AsbConsumerOptions.deadLetterSender} the raw body goes
|
|
214
|
+
* to `<queue>.dlq` and the message is completed only after the send (a failed send is reported and the message
|
|
215
|
+
* abandoned); without one it is reported and left to the native mechanism.
|
|
216
|
+
*/
|
|
217
|
+
private settleVersionGated;
|
|
218
|
+
/**
|
|
219
|
+
* Poison body (ADR-0037 decision 2): with a {@link AsbConsumerOptions.deadLetterSender} the ADR-0035 record goes to
|
|
220
|
+
* `<queue>.dlq` and the message is completed only after the send (a failed send is reported and the message
|
|
221
|
+
* abandoned); without one it is reported and abandoned, so `MaxDeliveryCount` moves it to the native sub-queue.
|
|
222
|
+
*/
|
|
223
|
+
private settlePoison;
|
|
177
224
|
}
|
|
178
225
|
|
|
179
226
|
export { AsbConsumer, type AsbConsumerOptions, type AsbMessage, AsbPublisher, type AsbReceivedMessage, type AsbReceiver, type AsbSender, type BabelHandler, type BabelHandlers, type PublishOptions, headersOf, toServiceBusMessage };
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
import { BabelQueueError, EnvelopeCodec, SCHEMA_VERSION, UnknownUrnError } from "@babelqueue/core";
|
|
2
|
+
import { BabelQueueError, EnvelopeCodec, SCHEMA_VERSION, UnknownUrnError, annotate, buildPoisonRecord } from "@babelqueue/core";
|
|
3
3
|
|
|
4
4
|
// src/headers.ts
|
|
5
5
|
function sanitizeHeaders(headers) {
|
|
@@ -15,6 +15,7 @@ function sanitizeHeaders(headers) {
|
|
|
15
15
|
|
|
16
16
|
// src/index.ts
|
|
17
17
|
var BLANK_ASCII = /^[ \t\n\v\f\r]*$/;
|
|
18
|
+
var UNKNOWN_BODY_BYTES = "The message body was already parsed from JSON by the receiver and the message exposes no raw AMQP body, so its exact bytes are unknown and no DLQ record is written. Create the receiver with skipParsingBodyAsJson: true.";
|
|
18
19
|
var UNSUPPORTED_SCHEMA_VERSION_REASON = "unsupported schema version";
|
|
19
20
|
var MAX_REPORTED_SCHEMA_VERSION_LENGTH = 64;
|
|
20
21
|
function unsupportedSchemaVersion(message) {
|
|
@@ -39,7 +40,9 @@ function toServiceBusMessage(envelope, extra) {
|
|
|
39
40
|
applicationProperties["bq-created-at"] = envelope.meta.created_at;
|
|
40
41
|
}
|
|
41
42
|
const message = {
|
|
42
|
-
|
|
43
|
+
// Bytes, never a string: `@azure/service-bus` JSON-encodes every body that is not a Buffer / Uint8Array, so a
|
|
44
|
+
// string would reach the wire as a quoted JSON string literal and the other SDKs would not read an envelope (GR-5).
|
|
45
|
+
body: Buffer.from(EnvelopeCodec.encode(envelope), "utf8"),
|
|
43
46
|
contentType: "application/json"
|
|
44
47
|
};
|
|
45
48
|
if (envelope.job) message.subject = envelope.job;
|
|
@@ -87,6 +90,59 @@ var AsbPublisher = class {
|
|
|
87
90
|
return envelope.meta.id;
|
|
88
91
|
}
|
|
89
92
|
};
|
|
93
|
+
function rawAmqpBodyBytes(message) {
|
|
94
|
+
let raw;
|
|
95
|
+
try {
|
|
96
|
+
raw = message._rawAmqpMessage?.body;
|
|
97
|
+
} catch {
|
|
98
|
+
return void 0;
|
|
99
|
+
}
|
|
100
|
+
return sectionBytes(raw);
|
|
101
|
+
}
|
|
102
|
+
function sectionBytes(raw) {
|
|
103
|
+
if (raw instanceof Uint8Array) return raw;
|
|
104
|
+
if (typeof raw === "string") return Buffer.from(raw, "utf8");
|
|
105
|
+
if (raw === null || typeof raw !== "object") return void 0;
|
|
106
|
+
const { typecode, content } = raw;
|
|
107
|
+
if (typecode === 117) {
|
|
108
|
+
if (content instanceof Uint8Array) return content;
|
|
109
|
+
if (Array.isArray(content) && content.length > 0 && content.every((part) => part instanceof Uint8Array)) {
|
|
110
|
+
return Buffer.concat(content);
|
|
111
|
+
}
|
|
112
|
+
return void 0;
|
|
113
|
+
}
|
|
114
|
+
if (typecode === 119) {
|
|
115
|
+
if (typeof content === "string") return Buffer.from(content, "utf8");
|
|
116
|
+
if (content instanceof Uint8Array) return content;
|
|
117
|
+
}
|
|
118
|
+
return void 0;
|
|
119
|
+
}
|
|
120
|
+
function bodyBytes(message) {
|
|
121
|
+
const raw = rawAmqpBodyBytes(message);
|
|
122
|
+
if (raw !== void 0) return raw;
|
|
123
|
+
const body = message.body;
|
|
124
|
+
if (body == null) return new Uint8Array(0);
|
|
125
|
+
if (typeof body === "string") return Buffer.from(body, "utf8");
|
|
126
|
+
if (body instanceof Uint8Array) return body;
|
|
127
|
+
return void 0;
|
|
128
|
+
}
|
|
129
|
+
function unwrapLegacyQuotedEnvelope(text) {
|
|
130
|
+
if (!text.startsWith('"')) return text;
|
|
131
|
+
try {
|
|
132
|
+
const inner = JSON.parse(text);
|
|
133
|
+
if (typeof inner === "string" && EnvelopeCodec.accepts(EnvelopeCodec.decode(inner))) return inner;
|
|
134
|
+
} catch {
|
|
135
|
+
}
|
|
136
|
+
return text;
|
|
137
|
+
}
|
|
138
|
+
function bodyText(message) {
|
|
139
|
+
const bytes = bodyBytes(message);
|
|
140
|
+
if (bytes !== void 0) return unwrapLegacyQuotedEnvelope(Buffer.from(bytes).toString("utf8"));
|
|
141
|
+
return JSON.stringify(message.body) ?? "";
|
|
142
|
+
}
|
|
143
|
+
function stripDlqSuffix(entityPath) {
|
|
144
|
+
return entityPath.endsWith(".dlq") && entityPath.length > 4 ? entityPath.slice(0, -4) : entityPath;
|
|
145
|
+
}
|
|
90
146
|
var AsbConsumer = class {
|
|
91
147
|
constructor(receiver, handlers, options = {}) {
|
|
92
148
|
this.receiver = receiver;
|
|
@@ -118,26 +174,10 @@ var AsbConsumer = class {
|
|
|
118
174
|
if (rejectedVersion !== void 0) {
|
|
119
175
|
const reported = rejectedVersion.length > MAX_REPORTED_SCHEMA_VERSION_LENGTH ? `${rejectedVersion.slice(0, MAX_REPORTED_SCHEMA_VERSION_LENGTH)}...` : rejectedVersion;
|
|
120
176
|
const description = `Rejected a message from Azure Service Bus with unsupported bq-schema-version "${reported}" (supported: ${SCHEMA_VERSION}).`;
|
|
121
|
-
|
|
122
|
-
this.options.onError?.(
|
|
123
|
-
new BabelQueueError(
|
|
124
|
-
deadLetter ? description : `${description} The receiver has no deadLetterMessage; degraded to abandon.`
|
|
125
|
-
),
|
|
126
|
-
{},
|
|
127
|
-
message
|
|
128
|
-
);
|
|
129
|
-
if (deadLetter) {
|
|
130
|
-
await deadLetter(message, {
|
|
131
|
-
deadLetterReason: UNSUPPORTED_SCHEMA_VERSION_REASON,
|
|
132
|
-
deadLetterErrorDescription: description
|
|
133
|
-
});
|
|
134
|
-
} else {
|
|
135
|
-
await this.receiver.abandonMessage(message);
|
|
136
|
-
}
|
|
177
|
+
await this.settleVersionGated(message, description);
|
|
137
178
|
return;
|
|
138
179
|
}
|
|
139
|
-
const
|
|
140
|
-
const envelope = EnvelopeCodec.decode(raw);
|
|
180
|
+
const envelope = EnvelopeCodec.decode(bodyText(message));
|
|
141
181
|
const deliveryCount = typeof message.deliveryCount === "number" ? message.deliveryCount : 0;
|
|
142
182
|
const native = deliveryCount - 1;
|
|
143
183
|
const current = typeof envelope.attempts === "number" ? envelope.attempts : 0;
|
|
@@ -145,12 +185,11 @@ var AsbConsumer = class {
|
|
|
145
185
|
envelope.attempts = native;
|
|
146
186
|
}
|
|
147
187
|
if (!EnvelopeCodec.accepts(envelope)) {
|
|
148
|
-
this.
|
|
149
|
-
|
|
188
|
+
await this.settlePoison(
|
|
189
|
+
message,
|
|
150
190
|
envelope,
|
|
151
|
-
|
|
191
|
+
new BabelQueueError("Rejected a non-conformant BabelQueue envelope from Azure Service Bus.")
|
|
152
192
|
);
|
|
153
|
-
await this.receiver.abandonMessage(message);
|
|
154
193
|
return;
|
|
155
194
|
}
|
|
156
195
|
const urn = EnvelopeCodec.urn(envelope);
|
|
@@ -173,6 +212,77 @@ var AsbConsumer = class {
|
|
|
173
212
|
await this.receiver.abandonMessage(message);
|
|
174
213
|
}
|
|
175
214
|
}
|
|
215
|
+
/**
|
|
216
|
+
* Version-gated delivery (ADR-0037 decision 1): with a {@link AsbConsumerOptions.deadLetterSender} the raw body goes
|
|
217
|
+
* to `<queue>.dlq` and the message is completed only after the send (a failed send is reported and the message
|
|
218
|
+
* abandoned); without one it is reported and left to the native mechanism.
|
|
219
|
+
*/
|
|
220
|
+
async settleVersionGated(message, description) {
|
|
221
|
+
const sender = this.options.deadLetterSender;
|
|
222
|
+
if (sender === void 0) {
|
|
223
|
+
const deadLetter = this.receiver.deadLetterMessage?.bind(this.receiver);
|
|
224
|
+
this.options.onError?.(
|
|
225
|
+
new BabelQueueError(
|
|
226
|
+
deadLetter ? description : `${description} The receiver has no deadLetterMessage; degraded to abandon.`
|
|
227
|
+
),
|
|
228
|
+
{},
|
|
229
|
+
message
|
|
230
|
+
);
|
|
231
|
+
if (deadLetter) {
|
|
232
|
+
await deadLetter(message, {
|
|
233
|
+
deadLetterReason: UNSUPPORTED_SCHEMA_VERSION_REASON,
|
|
234
|
+
deadLetterErrorDescription: description
|
|
235
|
+
});
|
|
236
|
+
} else {
|
|
237
|
+
await this.receiver.abandonMessage(message);
|
|
238
|
+
}
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
this.options.onError?.(new BabelQueueError(description), {}, message);
|
|
242
|
+
try {
|
|
243
|
+
const bytes = bodyBytes(message);
|
|
244
|
+
if (bytes === void 0) throw new BabelQueueError(UNKNOWN_BODY_BYTES);
|
|
245
|
+
const record = { body: Buffer.from(bytes) };
|
|
246
|
+
if (message.contentType !== void 0) record.contentType = message.contentType;
|
|
247
|
+
await sender.sendMessages(record);
|
|
248
|
+
} catch (writeError) {
|
|
249
|
+
this.options.onError?.(writeError, {}, message);
|
|
250
|
+
await this.receiver.abandonMessage(message);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
await this.receiver.completeMessage(message);
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Poison body (ADR-0037 decision 2): with a {@link AsbConsumerOptions.deadLetterSender} the ADR-0035 record goes to
|
|
257
|
+
* `<queue>.dlq` and the message is completed only after the send (a failed send is reported and the message
|
|
258
|
+
* abandoned); without one it is reported and abandoned, so `MaxDeliveryCount` moves it to the native sub-queue.
|
|
259
|
+
*/
|
|
260
|
+
async settlePoison(message, envelope, error) {
|
|
261
|
+
this.options.onError?.(error, envelope, message);
|
|
262
|
+
const sender = this.options.deadLetterSender;
|
|
263
|
+
if (sender === void 0) {
|
|
264
|
+
await this.receiver.abandonMessage(message);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
try {
|
|
268
|
+
const delivered = Math.max(0, (typeof message.deliveryCount === "number" ? message.deliveryCount : 1) - 1);
|
|
269
|
+
const original = this.options.queue ?? this.receiver.entityPath ?? stripDlqSuffix(sender.entityPath);
|
|
270
|
+
const deadLetter = annotate({ attempts: delivered }, "poison", original, {
|
|
271
|
+
attempts: delivered,
|
|
272
|
+
error: error.message,
|
|
273
|
+
exception: error.name
|
|
274
|
+
}).dead_letter;
|
|
275
|
+
const bytes = bodyBytes(message);
|
|
276
|
+
if (bytes === void 0) throw new BabelQueueError(UNKNOWN_BODY_BYTES);
|
|
277
|
+
const record = buildPoisonRecord(bytes, JSON.stringify(deadLetter));
|
|
278
|
+
await sender.sendMessages({ body: Buffer.from(record, "utf8"), contentType: "application/json" });
|
|
279
|
+
} catch (writeError) {
|
|
280
|
+
this.options.onError?.(writeError, envelope, message);
|
|
281
|
+
await this.receiver.abandonMessage(message);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
await this.receiver.completeMessage(message);
|
|
285
|
+
}
|
|
176
286
|
};
|
|
177
287
|
export {
|
|
178
288
|
AsbConsumer,
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts","../src/headers.ts"],"sourcesContent":["/**\n * Azure Service Bus adapter for BabelQueue.\n *\n * A canonical-envelope **publisher** and a URN-routed **consumer** over Azure Service\n * Bus, so an ASB-based Node service speaks the same contract (envelope shape, URN\n * identity, trace propagation) as the .NET, Java, Python and Go SDKs.\n *\n * import { ServiceBusClient } from \"@azure/service-bus\";\n * import { AsbPublisher, AsbConsumer } from \"@babelqueue/azure-service-bus\";\n *\n * const client = new ServiceBusClient(connectionString);\n * await new AsbPublisher(client.createSender(\"orders\"))\n * .publish(\"urn:babel:orders:created\", { order_id: 1042 });\n *\n * const consumer = new AsbConsumer(client.createReceiver(\"orders\"), {\n * \"urn:babel:orders:created\": async (env) => { ... },\n * });\n * await consumer.run();\n *\n * This implements §4 of the broker-bindings contract: the canonical envelope is the\n * message body, projected onto native Service Bus fields (`subject` = URN,\n * `correlationId` = trace_id, `messageId` = meta.id, plus the `bq-` application\n * properties). The envelope is unchanged (`schema_version` stays 1); ASB is purely\n * additive. Retry is broker-native — a failed handler `abandon`s the message, so it is\n * redelivered and `deliveryCount` is incremented; the authoritative attempt count is the\n * native `deliveryCount`, surfaced to handlers as `attempts = deliveryCount − 1`.\n *\n * **Version gate (§4.7).** Before the body is decoded the consumer reads the `bq-schema-version`\n * application property. A missing or blank value (empty or only ASCII whitespace) changes nothing;\n * the string `\"1\"` and the integral number `1` (what the producer writes) decode; any other value\n * or type (`\"2\"`, `\"x\"`, `\"01\"`, `2`, `1.5`, ...) rejects the message undecoded — `onError` (with an\n * empty envelope) and an explicit `deadLetterMessage` (reason `unsupported schema version`), never\n * `abandon`, so the message is not redelivered to the same consumer.\n *\n * **Out-of-band headers (ADR-0028).** A `headers` carrier (e.g. a W3C `traceparent` for cross-hop\n * span linkage) rides as additional native `applicationProperties` beside the contract `bq-*`\n * properties — the contract properties win a key collision (merge-not-clobber), blanks dropped. On\n * consume the inbound `applicationProperties` are surfaced as a `Record<string,string>` so the\n * core's `otel` extract sees the `traceparent` and links the consumer span as a child. A\n * header-less publish is byte-identical. GR-1: the wire envelope body is never touched.\n */\n\nimport { BabelQueueError, EnvelopeCodec, SCHEMA_VERSION, UnknownUrnError } from \"@babelqueue/core\";\nimport type { Envelope, HeaderCarrier, IncomingEnvelope } from \"@babelqueue/core\";\n\nimport { sanitizeHeaders } from \"./headers.js\";\n\n// --- Minimal Service Bus shapes (a structural subset of @azure/service-bus) -----\n\n/** A message to send (structural subset of @azure/service-bus `ServiceBusMessage`). */\nexport interface AsbMessage {\n body: unknown;\n subject?: string;\n correlationId?: string;\n messageId?: string;\n contentType?: string;\n applicationProperties?: { [key: string]: number | boolean | string | Date | null };\n scheduledEnqueueTimeUtc?: Date;\n}\n\n/** A received message (structural subset of @azure/service-bus `ServiceBusReceivedMessage`). */\nexport interface AsbReceivedMessage {\n body: unknown;\n subject?: string;\n correlationId?: string;\n messageId?: string;\n deliveryCount?: number;\n applicationProperties?: { [key: string]: unknown };\n}\n\n/**\n * The subset of the Service Bus sender this adapter calls. A `ServiceBusSender` from\n * `@azure/service-bus` satisfies it structurally; a fake satisfies it in tests.\n */\nexport interface AsbSender {\n entityPath: string;\n sendMessages(message: AsbMessage): Promise<void>;\n scheduleMessages(message: AsbMessage, scheduledEnqueueTimeUtc: Date): Promise<bigint[]>;\n}\n\n/**\n * The subset of the Service Bus receiver this adapter calls. A `ServiceBusReceiver`\n * from `@azure/service-bus` satisfies it structurally; a fake satisfies it in tests.\n */\nexport interface AsbReceiver {\n receiveMessages(maxMessageCount: number, options?: { maxWaitTimeInMs?: number }): Promise<AsbReceivedMessage[]>;\n completeMessage(message: AsbReceivedMessage): Promise<void>;\n abandonMessage(message: AsbReceivedMessage): Promise<void>;\n /**\n * Move a message to the entity's dead-letter sub-queue. A `ServiceBusReceiver` from\n * `@azure/service-bus` provides it. Optional so existing structural implementations keep\n * compiling; a receiver without it cannot dead-letter, so an unsupported-schema-version\n * message degrades to `abandonMessage` (the broker's `MaxDeliveryCount` then dead-letters it).\n */\n deadLetterMessage?(\n message: AsbReceivedMessage,\n options?: { deadLetterReason?: string; deadLetterErrorDescription?: string },\n ): Promise<void>;\n}\n\n/** Blank = empty or only ASCII whitespace (space, \\t, \\n, \\v, \\f, \\r) — not `String.prototype.trim()`'s wider set. */\nconst BLANK_ASCII = /^[ \\t\\n\\v\\f\\r]*$/;\n\n/** Reason recorded on a message dead-lettered by the version gate. */\nconst UNSUPPORTED_SCHEMA_VERSION_REASON = \"unsupported schema version\";\n\n/** Longest rejected value echoed into reports; Service Bus caps the dead-letter description at 4096 chars. */\nconst MAX_REPORTED_SCHEMA_VERSION_LENGTH = 64;\n\n/**\n * The `bq-schema-version` application property rendered for reporting when it is present, non-blank\n * and not the supported version (compared against the core's `SCHEMA_VERSION`). The string must be\n * exactly `\"1\"` (`\"01\"`, `\" 1\"`, `\"1.0\"` are rejected, never trimmed); blank means the empty string\n * or only ASCII whitespace (the same set in every SDK, GR-5), while NBSP, U+0085, U+FEFF, U+3000\n * and the like are not blank. AMQP-typed numbers are accepted when they are the integer 1 (a JS\n * `number`, or the `bigint` an int64 may surface as); every other number or type is rejected.\n * `undefined` when the message may be decoded.\n */\nfunction unsupportedSchemaVersion(message: AsbReceivedMessage): string | undefined {\n const raw = message.applicationProperties?.[\"bq-schema-version\"];\n if (raw == null) return undefined;\n if (typeof raw === \"string\") {\n if (BLANK_ASCII.test(raw)) return undefined;\n return raw === String(SCHEMA_VERSION) ? undefined : raw;\n }\n if (typeof raw === \"number\") return raw === SCHEMA_VERSION ? undefined : String(raw);\n if (typeof raw === \"bigint\") return raw === BigInt(SCHEMA_VERSION) ? undefined : String(raw);\n return `${typeof raw}:${String(raw)}`;\n}\n\n// --- Native projection (contract §4.2–§4.3) ------------------------------------\n\n/**\n * Project the envelope's contract fields onto a native Service Bus message — `subject`\n * = URN, `correlationId` = trace_id, `messageId` = meta.id, plus the `bq-` application\n * properties. The body stays authoritative.\n *\n * When `extra` is given, those out-of-band headers (e.g. a W3C `traceparent`, ADR-0028) are merged\n * into `applicationProperties` **first**, then the contract `bq-*` properties are written so they\n * always win a key collision (merge-not-clobber); blank keys/values are dropped. GR-1: the wire\n * envelope body is never touched.\n */\nexport function toServiceBusMessage(envelope: Envelope, extra?: HeaderCarrier | null): AsbMessage {\n const applicationProperties: { [key: string]: number | boolean | string } = {};\n // Out-of-band riders go in first so the contract bq-* properties below win any key collision.\n for (const [key, value] of Object.entries(sanitizeHeaders(extra))) applicationProperties[key] = value;\n if (envelope.meta.schema_version != null) {\n applicationProperties[\"bq-schema-version\"] = envelope.meta.schema_version;\n }\n if (envelope.meta.lang) applicationProperties[\"bq-source-lang\"] = envelope.meta.lang;\n if (envelope.meta.created_at != null) {\n applicationProperties[\"bq-created-at\"] = envelope.meta.created_at;\n }\n\n const message: AsbMessage = {\n body: EnvelopeCodec.encode(envelope),\n contentType: \"application/json\",\n };\n if (envelope.job) message.subject = envelope.job;\n if (envelope.trace_id) message.correlationId = envelope.trace_id;\n if (envelope.meta.id) message.messageId = envelope.meta.id;\n if (Object.keys(applicationProperties).length > 0) {\n message.applicationProperties = applicationProperties;\n }\n return message;\n}\n\n/**\n * Read a received message's `applicationProperties` back into a flat {@link HeaderCarrier},\n * stringifying values defensively. Returns an empty object when there are none. Both the contract\n * `bq-*` properties and any out-of-band rider (e.g. `traceparent`) surface — the core's `otel`\n * extract reads only the keys it knows.\n */\nexport function headersOf(message: AsbReceivedMessage): HeaderCarrier {\n const props = message.applicationProperties;\n const out: HeaderCarrier = {};\n if (!props) return out;\n for (const key of Object.keys(props)) {\n const value = props[key];\n if (value == null) continue;\n const s = String(value);\n if (s !== \"\") out[key] = s;\n }\n return out;\n}\n\n// --- Publisher -----------------------------------------------------------------\n\n/** Options for {@link AsbPublisher.publish}. */\nexport interface PublishOptions {\n /** Reuse an existing trace id (trace continuation). */\n traceId?: string;\n /** Schedule native delayed delivery this many milliseconds from now (`scheduledEnqueueTimeUtc`). */\n delayMs?: number;\n /**\n * Out-of-band transport headers carried as native `applicationProperties` beside the contract\n * `bq-*` properties (ADR-0028) — e.g. a W3C `traceparent` written by `@babelqueue/core/otel`'s\n * `publish`. The contract properties win a key collision; an empty/omitted carrier leaves the\n * message unchanged.\n */\n headers?: HeaderCarrier;\n}\n\n/** Sends canonical-envelope messages to one Service Bus entity with the §4 native projection. */\nexport class AsbPublisher {\n constructor(private readonly sender: AsbSender) {}\n\n /**\n * Build the canonical envelope for `(urn, data)`, send it with the native projection,\n * and return the message id (`meta.id`). A positive `delayMs` schedules native delayed\n * delivery.\n */\n async publish(\n urn: string,\n data: Record<string, unknown>,\n options: PublishOptions = {},\n ): Promise<string> {\n const envelope = EnvelopeCodec.make(urn, data, {\n queue: this.sender.entityPath,\n traceId: options.traceId,\n });\n const message = toServiceBusMessage(envelope, options.headers);\n\n if (options.delayMs != null && options.delayMs > 0) {\n message.applicationProperties = { ...message.applicationProperties, \"bq-delay\": options.delayMs };\n await this.sender.scheduleMessages(message, new Date(Date.now() + options.delayMs));\n } else {\n await this.sender.sendMessages(message);\n }\n return envelope.meta.id;\n }\n}\n\n// --- Consumer ------------------------------------------------------------------\n\n/**\n * A URN handler. Receives the validated envelope, the raw Service Bus message, and the out-of-band\n * {@link HeaderCarrier} read from the message's `applicationProperties` (empty when there are none).\n * Pass `headers` to `@babelqueue/core/otel`'s `wrapHandler` to link the consumer span as a child of\n * the producer span (ADR-0028).\n */\nexport type BabelHandler = (envelope: Envelope, message: AsbReceivedMessage, headers: HeaderCarrier) => unknown | Promise<unknown>;\n\n/** A map of URN → handler. */\nexport type BabelHandlers = Record<string, BabelHandler>;\n\n/** Options for {@link AsbConsumer}. */\nexport interface AsbConsumerOptions {\n /** Called instead of erroring when a message's URN has no handler (then the message is completed). */\n onUnknownUrn?: (envelope: IncomingEnvelope, message: AsbReceivedMessage) => unknown | Promise<unknown>;\n /** Called for a non-conformant envelope, an unmapped URN (no `onUnknownUrn`), or a throwing handler. The loop never stops. */\n onError?: (error: unknown, envelope: IncomingEnvelope, message: AsbReceivedMessage) => void;\n /** Max messages per receive (default 10). */\n maxMessages?: number;\n /** Max time to wait for a batch, in ms. */\n maxWaitTimeInMs?: number;\n}\n\n/**\n * Receives from a Service Bus entity (PeekLock), decodes + validates each message, routes\n * it to the handler for its URN, and completes it on success. A throwing handler abandons\n * the message — the broker redelivers it and increments `deliveryCount` (at-least-once);\n * `attempts` is reconciled to `deliveryCount − 1` for the handler.\n */\nexport class AsbConsumer {\n constructor(\n private readonly receiver: AsbReceiver,\n private readonly handlers: BabelHandlers,\n private readonly options: AsbConsumerOptions = {},\n ) {}\n\n /** Receive one batch, route each message, settle each. Returns the batch size. */\n async poll(): Promise<number> {\n const messages = await this.receiver.receiveMessages(\n this.options.maxMessages ?? 10,\n this.options.maxWaitTimeInMs != null ? { maxWaitTimeInMs: this.options.maxWaitTimeInMs } : undefined,\n );\n for (const message of messages) {\n await this.handle(message);\n }\n return messages.length;\n }\n\n /** Poll until `signal` aborts. */\n async run(signal?: AbortSignal): Promise<void> {\n while (signal?.aborted !== true) {\n await this.poll();\n }\n }\n\n private async handle(message: AsbReceivedMessage): Promise<void> {\n // §4.7: gate on bq-schema-version before the body is decoded. The message is dead-lettered\n // explicitly (never abandoned) so it does not loop back to the same consumer.\n const rejectedVersion = unsupportedSchemaVersion(message);\n if (rejectedVersion !== undefined) {\n const reported =\n rejectedVersion.length > MAX_REPORTED_SCHEMA_VERSION_LENGTH\n ? `${rejectedVersion.slice(0, MAX_REPORTED_SCHEMA_VERSION_LENGTH)}...`\n : rejectedVersion;\n const description = `Rejected a message from Azure Service Bus with unsupported bq-schema-version \"${reported}\" (supported: ${SCHEMA_VERSION}).`;\n const deadLetter = this.receiver.deadLetterMessage?.bind(this.receiver);\n this.options.onError?.(\n new BabelQueueError(\n deadLetter ? description : `${description} The receiver has no deadLetterMessage; degraded to abandon.`,\n ),\n {},\n message,\n );\n if (deadLetter) {\n await deadLetter(message, {\n deadLetterReason: UNSUPPORTED_SCHEMA_VERSION_REASON,\n deadLetterErrorDescription: description,\n });\n } else {\n await this.receiver.abandonMessage(message);\n }\n return;\n }\n\n const raw = typeof message.body === \"string\" ? message.body : String(message.body ?? \"\");\n const envelope = EnvelopeCodec.decode(raw);\n\n // attempts = max(current, deliveryCount − 1): deliveryCount (1-based) is the native\n // redelivery floor; the max never lowers a higher body count republished from another SDK.\n const deliveryCount = typeof message.deliveryCount === \"number\" ? message.deliveryCount : 0;\n const native = deliveryCount - 1;\n const current = typeof envelope.attempts === \"number\" ? envelope.attempts : 0;\n if (native > current) {\n envelope.attempts = native;\n }\n\n if (!EnvelopeCodec.accepts(envelope)) {\n this.options.onError?.(\n new BabelQueueError(\"Rejected a non-conformant BabelQueue envelope from Azure Service Bus.\"),\n envelope,\n message,\n );\n await this.receiver.abandonMessage(message);\n return;\n }\n\n const urn = EnvelopeCodec.urn(envelope);\n const handler = this.handlers[urn];\n if (!handler) {\n if (this.options.onUnknownUrn) {\n await this.options.onUnknownUrn(envelope, message);\n await this.receiver.completeMessage(message);\n } else {\n this.options.onError?.(new UnknownUrnError(urn), envelope, message);\n await this.receiver.abandonMessage(message);\n }\n return;\n }\n\n try {\n await handler(envelope, message, headersOf(message));\n await this.receiver.completeMessage(message);\n } catch (error) {\n // Abandon releases the lock — the broker redelivers and increments deliveryCount.\n this.options.onError?.(error, envelope, message);\n await this.receiver.abandonMessage(message);\n }\n }\n}\n","/**\n * Pure, dependency-free helpers for the out-of-band transport-header seam (ADR-0028) — the Node\n * adapter mirror of Go's `sanitizeHeaders` and PHP's `Support\\Headers`. They fold an injected\n * {@link HeaderCarrier} (e.g. a W3C `traceparent`) onto a transport's metadata channel **without\n * clobbering** the contract headers it already carries.\n *\n * Headers ride beside the frozen wire envelope (GR-1), never inside it.\n */\n\nimport type { HeaderCarrier } from \"@babelqueue/core\";\n\n/**\n * Copy `headers`, dropping blank keys and blank values. Returns an empty object when nothing\n * survives. A nullish input yields an empty object.\n */\nexport function sanitizeHeaders(headers: HeaderCarrier | null | undefined): HeaderCarrier {\n const out: HeaderCarrier = {};\n if (!headers) return out;\n for (const key of Object.keys(headers)) {\n const value = headers[key];\n if (key === \"\" || value == null || value === \"\") continue;\n out[key] = value;\n }\n return out;\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/headers.ts"],"sourcesContent":["/**\n * Azure Service Bus adapter for BabelQueue.\n *\n * A canonical-envelope **publisher** and a URN-routed **consumer** over Azure Service\n * Bus, so an ASB-based Node service speaks the same contract (envelope shape, URN\n * identity, trace propagation) as the .NET, Java, Python and Go SDKs.\n *\n * import { ServiceBusClient } from \"@azure/service-bus\";\n * import { AsbPublisher, AsbConsumer } from \"@babelqueue/azure-service-bus\";\n *\n * const client = new ServiceBusClient(connectionString);\n * await new AsbPublisher(client.createSender(\"orders\"))\n * .publish(\"urn:babel:orders:created\", { order_id: 1042 });\n *\n * const consumer = new AsbConsumer(client.createReceiver(\"orders\"), {\n * \"urn:babel:orders:created\": async (env) => { ... },\n * });\n * await consumer.run();\n *\n * This implements §4 of the broker-bindings contract: the canonical envelope is the\n * message body, projected onto native Service Bus fields (`subject` = URN,\n * `correlationId` = trace_id, `messageId` = meta.id, plus the `bq-` application\n * properties). The envelope is unchanged (`schema_version` stays 1); ASB is purely\n * additive. Retry is broker-native — a failed handler `abandon`s the message, so it is\n * redelivered and `deliveryCount` is incremented; the authoritative attempt count is the\n * native `deliveryCount`, surfaced to handlers as `attempts = deliveryCount − 1`.\n *\n * **Version gate (§4.7, ADR-0037 decision 1).** Before the body is decoded the consumer reads the `bq-schema-version`\n * application property. A missing or blank value (empty or only ASCII whitespace) changes nothing;\n * the string `\"1\"` and the integral number `1` (what the producer writes) decode; any other value\n * or type (`\"2\"`, `\"x\"`, `\"01\"`, `2`, `1.5`, ...) rejects the message undecoded (`onError` with an empty\n * envelope) and settles it on its **first delivery**: never decoded, never given to a handler or `onUnknownUrn`,\n * never retried. With a `deadLetterSender` (`<queue>.dlq`) the body, byte for byte and without a `dead_letter`\n * block, is sent there and the message completed only after the send succeeded (a failed send abandons it, so\n * the write is tried again); without one it is left to the **native** mechanism — an explicit `deadLetterMessage`\n * (reason `unsupported schema version`) into `$DeadLetterQueue`, or `abandon` when the receiver cannot dead-letter.\n * This consumer has no unknown-URN strategy option and an `onUnknownUrn` hook never applies here: it behaves as `fail`.\n *\n * **Poison (ADR-0037 decision 2, ADR-0035).** A body that does not decode into a valid envelope is never given\n * to a handler. With a `deadLetterSender` the ADR-0035 record (`reason: \"poison\"`, built with the core's\n * `buildPoisonRecord`) is sent to `<queue>.dlq` and the message completed only after the send; without one it is\n * abandoned, so `MaxDeliveryCount` moves it to the native dead-letter sub-queue.\n *\n * **Out-of-band headers (ADR-0028).** A `headers` carrier (e.g. a W3C `traceparent` for cross-hop\n * span linkage) rides as additional native `applicationProperties` beside the contract `bq-*`\n * properties — the contract properties win a key collision (merge-not-clobber), blanks dropped. On\n * consume the inbound `applicationProperties` are surfaced as a `Record<string,string>` so the\n * core's `otel` extract sees the `traceparent` and links the consumer span as a child. A\n * header-less publish is byte-identical. GR-1: the wire envelope body is never touched.\n */\n\nimport { BabelQueueError, EnvelopeCodec, SCHEMA_VERSION, UnknownUrnError, annotate, buildPoisonRecord } from \"@babelqueue/core\";\nimport type { Envelope, HeaderCarrier, IncomingEnvelope } from \"@babelqueue/core\";\n\nimport { sanitizeHeaders } from \"./headers.js\";\n\n// --- Minimal Service Bus shapes (a structural subset of @azure/service-bus) -----\n\n/** A message to send (structural subset of @azure/service-bus `ServiceBusMessage`). */\nexport interface AsbMessage {\n body: unknown;\n subject?: string;\n correlationId?: string;\n messageId?: string;\n contentType?: string;\n applicationProperties?: { [key: string]: number | boolean | string | Date | null };\n scheduledEnqueueTimeUtc?: Date;\n}\n\n/** A received message (structural subset of @azure/service-bus `ServiceBusReceivedMessage`). */\nexport interface AsbReceivedMessage {\n body: unknown;\n contentType?: string;\n subject?: string;\n correlationId?: string;\n messageId?: string;\n deliveryCount?: number;\n applicationProperties?: { [key: string]: unknown };\n /**\n * Optional (a `ServiceBusReceivedMessage` has it, `readonly _rawAmqpMessage: AmqpAnnotatedMessage`): the raw AMQP\n * message the SDK decoded this one from. Its `body` is rhea's undecoded view: for a data section a\n * `Section { typecode: 0x75, content: Buffer }` (`content` an array of Buffers when there are several sections), for an\n * amqp-value section the plain value itself (rhea does not wrap an amqp-value: a string or a Buffer), whereas {@link body} is the SDK's JSON-parsed view (unless the receiver\n * was created with `skipParsingBodyAsJson: true`). The consumer reads the exact bytes from here.\n */\n _rawAmqpMessage?: { body?: unknown; bodyType?: string };\n}\n\n/**\n * The subset of the Service Bus sender this adapter calls. A `ServiceBusSender` from\n * `@azure/service-bus` satisfies it structurally; a fake satisfies it in tests.\n */\nexport interface AsbSender {\n entityPath: string;\n sendMessages(message: AsbMessage): Promise<void>;\n scheduleMessages(message: AsbMessage, scheduledEnqueueTimeUtc: Date): Promise<bigint[]>;\n}\n\n/**\n * The subset of the Service Bus receiver this adapter calls. A `ServiceBusReceiver`\n * from `@azure/service-bus` satisfies it structurally; a fake satisfies it in tests.\n */\nexport interface AsbReceiver {\n /** Optional (a `ServiceBusReceiver` has it): the entity this receiver reads, the default `original_queue` of a poison record. */\n entityPath?: string;\n receiveMessages(maxMessageCount: number, options?: { maxWaitTimeInMs?: number }): Promise<AsbReceivedMessage[]>;\n completeMessage(message: AsbReceivedMessage): Promise<void>;\n abandonMessage(message: AsbReceivedMessage): Promise<void>;\n /**\n * Move a message to the entity's dead-letter sub-queue. A `ServiceBusReceiver` from\n * `@azure/service-bus` provides it. Optional so existing structural implementations keep\n * compiling; a receiver without it cannot dead-letter, so an unsupported-schema-version\n * message degrades to `abandonMessage` (the broker's `MaxDeliveryCount` then dead-letters it).\n */\n deadLetterMessage?(\n message: AsbReceivedMessage,\n options?: { deadLetterReason?: string; deadLetterErrorDescription?: string },\n ): Promise<void>;\n}\n\n/** Blank = empty or only ASCII whitespace (space, \\t, \\n, \\v, \\f, \\r) — not `String.prototype.trim()`'s wider set. */\nconst BLANK_ASCII = /^[ \\t\\n\\v\\f\\r]*$/;\n\n/**\n * Reported (and the message abandoned, never completed) when a DLQ record cannot be written byte-faithfully: the receiver\n * parsed the body and the message exposes no raw AMQP view to recover the bytes from. Only a receiver or SDK build that\n * hides both is affected; `skipParsingBodyAsJson: true` always delivers exact bytes.\n */\nconst UNKNOWN_BODY_BYTES =\n \"The message body was already parsed from JSON by the receiver and the message exposes no raw AMQP body, so its exact bytes are \" +\n \"unknown and no DLQ record is written. Create the receiver with skipParsingBodyAsJson: true.\";\n\n/** Reason recorded on a message dead-lettered by the version gate. */\nconst UNSUPPORTED_SCHEMA_VERSION_REASON = \"unsupported schema version\";\n\n/** Longest rejected value echoed into reports; Service Bus caps the dead-letter description at 4096 chars. */\nconst MAX_REPORTED_SCHEMA_VERSION_LENGTH = 64;\n\n/**\n * The `bq-schema-version` application property rendered for reporting when it is present, non-blank\n * and not the supported version (compared against the core's `SCHEMA_VERSION`). The string must be\n * exactly `\"1\"` (`\"01\"`, `\" 1\"`, `\"1.0\"` are rejected, never trimmed); blank means the empty string\n * or only ASCII whitespace (the same set in every SDK, GR-5), while NBSP, U+0085, U+FEFF, U+3000\n * and the like are not blank. AMQP-typed numbers are accepted when they are the integer 1 (a JS\n * `number`, or the `bigint` an int64 may surface as); every other number or type is rejected.\n * `undefined` when the message may be decoded.\n */\nfunction unsupportedSchemaVersion(message: AsbReceivedMessage): string | undefined {\n const raw = message.applicationProperties?.[\"bq-schema-version\"];\n if (raw == null) return undefined;\n if (typeof raw === \"string\") {\n if (BLANK_ASCII.test(raw)) return undefined;\n return raw === String(SCHEMA_VERSION) ? undefined : raw;\n }\n if (typeof raw === \"number\") return raw === SCHEMA_VERSION ? undefined : String(raw);\n if (typeof raw === \"bigint\") return raw === BigInt(SCHEMA_VERSION) ? undefined : String(raw);\n return `${typeof raw}:${String(raw)}`;\n}\n\n// --- Native projection (contract §4.2–§4.3) ------------------------------------\n\n/**\n * Project the envelope's contract fields onto a native Service Bus message — `subject`\n * = URN, `correlationId` = trace_id, `messageId` = meta.id, plus the `bq-` application\n * properties. The body stays authoritative.\n *\n * When `extra` is given, those out-of-band headers (e.g. a W3C `traceparent`, ADR-0028) are merged\n * into `applicationProperties` **first**, then the contract `bq-*` properties are written so they\n * always win a key collision (merge-not-clobber); blank keys/values are dropped. GR-1: the wire\n * envelope body is never touched.\n */\nexport function toServiceBusMessage(envelope: Envelope, extra?: HeaderCarrier | null): AsbMessage {\n const applicationProperties: { [key: string]: number | boolean | string } = {};\n // Out-of-band riders go in first so the contract bq-* properties below win any key collision.\n for (const [key, value] of Object.entries(sanitizeHeaders(extra))) applicationProperties[key] = value;\n if (envelope.meta.schema_version != null) {\n applicationProperties[\"bq-schema-version\"] = envelope.meta.schema_version;\n }\n if (envelope.meta.lang) applicationProperties[\"bq-source-lang\"] = envelope.meta.lang;\n if (envelope.meta.created_at != null) {\n applicationProperties[\"bq-created-at\"] = envelope.meta.created_at;\n }\n\n const message: AsbMessage = {\n // Bytes, never a string: `@azure/service-bus` JSON-encodes every body that is not a Buffer / Uint8Array, so a\n // string would reach the wire as a quoted JSON string literal and the other SDKs would not read an envelope (GR-5).\n body: Buffer.from(EnvelopeCodec.encode(envelope), \"utf8\"),\n contentType: \"application/json\",\n };\n if (envelope.job) message.subject = envelope.job;\n if (envelope.trace_id) message.correlationId = envelope.trace_id;\n if (envelope.meta.id) message.messageId = envelope.meta.id;\n if (Object.keys(applicationProperties).length > 0) {\n message.applicationProperties = applicationProperties;\n }\n return message;\n}\n\n/**\n * Read a received message's `applicationProperties` back into a flat {@link HeaderCarrier},\n * stringifying values defensively. Returns an empty object when there are none. Both the contract\n * `bq-*` properties and any out-of-band rider (e.g. `traceparent`) surface — the core's `otel`\n * extract reads only the keys it knows.\n */\nexport function headersOf(message: AsbReceivedMessage): HeaderCarrier {\n const props = message.applicationProperties;\n const out: HeaderCarrier = {};\n if (!props) return out;\n for (const key of Object.keys(props)) {\n const value = props[key];\n if (value == null) continue;\n const s = String(value);\n if (s !== \"\") out[key] = s;\n }\n return out;\n}\n\n// --- Publisher -----------------------------------------------------------------\n\n/** Options for {@link AsbPublisher.publish}. */\nexport interface PublishOptions {\n /** Reuse an existing trace id (trace continuation). */\n traceId?: string;\n /** Schedule native delayed delivery this many milliseconds from now (`scheduledEnqueueTimeUtc`). */\n delayMs?: number;\n /**\n * Out-of-band transport headers carried as native `applicationProperties` beside the contract\n * `bq-*` properties (ADR-0028) — e.g. a W3C `traceparent` written by `@babelqueue/core/otel`'s\n * `publish`. The contract properties win a key collision; an empty/omitted carrier leaves the\n * message unchanged.\n */\n headers?: HeaderCarrier;\n}\n\n/** Sends canonical-envelope messages to one Service Bus entity with the §4 native projection. */\nexport class AsbPublisher {\n constructor(private readonly sender: AsbSender) {}\n\n /**\n * Build the canonical envelope for `(urn, data)`, send it with the native projection,\n * and return the message id (`meta.id`). A positive `delayMs` schedules native delayed\n * delivery.\n */\n async publish(\n urn: string,\n data: Record<string, unknown>,\n options: PublishOptions = {},\n ): Promise<string> {\n const envelope = EnvelopeCodec.make(urn, data, {\n queue: this.sender.entityPath,\n traceId: options.traceId,\n });\n const message = toServiceBusMessage(envelope, options.headers);\n\n if (options.delayMs != null && options.delayMs > 0) {\n message.applicationProperties = { ...message.applicationProperties, \"bq-delay\": options.delayMs };\n await this.sender.scheduleMessages(message, new Date(Date.now() + options.delayMs));\n } else {\n await this.sender.sendMessages(message);\n }\n return envelope.meta.id;\n }\n}\n\n// --- Consumer ------------------------------------------------------------------\n\n/**\n * The exact bytes held by the raw AMQP body (`_rawAmqpMessage.body`), or `undefined` when it is absent or has a shape this\n * adapter does not read. rhea hands over a decoded section as `{ typecode, content }`:\n * - `0x75` (data): `content` is the `Buffer` received; several data sections arrive as an array of Buffers, which are\n * concatenated (the AMQP body is the sequence of the sections' bytes);\n * - an amqp-value is not wrapped by rhea: it arrives as the plain value, a string (its UTF-8 bytes) or a Buffer (its bytes);\n * - `0x77` `{ typecode, content }` (defensive only, not a shape rhea produces): a string or binary `content` is read the same way;\n * - `0x76` (amqp-sequence) and everything else are unknown.\n * Unknown shapes leave the bytes unknown, and\n * the caller falls back to `message.body` (then to the safe path: report and abandon).\n */\nfunction rawAmqpBodyBytes(message: AsbReceivedMessage): Uint8Array | undefined {\n let raw: unknown;\n try {\n raw = message._rawAmqpMessage?.body;\n } catch {\n return undefined;\n }\n return sectionBytes(raw);\n}\n\nfunction sectionBytes(raw: unknown): Uint8Array | undefined {\n if (raw instanceof Uint8Array) return raw;\n if (typeof raw === \"string\") return Buffer.from(raw, \"utf8\");\n if (raw === null || typeof raw !== \"object\") return undefined;\n const { typecode, content } = raw as { typecode?: unknown; content?: unknown };\n if (typecode === 0x75) {\n if (content instanceof Uint8Array) return content;\n if (Array.isArray(content) && content.length > 0 && content.every((part) => part instanceof Uint8Array)) {\n return Buffer.concat(content as Uint8Array[]);\n }\n return undefined;\n }\n if (typecode === 0x77) {\n if (typeof content === \"string\") return Buffer.from(content, \"utf8\");\n if (content instanceof Uint8Array) return content;\n }\n return undefined;\n}\n\n/**\n * The exact bytes of a received body, or `undefined` when they are not known. `@azure/service-bus` parses a data-section\n * body that holds valid JSON into an object / array / number unless the receiver is created with\n * `skipParsingBodyAsJson: true`, so `message.body` alone may have lost the original bytes (whitespace, number forms,\n * key order). The raw AMQP message the SDK keeps (`_rawAmqpMessage`) still holds them, so it is read first (see\n * {@link rawAmqpBodyBytes} for the rhea section shapes). Otherwise a\n * string `message.body` is its UTF-8 encoding, a `Buffer` / `Uint8Array` (a binary body, or any body with\n * `skipParsingBodyAsJson: true`) is returned as is, and an empty body is no bytes. A body that was parsed and has no raw\n * view has no known bytes.\n */\nfunction bodyBytes(message: AsbReceivedMessage): Uint8Array | undefined {\n const raw = rawAmqpBodyBytes(message);\n if (raw !== undefined) return raw;\n const body = message.body;\n if (body == null) return new Uint8Array(0);\n if (typeof body === \"string\") return Buffer.from(body, \"utf8\");\n if (body instanceof Uint8Array) return body;\n return undefined;\n}\n\n/**\n * Migration step for the double-encoded body that releases before this fix published: `@azure/service-bus` JSON-encoded\n * the envelope *string*, so the data section held a quoted JSON string literal (`\"{\\\"job\\\":...}\"`). Only a body that is\n * a quoted JSON string **and** whose content decodes into a valid envelope is opened (once); anything else, a quoted\n * string that is not an envelope included, is returned unchanged and takes the normal (poison) path.\n */\nfunction unwrapLegacyQuotedEnvelope(text: string): string {\n if (!text.startsWith('\"')) return text;\n try {\n const inner: unknown = JSON.parse(text);\n if (typeof inner === \"string\" && EnvelopeCodec.accepts(EnvelopeCodec.decode(inner))) return inner;\n } catch {\n // not a JSON string literal\n }\n return text;\n}\n\n/**\n * The text an envelope is decoded from: the exact bytes when known (the raw AMQP body, or a `skipParsingBodyAsJson: true`\n * receiver's `Buffer`), the string a default receiver parsed out of a legacy quoted literal, or the JSON of an\n * already-parsed body. A legacy double-encoded body is opened by {@link unwrapLegacyQuotedEnvelope} on every path.\n */\nfunction bodyText(message: AsbReceivedMessage): string {\n const bytes = bodyBytes(message);\n if (bytes !== undefined) return unwrapLegacyQuotedEnvelope(Buffer.from(bytes).toString(\"utf8\"));\n return JSON.stringify(message.body) ?? \"\";\n}\n\n/** `orders.dlq` -> `orders`: the queue a DLQ entity belongs to (a name without the suffix is kept). */\nfunction stripDlqSuffix(entityPath: string): string {\n return entityPath.endsWith(\".dlq\") && entityPath.length > 4 ? entityPath.slice(0, -4) : entityPath;\n}\n\n/**\n * A URN handler. Receives the validated envelope, the raw Service Bus message, and the out-of-band\n * {@link HeaderCarrier} read from the message's `applicationProperties` (empty when there are none).\n * Pass `headers` to `@babelqueue/core/otel`'s `wrapHandler` to link the consumer span as a child of\n * the producer span (ADR-0028).\n */\nexport type BabelHandler = (envelope: Envelope, message: AsbReceivedMessage, headers: HeaderCarrier) => unknown | Promise<unknown>;\n\n/** A map of URN → handler. */\nexport type BabelHandlers = Record<string, BabelHandler>;\n\n/** Options for {@link AsbConsumer}. */\nexport interface AsbConsumerOptions {\n /** Called instead of erroring when a message's URN has no handler (then the message is completed). */\n onUnknownUrn?: (envelope: IncomingEnvelope, message: AsbReceivedMessage) => unknown | Promise<unknown>;\n /** Called for a non-conformant envelope, an unmapped URN (no `onUnknownUrn`), or a throwing handler. The loop never stops. */\n onError?: (error: unknown, envelope: IncomingEnvelope, message: AsbReceivedMessage) => void;\n /** Max messages per receive (default 10). */\n maxMessages?: number;\n /** Max time to wait for a batch, in ms. */\n maxWaitTimeInMs?: number;\n /**\n * A sender on the cross-language `<queue>.dlq` entity (an ordinary Service Bus queue, §4.5); opt-in, default\n * none. With it a poison body (the ADR-0035 record) and a version-gated delivery (the raw body) are written\n * there and the source message is completed only after the send succeeded. Without it they are left to the\n * entity's native mechanism (`MaxDeliveryCount` / `$DeadLetterQueue`).\n */\n deadLetterSender?: AsbSender;\n /**\n * The queue this consumer reads, written to `dead_letter.original_queue` of a poison record. Defaults to the\n * receiver's `entityPath`, else the {@link deadLetterSender} entity path without its `.dlq` suffix.\n */\n queue?: string;\n}\n\n/**\n * Receives from a Service Bus entity (PeekLock), decodes + validates each message, routes\n * it to the handler for its URN, and completes it on success. A throwing handler abandons\n * the message — the broker redelivers it and increments `deliveryCount` (at-least-once);\n * `attempts` is reconciled to `deliveryCount − 1` for the handler.\n */\nexport class AsbConsumer {\n constructor(\n private readonly receiver: AsbReceiver,\n private readonly handlers: BabelHandlers,\n private readonly options: AsbConsumerOptions = {},\n ) {}\n\n /** Receive one batch, route each message, settle each. Returns the batch size. */\n async poll(): Promise<number> {\n const messages = await this.receiver.receiveMessages(\n this.options.maxMessages ?? 10,\n this.options.maxWaitTimeInMs != null ? { maxWaitTimeInMs: this.options.maxWaitTimeInMs } : undefined,\n );\n for (const message of messages) {\n await this.handle(message);\n }\n return messages.length;\n }\n\n /** Poll until `signal` aborts. */\n async run(signal?: AbortSignal): Promise<void> {\n while (signal?.aborted !== true) {\n await this.poll();\n }\n }\n\n private async handle(message: AsbReceivedMessage): Promise<void> {\n // §4.7: gate on bq-schema-version before the body is decoded; settled on the first delivery (ADR-0037).\n const rejectedVersion = unsupportedSchemaVersion(message);\n if (rejectedVersion !== undefined) {\n const reported =\n rejectedVersion.length > MAX_REPORTED_SCHEMA_VERSION_LENGTH\n ? `${rejectedVersion.slice(0, MAX_REPORTED_SCHEMA_VERSION_LENGTH)}...`\n : rejectedVersion;\n const description = `Rejected a message from Azure Service Bus with unsupported bq-schema-version \"${reported}\" (supported: ${SCHEMA_VERSION}).`;\n await this.settleVersionGated(message, description);\n return;\n }\n\n const envelope = EnvelopeCodec.decode(bodyText(message));\n\n // attempts = max(current, deliveryCount − 1): deliveryCount (1-based) is the native\n // redelivery floor; the max never lowers a higher body count republished from another SDK.\n const deliveryCount = typeof message.deliveryCount === \"number\" ? message.deliveryCount : 0;\n const native = deliveryCount - 1;\n const current = typeof envelope.attempts === \"number\" ? envelope.attempts : 0;\n if (native > current) {\n envelope.attempts = native;\n }\n\n if (!EnvelopeCodec.accepts(envelope)) {\n await this.settlePoison(\n message,\n envelope,\n new BabelQueueError(\"Rejected a non-conformant BabelQueue envelope from Azure Service Bus.\"),\n );\n return;\n }\n\n const urn = EnvelopeCodec.urn(envelope);\n const handler = this.handlers[urn];\n if (!handler) {\n if (this.options.onUnknownUrn) {\n await this.options.onUnknownUrn(envelope, message);\n await this.receiver.completeMessage(message);\n } else {\n this.options.onError?.(new UnknownUrnError(urn), envelope, message);\n await this.receiver.abandonMessage(message);\n }\n return;\n }\n\n try {\n await handler(envelope, message, headersOf(message));\n await this.receiver.completeMessage(message);\n } catch (error) {\n // Abandon releases the lock — the broker redelivers and increments deliveryCount.\n this.options.onError?.(error, envelope, message);\n await this.receiver.abandonMessage(message);\n }\n }\n\n /**\n * Version-gated delivery (ADR-0037 decision 1): with a {@link AsbConsumerOptions.deadLetterSender} the raw body goes\n * to `<queue>.dlq` and the message is completed only after the send (a failed send is reported and the message\n * abandoned); without one it is reported and left to the native mechanism.\n */\n private async settleVersionGated(message: AsbReceivedMessage, description: string): Promise<void> {\n const sender = this.options.deadLetterSender;\n if (sender === undefined) {\n const deadLetter = this.receiver.deadLetterMessage?.bind(this.receiver);\n this.options.onError?.(\n new BabelQueueError(\n deadLetter ? description : `${description} The receiver has no deadLetterMessage; degraded to abandon.`,\n ),\n {},\n message,\n );\n if (deadLetter) {\n await deadLetter(message, {\n deadLetterReason: UNSUPPORTED_SCHEMA_VERSION_REASON,\n deadLetterErrorDescription: description,\n });\n } else {\n await this.receiver.abandonMessage(message);\n }\n return;\n }\n this.options.onError?.(new BabelQueueError(description), {}, message);\n try {\n const bytes = bodyBytes(message);\n if (bytes === undefined) throw new BabelQueueError(UNKNOWN_BODY_BYTES);\n // Bytes, never a string: the SDK would JSON-encode a string into a quoted literal.\n const record: AsbMessage = { body: Buffer.from(bytes) };\n if (message.contentType !== undefined) record.contentType = message.contentType;\n await sender.sendMessages(record);\n } catch (writeError) {\n this.options.onError?.(writeError, {}, message);\n await this.receiver.abandonMessage(message); // unsettled: redelivered, the gate rejects it again, the write is retried\n return;\n }\n await this.receiver.completeMessage(message);\n }\n\n /**\n * Poison body (ADR-0037 decision 2): with a {@link AsbConsumerOptions.deadLetterSender} the ADR-0035 record goes to\n * `<queue>.dlq` and the message is completed only after the send (a failed send is reported and the message\n * abandoned); without one it is reported and abandoned, so `MaxDeliveryCount` moves it to the native sub-queue.\n */\n private async settlePoison(message: AsbReceivedMessage, envelope: IncomingEnvelope, error: BabelQueueError): Promise<void> {\n this.options.onError?.(error, envelope, message);\n const sender = this.options.deadLetterSender;\n if (sender === undefined) {\n await this.receiver.abandonMessage(message);\n return;\n }\n try {\n const delivered = Math.max(0, (typeof message.deliveryCount === \"number\" ? message.deliveryCount : 1) - 1); // informational\n const original = this.options.queue ?? this.receiver.entityPath ?? stripDlqSuffix(sender.entityPath);\n const deadLetter = annotate({ attempts: delivered } as unknown as Envelope, \"poison\", original, {\n attempts: delivered,\n error: error.message,\n exception: error.name,\n }).dead_letter;\n const bytes = bodyBytes(message);\n if (bytes === undefined) throw new BabelQueueError(UNKNOWN_BODY_BYTES);\n const record = buildPoisonRecord(bytes, JSON.stringify(deadLetter));\n // Bytes, never a string: the SDK would JSON-encode a string into a quoted literal.\n await sender.sendMessages({ body: Buffer.from(record, \"utf8\"), contentType: \"application/json\" });\n } catch (writeError) {\n this.options.onError?.(writeError, envelope, message);\n await this.receiver.abandonMessage(message);\n return;\n }\n await this.receiver.completeMessage(message);\n }\n}\n","/**\n * Pure, dependency-free helpers for the out-of-band transport-header seam (ADR-0028) — the Node\n * adapter mirror of Go's `sanitizeHeaders` and PHP's `Support\\Headers`. They fold an injected\n * {@link HeaderCarrier} (e.g. a W3C `traceparent`) onto a transport's metadata channel **without\n * clobbering** the contract headers it already carries.\n *\n * Headers ride beside the frozen wire envelope (GR-1), never inside it.\n */\n\nimport type { HeaderCarrier } from \"@babelqueue/core\";\n\n/**\n * Copy `headers`, dropping blank keys and blank values. Returns an empty object when nothing\n * survives. A nullish input yields an empty object.\n */\nexport function sanitizeHeaders(headers: HeaderCarrier | null | undefined): HeaderCarrier {\n const out: HeaderCarrier = {};\n if (!headers) return out;\n for (const key of Object.keys(headers)) {\n const value = headers[key];\n if (key === \"\" || value == null || value === \"\") continue;\n out[key] = value;\n }\n return out;\n}\n"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@babelqueue/azure-service-bus",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Azure Service Bus adapter for BabelQueue — canonical-envelope publisher and a URN-routed consumer.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"babelqueue",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"test": "node --import tsx --test test/*.test.ts"
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
|
-
"@babelqueue/core": "^1.
|
|
54
|
+
"@babelqueue/core": "^1.13.0"
|
|
55
55
|
},
|
|
56
56
|
"peerDependencies": {
|
|
57
57
|
"@azure/service-bus": "^7.0.0"
|