@zdavison/matador 4.0.1 → 4.0.3

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.
Files changed (38) hide show
  1. package/dist/core/fanout.d.ts +62 -0
  2. package/dist/core/fanout.d.ts.map +1 -1
  3. package/dist/core/fanout.js +217 -15
  4. package/dist/core/fanout.test.js +904 -6
  5. package/dist/core/matador.d.ts +33 -0
  6. package/dist/core/matador.d.ts.map +1 -1
  7. package/dist/core/matador.js +6 -1
  8. package/dist/core/matador.test.js +243 -7
  9. package/dist/errors/index.d.ts +1 -1
  10. package/dist/errors/index.d.ts.map +1 -1
  11. package/dist/errors/index.js +2 -2
  12. package/dist/errors/matador-errors.d.ts +10 -8
  13. package/dist/errors/matador-errors.d.ts.map +1 -1
  14. package/dist/errors/matador-errors.js +18 -14
  15. package/dist/index.cjs +345 -73
  16. package/dist/index.cjs.map +1 -1
  17. package/dist/index.d.cts +1 -1
  18. package/dist/index.d.ts +1 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +2 -2
  21. package/dist/index.js.map +1 -1
  22. package/dist/transport/local/local-transport.d.ts +6 -0
  23. package/dist/transport/local/local-transport.d.ts.map +1 -1
  24. package/dist/transport/local/local-transport.js +35 -18
  25. package/dist/transport/local/local-transport.test.js +40 -4
  26. package/dist/transport/multi/multi-transport.d.ts +6 -0
  27. package/dist/transport/multi/multi-transport.d.ts.map +1 -1
  28. package/dist/transport/multi/multi-transport.js +20 -0
  29. package/dist/transport/multi/multi-transport.test.js +82 -0
  30. package/dist/transport/rabbitmq/rabbitmq-transport-reconnection.test.js +45 -6
  31. package/dist/transport/rabbitmq/rabbitmq-transport.d.ts +6 -0
  32. package/dist/transport/rabbitmq/rabbitmq-transport.d.ts.map +1 -1
  33. package/dist/transport/rabbitmq/rabbitmq-transport.js +83 -40
  34. package/dist/transport/transport.d.ts +12 -0
  35. package/dist/transport/transport.d.ts.map +1 -1
  36. package/dist/types/event.d.ts +17 -0
  37. package/dist/types/event.d.ts.map +1 -1
  38. package/package.json +1 -1
package/dist/index.cjs CHANGED
@@ -202,6 +202,16 @@ var DelayedMessagesNotSupportedError = class extends MatadorError {
202
202
  transportName;
203
203
  description = "Delayed messages were requested but the transport does not support them. ACTION: For RabbitMQ, install the rabbitmq_delayed_message_exchange plugin. Run: rabbitmq-plugins enable rabbitmq_delayed_message_exchange Then restart RabbitMQ and reconnect. Alternatively, remove delayMs from your event options if delays are not required.";
204
204
  };
