@zdavison/matador 2.0.2 → 2.0.4

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 (55) hide show
  1. package/dist/core/matador.d.ts +2 -2
  2. package/dist/core/matador.d.ts.map +1 -1
  3. package/dist/core/matador.js +6 -0
  4. package/dist/index.cjs +80 -32
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.cts +3 -3
  7. package/dist/index.d.ts +3 -3
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +1 -1
  10. package/dist/index.js.map +1 -1
  11. package/dist/pipeline/pipeline.d.ts +4 -1
  12. package/dist/pipeline/pipeline.d.ts.map +1 -1
  13. package/dist/pipeline/pipeline.js +16 -4
  14. package/dist/pipeline/pipeline.test.js +15 -1
  15. package/dist/topology/builder.d.ts +14 -0
  16. package/dist/topology/builder.d.ts.map +1 -1
  17. package/dist/topology/builder.js +20 -11
  18. package/dist/topology/builder.test.js +138 -0
  19. package/dist/topology/index.d.ts +2 -2
  20. package/dist/topology/index.d.ts.map +1 -1
  21. package/dist/topology/index.js +1 -1
  22. package/dist/topology/types.d.ts +53 -1
  23. package/dist/topology/types.d.ts.map +1 -1
  24. package/dist/topology/types.js +10 -0
  25. package/dist/transport/local/local-transport.d.ts.map +1 -1
  26. package/dist/transport/rabbitmq/rabbitmq-transport.d.ts +8 -0
  27. package/dist/transport/rabbitmq/rabbitmq-transport.d.ts.map +1 -1
  28. package/dist/transport/rabbitmq/rabbitmq-transport.js +42 -22
  29. package/dist/transport/rabbitmq/rabbitmq-transport.test.d.ts +2 -0
  30. package/dist/transport/rabbitmq/rabbitmq-transport.test.d.ts.map +1 -0
  31. package/dist/transport/rabbitmq/rabbitmq-transport.test.js +98 -0
  32. package/dist/types/dispatcher.d.ts +14 -0
  33. package/dist/types/dispatcher.d.ts.map +1 -0
  34. package/dist/types/dispatcher.js +1 -0
  35. package/dist/types/index.d.ts +2 -1
  36. package/dist/types/index.d.ts.map +1 -1
  37. package/dist/types/subscriber.d.ts +20 -4
  38. package/dist/types/subscriber.d.ts.map +1 -1
  39. package/package.json +1 -1
  40. package/src/core/matador.ts +14 -1
  41. package/src/index.ts +9 -0
  42. package/src/pipeline/pipeline.test.ts +24 -2
  43. package/src/pipeline/pipeline.ts +28 -6
  44. package/src/topology/builder.test.ts +176 -0
  45. package/src/topology/builder.ts +44 -5
  46. package/src/topology/index.ts +4 -0
  47. package/src/topology/types.ts +75 -1
  48. package/src/transport/local/local-transport.ts +4 -1
  49. package/src/transport/rabbitmq/rabbitmq-transport.test.ts +118 -0
  50. package/src/transport/rabbitmq/rabbitmq-transport.ts +48 -25
  51. package/src/types/dispatcher.ts +18 -0
  52. package/src/types/index.ts +4 -0
  53. package/src/types/subscriber.ts +22 -3
  54. package/test/e2e/rabbitmq-transport.e2e.test.ts +287 -0
  55. package/tsconfig.tsbuildinfo +1 -1
