@amqp-contract/worker 0.6.0 → 0.8.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.mjs CHANGED
@@ -1,5 +1,7 @@
1
1
  import { AmqpClient } from "@amqp-contract/core";
2
2
  import { Future, Result } from "@swan-io/boxed";
3
+ import { gunzip, inflate } from "node:zlib";
4
+ import { promisify } from "node:util";
3
5
 
4
6
  //#region src/errors.ts
5
7
  /**
@@ -35,6 +37,57 @@ var MessageValidationError = class extends WorkerError {
35
37
  this.name = "MessageValidationError";
36
38
  }
37
39
  };
40
+ /**
41
+ * Retryable errors - transient failures that may succeed on retry
42
+ * Examples: network timeouts, rate limiting, temporary service unavailability
43
+ *
44
+ * Use this error type when the operation might succeed if retried.
45
+ * The worker will apply exponential backoff and retry the message.
46
+ */
47
+ var RetryableError = class extends WorkerError {
48
+ constructor(message, cause) {
49
+ super(message);
50
+ this.cause = cause;
51
+ this.name = "RetryableError";
52
+ }
53
+ };
54
+ /**
55
+ * Non-retryable errors - permanent failures that should not be retried
56
+ * Examples: invalid data, business rule violations, permanent external failures
57
+ *
58
+ * Use this error type when retrying would not help - the message will be
59
+ * immediately sent to the dead letter queue (DLQ) if configured.
60
+ */
61
+ var NonRetryableError = class extends WorkerError {
62
+ constructor(message, cause) {
63
+ super(message);
64
+ this.cause = cause;
65
+ this.name = "NonRetryableError";
66
+ }
67
+ };
68
+
69
+ //#endregion
70
+ //#region src/decompression.ts
71
+ const gunzipAsync = promisify(gunzip);
72
+ const inflateAsync = promisify(inflate);
73
+ /**
74
+ * Decompress a buffer based on the content-encoding header.
75
+ *
76
+ * @param buffer - The buffer to decompress
77
+ * @param contentEncoding - The content-encoding header value (e.g., 'gzip', 'deflate')
78
+ * @returns A promise that resolves to the decompressed buffer
79
+ * @throws Error if decompression fails or if the encoding is unsupported
80
+ *
81
+ * @internal
82
+ */
83
+ async function decompressBuffer(buffer, contentEncoding) {
84
+ if (!contentEncoding) return buffer;
85
+ switch (contentEncoding.toLowerCase()) {
86
+ case "gzip": return gunzipAsync(buffer);
87
+ case "deflate": return inflateAsync(buffer);
88
+ default: throw new Error(`Unsupported content-encoding: ${contentEncoding}`);
89
+ }
90
+ }
38
91
 
39
92
  //#endregion
40
93
  //#region src/worker.ts