205
+ var LocalTransportNoActiveSubscriberError = class extends MatadorError {
206
+ constructor(queue) {
207
+ super(
208
+ `LocalTransport has no active subscriber for queue "${queue}"; message cannot be delivered.`
209
+ );
210
+ this.queue = queue;
211
+ }
212
+ queue;
213
+ description = "The LocalTransport has no active subscriber for this queue in the current process, so it cannot guarantee delivery. ACTION: If the subscriber is a SubscriberStub (implemented in another service), the local transport can never deliver it locally \u2014 a distributed transport like RabbitMQ is required. If the subscriber should run in this process, ensure matador.start() has subscribed to this queue before sending.";
214
+ };
205
215
  var EventNotRegisteredError = class extends MatadorError {
206
216
  constructor(eventKey) {
207
217
  super(
@@ -252,16 +262,6 @@ var SubscriberIsStubError = class extends MatadorError {
252
262
  subscriberName;
253
263
  description = "A SubscriberStub was registered in a consuming schema. ACTION: SubscriberStubs should only be used in producer schemas to declare that a subscriber exists in another service. In the consumer service, provide a full Subscriber with a callback function. Remove the stub from the consumer schema and add the actual implementation.";
254
264
  };
255
- var LocalTransportCannotProcessStubError = class extends MatadorError {
256
- constructor(subscriberName) {
257
- super(
258
- `LocalTransport cannot process stub subscriber "${subscriberName}". Stub subscribers require a distributed transport like RabbitMQ.`
259
- );
260
- this.subscriberName = subscriberName;
261
- }
262
- subscriberName;
263
- description = "The LocalTransport cannot process events for SubscriberStubs. ACTION: SubscriberStubs represent remote implementations that only RabbitMQ can route. If using LocalTransport for testing, provide mock implementations instead of stubs. For production fallback scenarios, be aware that stub-targeted events will be dropped.";
264
- };
265
265
  var UnknownQueueReferenceError = class extends MatadorError {
266
266
  constructor(queueName) {
267
267
  super(
@@ -801,6 +801,7 @@ function createSubscriberStub(input) {
801
801
  }
802
802
 
803
803
  // src/core/fanout.ts
804
+ var DEFAULT_MAX_CONSECUTIVE_FLUSH_FAILURES = 10;
804
805
  var FanoutEngine = class {
805
806
  transport;
806
807
  schema;
@@ -808,18 +809,43 @@ var FanoutEngine = class {
808
809
  topology;
809
810
  defaultQueue;
810
811
  enqueuingCount = 0;
812
+ flushInFlightCount = 0;
813
+ retryBuffer = [];
814
+ maxRetryBufferSize;
815
+ maxRetryAttempts;
816
+ maxConsecutiveFlushFailures;
817
+ disposeOnConnected;
818
+ retryTimer;
811
819
  constructor(config) {
812
820
  this.transport = config.transport;
813
821
  this.schema = config.schema;
814
822
  this.hooks = config.hooks;
815
823
  this.topology = config.topology;
816
824
  this.defaultQueue = config.defaultQueue;
825
+ this.maxRetryBufferSize = config.maxRetryBufferSize ?? 5e3;
826
+ this.maxRetryAttempts = config.maxRetryAttempts;
827
+ const maxConsecutiveFlushFailures = config.maxConsecutiveFlushFailures ?? DEFAULT_MAX_CONSECUTIVE_FLUSH_FAILURES;
828
+ this.maxConsecutiveFlushFailures = maxConsecutiveFlushFailures > 0 ? maxConsecutiveFlushFailures : Number.POSITIVE_INFINITY;
829
+ this.disposeOnConnected = this.transport.onConnected?.(() => {
830
+ void this.flushRetryBuffer();
831
+ });
832
+ const retryIntervalMs = config.retryIntervalMs ?? 3e4;
833
+ if (retryIntervalMs > 0) {
834
+ this.retryTimer = setInterval(() => {
835
+ void this.flushRetryBuffer();
836
+ }, retryIntervalMs);
837
+ this.retryTimer.unref?.();
838
+ }
839
+ }
840
+ dispose() {
841
+ this.disposeOnConnected?.();
842
+ clearInterval(this.retryTimer);
817
843
  }
818
844
  /**
819
845
  * Current count of events being enqueued.
820
846
  */
821
847
  get eventsBeingEnqueuedCount() {
822
- return this.enqueuingCount;
848
+ return this.enqueuingCount + this.flushInFlightCount;
823
849
  }
824
850
  /**
825
851
  * Sends an event to all registered subscribers.
@@ -851,12 +877,13 @@ var FanoutEngine = class {
851
877
  universalMetadata,
852
878
  delayMs: options.delayMs
853
879
  });
880
+ const sendOptions = options.delayMs !== void 0 ? { delay: options.delayMs } : void 0;
854
881
  this.enqueuingCount++;
855
882
  try {
856
883
  const usedTransport = await this.transport.send(
857
884
  qualifiedQueue,
858
885
  envelope,
859
- options.delayMs !== void 0 ? { delay: options.delayMs } : void 0
886
+ sendOptions
860
887
  );
861
888
  sent++;
862
889
  await this.hooks.onEnqueueSuccess({
@@ -866,17 +893,55 @@ var FanoutEngine = class {
866
893
  });
867
894
  } catch (error) {
868
895
  const cause = error instanceof Error ? error : new Error(String(error));
869
- const err = new TransportSendError(qualifiedQueue, cause);
870
- errors.push({
871
- subscriberName: subscriber.name,
872
- queue: qualifiedQueue,
873
- error: err
874
- });
875
- await this.hooks.onEnqueueError({
876
- envelope,
877
- error: err,
878
- transport: this.transport.name
879
- });
896
+ const shouldBuffer = options.buffer !== false;
897
+ if (shouldBuffer && this.retryBuffer.length < this.maxRetryBufferSize) {
898
+ this.retryBuffer.push({
899
+ queue: qualifiedQueue,
900
+ envelope,
901
+ sendOptions,
902
+ subscriberName: subscriber.name,
903
+ attempts: 0
904
+ });
905
+ this.hooks.logger.warn(
906
+ `[Matador] \u{1F7E1} Message for '${subscriber.name}' buffered for retry on reconnect.`,
907
+ {
908
+ queue: qualifiedQueue,
909
+ subscriberName: subscriber.name,
910
+ bufferSize: this.retryBuffer.length,
911
+ maxBufferSize: this.maxRetryBufferSize
912
+ }
913
+ );
914
+ if (options.throwOnBufferedFailure) {
915
+ const err = new TransportSendError(qualifiedQueue, cause);
916
+ errors.push({
917
+ subscriberName: subscriber.name,
918
+ queue: qualifiedQueue,
919
+ error: err
920
+ });
921
+ await this.hooks.onEnqueueError({
922
+ envelope,
923
+ error: err,
924
+ transport: this.transport.name
925
+ });
926
+ }
927
+ } else {
928
+ if (shouldBuffer) {
929
+ this.hooks.logger.error(
930
+ `[Matador] \u{1F534} Retry buffer full (${this.maxRetryBufferSize}). Message for '${subscriber.name}' dropped and will not be retried.`
931
+ );
932
+ }
933
+ const err = new TransportSendError(qualifiedQueue, cause);
934
+ errors.push({
935
+ subscriberName: subscriber.name,
936
+ queue: qualifiedQueue,
937
+ error: err
938
+ });
939
+ await this.hooks.onEnqueueError({
940
+ envelope,
941
+ error: err,
942
+ transport: this.transport.name
943
+ });
944
+ }
880
945
  } finally {
881
946
  this.enqueuingCount--;
882
947
  }
@@ -888,6 +953,140 @@ var FanoutEngine = class {
888
953
  errors
889
954
  };
890
955
  }
956
+ /**
957
+ * Attempts a single buffered item during a flush pass
958
+ * @returns true if the attempt succeeded; false on failure
959
+ */
960
+ async attemptFlushItem(item) {
961
+ this.enqueuingCount++;
962
+ try {
963
+ const usedTransport = await this.transport.send(
964
+ item.queue,
965
+ item.envelope,
966
+ item.sendOptions
967
+ );
968
+ await this.hooks.onEnqueueSuccess({
969
+ envelope: item.envelope,
970
+ queue: item.queue,
971
+ transport: usedTransport
972
+ });
973
+ return true;
974
+ } catch (error) {
975
+ await this.handleFlushFailure(item, error);
976
+ return false;
977
+ } finally {
978
+ this.enqueuingCount--;
979
+ }
980
+ }
981
+ /**
982
+ * Flushes the retry buffer
983
+ *
984
+ * This is called when the transport reconnects, and is used to retry any messages that were buffered while the transport was disconnected
985
+ */
986
+ async flushRetryBuffer() {
987
+ if (this.retryBuffer.length === 0) return;
988
+ this.hooks.logger.info(
989
+ `[Matador] \u23F3 Flushing ${this.retryBuffer.length} buffered message(s)...`
990
+ );
991
+ const toFlush = this.retryBuffer.splice(0);
992
+ this.flushInFlightCount += toFlush.length;
993
+ let consecutiveFailures = 0;
994
+ try {
995
+ for (let i = 0; i < toFlush.length; i++) {
996
+ const item = toFlush[i];
997
+ if (!item) {
998
+ this.flushInFlightCount--;
999
+ continue;
1000
+ }
1001
+ const succeeded = await this.attemptFlushItem(item);
1002
+ this.flushInFlightCount--;
1003
+ const randomMs = Math.random() * 10;
1004
+ await new Promise((resolve) => setTimeout(resolve, randomMs));
1005
+ consecutiveFailures = succeeded ? 0 : consecutiveFailures + 1;
1006
+ if (consecutiveFailures >= this.maxConsecutiveFlushFailures) {
1007
+ const untried = toFlush.slice(i + 1);
1008
+ this.flushInFlightCount -= untried.length;
1009
+ if (untried.length > 0) {
1010
+ await this.rebufferUntried(untried, consecutiveFailures);
1011
+ }
1012
+ break;
1013
+ }
1014
+ }
1015
+ } finally {
1016
+ this.flushInFlightCount = 0;
1017
+ }
1018
+ const remaining = this.retryBuffer.length;
1019
+ if (remaining > 0) {
1020
+ this.hooks.logger.warn(
1021
+ `[Matador] \u{1F7E1} ${remaining} buffered message(s) could not be flushed; will retry later.`
1022
+ );
1023
+ } else {
1024
+ this.hooks.logger.info(
1025
+ "[Matador] \u{1F7E2} All buffered messages flushed successfully."
1026
+ );
1027
+ }
1028
+ }
1029
+ /**
1030
+ * Re-buffers messages that were never attempted because a flush pass
1031
+ * stopped out early. Respects maxRetryBufferSize: concurrent sends can have
1032
+ * refilled the buffer while this flush was running, so anything beyond
1033
+ * remaining capacity is dropped and reported instead of silently growing
1034
+ * the buffer past its cap.
1035
+ */
1036
+ async rebufferUntried(untried, consecutiveFailures) {
1037
+ const capacity = Math.max(
1038
+ 0,
1039
+ this.maxRetryBufferSize - this.retryBuffer.length
1040
+ );
1041
+ const toRebuffer = untried.slice(0, capacity);
1042
+ const dropped = untried.slice(capacity);
1043
+ if (toRebuffer.length > 0) {
1044
+ this.retryBuffer.unshift(...toRebuffer);
1045
+ }
1046
+ const droppedSuffix = dropped.length > 0 ? `, ${dropped.length} dropped (buffer full).` : ".";
1047
+ this.hooks.logger.warn(
1048
+ `[Matador] \u{1F7E1} Stopping this flush pass after ${consecutiveFailures} consecutive failures; ${toRebuffer.length} untried message(s) re-buffered for the next attempt${droppedSuffix}`
1049
+ );
1050
+ for (const item of dropped) {
1051
+ this.hooks.logger.error(
1052
+ `[Matador] \u{1F534} Retry buffer full (${this.maxRetryBufferSize}). Message for '${item.subscriberName}' dropped and will not be retried.`
1053
+ );
1054
+ const err = new TransportSendError(
1055
+ item.queue,
1056
+ new Error("Retry buffer full")
1057
+ );
1058
+ await this.hooks.onEnqueueError({
1059
+ envelope: item.envelope,
1060
+ error: err,
1061
+ transport: this.transport.name
1062
+ });
1063
+ }
1064
+ }
1065
+ /**
1066
+ * Handles a failed flush attempt for a single buffered item: drops it once
1067
+ * maxRetryAttempts is exceeded, otherwise re-buffers it (unless the buffer
1068
+ * is full, in which case it's dropped too).
1069
+ */
1070
+ async handleFlushFailure(item, error) {
1071
+ item.attempts++;
1072
+ const cause = error instanceof Error ? error : new Error(String(error));
1073
+ const exceededAttempts = this.maxRetryAttempts !== void 0 && item.attempts >= this.maxRetryAttempts;
1074
+ if (!exceededAttempts && this.retryBuffer.length < this.maxRetryBufferSize) {
1075
+ this.retryBuffer.push(item);
1076
+ return;
1077
+ }
1078
+ if (exceededAttempts) {
1079
+ this.hooks.logger.error(
1080
+ `[Matador] \u{1F534} Message for '${item.subscriberName}' exceeded max retry attempts (${this.maxRetryAttempts}) and will not be retried further.`
1081
+ );
1082
+ }
1083
+ const err = new TransportSendError(item.queue, cause);
1084
+ await this.hooks.onEnqueueError({
1085
+ envelope: item.envelope,
1086
+ error: err,
1087
+ transport: this.transport.name
1088
+ });
1089
+ }
891
1090
  async isSubscriberEnabled(subscriber) {
892
1091
  if (!subscriber.enabled) {
893
1092
  return true;
@@ -2180,7 +2379,11 @@ var Matador = class {
2180
2379
  schema: this.schema,
2181
2380
  hooks: this.hooks,
2182
2381
  topology: this.topology,
2183
- defaultQueue
2382
+ defaultQueue,
2383
+ maxRetryBufferSize: config.maxRetryBufferSize,
2384
+ maxRetryAttempts: config.maxRetryAttempts,
2385
+ retryIntervalMs: config.retryIntervalMs,
2386
+ maxConsecutiveFlushFailures: config.maxConsecutiveFlushFailures
2184
2387
  });
2185
2388
  this.shutdownManager = new ShutdownManager(
2186
2389
  () => this.fanout.eventsBeingEnqueuedCount,
@@ -2354,6 +2557,7 @@ var Matador = class {
2354
2557
  if (!this.started) {
2355
2558
  return;
2356
2559
  }
2560
+ this.fanout.dispose();
2357
2561
  await this.shutdownManager.shutdown();
2358
2562
  this.started = false;
2359
2563
  }
@@ -2365,7 +2569,7 @@ var Matador = class {
2365
2569
  }
2366
2570
  async unsubscribeAll() {
2367
2571
  for (const subscription of this.subscriptions) {
2368
- await subscription.unsubscribe();
2572
+ await (subscription.pauseForShutdown ?? subscription.unsubscribe)();
2369
2573
  }
2370
2574
  this.subscriptions.length = 0;
2371
2575
  }
@@ -2527,6 +2731,7 @@ var localCapabilities = {
2527
2731
  ordering: "queue",
2528
2732
  priorities: false
2529
2733
  };
2734
+ var MAX_COMPLETED_MESSAGES = 1e3;
2530
2735
  var LocalTransport = class {
2531
2736
  name = "local";
2532
2737
  capabilities = localCapabilities;
@@ -2570,7 +2775,7 @@ var LocalTransport = class {
2570
2775
  topology.prefix
2571
2776
  );
2572
2777
  if (!this.queues.has(queueName)) {
2573
- this.queues.set(queueName, []);
2778
+ this.queues.set(queueName, /* @__PURE__ */ new Map());
2574
2779
  }
2575
2780
  }
2576
2781
  }
@@ -2602,6 +2807,18 @@ var LocalTransport = class {
2602
2807
  this.delayedTimers.add(timer);
2603
2808
  }
2604
2809
  async enqueue(queue, envelope) {
2810
+ const subs = this.subscriptions.get(queue);
2811
+ if (!subs || subs.length === 0) {
2812
+ throw new LocalTransportNoActiveSubscriberError(queue);
2813
+ }
2814
+ await this.storeAndDeliver(queue, envelope);
2815
+ }
2816
+ /**
2817
+ * Stores a message and delivers it to any active subscribers, without
2818
+ * requiring one to be present. Dead-letter queues are meant to hold
2819
+ * messages for later manual inspection, so they don't need a live consumer.
2820
+ */
2821
+ async storeAndDeliver(queue, envelope) {
2605
2822
  const messages = this.getOrCreateQueue(queue);
2606
2823
  const messageId = `${++this.messageIdCounter}`;
2607
2824
  const queuedMessage = {
@@ -2609,17 +2826,11 @@ var LocalTransport = class {
2609
2826
  id: messageId,
2610
2827
  completed: false
2611
2828
  };
2612
- messages.push(queuedMessage);
2829
+ messages.set(messageId, queuedMessage);
2613
2830
  await this.deliverToSubscribers(queue, queuedMessage);
2614
2831
  }
2615
2832
  async deliverToSubscribers(queue, message) {
2616
- const subs = this.subscriptions.get(queue);
2617
- if (!subs) {
2618
- this.logger.warn(
2619
- `[Matador][LocalTransport] \u{1F7E1} No subscriptions found for queue '${queue}', message will be lost.`
2620
- );
2621
- return;
2622
- }
2833
+ const subs = this.subscriptions.get(queue) ?? [];
2623
2834
  for (const sub of subs) {
2624
2835
  if (!sub.active || message.completed) continue;
2625
2836
  const receipt = {
@@ -2652,7 +2863,7 @@ var LocalTransport = class {
2652
2863
  const subs = this.subscriptions.get(queue) ?? [];
2653
2864
  subs.push(subscription);
2654
2865
  this.subscriptions.set(queue, subs);
2655
- const messages = this.queues.get(queue) ?? [];
2866
+ const messages = this.queues.get(queue)?.values() ?? [];
2656
2867
  for (const message of messages) {
2657
2868
  if (message.completed) continue;
2658
2869
  await this.deliverToSubscribers(queue, message);
@@ -2667,6 +2878,11 @@ var LocalTransport = class {
2667
2878
  this.subscriptions.delete(queue);
2668
2879
  }
2669
2880
  },
2881
+ // No-op: LocalTransport is only ever fed from within the process
2882
+ // So, when shutting down, it needs to continue and handle new messages until the end
2883
+ // As, an ongoing message (before shutdown) could create new (local) messages
2884
+ pauseForShutdown: async () => {
2885
+ },
2670
2886
  get isActive() {
2671
2887
  return subscription.active;
2672
2888
  }
@@ -2676,10 +2892,14 @@ var LocalTransport = class {
2676
2892
  const message = receipt.handle;
2677
2893
  message.completed = true;
2678
2894
  this.completedMessages.push(receipt);
2895
+ if (this.completedMessages.length > MAX_COMPLETED_MESSAGES) {
2896
+ this.completedMessages.shift();
2897
+ }
2898
+ this.queues.get(receipt.sourceQueue)?.delete(message.id);
2679
2899
  }
2680
2900
  async sendToDeadLetter(receipt, dlqName, envelope, _reason) {
2681
2901
  const dlqQueueName = `${receipt.sourceQueue}.${dlqName}`;
2682
- await this.enqueue(dlqQueueName, envelope);
2902
+ await this.storeAndDeliver(dlqQueueName, envelope);
2683
2903
  await this.complete(receipt);
2684
2904
  }
2685
2905
  // Test helpers
@@ -2687,9 +2907,7 @@ var LocalTransport = class {
2687
2907
  * Gets the current size of a queue.
2688
2908
  */
2689
2909
  getQueueSize(queue) {
2690
- const messages = this.queues.get(queue);
2691
- if (!messages) return 0;
2692
- return messages.filter((m) => !m.completed).length;
2910
+ return this.queues.get(queue)?.size ?? 0;
2693
2911
  }
2694
2912
  /**
2695
2913
  * Gets all completed message receipts.
@@ -2703,7 +2921,7 @@ var LocalTransport = class {
2703
2921
  getPendingMessages(queue) {
2704
2922
  const messages = this.queues.get(queue);
2705
2923
  if (!messages) return [];
2706
- return messages.filter((m) => !m.completed).map((m) => m.envelope);
2924
+ return Array.from(messages.values()).map((m) => m.envelope);
2707
2925
  }
2708
2926
  /**
2709
2927
  * Clears all state (for test isolation).
@@ -2725,7 +2943,7 @@ var LocalTransport = class {
2725
2943
  async receiveOne(queue) {
2726
2944
  const messages = this.queues.get(queue);
2727
2945
  if (!messages) return null;
2728
- const pending = messages.find((m) => !m.completed);
2946
+ const pending = messages.values().next().value;
2729
2947
  if (!pending) return null;
2730
2948
  const receipt = {
2731
2949
  handle: pending,
@@ -2740,7 +2958,7 @@ var LocalTransport = class {
2740
2958
  getOrCreateQueue(queue) {
2741
2959
  let messages = this.queues.get(queue);
2742
2960
  if (!messages) {
2743
- messages = [];
2961
+ messages = /* @__PURE__ */ new Map();
2744
2962
  this.queues.set(queue, messages);
2745
2963
  }
2746
2964
  return messages;
@@ -2792,6 +3010,19 @@ var MultiTransport = class {
2792
3010
  isConnected() {
2793
3011
  return this.connected && this.primary.isConnected();
2794
3012
  }
3013
+ /**
3014
+ * Registers a callback to fire each time the transport successfully (re)connects
3015
+ * @param callback - The callback to fire when the transport successfully (re)connects
3016
+ * @returns A function to unsubscribe from the callback
3017
+ */
3018
+ onConnected(callback) {
3019
+ const unsubFunctions = this.transports.map((t) => t.onConnected?.(callback)).filter(Boolean);
3020
+ return () => {
3021
+ for (const unsub of unsubFunctions) {
3022
+ unsub();
3023
+ }
3024
+ };
3025
+ }
2795
3026
  async applyTopology(topology) {
2796
3027
  await Promise.all(this.transports.map((t) => t.applyTopology(topology)));
2797
3028
  }
@@ -2870,6 +3101,11 @@ var MultiTransport = class {
2870
3101
  unsubscribe: async () => {
2871
3102
  await Promise.all(subscriptions.map((s) => s.unsubscribe()));
2872
3103
  },
3104
+ pauseForShutdown: async () => {
3105
+ await Promise.all(
3106
+ subscriptions.map((s) => (s.pauseForShutdown ?? s.unsubscribe)())
3107
+ );
3108
+ },
2873
3109
  get isActive() {
2874
3110
  return subscriptions.some((s) => s.isActive);
2875
3111
  }
@@ -2941,6 +3177,18 @@ var RabbitMQTransport = class {
2941
3177
  isConnected() {
2942
3178
  return this.connectionManager.isConnected();
2943
3179
  }
3180
+ /**
3181
+ * Registers a callback to fire each time the transport (here RabbitMQ) successfully (re)connects
3182
+ * @param callback - The callback to fire when the transport (here RabbitMQ) successfully (re)connects
3183
+ * @returns A function to unsubscribe from the callback
3184
+ */
3185
+ onConnected(callback) {
3186
+ return this.connectionManager.onStateChange((state) => {
3187
+ if (state.status === "connected") {
3188
+ callback();
3189
+ }
3190
+ });
3191
+ }
2944
3192
  async applyTopology(topology) {
2945
3193
  this.topology = topology;
2946
3194
  if (!this.publishChannel) {
@@ -3034,33 +3282,37 @@ var RabbitMQTransport = class {
3034
3282
  };
3035
3283
  this.subscriptionIntents.push(intent);
3036
3284
  await this.activateIntent(intent);
3037
- return {
3038
- unsubscribe: async () => {
3039
- intent.active = false;
3040
- const idx = this.subscriptionIntents.indexOf(intent);
3041
- if (idx !== -1) this.subscriptionIntents.splice(idx, 1);
3042
- const consumer = intent.currentConsumer;
3043
- if (consumer) {
3044
- consumer.active = false;
3045
- const queueChannel = this.queueChannels.get(queue);
3046
- if (queueChannel) {
3285
+ const unsubscribe = async () => {
3286
+ intent.active = false;
3287
+ const idx = this.subscriptionIntents.indexOf(intent);
3288
+ if (idx !== -1) this.subscriptionIntents.splice(idx, 1);
3289
+ const consumer = intent.currentConsumer;
3290
+ if (consumer) {
3291
+ consumer.active = false;
3292
+ const queueChannel = this.queueChannels.get(queue);
3293
+ if (queueChannel) {
3294
+ try {
3295
+ await queueChannel.channel.cancel(consumer.consumerTag);
3296
+ } catch {
3297
+ }
3298
+ const cIdx = queueChannel.consumers.indexOf(consumer);
3299
+ if (cIdx !== -1) queueChannel.consumers.splice(cIdx, 1);
3300
+ if (queueChannel.consumers.length === 0) {
3047
3301
  try {
3048
- await queueChannel.channel.cancel(consumer.consumerTag);
3302
+ await queueChannel.channel.close();
3049
3303
  } catch {
3050
3304
  }
3051
- const cIdx = queueChannel.consumers.indexOf(consumer);
3052
- if (cIdx !== -1) queueChannel.consumers.splice(cIdx, 1);
3053
- if (queueChannel.consumers.length === 0) {
3054
- try {
3055
- await queueChannel.channel.close();
3056
- } catch {
3057
- }
3058
- this.queueChannels.delete(queue);
3059
- }
3305
+ this.queueChannels.delete(queue);
3060
3306
  }
3061
- intent.currentConsumer = null;
3062
3307
  }
3063
- },
3308
+ intent.currentConsumer = null;
3309
+ }
3310
+ };
3311
+ return {
3312
+ unsubscribe,
3313
+ // Messages on this transport comes from outside (RabbitMQ), so, when shutting down, we need to unsubscribe directly
3314
+ // Otherwise, we could receive traffic forever, and an idle state would never be reached.
3315
+ pauseForShutdown: unsubscribe,
3064
3316
  get isActive() {
3065
3317
  return intent.active;
3066
3318
  }
@@ -3219,6 +3471,14 @@ var RabbitMQTransport = class {
3219
3471
  return queueChannel;
3220
3472
  }
3221
3473
  async doConnect() {
3474
+ if (this.connection) {
3475
+ try {
3476
+ await this.connection.close();
3477
+ } catch {
3478
+ }
3479
+ this.connection = null;
3480
+ this.publishChannel = null;
3481
+ }
3222
3482
  this.queueChannels.clear();
3223
3483
  this.logger.info(
3224
3484
  `[Matador] \u23F3 Connecting to RabbitMQ at '${redactAmqpUrl(this.config.url)}'.`
@@ -3236,21 +3496,33 @@ var RabbitMQTransport = class {
3236
3496
  consumer.active = false;
3237
3497
  }
3238
3498
  }
3499
+ this.connection = null;
3500
+ this.publishChannel = null;
3239
3501
  if (this.connectionManager.isConnected()) {
3240
3502
  this.connectionManager.handleConnectionLost(
3241
3503
  new Error("Connection closed unexpectedly")
3242
3504
  );
3243
3505
  }
3244
3506
  });
3245
- this.publishChannel = await connection.createConfirmChannel();
3246
- this.publishChannel.on("error", (err) => {
3247
- this.logger.error("[Matador] \u{1F534} RabbitMQ publish channel error", err);
3248
- });
3249
- if (this.topology) {
3250
- await this.applyTopology(this.topology);
3251
- for (const intent of this.subscriptionIntents) {
3252
- await this.activateIntent(intent);
3507
+ try {
3508
+ this.publishChannel = await connection.createConfirmChannel();
3509
+ this.publishChannel.on("error", (err) => {
3510
+ this.logger.error("[Matador] \u{1F534} RabbitMQ publish channel error", err);
3511
+ });
3512
+ if (this.topology) {
3513
+ await this.applyTopology(this.topology);
3514
+ for (const intent of this.subscriptionIntents) {
3515
+ await this.activateIntent(intent);
3516
+ }
3517
+ }
3518
+ } catch (err) {
3519
+ try {
3520
+ await connection.close();
3521
+ } catch {
3253
3522
  }
3523
+ this.connection = null;
3524
+ this.publishChannel = null;
3525
+ throw err;
3254
3526
  }
3255
3527
  this.logger.info("[Matador] \u{1F50C} Connected to RabbitMQ");
3256
3528
  }
@@ -3495,7 +3767,7 @@ exports.InvalidEventError = InvalidEventError;
3495
3767
  exports.InvalidSchemaError = InvalidSchemaError;
3496
3768
  exports.JsonCodec = JsonCodec;
3497
3769
  exports.LocalTransport = LocalTransport;
3498
- exports.LocalTransportCannotProcessStubError = LocalTransportCannotProcessStubError;
3770
+ exports.LocalTransportNoActiveSubscriberError = LocalTransportNoActiveSubscriberError;
3499
3771
  exports.Matador = Matador;
3500
3772
  exports.MatadorError = MatadorError;
3501
3773
  exports.MatadorEvent = MatadorEvent;