@amqp-contract/core 0.7.0 → 0.9.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/dist/index.cjs CHANGED
@@ -30,19 +30,44 @@ amqp_connection_manager = __toESM(amqp_connection_manager);
30
30
 
31
31
  //#region src/connection-manager.ts
32
32
  /**
33
- * Connection manager singleton for sharing connections across clients
33
+ * Connection manager singleton for sharing AMQP connections across clients.
34
+ *
35
+ * This singleton implements connection pooling to avoid creating multiple connections
36
+ * to the same broker, which is a RabbitMQ best practice. Connections are identified
37
+ * by their URLs and connection options, and reference counting ensures connections
38
+ * are only closed when all clients have released them.
39
+ *
40
+ * @example
41
+ * ```typescript
42
+ * const manager = ConnectionManagerSingleton.getInstance();
43
+ * const connection = manager.getConnection(['amqp://localhost']);
44
+ * // ... use connection ...
45
+ * await manager.releaseConnection(['amqp://localhost']);
46
+ * ```
34
47
  */
35
48
  var ConnectionManagerSingleton = class ConnectionManagerSingleton {
36
49
  static instance;
37
50
  connections = /* @__PURE__ */ new Map();
38
51
  refCounts = /* @__PURE__ */ new Map();
39
52
  constructor() {}
53
+ /**
54
+ * Get the singleton instance of the connection manager.
55
+ *
56
+ * @returns The singleton instance
57
+ */
40
58
  static getInstance() {
41
59
  if (!ConnectionManagerSingleton.instance) ConnectionManagerSingleton.instance = new ConnectionManagerSingleton();
42
60
  return ConnectionManagerSingleton.instance;
43
61
  }
44
62
  /**
45
- * Get or create a connection for the given URLs and options
63
+ * Get or create a connection for the given URLs and options.
64
+ *
65
+ * If a connection already exists with the same URLs and options, it is reused
66
+ * and its reference count is incremented. Otherwise, a new connection is created.
67
+ *
68
+ * @param urls - AMQP broker URL(s)
69
+ * @param connectionOptions - Optional connection configuration
70
+ * @returns The AMQP connection manager instance
46
71
  */
47
72
  getConnection(urls, connectionOptions) {
48
73
  const key = this.createConnectionKey(urls, connectionOptions);
@@ -55,7 +80,14 @@ var ConnectionManagerSingleton = class ConnectionManagerSingleton {
55
80
  return this.connections.get(key);
56
81
  }
57
82
  /**
58
- * Release a connection reference. If no more references exist, close the connection.
83
+ * Release a connection reference.
84
+ *
85
+ * Decrements the reference count for the connection. If the count reaches zero,
86
+ * the connection is closed and removed from the pool.
87
+ *
88
+ * @param urls - AMQP broker URL(s) used to identify the connection
89
+ * @param connectionOptions - Optional connection configuration used to identify the connection
90
+ * @returns A promise that resolves when the connection is released (and closed if necessary)
59
91
  */
60
92
  async releaseConnection(urls, connectionOptions) {
61
93
  const key = this.createConnectionKey(urls, connectionOptions);
@@ -69,13 +101,35 @@ var ConnectionManagerSingleton = class ConnectionManagerSingleton {
69
101
  }
70
102
  } else this.refCounts.set(key, refCount - 1);
71
103
  }
104
+ /**
105
+ * Create a unique key for a connection based on URLs and options.
106
+ *
107
+ * The key is deterministic: same URLs and options always produce the same key,
108
+ * enabling connection reuse.
109
+ *
110
+ * @param urls - AMQP broker URL(s)
111
+ * @param connectionOptions - Optional connection configuration
112
+ * @returns A unique string key identifying the connection
113
+ */
72
114
  createConnectionKey(urls, connectionOptions) {
73
115
  return `${JSON.stringify(urls)}::${connectionOptions ? this.serializeOptions(connectionOptions) : ""}`;
74
116
  }
117
+ /**
118
+ * Serialize connection options to a deterministic string.
119
+ *
120
+ * @param options - Connection options to serialize
121
+ * @returns A JSON string with sorted keys for deterministic comparison
122
+ */
75
123
  serializeOptions(options) {
76
124
  const sorted = this.deepSort(options);
77
125
  return JSON.stringify(sorted);
78
126
  }
127
+ /**
128
+ * Deep sort an object's keys for deterministic serialization.
129
+ *
130
+ * @param value - The value to deep sort (can be object, array, or primitive)
131
+ * @returns The value with all object keys sorted alphabetically
132
+ */
79
133
  deepSort(value) {
80
134
  if (Array.isArray(value)) return value.map((item) => this.deepSort(item));
81
135
  if (value !== null && typeof value === "object") {
@@ -102,7 +156,24 @@ var ConnectionManagerSingleton = class ConnectionManagerSingleton {
102
156
  //#endregion
103
157
  //#region src/setup.ts
104
158
  /**
105
- * Setup AMQP topology (exchanges, queues, and bindings) from a contract definition
159
+ * Setup AMQP topology (exchanges, queues, and bindings) from a contract definition.
160
+ *
161
+ * This function sets up the complete AMQP topology in the correct order:
162
+ * 1. Assert all exchanges defined in the contract
163
+ * 2. Validate dead letter exchanges are declared before referencing them
164
+ * 3. Assert all queues with their configurations (including dead letter settings)
165
+ * 4. Create all bindings (queue-to-exchange and exchange-to-exchange)
166
+ *
167
+ * @param channel - The AMQP channel to use for topology setup
168
+ * @param contract - The contract definition containing the topology specification
169
+ * @throws {AggregateError} If any exchanges, queues, or bindings fail to be created
170
+ * @throws {Error} If a queue references a dead letter exchange not declared in the contract
171
+ *
172
+ * @example
173
+ * ```typescript
174
+ * const channel = await connection.createChannel();
175
+ * await setupAmqpTopology(channel, contract);
176
+ * ```
106
177
  */
107
178
  async function setupAmqpTopology(channel, contract) {
108
179
  const exchangeErrors = (await Promise.allSettled(Object.values(contract.exchanges ?? {}).map((exchange) => channel.assertExchange(exchange.name, exchange.type, {
@@ -139,11 +210,45 @@ async function setupAmqpTopology(channel, contract) {
139
210
 
140
211
  //#endregion
141
212
  //#region src/amqp-client.ts
213
+ /**
214
+ * AMQP client that manages connections and channels with automatic topology setup.
215
+ *
216
+ * This class handles:
217
+ * - Connection management with automatic reconnection via amqp-connection-manager
218
+ * - Connection pooling and sharing across instances with the same URLs
219
+ * - Automatic AMQP topology setup (exchanges, queues, bindings) from contract
220
+ * - Channel creation with JSON serialization enabled by default
221
+ *
222
+ * @example
223
+ * ```typescript
224
+ * const client = new AmqpClient(contract, {
225
+ * urls: ['amqp://localhost'],
226
+ * connectionOptions: { heartbeatIntervalInSeconds: 30 }
227
+ * });
228
+ *
229
+ * // Use the channel to publish messages
230
+ * await client.channel.publish('exchange', 'routingKey', { data: 'value' });
231
+ *
232
+ * // Close when done
233
+ * await client.close();
234
+ * ```
235
+ */
142
236
  var AmqpClient = class {
143
237
  connection;
144
238
  channel;
145
239
  urls;
146
240
  connectionOptions;
241
+ /**
242
+ * Create a new AMQP client instance.
243
+ *
244
+ * The client will automatically:
245
+ * - Get or create a shared connection using the singleton pattern
246
+ * - Set up AMQP topology (exchanges, queues, bindings) from the contract
247
+ * - Create a channel with JSON serialization enabled
248
+ *
249
+ * @param contract - The contract definition specifying the AMQP topology
250
+ * @param options - Client configuration options
251
+ */
147
252
  constructor(contract, options) {
148
253
  this.contract = contract;
149
254
  this.urls = options.urls;
@@ -180,6 +285,16 @@ var AmqpClient = class {
180
285
  getConnection() {
181
286
  return this.connection;
182
287
  }
288
+ /**
289
+ * Close the channel and release the connection reference.
290
+ *
291
+ * This will:
292
+ * - Close the channel wrapper
293
+ * - Decrease the reference count on the shared connection
294
+ * - Close the connection if this was the last client using it
295
+ *
296
+ * @returns A promise that resolves when the channel and connection are closed
297
+ */
183
298
  async close() {
184
299
  await this.channel.close();
185
300
  await ConnectionManagerSingleton.getInstance().releaseConnection(this.urls, this.connectionOptions);
@@ -193,7 +308,242 @@ var AmqpClient = class {
193
308
  }
194
309
  };
195
310
 
311
+ //#endregion
312
+ //#region src/telemetry.ts
313
+ /**
314
+ * SpanKind values from OpenTelemetry.
315
+ * Defined as constants to avoid runtime dependency when types are used.
316
+ * @see https://opentelemetry.io/docs/specs/otel/trace/api/#spankind
317
+ */
318
+ const SpanKind = {
319
+ PRODUCER: 3,
320
+ CONSUMER: 4
321
+ };
322
+ /**
323
+ * Semantic conventions for AMQP messaging following OpenTelemetry standards.
324
+ * @see https://opentelemetry.io/docs/specs/semconv/messaging/messaging-spans/
325
+ */
326
+ const MessagingSemanticConventions = {
327
+ MESSAGING_SYSTEM: "messaging.system",
328
+ MESSAGING_DESTINATION: "messaging.destination.name",
329
+ MESSAGING_DESTINATION_KIND: "messaging.destination.kind",
330
+ MESSAGING_OPERATION: "messaging.operation",
331
+ MESSAGING_MESSAGE_ID: "messaging.message.id",
332
+ MESSAGING_MESSAGE_PAYLOAD_SIZE: "messaging.message.body.size",
333
+ MESSAGING_MESSAGE_CONVERSATION_ID: "messaging.message.conversation_id",
334
+ MESSAGING_RABBITMQ_ROUTING_KEY: "messaging.rabbitmq.destination.routing_key",
335
+ MESSAGING_RABBITMQ_MESSAGE_DELIVERY_TAG: "messaging.rabbitmq.message.delivery_tag",
336
+ ERROR_TYPE: "error.type",
337
+ MESSAGING_SYSTEM_RABBITMQ: "rabbitmq",
338
+ MESSAGING_DESTINATION_KIND_EXCHANGE: "exchange",
339
+ MESSAGING_DESTINATION_KIND_QUEUE: "queue",
340
+ MESSAGING_OPERATION_PUBLISH: "publish",
341
+ MESSAGING_OPERATION_RECEIVE: "receive",
342
+ MESSAGING_OPERATION_PROCESS: "process"
343
+ };
344
+ /**
345
+ * Instrumentation scope name for amqp-contract.
346
+ */
347
+ const INSTRUMENTATION_SCOPE_NAME = "@amqp-contract";
348
+ const INSTRUMENTATION_SCOPE_VERSION = "0.1.0";
349
+ let otelApi;
350
+ let cachedTracer;
351
+ let cachedPublishCounter;
352
+ let cachedConsumeCounter;
353
+ let cachedPublishLatencyHistogram;
354
+ let cachedConsumeLatencyHistogram;
355
+ /**
356
+ * Try to load the OpenTelemetry API module.
357
+ * Returns null if the module is not available.
358
+ */
359
+ function tryLoadOpenTelemetryApi() {
360
+ if (otelApi === void 0) try {
361
+ otelApi = require("@opentelemetry/api");
362
+ } catch {
363
+ otelApi = null;
364
+ }
365
+ return otelApi;
366
+ }
367
+ /**
368
+ * Get or create a tracer instance.
369
+ */
370
+ function getTracer() {
371
+ if (cachedTracer !== void 0) return cachedTracer;
372
+ const api = tryLoadOpenTelemetryApi();
373
+ if (!api) return;
374
+ cachedTracer = api.trace.getTracer(INSTRUMENTATION_SCOPE_NAME, INSTRUMENTATION_SCOPE_VERSION);
375
+ return cachedTracer;
376
+ }
377
+ /**
378
+ * Get or create a meter and its instruments.
379
+ */
380
+ function getMeterInstruments() {
381
+ if (cachedPublishCounter !== void 0) return {
382
+ publishCounter: cachedPublishCounter,
383
+ consumeCounter: cachedConsumeCounter,
384
+ publishLatencyHistogram: cachedPublishLatencyHistogram,
385
+ consumeLatencyHistogram: cachedConsumeLatencyHistogram
386
+ };
387
+ const api = tryLoadOpenTelemetryApi();
388
+ if (!api) return {
389
+ publishCounter: void 0,
390
+ consumeCounter: void 0,
391
+ publishLatencyHistogram: void 0,
392
+ consumeLatencyHistogram: void 0
393
+ };
394
+ const meter = api.metrics.getMeter(INSTRUMENTATION_SCOPE_NAME, INSTRUMENTATION_SCOPE_VERSION);
395
+ cachedPublishCounter = meter.createCounter("amqp.client.messages.published", {
396
+ description: "Number of messages published to AMQP broker",
397
+ unit: "{message}"
398
+ });
399
+ cachedConsumeCounter = meter.createCounter("amqp.worker.messages.consumed", {
400
+ description: "Number of messages consumed from AMQP broker",
401
+ unit: "{message}"
402
+ });
403
+ cachedPublishLatencyHistogram = meter.createHistogram("amqp.client.publish.duration", {
404
+ description: "Duration of message publish operations",
405
+ unit: "ms"
406
+ });
407
+ cachedConsumeLatencyHistogram = meter.createHistogram("amqp.worker.process.duration", {
408
+ description: "Duration of message processing operations",
409
+ unit: "ms"
410
+ });
411
+ return {
412
+ publishCounter: cachedPublishCounter,
413
+ consumeCounter: cachedConsumeCounter,
414
+ publishLatencyHistogram: cachedPublishLatencyHistogram,
415
+ consumeLatencyHistogram: cachedConsumeLatencyHistogram
416
+ };
417
+ }
418
+ /**
419
+ * Default telemetry provider that uses OpenTelemetry API if available.
420
+ */
421
+ const defaultTelemetryProvider = {
422
+ getTracer,
423
+ getPublishCounter: () => getMeterInstruments().publishCounter,
424
+ getConsumeCounter: () => getMeterInstruments().consumeCounter,
425
+ getPublishLatencyHistogram: () => getMeterInstruments().publishLatencyHistogram,
426
+ getConsumeLatencyHistogram: () => getMeterInstruments().consumeLatencyHistogram
427
+ };
428
+ /**
429
+ * Create a span for a publish operation.
430
+ * Returns undefined if OpenTelemetry is not available.
431
+ */
432
+ function startPublishSpan(provider, exchangeName, routingKey, attributes) {
433
+ const tracer = provider.getTracer();
434
+ if (!tracer) return;
435
+ const spanName = `${exchangeName} publish`;
436
+ return tracer.startSpan(spanName, {
437
+ kind: SpanKind.PRODUCER,
438
+ attributes: {
439
+ [MessagingSemanticConventions.MESSAGING_SYSTEM]: MessagingSemanticConventions.MESSAGING_SYSTEM_RABBITMQ,
440
+ [MessagingSemanticConventions.MESSAGING_DESTINATION]: exchangeName,
441
+ [MessagingSemanticConventions.MESSAGING_DESTINATION_KIND]: MessagingSemanticConventions.MESSAGING_DESTINATION_KIND_EXCHANGE,
442
+ [MessagingSemanticConventions.MESSAGING_OPERATION]: MessagingSemanticConventions.MESSAGING_OPERATION_PUBLISH,
443
+ ...routingKey ? { [MessagingSemanticConventions.MESSAGING_RABBITMQ_ROUTING_KEY]: routingKey } : {},
444
+ ...attributes
445
+ }
446
+ });
447
+ }
448
+ /**
449
+ * Create a span for a consume/process operation.
450
+ * Returns undefined if OpenTelemetry is not available.
451
+ */
452
+ function startConsumeSpan(provider, queueName, consumerName, attributes) {
453
+ const tracer = provider.getTracer();
454
+ if (!tracer) return;
455
+ const spanName = `${queueName} process`;
456
+ return tracer.startSpan(spanName, {
457
+ kind: SpanKind.CONSUMER,
458
+ attributes: {
459
+ [MessagingSemanticConventions.MESSAGING_SYSTEM]: MessagingSemanticConventions.MESSAGING_SYSTEM_RABBITMQ,
460
+ [MessagingSemanticConventions.MESSAGING_DESTINATION]: queueName,
461
+ [MessagingSemanticConventions.MESSAGING_DESTINATION_KIND]: MessagingSemanticConventions.MESSAGING_DESTINATION_KIND_QUEUE,
462
+ [MessagingSemanticConventions.MESSAGING_OPERATION]: MessagingSemanticConventions.MESSAGING_OPERATION_PROCESS,
463
+ "amqp.consumer.name": consumerName,
464
+ ...attributes
465
+ }
466
+ });
467
+ }
468
+ /**
469
+ * End a span with success status.
470
+ */
471
+ function endSpanSuccess(span) {
472
+ if (!span) return;
473
+ const api = tryLoadOpenTelemetryApi();
474
+ if (api) span.setStatus({ code: api.SpanStatusCode.OK });
475
+ span.end();
476
+ }
477
+ /**
478
+ * End a span with error status.
479
+ */
480
+ function endSpanError(span, error) {
481
+ if (!span) return;
482
+ const api = tryLoadOpenTelemetryApi();
483
+ if (api) {
484
+ span.setStatus({
485
+ code: api.SpanStatusCode.ERROR,
486
+ message: error.message
487
+ });
488
+ span.recordException(error);
489
+ span.setAttribute(MessagingSemanticConventions.ERROR_TYPE, error.name);
490
+ }
491
+ span.end();
492
+ }
493
+ /**
494
+ * Record a publish metric.
495
+ */
496
+ function recordPublishMetric(provider, exchangeName, routingKey, success, durationMs) {
497
+ const publishCounter = provider.getPublishCounter();
498
+ const publishLatencyHistogram = provider.getPublishLatencyHistogram();
499
+ const attributes = {
500
+ [MessagingSemanticConventions.MESSAGING_SYSTEM]: MessagingSemanticConventions.MESSAGING_SYSTEM_RABBITMQ,
501
+ [MessagingSemanticConventions.MESSAGING_DESTINATION]: exchangeName,
502
+ ...routingKey ? { [MessagingSemanticConventions.MESSAGING_RABBITMQ_ROUTING_KEY]: routingKey } : {},
503
+ success
504
+ };
505
+ publishCounter?.add(1, attributes);
506
+ publishLatencyHistogram?.record(durationMs, attributes);
507
+ }
508
+ /**
509
+ * Record a consume metric.
510
+ */
511
+ function recordConsumeMetric(provider, queueName, consumerName, success, durationMs) {
512
+ const consumeCounter = provider.getConsumeCounter();
513
+ const consumeLatencyHistogram = provider.getConsumeLatencyHistogram();
514
+ const attributes = {
515
+ [MessagingSemanticConventions.MESSAGING_SYSTEM]: MessagingSemanticConventions.MESSAGING_SYSTEM_RABBITMQ,
516
+ [MessagingSemanticConventions.MESSAGING_DESTINATION]: queueName,
517
+ "amqp.consumer.name": consumerName,
518
+ success
519
+ };
520
+ consumeCounter?.add(1, attributes);
521
+ consumeLatencyHistogram?.record(durationMs, attributes);
522
+ }
523
+ /**
524
+ * Reset the cached OpenTelemetry API module and instruments.
525
+ * For testing purposes only.
526
+ * @internal
527
+ */
528
+ function _resetTelemetryCacheForTesting() {
529
+ otelApi = void 0;
530
+ cachedTracer = void 0;
531
+ cachedPublishCounter = void 0;
532
+ cachedConsumeCounter = void 0;
533
+ cachedPublishLatencyHistogram = void 0;
534
+ cachedConsumeLatencyHistogram = void 0;
535
+ }
536
+
196
537
  //#endregion
197
538
  exports.AmqpClient = AmqpClient;
198
539
  exports.ConnectionManagerSingleton = ConnectionManagerSingleton;
199
- exports.setupAmqpTopology = setupAmqpTopology;
540
+ exports.MessagingSemanticConventions = MessagingSemanticConventions;
541
+ exports._resetTelemetryCacheForTesting = _resetTelemetryCacheForTesting;
542
+ exports.defaultTelemetryProvider = defaultTelemetryProvider;
543
+ exports.endSpanError = endSpanError;
544
+ exports.endSpanSuccess = endSpanSuccess;
545
+ exports.recordConsumeMetric = recordConsumeMetric;
546
+ exports.recordPublishMetric = recordPublishMetric;
547
+ exports.setupAmqpTopology = setupAmqpTopology;
548
+ exports.startConsumeSpan = startConsumeSpan;
549
+ exports.startPublishSpan = startPublishSpan;