@amqp-contract/worker 0.1.4 → 0.2.1

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 CHANGED
@@ -1,6 +1,12 @@
1
1
  # @amqp-contract/worker
2
2
 
3
- Type-safe AMQP worker for consuming messages using amqp-contract with standard async/await error handling.
3
+ **Type-safe AMQP worker for consuming messages using amqp-contract with standard async/await error handling.**
4
+
5
+ [![CI](https://github.com/btravers/amqp-contract/actions/workflows/ci.yml/badge.svg)](https://github.com/btravers/amqp-contract/actions/workflows/ci.yml)
6
+ [![npm version](https://img.shields.io/npm/v/@amqp-contract/worker.svg?logo=npm)](https://www.npmjs.com/package/@amqp-contract/worker)
7
+ [![npm downloads](https://img.shields.io/npm/dm/@amqp-contract/worker.svg)](https://www.npmjs.com/package/@amqp-contract/worker)
8
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5.9-blue?logo=typescript)](https://www.typescriptlang.org/)
9
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
4
10
 
5
11
  📖 **[Full documentation →](https://btravers.github.io/amqp-contract/api/worker)**
6
12
 
@@ -39,6 +45,10 @@ const worker = await TypedAmqpWorker.create({
39
45
  // await worker.close();
40
46
  ```
41
47
 
48
+ ## Defining Handlers Externally
49
+
50
+ You can define handlers outside of the worker creation using `defineHandler` and `defineHandlers` for better code organization. See the [Worker API documentation](https://btravers.github.io/amqp-contract/api/worker) for details.
51
+
42
52
  ## Error Handling
43
53
 
44
54
  Worker handlers use standard Promise-based async/await pattern:
@@ -70,61 +80,11 @@ These errors are logged but **handlers don't need to use them** - just throw sta
70
80
 
71
81
  ## API
72
82
 
73
- ### `TypedAmqpWorker.create(options)`
74
-
75
- Create a type-safe AMQP worker from a contract with message handlers. Automatically connects and starts consuming all messages.
76
-
77
- **Parameters:**
78
-
79
- - `options.contract` - Contract definition
80
- - `options.handlers` - Object with async handler functions for each consumer
81
- - `options.connection` - AMQP connection URL (string) or connection options (Options.Connect)
82
-
83
- **Returns:** `Promise<TypedAmqpWorker>`
84
-
85
- **Example:**
86
-
87
- ```typescript
88
- const worker = await TypedAmqpWorker.create({
89
- contract,
90
- handlers: {
91
- // Each handler receives type-checked message
92
- processOrder: async (message) => {
93
- // message.orderId is type-checked
94
- console.log(message.orderId);
95
- },
96
- processPayment: async (message) => {
97
- // Different message type for this consumer
98
- await handlePayment(message);
99
- },
100
- },
101
- connection: {
102
- hostname: 'localhost',
103
- port: 5672,
104
- username: 'guest',
105
- password: 'guest',
106
- },
107
- });
108
- ```
109
-
110
- ### Handler Signature
111
-
112
- ```typescript
113
- type Handler<T> = (message: T) => Promise<void>
114
- ```
115
-
116
- Handlers are simple async functions that:
117
-
118
- - Receive type-checked message as parameter
119
- - Return `Promise<void>`
120
- - Can throw exceptions (message will be requeued)
121
- - Message is acknowledged automatically on success
122
-
123
- ### `TypedAmqpWorker.close()`
83
+ See the [Worker API documentation](https://btravers.github.io/amqp-contract/api/worker) for complete API reference.
124
84
 
125
- Stop consuming and close the channel and connection.
85
+ ## Documentation
126
86
 
127
- **Returns:** `Promise<void>`
87
+ 📖 **[Read the full documentation →](https://btravers.github.io/amqp-contract)**
128
88
 
129
89
  ## License
130
90
 
package/dist/index.cjs CHANGED
@@ -1,4 +1,4 @@
1
- let amqplib = require("amqplib");
1
+ let _amqp_contract_core = require("@amqp-contract/core");
2
2
  let _swan_io_boxed = require("@swan-io/boxed");
3
3
 
4
4
  //#region src/errors.ts
@@ -39,128 +39,296 @@ var MessageValidationError = class extends WorkerError {
39
39
  //#endregion
40
40
  //#region src/worker.ts
41
41
  /**
42
- * Type-safe AMQP worker for consuming messages
42
+ * Type-safe AMQP worker for consuming messages from RabbitMQ.
43
+ *
44
+ * This class provides automatic message validation, connection management,
45
+ * and error handling for consuming messages based on a contract definition.
46
+ *
47
+ * @typeParam TContract - The contract definition type
48
+ *
49
+ * @example
50
+ * ```typescript
51
+ * import { TypedAmqpWorker } from '@amqp-contract/worker';
52
+ * import { z } from 'zod';
53
+ *
54
+ * const contract = defineContract({
55
+ * queues: {
56
+ * orderProcessing: defineQueue('order-processing', { durable: true })
57
+ * },
58
+ * consumers: {
59
+ * processOrder: defineConsumer('order-processing', z.object({
60
+ * orderId: z.string(),
61
+ * amount: z.number()
62
+ * }))
63
+ * }
64
+ * });
65
+ *
66
+ * const worker = await TypedAmqpWorker.create({
67
+ * contract,
68
+ * handlers: {
69
+ * processOrder: async (message) => {
70
+ * console.log('Processing order', message.orderId);
71
+ * // Process the order...
72
+ * }
73
+ * },
74
+ * urls: ['amqp://localhost']
75
+ * }).resultToPromise();
76
+ *
77
+ * // Close when done
78
+ * await worker.close().resultToPromise();
79
+ * ```
43
80
  */
44
81
  var TypedAmqpWorker = class TypedAmqpWorker {
45
- channel = null;
46
- connection = null;
47
- consumerTags = [];
48
- constructor(contract, handlers, connectionOptions) {
82
+ constructor(contract, amqpClient, handlers) {
49
83
  this.contract = contract;
84
+ this.amqpClient = amqpClient;
50
85
  this.handlers = handlers;
51
- this.connectionOptions = connectionOptions;
52
86
  }
53
87
  /**
54
- * Create a type-safe AMQP worker from a contract
55
- * The worker will automatically connect and start consuming all messages
88
+ * Create a type-safe AMQP worker from a contract.
89
+ *
90
+ * Connection management (including automatic reconnection) is handled internally
91
+ * by amqp-connection-manager via the {@link AmqpClient}. The worker will set up
92
+ * consumers for all contract-defined handlers asynchronously in the background
93
+ * once the underlying connection and channels are ready.
94
+ *
95
+ * @param options - Configuration options for the worker
96
+ * @returns A Future that resolves to a Result containing the worker or an error
97
+ *
98
+ * @example
99
+ * ```typescript
100
+ * const workerResult = await TypedAmqpWorker.create({
101
+ * contract: myContract,
102
+ * handlers: {
103
+ * processOrder: async (msg) => console.log('Order:', msg.orderId)
104
+ * },
105
+ * urls: ['amqp://localhost']
106
+ * }).resultToPromise();
107
+ *
108
+ * if (workerResult.isError()) {
109
+ * console.error('Failed to create worker:', workerResult.error);
110
+ * }
111
+ * ```
56
112
  */
57
- static async create(options) {
58
- const worker = new TypedAmqpWorker(options.contract, options.handlers, options.connection);
59
- await worker.init();
60
- await worker.consumeAll();
61
- return worker;
113
+ static create({ contract, handlers, urls, connectionOptions }) {
114
+ const worker = new TypedAmqpWorker(contract, new _amqp_contract_core.AmqpClient(contract, {
115
+ urls,
116
+ connectionOptions
117
+ }), handlers);
118
+ return worker.consumeAll().mapOk(() => worker);
62
119
  }
63
120
  /**
64
- * Close the connection
121
+ * Close the AMQP channel and connection.
122
+ *
123
+ * This gracefully closes the connection to the AMQP broker,
124
+ * stopping all message consumption and cleaning up resources.
125
+ *
126
+ * @returns A Future that resolves to a Result indicating success or failure
127
+ *
128
+ * @example
129
+ * ```typescript
130
+ * const closeResult = await worker.close().resultToPromise();
131
+ * if (closeResult.isOk()) {
132
+ * console.log('Worker closed successfully');
133
+ * }
134
+ * ```
65
135
  */
66
- async close() {
67
- await this.stopConsuming();
68
- if (this.channel) {
69
- await this.channel.close();
70
- this.channel = null;
71
- }
72
- if (this.connection) {
73
- await this.connection.close();
74
- this.connection = null;
75
- }
76
- }
77
- /**
78
- * Connect to AMQP broker
79
- */
80
- async init() {
81
- this.connection = await (0, amqplib.connect)(this.connectionOptions);
82
- this.channel = await this.connection.createChannel();
83
- if (this.contract.exchanges) for (const exchange of Object.values(this.contract.exchanges)) await this.channel.assertExchange(exchange.name, exchange.type, {
84
- durable: exchange.durable,
85
- autoDelete: exchange.autoDelete,
86
- internal: exchange.internal,
87
- arguments: exchange.arguments
88
- });
89
- if (this.contract.queues) for (const queue of Object.values(this.contract.queues)) await this.channel.assertQueue(queue.name, {
90
- durable: queue.durable,
91
- exclusive: queue.exclusive,
92
- autoDelete: queue.autoDelete,
93
- arguments: queue.arguments
94
- });
95
- if (this.contract.bindings) {
96
- for (const binding of Object.values(this.contract.bindings)) if (binding.type === "queue") await this.channel.bindQueue(binding.queue, binding.exchange, binding.routingKey ?? "", binding.arguments);
97
- else if (binding.type === "exchange") await this.channel.bindExchange(binding.destination, binding.source, binding.routingKey ?? "", binding.arguments);
98
- }
136
+ close() {
137
+ return _swan_io_boxed.Future.fromPromise(this.amqpClient.close()).mapError((error) => new TechnicalError("Failed to close AMQP connection", error)).mapOk(() => void 0);
99
138
  }
100
139
  /**
101
140
  * Start consuming messages for all consumers
102
141
  */
103
- async consumeAll() {
104
- if (!this.contract.consumers) throw new Error("No consumers defined in contract");
142
+ consumeAll() {
143
+ if (!this.contract.consumers) return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Error(new TechnicalError("No consumers defined in contract")));
105
144
  const consumerNames = Object.keys(this.contract.consumers);
106
- for (const consumerName of consumerNames) await this.consume(consumerName);
145
+ return _swan_io_boxed.Future.all(consumerNames.map((consumerName) => this.consume(consumerName))).map(_swan_io_boxed.Result.all).mapOk(() => void 0);
107
146
  }
108
147
  /**
109
148
  * Start consuming messages for a specific consumer
110
149
  */
111
- async consume(consumerName) {
112
- if (!this.channel) throw new Error("Worker not initialized. Use TypedAmqpWorker.create() to obtain an initialized worker instance.");
150
+ consume(consumerName) {
113
151
  const consumers = this.contract.consumers;
114
- if (!consumers) throw new Error("No consumers defined in contract");
152
+ if (!consumers) return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Error(new TechnicalError("No consumers defined in contract")));
115
153
  const consumer = consumers[consumerName];
116
- if (!consumer || typeof consumer !== "object") {
154
+ if (!consumer) {
117
155
  const availableConsumers = Object.keys(consumers);
118
156
  const available = availableConsumers.length > 0 ? availableConsumers.join(", ") : "none";
119
- throw new Error(`Consumer not found: "${String(consumerName)}". Available consumers: ${available}`);
157
+ return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Error(new TechnicalError(`Consumer not found: "${String(consumerName)}". Available consumers: ${available}`)));
120
158
  }
121
- const consumerDef = consumer;
122
159
  const handler = this.handlers[consumerName];
123
- if (!handler) throw new Error(`Handler for "${String(consumerName)}" not provided`);
124
- if (consumerDef.prefetch !== void 0) await this.channel.prefetch(consumerDef.prefetch);
125
- const result = await this.channel.consume(consumerDef.queue, async (msg) => {
126
- if (!msg) return;
160
+ if (!handler) return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Error(new TechnicalError(`Handler for "${String(consumerName)}" not provided`)));
161
+ return _swan_io_boxed.Future.fromPromise(this.amqpClient.channel.consume(consumer.queue.name, async (msg) => {
127
162
  const parseResult = _swan_io_boxed.Result.fromExecution(() => JSON.parse(msg.content.toString()));
128
163
  if (parseResult.isError()) {
129
164
  console.error(new TechnicalError(`Error parsing message for consumer "${String(consumerName)}"`, parseResult.error));
130
- this.channel?.nack(msg, false, false);
131
- return;
132
- }
133
- const content = parseResult.value;
134
- const rawValidation = consumerDef.message["~standard"].validate(content);
135
- const resolvedValidation = rawValidation instanceof Promise ? await rawValidation : rawValidation;
136
- const validationResult = typeof resolvedValidation === "object" && resolvedValidation !== null && "issues" in resolvedValidation && resolvedValidation.issues ? _swan_io_boxed.Result.Error(new MessageValidationError(String(consumerName), resolvedValidation.issues)) : _swan_io_boxed.Result.Ok(typeof resolvedValidation === "object" && resolvedValidation !== null && "value" in resolvedValidation ? resolvedValidation.value : content);
137
- if (validationResult.isError()) {
138
- console.error(validationResult.error);
139
- this.channel?.nack(msg, false, false);
165
+ this.amqpClient.channel.nack(msg, false, false);
140
166
  return;
141
167
  }
142
- const validatedMessage = validationResult.value;
143
- try {
144
- await handler(validatedMessage);
145
- if (!consumerDef.noAck) this.channel?.ack(msg);
146
- } catch (error) {
168
+ const rawValidation = consumer.message.payload["~standard"].validate(parseResult.value);
169
+ await _swan_io_boxed.Future.fromPromise(rawValidation instanceof Promise ? rawValidation : Promise.resolve(rawValidation)).mapOkToResult((validationResult) => {
170
+ if (validationResult.issues) return _swan_io_boxed.Result.Error(new MessageValidationError(String(consumerName), validationResult.issues));
171
+ return _swan_io_boxed.Result.Ok(validationResult.value);
172
+ }).tapError((error) => {
173
+ console.error(error);
174
+ this.amqpClient.channel.nack(msg, false, false);
175
+ }).flatMapOk((validatedMessage) => _swan_io_boxed.Future.fromPromise(handler(validatedMessage)).tapError((error) => {
147
176
  console.error(new TechnicalError(`Error processing message for consumer "${String(consumerName)}"`, error));
148
- this.channel?.nack(msg, false, true);
149
- }
150
- }, { noAck: consumerDef.noAck ?? false });
151
- this.consumerTags.push(result.consumerTag);
152
- }
153
- /**
154
- * Stop consuming messages
155
- */
156
- async stopConsuming() {
157
- if (!this.channel) return;
158
- for (const tag of this.consumerTags) await this.channel.cancel(tag);
159
- this.consumerTags = [];
177
+ this.amqpClient.channel.nack(msg, false, true);
178
+ })).tapOk(() => {
179
+ this.amqpClient.channel.ack(msg);
180
+ }).toPromise();
181
+ })).mapError((error) => new TechnicalError(`Failed to start consuming for "${String(consumerName)}"`, error)).mapOk(() => void 0);
160
182
  }
161
183
  };
162
184
 
185
+ //#endregion
186
+ //#region src/handlers.ts
187
+ /**
188
+ * Define a type-safe handler for a specific consumer in a contract.
189
+ *
190
+ * This utility allows you to define handlers outside of the worker creation,
191
+ * providing better code organization and reusability.
192
+ *
193
+ * @template TContract - The contract definition type
194
+ * @template TName - The consumer name from the contract
195
+ * @param contract - The contract definition containing the consumer
196
+ * @param consumerName - The name of the consumer from the contract
197
+ * @param handler - The async handler function that processes messages
198
+ * @returns A type-safe handler that can be used with TypedAmqpWorker
199
+ *
200
+ * @example
201
+ * ```typescript
202
+ * import { defineHandler } from '@amqp-contract/worker';
203
+ * import { orderContract } from './contract';
204
+ *
205
+ * // Define handler outside of worker creation
206
+ * const processOrderHandler = defineHandler(
207
+ * orderContract,
208
+ * 'processOrder',
209
+ * async (message) => {
210
+ * // message is fully typed based on the contract
211
+ * console.log('Processing order:', message.orderId);
212
+ * await processPayment(message);
213
+ * }
214
+ * );
215
+ *
216
+ * // Use the handler in worker
217
+ * const worker = await TypedAmqpWorker.create({
218
+ * contract: orderContract,
219
+ * handlers: {
220
+ * processOrder: processOrderHandler,
221
+ * },
222
+ * connection: 'amqp://localhost',
223
+ * });
224
+ * ```
225
+ *
226
+ * @example
227
+ * ```typescript
228
+ * // Define multiple handlers
229
+ * const processOrderHandler = defineHandler(
230
+ * orderContract,
231
+ * 'processOrder',
232
+ * async (message) => {
233
+ * await processOrder(message);
234
+ * }
235
+ * );
236
+ *
237
+ * const notifyOrderHandler = defineHandler(
238
+ * orderContract,
239
+ * 'notifyOrder',
240
+ * async (message) => {
241
+ * await sendNotification(message);
242
+ * }
243
+ * );
244
+ *
245
+ * // Compose handlers
246
+ * const worker = await TypedAmqpWorker.create({
247
+ * contract: orderContract,
248
+ * handlers: {
249
+ * processOrder: processOrderHandler,
250
+ * notifyOrder: notifyOrderHandler,
251
+ * },
252
+ * connection: 'amqp://localhost',
253
+ * });
254
+ * ```
255
+ */
256
+ function defineHandler(contract, consumerName, handler) {
257
+ const consumers = contract.consumers;
258
+ if (!consumers || !(consumerName in consumers)) {
259
+ const availableConsumers = consumers ? Object.keys(consumers) : [];
260
+ const available = availableConsumers.length > 0 ? availableConsumers.join(", ") : "none";
261
+ throw new Error(`Consumer "${String(consumerName)}" not found in contract. Available consumers: ${available}`);
262
+ }
263
+ return handler;
264
+ }
265
+ /**
266
+ * Define multiple type-safe handlers for consumers in a contract.
267
+ *
268
+ * This utility allows you to define all handlers at once outside of the worker creation,
269
+ * ensuring type safety and providing better code organization.
270
+ *
271
+ * @template TContract - The contract definition type
272
+ * @param contract - The contract definition containing the consumers
273
+ * @param handlers - An object with async handler functions for each consumer
274
+ * @returns A type-safe handlers object that can be used with TypedAmqpWorker
275
+ *
276
+ * @example
277
+ * ```typescript
278
+ * import { defineHandlers } from '@amqp-contract/worker';
279
+ * import { orderContract } from './contract';
280
+ *
281
+ * // Define all handlers at once
282
+ * const handlers = defineHandlers(orderContract, {
283
+ * processOrder: async (message) => {
284
+ * // message is fully typed based on the contract
285
+ * console.log('Processing order:', message.orderId);
286
+ * await processPayment(message);
287
+ * },
288
+ * notifyOrder: async (message) => {
289
+ * await sendNotification(message);
290
+ * },
291
+ * shipOrder: async (message) => {
292
+ * await prepareShipment(message);
293
+ * },
294
+ * });
295
+ *
296
+ * // Use the handlers in worker
297
+ * const worker = await TypedAmqpWorker.create({
298
+ * contract: orderContract,
299
+ * handlers,
300
+ * connection: 'amqp://localhost',
301
+ * });
302
+ * ```
303
+ *
304
+ * @example
305
+ * ```typescript
306
+ * // Separate handler definitions for better organization
307
+ * async function handleProcessOrder(message: WorkerInferConsumerInput<typeof orderContract, 'processOrder'>) {
308
+ * await processOrder(message);
309
+ * }
310
+ *
311
+ * async function handleNotifyOrder(message: WorkerInferConsumerInput<typeof orderContract, 'notifyOrder'>) {
312
+ * await sendNotification(message);
313
+ * }
314
+ *
315
+ * const handlers = defineHandlers(orderContract, {
316
+ * processOrder: handleProcessOrder,
317
+ * notifyOrder: handleNotifyOrder,
318
+ * });
319
+ * ```
320
+ */
321
+ function defineHandlers(contract, handlers) {
322
+ const consumers = contract.consumers;
323
+ const availableConsumers = Object.keys(consumers ?? {});
324
+ const availableConsumerNames = availableConsumers.length > 0 ? availableConsumers.join(", ") : "none";
325
+ for (const handlerName of Object.keys(handlers)) if (!consumers || !(handlerName in consumers)) throw new Error(`Consumer "${handlerName}" not found in contract. Available consumers: ${availableConsumerNames}`);
326
+ return handlers;
327
+ }
328
+
163
329
  //#endregion
164
330
  exports.MessageValidationError = MessageValidationError;
165
331
  exports.TechnicalError = TechnicalError;
166
- exports.TypedAmqpWorker = TypedAmqpWorker;
332
+ exports.TypedAmqpWorker = TypedAmqpWorker;
333
+ exports.defineHandler = defineHandler;
334
+ exports.defineHandlers = defineHandlers;