@@ -79,22 +132,38 @@ var MessageValidationError = class extends WorkerError {
79
132
  * ```
80
133
  */
81
134
  var TypedAmqpWorker = class TypedAmqpWorker {
135
+ /**
136
+ * Internal handler type - always safe handlers (`Future<Result>`).
137
+ * Unsafe handlers are wrapped into safe handlers by defineUnsafeHandler/defineUnsafeHandlers.
138
+ */
82
139
  actualHandlers;
83
140
  consumerOptions;
84
141
  batchTimers = /* @__PURE__ */ new Map();
85
- constructor(contract, amqpClient, handlers, logger) {
142
+ consumerTags = /* @__PURE__ */ new Set();
143
+ retryConfig;
144
+ constructor(contract, amqpClient, handlers, logger, retryOptions) {
86
145
  this.contract = contract;
87
146
  this.amqpClient = amqpClient;
88
147
  this.logger = logger;
89
148
  this.actualHandlers = {};
90
149
  this.consumerOptions = {};
91
- for (const consumerName of Object.keys(handlers)) {
92
- const handlerEntry = handlers[consumerName];
150
+ const handlersRecord = handlers;
151
+ for (const consumerName of Object.keys(handlersRecord)) {
152
+ const handlerEntry = handlersRecord[consumerName];
153
+ const typedConsumerName = consumerName;
93
154
  if (Array.isArray(handlerEntry)) {
94
- this.actualHandlers[consumerName] = handlerEntry[0];
95
- this.consumerOptions[consumerName] = handlerEntry[1];
96
- } else this.actualHandlers[consumerName] = handlerEntry;
155
+ this.actualHandlers[typedConsumerName] = handlerEntry[0];
156
+ this.consumerOptions[typedConsumerName] = handlerEntry[1];
157
+ } else this.actualHandlers[typedConsumerName] = handlerEntry;
97
158
  }
159
+ if (retryOptions === void 0) this.retryConfig = null;
160
+ else this.retryConfig = {
161
+ maxRetries: retryOptions.maxRetries ?? 3,
162
+ initialDelayMs: retryOptions.initialDelayMs ?? 1e3,
163
+ maxDelayMs: retryOptions.maxDelayMs ?? 3e4,
164
+ backoffMultiplier: retryOptions.backoffMultiplier ?? 2,
165
+ jitter: retryOptions.jitter ?? true
166
+ };
98
167
  }
99
168
  /**
100
169
  * Create a type-safe AMQP worker from a contract.
@@ -112,25 +181,21 @@ var TypedAmqpWorker = class TypedAmqpWorker {
112
181
  *
113
182
  * @example
114
183
  * ```typescript
115
- * const workerResult = await TypedAmqpWorker.create({
184
+ * const worker = await TypedAmqpWorker.create({
116
185
  * contract: myContract,
117
186
  * handlers: {
118
187
  * processOrder: async (msg) => console.log('Order:', msg.orderId)
119
188
  * },
120
189
  * urls: ['amqp://localhost']
121
190
  * }).resultToPromise();
122
- *
123
- * if (workerResult.isError()) {
124
- * console.error('Failed to create worker:', workerResult.error);
125
- * }
126
191
  * ```
127
192
  */
128
- static create({ contract, handlers, urls, connectionOptions, logger }) {
193
+ static create({ contract, handlers, urls, connectionOptions, logger, retry }) {
129
194
  const worker = new TypedAmqpWorker(contract, new AmqpClient(contract, {
130
195
  urls,
131
196
  connectionOptions
132
- }), handlers, logger);
133
- return worker.waitForConnectionReady().flatMapOk(() => worker.consumeAll()).mapOk(() => worker);
197
+ }), handlers, logger, retry);
198
+ return worker.waitForConnectionReady().flatMapOk(() => worker.setupWaitQueues()).flatMapOk(() => worker.consumeAll()).mapOk(() => worker);
134
199
  }
135
200
  /**
136
201
  * Close the AMQP channel and connection.
@@ -151,7 +216,51 @@ var TypedAmqpWorker = class TypedAmqpWorker {
151
216
  close() {
152
217
  for (const timer of this.batchTimers.values()) clearTimeout(timer);
153
218
  this.batchTimers.clear();
154
- return Future.fromPromise(this.amqpClient.close()).mapError((error) => new TechnicalError("Failed to close AMQP connection", error)).mapOk(() => void 0);
219
+ return Future.all(Array.from(this.consumerTags).map((consumerTag) => Future.fromPromise(this.amqpClient.channel.cancel(consumerTag)).mapErrorToResult((error) => {
220
+ this.logger?.warn("Failed to cancel consumer during close", {
221
+ consumerTag,
222
+ error
223
+ });
224
+ return Result.Ok(void 0);
225
+ }))).map(Result.all).tapOk(() => {
226
+ this.consumerTags.clear();
227
+ }).flatMapOk(() => Future.fromPromise(this.amqpClient.close())).mapError((error) => new TechnicalError("Failed to close AMQP connection", error)).mapOk(() => void 0);
228
+ }
229
+ /**
230
+ * Set up wait queues for retry mechanism.
231
+ * Creates and binds wait queues for each consumer queue that has DLX configuration.
232
+ */
233
+ setupWaitQueues() {
234
+ if (this.retryConfig === null) return Future.value(Result.Ok(void 0));
235
+ if (!this.contract.consumers || !this.contract.queues) return Future.value(Result.Ok(void 0));
236
+ const setupTasks = [];
237
+ for (const consumerName of Object.keys(this.contract.consumers)) {
238
+ const consumer = this.contract.consumers[consumerName];
239
+ if (!consumer) continue;
240
+ const queue = consumer.queue;
241
+ const deadLetter = queue.deadLetter;
242
+ if (!deadLetter) continue;
243
+ const queueName = queue.name;
244
+ const waitQueueName = `${queueName}-wait`;
245
+ const dlxName = deadLetter.exchange.name;
246
+ const setupTask = Future.fromPromise(this.amqpClient.channel.addSetup(async (channel) => {
247
+ await channel.assertQueue(waitQueueName, {
248
+ durable: queue.durable ?? false,
249
+ deadLetterExchange: dlxName,
250
+ deadLetterRoutingKey: queueName
251
+ });
252
+ await channel.bindQueue(waitQueueName, dlxName, `${queueName}-wait`);
253
+ this.logger?.info("Wait queue created and bound", {
254
+ consumerName: String(consumerName),
255
+ queueName,
256
+ waitQueueName,
257
+ dlxName
258
+ });
259
+ })).mapError((error) => new TechnicalError(`Failed to setup wait queue for "${String(consumerName)}"`, error));
260
+ setupTasks.push(setupTask);
261
+ }
262
+ if (setupTasks.length === 0) return Future.value(Result.Ok(void 0));
263
+ return Future.all(setupTasks).map(Result.all).mapOk(() => void 0);
155
264
  }
156
265
  /**
157
266
  * Start consuming messages for all consumers
@@ -205,33 +314,48 @@ var TypedAmqpWorker = class TypedAmqpWorker {
205
314
  }
206
315
  /**
207
316
  * Parse and validate a message from AMQP
208
- * @returns Future<Result<validated message, void>> - Ok with validated message, or Error (already handled with nack)
317
+ * @returns `Future<Result<validated message, void>>` - Ok with validated message, or Error (already handled with nack)
209
318
  */
210
319
  parseAndValidateMessage(msg, consumer, consumerName) {
211
- const parseResult = Result.fromExecution(() => JSON.parse(msg.content.toString()));
212
- if (parseResult.isError()) {
213
- this.logger?.error("Error parsing message", {
320
+ const decompressMessage = Future.fromPromise(decompressBuffer(msg.content, msg.properties.contentEncoding)).tapError((error) => {
321
+ this.logger?.error("Error decompressing message", {
214
322
  consumerName: String(consumerName),
215
323
  queueName: consumer.queue.name,
216
- error: parseResult.error
324
+ contentEncoding: msg.properties.contentEncoding,
325
+ error
217
326
  });
218
327
  this.amqpClient.channel.nack(msg, false, false);
219
- return Future.value(Result.Error(void 0));
220
- }
221
- const rawValidation = consumer.message.payload["~standard"].validate(parseResult.value);
222
- return Future.fromPromise(rawValidation instanceof Promise ? rawValidation : Promise.resolve(rawValidation)).mapOkToResult((validationResult) => {
223
- if (validationResult.issues) {
224
- const error = new MessageValidationError(String(consumerName), validationResult.issues);
225
- this.logger?.error("Message validation failed", {
328
+ });
329
+ const parseMessage = (buffer) => {
330
+ const parseResult = Result.fromExecution(() => JSON.parse(buffer.toString()));
331
+ if (parseResult.isError()) {
332
+ this.logger?.error("Error parsing message", {
226
333
  consumerName: String(consumerName),
227
334
  queueName: consumer.queue.name,
228
- error
335
+ error: parseResult.error
229
336
  });
230
337
  this.amqpClient.channel.nack(msg, false, false);
231
- return Result.Error(void 0);
338
+ return Future.value(Result.Error(void 0));
232
339
  }
233
- return Result.Ok(validationResult.value);
234
- });
340
+ return Future.value(Result.Ok(parseResult.value));
341
+ };
342
+ const validateMessage = (parsedMessage) => {
343
+ const rawValidation = consumer.message.payload["~standard"].validate(parsedMessage);
344
+ return Future.fromPromise(rawValidation instanceof Promise ? rawValidation : Promise.resolve(rawValidation)).mapOkToResult((validationResult) => {
345
+ if (validationResult.issues) {
346
+ const error = new MessageValidationError(String(consumerName), validationResult.issues);
347
+ this.logger?.error("Message validation failed", {
348
+ consumerName: String(consumerName),
349
+ queueName: consumer.queue.name,
350
+ error
351
+ });
352
+ this.amqpClient.channel.nack(msg, false, false);
353
+ return Result.Error(void 0);
354
+ }
355
+ return Result.Ok(validationResult.value);
356
+ });
357
+ };
358
+ return decompressMessage.flatMapOk(parseMessage).flatMapOk(validateMessage);
235
359
  }
236
360
  /**
237
361
  * Consume messages one at a time
@@ -245,21 +369,31 @@ var TypedAmqpWorker = class TypedAmqpWorker {
245
369
  });
246
370
  return;
247
371
  }
248
- await this.parseAndValidateMessage(msg, consumer, consumerName).flatMapOk((validatedMessage) => Future.fromPromise(handler(validatedMessage)).tapError((error) => {
249
- this.logger?.error("Error processing message", {
250
- consumerName: String(consumerName),
251
- queueName: consumer.queue.name,
252
- error
253
- });
254
- this.amqpClient.channel.nack(msg, false, true);
255
- })).tapOk(() => {
372
+ await this.parseAndValidateMessage(msg, consumer, consumerName).flatMapOk((validatedMessage) => handler(validatedMessage).flatMapOk(() => {
256
373
  this.logger?.info("Message consumed successfully", {
257
374
  consumerName: String(consumerName),
258
375
  queueName: consumer.queue.name
259
376
  });
260
377
  this.amqpClient.channel.ack(msg);
261
- }).toPromise();
262
- })).mapError((error) => new TechnicalError(`Failed to start consuming for "${String(consumerName)}"`, error)).mapOk(() => void 0);
378
+ return Future.value(Result.Ok(void 0));
379
+ }).flatMapError((handlerError) => {
380
+ this.logger?.error("Error processing message", {
381
+ consumerName: String(consumerName),
382
+ queueName: consumer.queue.name,
383
+ errorType: handlerError.name,
384
+ error: handlerError.message
385
+ });
386
+ return this.handleError(handlerError, msg, String(consumerName), consumer);
387
+ })).toPromise();
388
+ })).tapOk((reply) => {
389
+ this.consumerTags.add(reply.consumerTag);
390
+ }).mapError((error) => new TechnicalError(`Failed to start consuming for "${String(consumerName)}"`, error)).mapOk(() => void 0);
391
+ }
392
+ /**
393
+ * Handle batch processing error by applying error handling to all messages.
394
+ */
395
+ handleBatchError(error, currentBatch, consumerName, consumer) {
396
+ return Future.all(currentBatch.map((item) => this.handleError(error, item.amqpMessage, consumerName, consumer))).map(Result.all).mapOk(() => void 0);
263
397
  }
264
398
  /**
265
399
  * Consume messages in batches
@@ -270,8 +404,8 @@ var TypedAmqpWorker = class TypedAmqpWorker {
270
404
  const timerKey = String(consumerName);
271
405
  let batch = [];
272
406
  let isProcessing = false;
273
- const processBatch = async () => {
274
- if (isProcessing || batch.length === 0) return;
407
+ const processBatch = () => {
408
+ if (isProcessing || batch.length === 0) return Future.value(Result.Ok(void 0));
275
409
  isProcessing = true;
276
410
  const currentBatch = batch;
277
411
  batch = [];
@@ -286,37 +420,33 @@ var TypedAmqpWorker = class TypedAmqpWorker {
286
420
  queueName: consumer.queue.name,
287
421
  batchSize: currentBatch.length
288
422
  });
289
- try {
290
- await handler(messages);
423
+ return handler(messages).flatMapOk(() => {
291
424
  for (const item of currentBatch) this.amqpClient.channel.ack(item.amqpMessage);
292
425
  this.logger?.info("Batch processed successfully", {
293
426
  consumerName: String(consumerName),
294
427
  queueName: consumer.queue.name,
295
428
  batchSize: currentBatch.length
296
429
  });
297
- } catch (error) {
430
+ return Future.value(Result.Ok(void 0));
431
+ }).flatMapError((handlerError) => {
298
432
  this.logger?.error("Error processing batch", {
299
433
  consumerName: String(consumerName),
300
434
  queueName: consumer.queue.name,
301
435
  batchSize: currentBatch.length,
302
- error
436
+ errorType: handlerError.name,
437
+ error: handlerError.message
303
438
  });
304
- for (const item of currentBatch) this.amqpClient.channel.nack(item.amqpMessage, false, true);
305
- } finally {
439
+ return this.handleBatchError(handlerError, currentBatch, String(consumerName), consumer);
440
+ }).tap(() => {
306
441
  isProcessing = false;
307
- }
442
+ });
308
443
  };
309
444
  const scheduleBatchProcessing = () => {
310
445
  if (isProcessing) return;
311
446
  const existingTimer = this.batchTimers.get(timerKey);
312
447
  if (existingTimer) clearTimeout(existingTimer);
313
448
  const timer = setTimeout(() => {
314
- processBatch().catch((error) => {
315
- this.logger?.error("Unexpected error in batch processing", {
316
- consumerName: String(consumerName),
317
- error
318
- });
319
- });
449
+ processBatch().toPromise();
320
450
  }, batchTimeout);
321
451
  this.batchTimers.set(timerKey, timer);
322
452
  };
@@ -326,7 +456,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
326
456
  consumerName: String(consumerName),
327
457
  queueName: consumer.queue.name
328
458
  });
329
- await processBatch();
459
+ await processBatch().toPromise();
330
460
  return;
331
461
  }
332
462
  const validationResult = await this.parseAndValidateMessage(msg, consumer, consumerName).toPromise();
@@ -336,89 +466,285 @@ var TypedAmqpWorker = class TypedAmqpWorker {
336
466
  amqpMessage: msg
337
467
  });
338
468
  if (batch.length >= batchSize) {
339
- await processBatch();
469
+ await processBatch().toPromise();
340
470
  if (batch.length > 0 && !this.batchTimers.has(timerKey)) scheduleBatchProcessing();
341
471
  } else if (!this.batchTimers.has(timerKey)) scheduleBatchProcessing();
342
- })).mapError((error) => new TechnicalError(`Failed to start consuming for "${String(consumerName)}"`, error)).mapOk(() => void 0);
472
+ })).tapOk((reply) => {
473
+ this.consumerTags.add(reply.consumerTag);
474
+ }).mapError((error) => new TechnicalError(`Failed to start consuming for "${String(consumerName)}"`, error)).mapOk(() => void 0);
475
+ }
476
+ /**
477
+ * Handle error in message processing with retry logic.
478
+ *
479
+ * Flow:
480
+ * 1. If NonRetryableError -> send directly to DLQ (no retry)
481
+ * 2. If no retry config -> legacy behavior (immediate requeue)
482
+ * 3. If max retries exceeded -> send to DLQ
483
+ * 4. Otherwise -> publish to wait queue with TTL for retry
484
+ */
485
+ handleError(error, msg, consumerName, consumer) {
486
+ if (error instanceof NonRetryableError) {
487
+ this.logger?.error("Non-retryable error, sending to DLQ immediately", {
488
+ consumerName,
489
+ errorType: error.name,
490
+ error: error.message
491
+ });
492
+ this.sendToDLQ(msg, consumer);
493
+ return Future.value(Result.Ok(void 0));
494
+ }
495
+ if (this.retryConfig === null) {
496
+ this.logger?.warn("Error in handler (legacy mode: immediate requeue)", {
497
+ consumerName,
498
+ error: error.message
499
+ });
500
+ this.amqpClient.channel.nack(msg, false, true);
501
+ return Future.value(Result.Ok(void 0));
502
+ }
503
+ const retryCount = msg.properties.headers?.["x-retry-count"] ?? 0;
504
+ const config = this.retryConfig;
505
+ if (retryCount >= config.maxRetries) {
506
+ this.logger?.error("Max retries exceeded, sending to DLQ", {
507
+ consumerName,
508
+ retryCount,
509
+ maxRetries: config.maxRetries,
510
+ error: error.message
511
+ });
512
+ this.sendToDLQ(msg, consumer);
513
+ return Future.value(Result.Ok(void 0));
514
+ }
515
+ const delayMs = this.calculateRetryDelay(retryCount);
516
+ this.logger?.warn("Retrying message", {
517
+ consumerName,
518
+ retryCount: retryCount + 1,
519
+ delayMs,
520
+ error: error.message
521
+ });
522
+ return this.publishForRetry(msg, consumer, retryCount + 1, delayMs, error);
523
+ }
524
+ /**
525
+ * Calculate retry delay with exponential backoff and optional jitter.
526
+ */
527
+ calculateRetryDelay(retryCount) {
528
+ const { initialDelayMs, maxDelayMs, backoffMultiplier, jitter } = this.retryConfig;
529
+ let delay = Math.min(initialDelayMs * Math.pow(backoffMultiplier, retryCount), maxDelayMs);
530
+ if (jitter) delay = delay * (.5 + Math.random() * .5);
531
+ return Math.floor(delay);
532
+ }
533
+ /**
534
+ * Parse message content for republishing.
535
+ * Prevents double JSON serialization by converting Buffer to object when possible.
536
+ */
537
+ parseMessageContentForRetry(msg, queueName) {
538
+ let content = msg.content;
539
+ if (!msg.properties.contentEncoding) try {
540
+ content = JSON.parse(msg.content.toString());
541
+ } catch (err) {
542
+ this.logger?.warn("Failed to parse message for retry, using original buffer", {
543
+ queueName,
544
+ error: err
545
+ });
546
+ }
547
+ return content;
548
+ }
549
+ /**
550
+ * Publish message to wait queue for retry after TTL expires.
551
+ *
552
+ * ┌─────────────────────────────────────────────────────────────────┐
553
+ * │ Retry Flow (Native RabbitMQ TTL + DLX Pattern) │
554
+ * ├─────────────────────────────────────────────────────────────────┤
555
+ * │ │
556
+ * │ 1. Handler throws any Error │
557
+ * │ ↓ │
558
+ * │ 2. Worker publishes to DLX with routing key: {queue}-wait │
559
+ * │ ↓ │
560
+ * │ 3. DLX routes to wait queue: {queue}-wait │
561
+ * │ (with expiration: calculated backoff delay) │
562
+ * │ ↓ │
563
+ * │ 4. Message waits in queue until TTL expires │
564
+ * │ ↓ │
565
+ * │ 5. Expired message dead-lettered to DLX │
566
+ * │ (with routing key: {queue}) │
567
+ * │ ↓ │
568
+ * │ 6. DLX routes back to main queue → RETRY │
569
+ * │ ↓ │
570
+ * │ 7. If retries exhausted: nack without requeue → DLQ │
571
+ * │ │
572
+ * └─────────────────────────────────────────────────────────────────┘
573
+ */
574
+ publishForRetry(msg, consumer, newRetryCount, delayMs, error) {
575
+ const queueName = consumer.queue.name;
576
+ const deadLetter = consumer.queue.deadLetter;
577
+ if (!deadLetter) {
578
+ this.logger?.warn("Cannot retry: queue does not have DLX configured, falling back to nack with requeue", { queueName });
579
+ this.amqpClient.channel.nack(msg, false, true);
580
+ return Future.value(Result.Ok(void 0));
581
+ }
582
+ const dlxName = deadLetter.exchange.name;
583
+ const waitRoutingKey = `${queueName}-wait`;
584
+ this.amqpClient.channel.ack(msg);
585
+ const content = this.parseMessageContentForRetry(msg, queueName);
586
+ return Future.fromPromise(this.amqpClient.channel.publish(dlxName, waitRoutingKey, content, {
587
+ ...msg.properties,
588
+ expiration: delayMs.toString(),
589
+ headers: {
590
+ ...msg.properties.headers,
591
+ "x-retry-count": newRetryCount,
592
+ "x-last-error": error.message,
593
+ "x-first-failure-timestamp": msg.properties.headers?.["x-first-failure-timestamp"] ?? Date.now()
594
+ }
595
+ })).mapError((error$1) => new TechnicalError("Failed to publish message for retry", error$1)).mapOkToResult((published) => {
596
+ if (!published) {
597
+ this.logger?.error("Failed to publish message for retry (write buffer full)", {
598
+ queueName,
599
+ waitRoutingKey,
600
+ retryCount: newRetryCount
601
+ });
602
+ return Result.Error(new TechnicalError("Failed to publish message for retry (write buffer full)"));
603
+ }
604
+ this.logger?.info("Message published for retry", {
605
+ queueName,
606
+ waitRoutingKey,
607
+ retryCount: newRetryCount,
608
+ delayMs
609
+ });
610
+ return Result.Ok(void 0);
611
+ });
612
+ }
613
+ /**
614
+ * Send message to dead letter queue.
615
+ * Nacks the message without requeue, relying on DLX configuration.
616
+ */
617
+ sendToDLQ(msg, consumer) {
618
+ const queueName = consumer.queue.name;
619
+ if (!(consumer.queue.deadLetter !== void 0)) this.logger?.warn("Queue does not have DLX configured - message will be lost on nack", { queueName });
620
+ this.logger?.info("Sending message to DLQ", {
621
+ queueName,
622
+ deliveryTag: msg.fields.deliveryTag
623
+ });
624
+ this.amqpClient.channel.nack(msg, false, false);
343
625
  }
344
626
  };
345
627
 
346
628
  //#endregion
347
629
  //#region src/handlers.ts
348
- function defineHandler(contract, consumerName, handler, options) {
630
+ /**
631
+ * Validate that a consumer exists in the contract
632
+ */
633
+ function validateConsumerExists(contract, consumerName) {
349
634
  const consumers = contract.consumers;
350
635
  if (!consumers || !(consumerName in consumers)) {
351
636
  const availableConsumers = consumers ? Object.keys(consumers) : [];
352
637
  const available = availableConsumers.length > 0 ? availableConsumers.join(", ") : "none";
353
- throw new Error(`Consumer "${String(consumerName)}" not found in contract. Available consumers: ${available}`);
638
+ throw new Error(`Consumer "${consumerName}" not found in contract. Available consumers: ${available}`);
354
639
  }
640
+ }
641
+ /**
642
+ * Validate that all handlers reference valid consumers
643
+ */
644
+ function validateHandlers(contract, handlers) {
645
+ const consumers = contract.consumers;
646
+ const availableConsumers = Object.keys(consumers ?? {});
647
+ const availableConsumerNames = availableConsumers.length > 0 ? availableConsumers.join(", ") : "none";
648
+ for (const handlerName of Object.keys(handlers)) if (!consumers || !(handlerName in consumers)) throw new Error(`Consumer "${handlerName}" not found in contract. Available consumers: ${availableConsumerNames}`);
649
+ }
650
+ /**
651
+ * Wrap a Promise-based handler into a Future-based safe handler.
652
+ * This is used internally by defineUnsafeHandler to convert Promise handlers to Future handlers.
653
+ */
654
+ function wrapUnsafeHandler(handler) {
655
+ return (input) => {
656
+ return Future.fromPromise(handler(input)).mapOkToResult(() => Result.Ok(void 0)).flatMapError((error) => {
657
+ if (error instanceof NonRetryableError || error instanceof RetryableError) return Future.value(Result.Error(error));
658
+ const retryableError = new RetryableError(error instanceof Error ? error.message : String(error), error);
659
+ return Future.value(Result.Error(retryableError));
660
+ });
661
+ };
662
+ }
663
+ function defineHandler(contract, consumerName, handler, options) {
664
+ validateConsumerExists(contract, String(consumerName));
355
665
  if (options) return [handler, options];
356
666
  return handler;
357
667
  }
358
668
  /**
359
669
  * Define multiple type-safe handlers for consumers in a contract.
360
670
  *
361
- * This utility allows you to define all handlers at once outside of the worker creation,
362
- * ensuring type safety and providing better code organization.
671
+ * **Recommended:** This function creates handlers that return `Future<Result<void, HandlerError>>`,
672
+ * providing explicit error handling and better control over retry behavior.
363
673
  *
364
674
  * @template TContract - The contract definition type
365
675
  * @param contract - The contract definition containing the consumers
366
- * @param handlers - An object with async handler functions for each consumer
676
+ * @param handlers - An object with handler functions for each consumer
367
677
  * @returns A type-safe handlers object that can be used with TypedAmqpWorker
368
678
  *
369
679
  * @example
370
680
  * ```typescript
371
- * import { defineHandlers } from '@amqp-contract/worker';
681
+ * import { defineHandlers, RetryableError } from '@amqp-contract/worker';
682
+ * import { Future } from '@swan-io/boxed';
372
683
  * import { orderContract } from './contract';
373
684
  *
374
- * // Define all handlers at once
375
685
  * const handlers = defineHandlers(orderContract, {
376
- * processOrder: async (message) => {
377
- * // message is fully typed based on the contract
378
- * console.log('Processing order:', message.orderId);
379
- * await processPayment(message);
380
- * },
381
- * notifyOrder: async (message) => {
382
- * await sendNotification(message);
383
- * },
384
- * shipOrder: async (message) => {
385
- * await prepareShipment(message);
386
- * },
387
- * });
388
- *
389
- * // Use the handlers in worker
390
- * const worker = await TypedAmqpWorker.create({
391
- * contract: orderContract,
392
- * handlers,
393
- * connection: 'amqp://localhost',
686
+ * processOrder: (message) =>
687
+ * Future.fromPromise(processPayment(message))
688
+ * .mapOk(() => undefined)
689
+ * .mapError((error) => new RetryableError('Payment failed', error)),
690
+ * notifyOrder: (message) =>
691
+ * Future.fromPromise(sendNotification(message))
692
+ * .mapOk(() => undefined)
693
+ * .mapError((error) => new RetryableError('Notification failed', error)),
394
694
  * });
395
695
  * ```
696
+ */
697
+ function defineHandlers(contract, handlers) {
698
+ validateHandlers(contract, handlers);
699
+ return handlers;
700
+ }
701
+ function defineUnsafeHandler(contract, consumerName, handler, options) {
702
+ validateConsumerExists(contract, String(consumerName));
703
+ const wrappedHandler = wrapUnsafeHandler(handler);
704
+ if (options) return [wrappedHandler, options];
705
+ return wrappedHandler;
706
+ }
707
+ /**
708
+ * Define multiple unsafe handlers for consumers in a contract.
709
+ *
710
+ * @deprecated Use `defineHandlers` instead for explicit error handling with `Future<Result>`.
711
+ *
712
+ * **Warning:** Unsafe handlers use exception-based error handling.
713
+ * Consider migrating to safe handlers for better error control.
714
+ *
715
+ * **Note:** Internally, this function wraps all Promise-based handlers into Future-based
716
+ * safe handlers for consistent processing in the worker.
717
+ *
718
+ * @template TContract - The contract definition type
719
+ * @param contract - The contract definition containing the consumers
720
+ * @param handlers - An object with async handler functions for each consumer
721
+ * @returns A type-safe handlers object that can be used with TypedAmqpWorker
396
722
  *
397
723
  * @example
398
724
  * ```typescript
399
- * // Separate handler definitions for better organization
400
- * async function handleProcessOrder(message: WorkerInferConsumerInput<typeof orderContract, 'processOrder'>) {
401
- * await processOrder(message);
402
- * }
403
- *
404
- * async function handleNotifyOrder(message: WorkerInferConsumerInput<typeof orderContract, 'notifyOrder'>) {
405
- * await sendNotification(message);
406
- * }
725
+ * import { defineUnsafeHandlers } from '@amqp-contract/worker';
407
726
  *
408
- * const handlers = defineHandlers(orderContract, {
409
- * processOrder: handleProcessOrder,
410
- * notifyOrder: handleNotifyOrder,
727
+ * // ⚠️ Consider using defineHandlers for better error handling
728
+ * const handlers = defineUnsafeHandlers(orderContract, {
729
+ * processOrder: async (message) => {
730
+ * await processPayment(message);
731
+ * },
732
+ * notifyOrder: async (message) => {
733
+ * await sendNotification(message);
734
+ * },
411
735
  * });
412
736
  * ```
413
737
  */
414
- function defineHandlers(contract, handlers) {
415
- const consumers = contract.consumers;
416
- const availableConsumers = Object.keys(consumers ?? {});
417
- const availableConsumerNames = availableConsumers.length > 0 ? availableConsumers.join(", ") : "none";
418
- for (const handlerName of Object.keys(handlers)) if (!consumers || !(handlerName in consumers)) throw new Error(`Consumer "${handlerName}" not found in contract. Available consumers: ${availableConsumerNames}`);
419
- return handlers;
738
+ function defineUnsafeHandlers(contract, handlers) {
739
+ validateHandlers(contract, handlers);
740
+ const result = {};
741
+ for (const [name, entry] of Object.entries(handlers)) if (Array.isArray(entry)) {
742
+ const [handler, options] = entry;
743
+ result[name] = [wrapUnsafeHandler(handler), options];
744
+ } else result[name] = wrapUnsafeHandler(entry);
745
+ return result;
420
746
  }
421
747
 
422
748
  //#endregion
423
- export { MessageValidationError, TechnicalError, TypedAmqpWorker, defineHandler, defineHandlers };
749
+ export { MessageValidationError, NonRetryableError, RetryableError, TechnicalError, TypedAmqpWorker, defineHandler, defineHandlers, defineUnsafeHandler, defineUnsafeHandlers };
424
750
  //# sourceMappingURL=index.mjs.map