@@ -12,6 +12,15 @@ import type {
12
12
  */
13
13
  export type QueueOptions = Omit<QueueDefinition, 'name'>;
14
14
 
15
+ /**
16
+ * Type guard to check if the argument is a QueueDefinition object.
17
+ */
18
+ function isQueueDefinition(
19
+ arg: string | QueueDefinition,
20
+ ): arg is QueueDefinition {
21
+ return typeof arg === 'object' && arg !== null && 'name' in arg;
22
+ }
23
+
15
24
  /**
16
25
  * Error thrown when topology validation fails.
17
26
  */
@@ -64,17 +73,46 @@ export class TopologyBuilder {
64
73
 
65
74
  /**
66
75
  * Adds a queue to the topology.
76
+ * @param definition - A complete QueueDefinition object
77
+ */
78
+ addQueue(definition: QueueDefinition): this;
79
+ /**
80
+ * Adds a queue to the topology.
81
+ * @param name - Queue name
82
+ * @param options - Queue options
67
83
  */
68
- addQueue(name: string, options: QueueOptions = {}): this {
69
- this.queues.push({ name, ...options });
84
+ addQueue(name: string, options?: QueueOptions): this;
85
+ addQueue(
86
+ nameOrDefinition: string | QueueDefinition,
87
+ options: QueueOptions = {},
88
+ ): this {
89
+ if (isQueueDefinition(nameOrDefinition)) {
90
+ this.queues.push(nameOrDefinition);
91
+ } else {
92
+ this.queues.push({ name: nameOrDefinition, ...options });
93
+ }
70
94
  return this;
71
95
  }
72
96
 
73
97
  /**
74
98
  * Alias for addQueue().
99
+ * @param definition - A complete QueueDefinition object
100
+ */
101
+ queue(definition: QueueDefinition): this;
102
+ /**
103
+ * Alias for addQueue().
104
+ * @param name - Queue name
105
+ * @param options - Queue options
75
106
  */
76
- queue(name: string, options: QueueOptions = {}): this {
77
- return this.addQueue(name, options);
107
+ queue(name: string, options?: QueueOptions): this;
108
+ queue(
109
+ nameOrDefinition: string | QueueDefinition,
110
+ options: QueueOptions = {},
111
+ ): this {
112
+ if (isQueueDefinition(nameOrDefinition)) {
113
+ return this.addQueue(nameOrDefinition);
114
+ }
115
+ return this.addQueue(nameOrDefinition, options);
78
116
  }
79
117
 
80
118
  /**
@@ -141,7 +179,8 @@ export class TopologyBuilder {
141
179
  for (const queue of this.queues) {
142
180
  if (!queue.name || queue.name.trim() === '') {
143
181
  issues.push('Queue name cannot be empty');
144
- } else if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(queue.name)) {
182
+ } else if (!queue.exact && !/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(queue.name)) {
183
+ // Skip pattern validation for exact queues (allows names like 'matador.shared.id-platform')
145
184
  issues.push(
146
185
  `Queue name "${queue.name}" must start with a letter and contain only alphanumeric characters, underscores, and hyphens`,
147
186
  );
@@ -2,13 +2,17 @@ export type {
2
2
  DeadLetterConfig,
3
3
  DeadLetterQueueConfig,
4
4
  QueueDefinition,
5
+ RabbitMQQueueDefinition,
6
+ RabbitMQQueueOptions,
5
7
  RetryConfig,
6
8
  Topology,
9
+ TransportQueueOptions,
7
10
  } from './types.js';
8
11
  export {
9
12
  getDeadLetterQueueName,
10
13
  getQualifiedQueueName,
11
14
  getRetryQueueName,
15
+ resolveQueueName,
12
16
  } from './types.js';
13
17
 
14
18
  export type { QueueOptions } from './builder.js';
@@ -20,7 +20,7 @@ export interface Topology {
20
20
  * Individual queue definition.
21
21
  */
22
22
  export interface QueueDefinition {
23
- /** Queue name (will be prefixed with namespace) */
23
+ /** Queue name (will be prefixed with namespace unless exact: true) */
24
24
  readonly name: string;
25
25
 
26
26
  /** Concurrency for this queue */
@@ -39,6 +39,66 @@ export interface QueueDefinition {
39
39
  * queues that are not managed by Matador.
40
40
  */
41
41
  readonly exact?: boolean | undefined;
42
+
43
+ /** Transport-specific queue options */
44
+ readonly transport?: TransportQueueOptions | undefined;
45
+ }
46
+
47
+ /**
48
+ * Transport-specific queue options.
49
+ * Each transport can define its own options under its transport name key.
50
+ */
51
+ export interface TransportQueueOptions {
52
+ /** RabbitMQ-specific queue options */
53
+ readonly rabbitmq?: RabbitMQQueueDefinition | undefined;
54
+ }
55
+
56
+ /**
57
+ * RabbitMQ-specific queue definition options.
58
+ */
59
+ export interface RabbitMQQueueDefinition {
60
+ /**
61
+ * Exact RabbitMQ queue assertion options.
62
+ * When provided, these options completely replace all auto-computed defaults
63
+ * (durable, x-queue-type, x-dead-letter-exchange, etc.).
64
+ */
65
+ readonly options?: RabbitMQQueueOptions | undefined;
66
+ }
67
+
68
+ /**
69
+ * RabbitMQ queue assertion options.
70
+ * Maps to amqplib's Options.AssertQueue.
71
+ */
72
+ export interface RabbitMQQueueOptions {
73
+ /** Queue survives broker restart */
74
+ readonly durable?: boolean | undefined;
75
+
76
+ /** Queue is deleted when last consumer unsubscribes */
77
+ readonly autoDelete?: boolean | undefined;
78
+
79
+ /** Queue can only be used by the declaring connection */
80
+ readonly exclusive?: boolean | undefined;
81
+
82
+ /** Exchange to which dead-lettered messages are sent */
83
+ readonly deadLetterExchange?: string | undefined;
84
+
85
+ /** Routing key for dead-lettered messages */
86
+ readonly deadLetterRoutingKey?: string | undefined;
87
+
88
+ /** Message TTL in milliseconds */
89
+ readonly messageTtl?: number | undefined;
90
+
91
+ /** Queue expires after this many milliseconds of non-use */
92
+ readonly expires?: number | undefined;
93
+
94
+ /** Maximum number of messages in the queue */
95
+ readonly maxLength?: number | undefined;
96
+
97
+ /** Maximum priority level (0-255) */
98
+ readonly maxPriority?: number | undefined;
99
+
100
+ /** Additional x-* arguments for RabbitMQ */
101
+ readonly arguments?: Record<string, unknown> | undefined;
42
102
  }
43
103
 
44
104
  /**
@@ -107,3 +167,17 @@ export function getRetryQueueName(
107
167
  ): string {
108
168
  return `${namespace}.${queueName}.retry`;
109
169
  }
170
+
171
+ /**
172
+ * Resolves the actual queue name for a given queue definition.
173
+ * When exact: true, returns name as-is. Otherwise, returns namespace.name.
174
+ */
175
+ export function resolveQueueName(
176
+ namespace: string,
177
+ queueDef: QueueDefinition,
178
+ ): string {
179
+ if (queueDef.exact) {
180
+ return queueDef.name;
181
+ }
182
+ return `${namespace}.${queueDef.name}`;
183
+ }
@@ -185,7 +185,10 @@ export class LocalTransport implements Transport {
185
185
  await sub.handler(message.envelope, receipt);
186
186
  } catch (error) {
187
187
  // Handler errors should be caught in the pipeline
188
- this.logger.error('[Matador] 🔴 Handler error in message processing', error);
188
+ this.logger.error(
189
+ '[Matador] 🔴 Handler error in message processing',
190
+ error,
191
+ );
189
192
  }
190
193
  }
191
194
  }
@@ -0,0 +1,118 @@
1
+ import { describe, expect, it, mock } from 'bun:test';
2
+ import type { Logger } from '../../hooks/index.js';
3
+ import { RabbitMQTransport, redactAmqpUrl } from './rabbitmq-transport.js';
4
+
5
+ describe('redactAmqpUrl', () => {
6
+ it('should redact username and password with 4 asterisks', () => {
7
+ const url = 'amqp://myuser:mypassword@localhost:5672';
8
+ const redacted = redactAmqpUrl(url);
9
+ expect(redacted).toBe('amqp://****:****@localhost:5672');
10
+ });
11
+
12
+ it('should redact credentials in amqps URLs', () => {
13
+ const url = 'amqps://admin:secret123@rabbitmq.example.com:5671';
14
+ const redacted = redactAmqpUrl(url);
15
+ expect(redacted).toBe('amqps://****:****@rabbitmq.example.com:5671');
16
+ });
17
+
18
+ it('should redact long credentials to exactly 4 asterisks', () => {
19
+ const url =
20
+ 'amqp://verylongusername:verylongpassword@rabbitmq-cluster.svc.local:5672';
21
+ const redacted = redactAmqpUrl(url);
22
+ expect(redacted).toBe('amqp://****:****@rabbitmq-cluster.svc.local:5672');
23
+ });
24
+
25
+ it('should redact short credentials to exactly 4 asterisks', () => {
26
+ const url = 'amqp://a:b@host:5672';
27
+ const redacted = redactAmqpUrl(url);
28
+ expect(redacted).toBe('amqp://****:****@host:5672');
29
+ });
30
+
31
+ it('should preserve vhost in URL', () => {
32
+ const url = 'amqp://user:pass@host:5672/myvhost';
33
+ const redacted = redactAmqpUrl(url);
34
+ expect(redacted).toBe('amqp://****:****@host:5672/myvhost');
35
+ });
36
+
37
+ it('should not modify URL without credentials', () => {
38
+ const url = 'amqp://localhost:5672';
39
+ const redacted = redactAmqpUrl(url);
40
+ expect(redacted).toBe('amqp://localhost:5672');
41
+ });
42
+
43
+ it('should not modify URL with only username (no password)', () => {
44
+ const url = 'amqp://guest@localhost:5672';
45
+ const redacted = redactAmqpUrl(url);
46
+ expect(redacted).toBe('amqp://guest@localhost:5672');
47
+ });
48
+
49
+ it('should handle URL-encoded credentials', () => {
50
+ // URL-encoded @ in password: p%40ssword
51
+ const url = 'amqp://user:p%40ssword@host:5672';
52
+ const redacted = redactAmqpUrl(url);
53
+ expect(redacted).toBe('amqp://****:****@host:5672');
54
+ });
55
+ });
56
+
57
+ describe('RabbitMQTransport', () => {
58
+ describe('connection logging', () => {
59
+ it('should log redacted connection URL when connecting', async () => {
60
+ const mockLogger: Logger = {
61
+ debug: mock(() => {}),
62
+ info: mock(() => {}),
63
+ warn: mock(() => {}),
64
+ error: mock(() => {}),
65
+ };
66
+
67
+ const transport = new RabbitMQTransport({
68
+ url: 'amqp://testuser:testpass@localhost:5672',
69
+ logger: mockLogger,
70
+ connection: {
71
+ maxReconnectAttempts: 1, // Only try once to avoid long retries
72
+ initialReconnectDelay: 10,
73
+ },
74
+ });
75
+
76
+ // Attempt to connect - it will fail since there's no RabbitMQ server
77
+ // but the log should still be emitted before the connection attempt
78
+ try {
79
+ await transport.connect();
80
+ } catch {
81
+ // Expected to fail - no RabbitMQ server running
82
+ }
83
+
84
+ // Verify the log was called with the redacted URL
85
+ expect(mockLogger.info).toHaveBeenCalledWith(
86
+ "[Matador] \u23F3 Connecting to RabbitMQ at 'amqp://****:****@localhost:5672'.",
87
+ );
88
+ });
89
+
90
+ it('should log connection URL as-is when no credentials provided', async () => {
91
+ const mockLogger: Logger = {
92
+ debug: mock(() => {}),
93
+ info: mock(() => {}),
94
+ warn: mock(() => {}),
95
+ error: mock(() => {}),
96
+ };
97
+
98
+ const transport = new RabbitMQTransport({
99
+ url: 'amqp://localhost:5672',
100
+ logger: mockLogger,
101
+ connection: {
102
+ maxReconnectAttempts: 1,
103
+ initialReconnectDelay: 10,
104
+ },
105
+ });
106
+
107
+ try {
108
+ await transport.connect();
109
+ } catch {
110
+ // Expected to fail
111
+ }
112
+
113
+ expect(mockLogger.info).toHaveBeenCalledWith(
114
+ "[Matador] \u23F3 Connecting to RabbitMQ at 'amqp://localhost:5672'.",
115
+ );
116
+ });
117
+ });
118
+ });
@@ -62,6 +62,18 @@ interface ActiveConsumer {
62
62
  active: boolean;
63
63
  }
64
64
 
65
+ /**
66
+ * Redacts credentials from an AMQP URL.
67
+ * Replaces username and password with '****' regardless of their length.
68
+ *
69
+ * @example
70
+ * redactAmqpUrl('amqp://user:pass@host:5672') // 'amqp://****:****@host:5672'
71
+ */
72
+ export function redactAmqpUrl(url: string): string {
73
+ const regex = /^(amqps?:\/\/)[^:]+:[^@]+@/;
74
+ return url.replace(regex, '$1****:****@');
75
+ }
76
+
65
77
  /**
66
78
  * RabbitMQ transport implementation using amqplib.
67
79
  */
@@ -430,6 +442,9 @@ export class RabbitMQTransport implements Transport {
430
442
  }
431
443
 
432
444
  private async doConnect(): Promise<void> {
445
+ this.logger.info(
446
+ `[Matador] ⏳ Connecting to RabbitMQ at '${redactAmqpUrl(this.config.url)}'.`,
447
+ );
433
448
  const connection = await amqplib.connect(this.config.url);
434
449
  this.connection = connection;
435
450
 
@@ -589,36 +604,44 @@ export class RabbitMQTransport implements Transport {
589
604
  ? queueDef.name
590
605
  : `${topology.namespace}.${queueDef.name}`;
591
606
 
592
- const queueOptions: Options.AssertQueue = {
593
- durable: true,
594
- arguments: {} as Record<string, unknown>,
595
- };
607
+ const rabbitmqOptions = queueDef.transport?.rabbitmq?.options;
596
608
 
597
- // Use quorum queues for durability
598
- if (this.config.quorumQueues && !queueDef.exact) {
599
- queueOptions.arguments['x-queue-type'] = 'quorum';
600
- }
609
+ // If user provided exact RabbitMQ options, use them directly (replaces all defaults)
610
+ if (rabbitmqOptions) {
611
+ await channel.assertQueue(queueName, rabbitmqOptions);
612
+ } else {
613
+ // Use computed defaults
614
+ const queueOptions: Options.AssertQueue = {
615
+ durable: true,
616
+ arguments: {} as Record<string, unknown>,
617
+ };
601
618
 
602
- // Set up dead-letter exchange routing
603
- const dlxExchange = this.getDLXExchangeName(topology.namespace);
604
- if (
605
- topology.deadLetter.unhandled.enabled ||
606
- topology.deadLetter.undeliverable.enabled
607
- ) {
608
- queueOptions.arguments['x-dead-letter-exchange'] = dlxExchange;
609
- }
619
+ // Use quorum queues for durability
620
+ if (this.config.quorumQueues && !queueDef.exact) {
621
+ queueOptions.arguments['x-queue-type'] = 'quorum';
622
+ }
610
623
 
611
- // Enable priority if requested
612
- if (queueDef.priorities) {
613
- queueOptions.arguments['x-max-priority'] = 10;
614
- }
624
+ // Set up dead-letter exchange routing
625
+ const dlxExchange = this.getDLXExchangeName(topology.namespace);
626
+ if (
627
+ topology.deadLetter.unhandled.enabled ||
628
+ topology.deadLetter.undeliverable.enabled
629
+ ) {
630
+ queueOptions.arguments['x-dead-letter-exchange'] = dlxExchange;
631
+ }
615
632
 
616
- // Set consumer timeout if specified
617
- if (queueDef.consumerTimeout) {
618
- queueOptions.arguments['x-consumer-timeout'] = queueDef.consumerTimeout;
619
- }
633
+ // Enable priority if requested
634
+ if (queueDef.priorities) {
635
+ queueOptions.arguments['x-max-priority'] = 10;
636
+ }
620
637
 
621
- await channel.assertQueue(queueName, queueOptions);
638
+ // Set consumer timeout if specified
639
+ if (queueDef.consumerTimeout) {
640
+ queueOptions.arguments['x-consumer-timeout'] = queueDef.consumerTimeout;
641
+ }
642
+
643
+ await channel.assertQueue(queueName, queueOptions);
644
+ }
622
645
 
623
646
  // Bind queue to main exchange
624
647
  const mainExchange = this.getMainExchangeName(topology.namespace);
@@ -0,0 +1,18 @@
1
+ import type { SendResult } from '../core/fanout.js';
2
+ import type { Event, EventClass, EventOptions } from './event.js';
3
+
4
+ /**
5
+ * Interface for dispatching events.
6
+ * Implemented by Matador to allow subscribers to send events.
7
+ */
8
+ export interface Dispatcher {
9
+ /**
10
+ * Sends an event to all registered subscribers.
11
+ */
12
+ send<T>(
13
+ eventClass: EventClass<T>,
14
+ data: T,
15
+ options?: EventOptions,
16
+ ): Promise<SendResult>;
17
+ send<T>(event: Event<T>, options?: EventOptions): Promise<SendResult>;
18
+ }
@@ -7,6 +7,8 @@ export type {
7
7
  } from './common.js';
8
8
  export { invalidResult, validResult } from './common.js';
9
9
 
10
+ export type { Dispatcher } from './dispatcher.js';
11
+
10
12
  export type { CreateEnvelopeOptions, Docket, Envelope } from './envelope.js';
11
13
  export { createEnvelope } from './envelope.js';
12
14
 
@@ -26,11 +28,13 @@ export { MatadorEvent } from './event.js';
26
28
  export type {
27
29
  AnySubscriber,
28
30
  BaseSubscriberOptions,
31
+ CallbackContext,
29
32
  CreateResumableSubscriberInput,
30
33
  CreateStandardSubscriberInput,
31
34
  CreateSubscriberInput,
32
35
  EnvelopeOf,
33
36
  ResumableCallback,
37
+ ResumableCallbackContext,
34
38
  ResumableSubscriber,
35
39
  ResumableSubscriberOptions,
36
40
  StandardCallback,
@@ -1,8 +1,18 @@
1
1
  import type { SubscriberContext } from '../checkpoint/index.js';
2
2
  import type { Idempotency, Importance } from './common.js';
3
+ import type { Dispatcher } from './dispatcher.js';
3
4
  import type { Envelope } from './envelope.js';
4
5
  import type { MatadorEvent } from './event.js';
5
6
 
7
+ /**
8
+ * Context passed to subscriber callbacks.
9
+ * Provides access to the Matador instance for sending additional events.
10
+ */
11
+ export interface CallbackContext {
12
+ /** Matador dispatcher for sending additional events from within a subscriber */
13
+ readonly matador: Dispatcher;
14
+ }
15
+
6
16
  /**
7
17
  * Helper type to get the envelope type for a subscriber callback.
8
18
  * Extracts the data type from a MatadorEvent and wraps it in an Envelope.
@@ -16,19 +26,28 @@ export type EnvelopeOf<T extends MatadorEvent> = Envelope<T['data']>;
16
26
 
17
27
  /**
18
28
  * Callback function executed when an event is received (standard subscribers).
19
- * Receives the full envelope containing id, data, and docket.
29
+ * Receives the full envelope containing id, data, and docket, plus a context
30
+ * with access to the matador instance for sending additional events.
20
31
  */
21
32
  export type StandardCallback<T = unknown> = (
22
33
  envelope: Envelope<T>,
34
+ context: CallbackContext,
23
35
  ) => Promise<void> | void;
24
36
 
37
+ /**
38
+ * Context for resumable subscriber callbacks.
39
+ * Combines checkpoint operations (io, all) with matador access.
40
+ */
41
+ export type ResumableCallbackContext = SubscriberContext & CallbackContext;
42
+
25
43
  /**
26
44
  * Callback function for resumable subscribers.
27
- * Receives the envelope and a SubscriberContext with io() for checkpointed operations.
45
+ * Receives the envelope and a context with io() for checkpointed operations
46
+ * and matador for sending additional events.
28
47
  */
29
48
  export type ResumableCallback<T = unknown> = (
30
49
  envelope: Envelope<T>,
31
- context: SubscriberContext,
50
+ context: ResumableCallbackContext,
32
51
  ) => Promise<void> | void;
33
52
 
34
53
  /**