@amqp-contract/worker 0.23.1 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -1,7 +1,7 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  let _amqp_contract_contract = require("@amqp-contract/contract");
3
3
  let _amqp_contract_core = require("@amqp-contract/core");
4
- let _swan_io_boxed = require("@swan-io/boxed");
4
+ let neverthrow = require("neverthrow");
5
5
  let node_zlib = require("node:zlib");
6
6
  let node_util = require("node:util");
7
7
  //#region src/decompression.ts
@@ -22,52 +22,55 @@ function isSupportedEncoding(encoding) {
22
22
  *
23
23
  * @param buffer - The buffer to decompress
24
24
  * @param contentEncoding - The content-encoding header value (e.g., 'gzip', 'deflate')
25
- * @returns A Future with the decompressed buffer or a TechnicalError
25
+ * @returns A ResultAsync resolving to the decompressed buffer or a TechnicalError
26
26
  *
27
27
  * @internal
28
28
  */
29
29
  function decompressBuffer(buffer, contentEncoding) {
30
- if (!contentEncoding) return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(buffer));
30
+ if (!contentEncoding) return (0, neverthrow.okAsync)(buffer);
31
31
  const normalizedEncoding = contentEncoding.toLowerCase();
32
- if (!isSupportedEncoding(normalizedEncoding)) return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Error(new _amqp_contract_core.TechnicalError(`Unsupported content-encoding: "${contentEncoding}". Supported encodings are: ${SUPPORTED_ENCODINGS.join(", ")}. Please check your publisher configuration.`)));
32
+ if (!isSupportedEncoding(normalizedEncoding)) return (0, neverthrow.errAsync)(new _amqp_contract_core.TechnicalError(`Unsupported content-encoding: "${contentEncoding}". Supported encodings are: ${SUPPORTED_ENCODINGS.join(", ")}. Please check your publisher configuration.`));
33
33
  switch (normalizedEncoding) {
34
- case "gzip": return _swan_io_boxed.Future.fromPromise(gunzipAsync(buffer)).mapError((error) => new _amqp_contract_core.TechnicalError("Failed to decompress gzip", error));
35
- case "deflate": return _swan_io_boxed.Future.fromPromise(inflateAsync(buffer)).mapError((error) => new _amqp_contract_core.TechnicalError("Failed to decompress deflate", error));
34
+ case "gzip": return neverthrow.ResultAsync.fromPromise(gunzipAsync(buffer), (error) => new _amqp_contract_core.TechnicalError("Failed to decompress gzip", error));
35
+ case "deflate": return neverthrow.ResultAsync.fromPromise(inflateAsync(buffer), (error) => new _amqp_contract_core.TechnicalError("Failed to decompress deflate", error));
36
36
  }
37
37
  }
38
38
  //#endregion
39
39
  //#region src/errors.ts
40
40
  /**
41
- * Retryable errors - transient failures that may succeed on retry
42
- * Examples: network timeouts, rate limiting, temporary service unavailability
41
+ * Abstract base class for all handler-signalled errors.
43
42
  *
44
- * Use this error type when the operation might succeed if retried.
45
- * The worker will apply exponential backoff and retry the message.
43
+ * Concrete subclasses (`RetryableError`, `NonRetryableError`) discriminate on
44
+ * the `name` property so exhaustive narrowing in user code keeps working.
45
+ * `error instanceof HandlerError` is true for any handler error.
46
46
  */
47
- var RetryableError = class extends Error {
47
+ var HandlerError = class extends Error {
48
48
  constructor(message, cause) {
49
49
  super(message);
50
50
  this.cause = cause;
51
- this.name = "RetryableError";
52
51
  const ErrorConstructor = Error;
53
52
  if (typeof ErrorConstructor.captureStackTrace === "function") ErrorConstructor.captureStackTrace(this, this.constructor);
54
53
  }
55
54
  };
56
55
  /**
56
+ * Retryable errors - transient failures that may succeed on retry
57
+ * Examples: network timeouts, rate limiting, temporary service unavailability
58
+ *
59
+ * Use this error type when the operation might succeed if retried.
60
+ * The worker will apply exponential backoff and retry the message.
61
+ */
62
+ var RetryableError = class extends HandlerError {
63
+ name = "RetryableError";
64
+ };
65
+ /**
57
66
  * Non-retryable errors - permanent failures that should not be retried
58
67
  * Examples: invalid data, business rule violations, permanent external failures
59
68
  *
60
69
  * Use this error type when retrying would not help - the message will be
61
70
  * immediately sent to the dead letter queue (DLQ) if configured.
62
71
  */
63
- var NonRetryableError = class extends Error {
64
- constructor(message, cause) {
65
- super(message);
66
- this.cause = cause;
67
- this.name = "NonRetryableError";
68
- const ErrorConstructor = Error;
69
- if (typeof ErrorConstructor.captureStackTrace === "function") ErrorConstructor.captureStackTrace(this, this.constructor);
70
- }
72
+ var NonRetryableError = class extends HandlerError {
73
+ name = "NonRetryableError";
71
74
  };
72
75
  /**
73
76
  * Type guard to check if an error is a RetryableError.
@@ -138,7 +141,7 @@ function isNonRetryableError(error) {
138
141
  * ```
139
142
  */
140
143
  function isHandlerError(error) {
141
- return isRetryableError(error) || isNonRetryableError(error);
144
+ return error instanceof HandlerError;
142
145
  }
143
146
  /**
144
147
  * Create a RetryableError with less verbosity.
@@ -153,15 +156,16 @@ function isHandlerError(error) {
153
156
  * @example
154
157
  * ```typescript
155
158
  * import { retryable } from '@amqp-contract/worker';
156
- * import { Future, Result } from '@swan-io/boxed';
159
+ * import { ResultAsync } from 'neverthrow';
157
160
  *
158
161
  * const handler = ({ payload }) =>
159
- * Future.fromPromise(processPayment(payload))
160
- * .mapOk(() => undefined)
161
- * .mapError((e) => retryable('Payment service unavailable', e));
162
+ * ResultAsync.fromPromise(
163
+ * processPayment(payload),
164
+ * (e) => retryable('Payment service unavailable', e),
165
+ * ).map(() => undefined);
162
166
  *
163
167
  * // Equivalent to:
164
- * // .mapError((e) => new RetryableError('Payment service unavailable', e));
168
+ * // ResultAsync.fromPromise(processPayment(payload), (e) => new RetryableError('...', e))
165
169
  * ```
166
170
  */
167
171
  function retryable(message, cause) {
@@ -180,17 +184,17 @@ function retryable(message, cause) {
180
184
  * @example
181
185
  * ```typescript
182
186
  * import { nonRetryable } from '@amqp-contract/worker';
183
- * import { Future, Result } from '@swan-io/boxed';
187
+ * import { errAsync, okAsync } from 'neverthrow';
184
188
  *
185
189
  * const handler = ({ payload }) => {
186
190
  * if (!isValidPayload(payload)) {
187
- * return Future.value(Result.Error(nonRetryable('Invalid payload format')));
191
+ * return errAsync(nonRetryable('Invalid payload format'));
188
192
  * }
189
- * return Future.value(Result.Ok(undefined));
193
+ * return okAsync(undefined);
190
194
  * };
191
195
  *
192
196
  * // Equivalent to:
193
- * // return Future.value(Result.Error(new NonRetryableError('Invalid payload format')));
197
+ * // return errAsync(new NonRetryableError('Invalid payload format'));
194
198
  * ```
195
199
  */
196
200
  function nonRetryable(message, cause) {
@@ -218,23 +222,18 @@ function nonRetryable(message, cause) {
218
222
  */
219
223
  function handleError(ctx, error, msg, consumerName, consumer) {
220
224
  if (error instanceof NonRetryableError) {
221
- ctx.logger?.error("Non-retryable error, sending to DLQ immediately", {
222
- consumerName,
223
- errorType: error.name,
224
- error: error.message
225
- });
226
225
  sendToDLQ(ctx, msg, consumer);
227
- return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
226
+ return (0, neverthrow.okAsync)(void 0);
228
227
  }
229
228
  const config = (0, _amqp_contract_contract.extractQueue)(consumer.queue).retry;
230
229
  if (config.mode === "immediate-requeue") return handleErrorImmediateRequeue(ctx, error, msg, consumerName, consumer, config);
231
230
  if (config.mode === "ttl-backoff") return handleErrorTtlBackoff(ctx, error, msg, consumerName, consumer, config);
232
- ctx.logger?.warn("Retry disabled (none mode), sending to DLQ", {
231
+ ctx.logger?.info("Retry disabled (none mode), sending to DLQ", {
233
232
  consumerName,
234
- error: error.message
233
+ queueName: (0, _amqp_contract_contract.extractQueue)(consumer.queue).name
235
234
  });
236
235
  sendToDLQ(ctx, msg, consumer);
237
- return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
236
+ return (0, neverthrow.okAsync)(void 0);
238
237
  }
239
238
  /**
240
239
  * Handle error by requeuing immediately.
@@ -250,26 +249,24 @@ function handleErrorImmediateRequeue(ctx, error, msg, consumerName, consumer, co
250
249
  const queueName = queue.name;
251
250
  const retryCount = queue.type === "quorum" ? msg.properties.headers?.["x-delivery-count"] ?? 0 : msg.properties.headers?.["x-retry-count"] ?? 0;
252
251
  if (retryCount >= config.maxRetries) {
253
- ctx.logger?.error("Max retries exceeded, sending to DLQ (immediate-requeue mode)", {
252
+ ctx.logger?.info("Max retries exceeded, sending to DLQ (immediate-requeue mode)", {
254
253
  consumerName,
255
254
  queueName,
256
255
  retryCount,
257
- maxRetries: config.maxRetries,
258
- error: error.message
256
+ maxRetries: config.maxRetries
259
257
  });
260
258
  sendToDLQ(ctx, msg, consumer);
261
- return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
259
+ return (0, neverthrow.okAsync)(void 0);
262
260
  }
263
- ctx.logger?.warn("Retrying message (immediate-requeue mode)", {
261
+ ctx.logger?.info("Retrying message (immediate-requeue mode)", {
264
262
  consumerName,
265
263
  queueName,
266
264
  retryCount,
267
- maxRetries: config.maxRetries,
268
- error: error.message
265
+ maxRetries: config.maxRetries
269
266
  });
270
267
  if (queue.type === "quorum") {
271
268
  ctx.amqpClient.nack(msg, false, true);
272
- return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
269
+ return (0, neverthrow.okAsync)(void 0);
273
270
  } else return publishForRetry(ctx, {
274
271
  msg,
275
272
  exchange: msg.fields.exchange,
@@ -310,30 +307,28 @@ function handleErrorTtlBackoff(ctx, error, msg, consumerName, consumer, config)
310
307
  consumerName,
311
308
  queueName: consumer.queue.name
312
309
  });
313
- return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Error(new _amqp_contract_core.TechnicalError("Queue does not have TTL-backoff infrastructure")));
310
+ return (0, neverthrow.errAsync)(new _amqp_contract_core.TechnicalError("Queue does not have TTL-backoff infrastructure"));
314
311
  }
315
312
  const queueEntry = consumer.queue;
316
313
  const queueName = (0, _amqp_contract_contract.extractQueue)(queueEntry).name;
317
314
  const retryCount = msg.properties.headers?.["x-retry-count"] ?? 0;
318
315
  if (retryCount >= config.maxRetries) {
319
- ctx.logger?.error("Max retries exceeded, sending to DLQ (ttl-backoff mode)", {
316
+ ctx.logger?.info("Max retries exceeded, sending to DLQ (ttl-backoff mode)", {
320
317
  consumerName,
321
318
  queueName,
322
319
  retryCount,
323
- maxRetries: config.maxRetries,
324
- error: error.message
320
+ maxRetries: config.maxRetries
325
321
  });
326
322
  sendToDLQ(ctx, msg, consumer);
327
- return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
323
+ return (0, neverthrow.okAsync)(void 0);
328
324
  }
329
325
  const delayMs = calculateRetryDelay(retryCount, config);
330
- ctx.logger?.warn("Retrying message (ttl-backoff mode)", {
326
+ ctx.logger?.info("Retrying message (ttl-backoff mode)", {
331
327
  consumerName,
332
328
  queueName,
333
329
  retryCount: retryCount + 1,
334
330
  maxRetries: config.maxRetries,
335
- delayMs,
336
- error: error.message
331
+ delayMs
337
332
  });
338
333
  return publishForRetry(ctx, {
339
334
  msg,
@@ -350,9 +345,9 @@ function handleErrorTtlBackoff(ctx, error, msg, consumerName, consumer, config)
350
345
  */
351
346
  function calculateRetryDelay(retryCount, config) {
352
347
  const { initialDelayMs, maxDelayMs, backoffMultiplier, jitter } = config;
353
- let delay = Math.min(initialDelayMs * Math.pow(backoffMultiplier, retryCount), maxDelayMs);
354
- if (jitter) delay = delay * (.5 + Math.random() * .5);
355
- return Math.floor(delay);
348
+ let delay = initialDelayMs * Math.pow(backoffMultiplier, retryCount);
349
+ if (jitter) delay = delay * (.5 + Math.random());
350
+ return Math.floor(Math.min(delay, maxDelayMs));
356
351
  }
357
352
  /**
358
353
  * Parse message content for republishing.
@@ -371,10 +366,10 @@ function parseMessageContentForRetry(ctx, msg, queueName) {
371
366
  if (!(contentType === void 0 || contentType === "application/json" || contentType.startsWith("application/json;") || contentType.endsWith("+json"))) return msg.content;
372
367
  try {
373
368
  return JSON.parse(msg.content.toString());
374
- } catch (err) {
369
+ } catch (parseErr) {
375
370
  ctx.logger?.warn("Failed to parse JSON message for retry, using original buffer", {
376
371
  queueName,
377
- error: err
372
+ error: parseErr
378
373
  });
379
374
  return msg.content;
380
375
  }
@@ -384,7 +379,6 @@ function parseMessageContentForRetry(ctx, msg, queueName) {
384
379
  */
385
380
  function publishForRetry(ctx, { msg, exchange, routingKey, queueName, waitQueueName, delayMs, error }) {
386
381
  const newRetryCount = (msg.properties.headers?.["x-retry-count"] ?? 0) + 1;
387
- ctx.amqpClient.ack(msg);
388
382
  const content = parseMessageContentForRetry(ctx, msg, queueName);
389
383
  return ctx.amqpClient.publish(exchange, routingKey, content, {
390
384
  ...msg.properties,
@@ -399,21 +393,30 @@ function publishForRetry(ctx, { msg, exchange, routingKey, queueName, waitQueueN
399
393
  "x-retry-queue": queueName
400
394
  } : {}
401
395
  }
402
- }).mapOkToResult((published) => {
396
+ }).andThen((published) => {
403
397
  if (!published) {
404
398
  ctx.logger?.error("Failed to publish message for retry (write buffer full)", {
405
399
  queueName,
406
400
  retryCount: newRetryCount,
407
401
  ...delayMs !== void 0 ? { delayMs } : {}
408
402
  });
409
- return _swan_io_boxed.Result.Error(new _amqp_contract_core.TechnicalError("Failed to publish message for retry (write buffer full)"));
403
+ return (0, neverthrow.err)(new _amqp_contract_core.TechnicalError("Failed to publish message for retry (write buffer full)"));
410
404
  }
405
+ ctx.amqpClient.ack(msg);
411
406
  ctx.logger?.info("Message published for retry", {
412
407
  queueName,
413
408
  retryCount: newRetryCount,
414
409
  ...delayMs !== void 0 ? { delayMs } : {}
415
410
  });
416
- return _swan_io_boxed.Result.Ok(void 0);
411
+ return (0, neverthrow.ok)(void 0);
412
+ }).orElse((publishError) => {
413
+ ctx.logger?.error("Publish for retry failed; leaving original un-ack'd for redelivery", {
414
+ queueName,
415
+ retryCount: newRetryCount,
416
+ ...delayMs !== void 0 ? { delayMs } : {},
417
+ error: publishError
418
+ });
419
+ return (0, neverthrow.err)(publishError);
417
420
  });
418
421
  }
419
422
  /**
@@ -450,6 +453,7 @@ function isHandlerTuple(entry) {
450
453
  * ```typescript
451
454
  * import { TypedAmqpWorker } from '@amqp-contract/worker';
452
455
  * import { defineQueue, defineMessage, defineContract, defineConsumer } from '@amqp-contract/contract';
456
+ * import { okAsync } from 'neverthrow';
453
457
  * import { z } from 'zod';
454
458
  *
455
459
  * const orderQueue = defineQueue('order-processing');
@@ -464,19 +468,22 @@ function isHandlerTuple(entry) {
464
468
  * }
465
469
  * });
466
470
  *
467
- * const worker = await TypedAmqpWorker.create({
471
+ * const result = await TypedAmqpWorker.create({
468
472
  * contract,
469
473
  * handlers: {
470
- * processOrder: async (message) => {
471
- * console.log('Processing order', message.orderId);
472
- * // Process the order...
473
- * }
474
+ * processOrder: ({ payload }) => {
475
+ * console.log('Processing order', payload.orderId);
476
+ * return okAsync(undefined);
477
+ * },
474
478
  * },
475
- * urls: ['amqp://localhost']
476
- * }).resultToPromise();
479
+ * urls: ['amqp://localhost'],
480
+ * });
481
+ *
482
+ * if (result.isErr()) throw result.error;
483
+ * const worker = result.value;
477
484
  *
478
485
  * // Close when done
479
- * await worker.close().resultToPromise();
486
+ * await worker.close();
480
487
  * ```
481
488
  */
482
489
  var TypedAmqpWorker = class TypedAmqpWorker {
@@ -552,18 +559,17 @@ var TypedAmqpWorker = class TypedAmqpWorker {
552
559
  * Connections are automatically shared across clients and workers with the same
553
560
  * URLs and connection options, following RabbitMQ best practices.
554
561
  *
555
- * @param options - Configuration options for the worker
556
- * @returns A Future that resolves to a Result containing the worker or an error
562
+ * @returns A ResultAsync that resolves to the worker or a TechnicalError.
557
563
  *
558
564
  * @example
559
565
  * ```typescript
560
- * const worker = await TypedAmqpWorker.create({
566
+ * const result = await TypedAmqpWorker.create({
561
567
  * contract: myContract,
562
568
  * handlers: {
563
- * processOrder: async ({ payload }) => console.log('Order:', payload.orderId)
569
+ * processOrder: ({ payload }) => okAsync(undefined),
564
570
  * },
565
- * urls: ['amqp://localhost']
566
- * }).resultToPromise();
571
+ * urls: ['amqp://localhost'],
572
+ * });
567
573
  * ```
568
574
  */
569
575
  static create({ contract, handlers, urls, connectionOptions, defaultConsumerOptions, logger, telemetry, connectTimeoutMs }) {
@@ -572,12 +578,14 @@ var TypedAmqpWorker = class TypedAmqpWorker {
572
578
  connectionOptions,
573
579
  connectTimeoutMs
574
580
  }), handlers, defaultConsumerOptions ?? {}, logger, telemetry);
575
- return worker.waitForConnectionReady().flatMapOk(() => worker.consumeAll()).flatMap((result) => result.match({
576
- Ok: () => _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(worker)),
577
- Error: (error) => worker.close().tapError((closeError) => {
578
- logger?.warn("Failed to close worker after setup failure", { error: closeError });
579
- }).map(() => _swan_io_boxed.Result.Error(error))
580
- }));
581
+ const setup = worker.waitForConnectionReady().andThen(() => worker.consumeAll());
582
+ return new neverthrow.ResultAsync((async () => {
583
+ const setupResult = await setup;
584
+ if (setupResult.isOk()) return (0, neverthrow.ok)(worker);
585
+ const closeResult = await worker.close();
586
+ if (closeResult.isErr()) logger?.warn("Failed to close worker after setup failure", { error: closeResult.error });
587
+ return (0, neverthrow.err)(setupResult.error);
588
+ })());
581
589
  }
582
590
  /**
583
591
  * Close the AMQP channel and connection.
@@ -585,26 +593,25 @@ var TypedAmqpWorker = class TypedAmqpWorker {
585
593
  * This gracefully closes the connection to the AMQP broker,
586
594
  * stopping all message consumption and cleaning up resources.
587
595
  *
588
- * @returns A Future that resolves to a Result indicating success or failure
589
- *
590
596
  * @example
591
597
  * ```typescript
592
- * const closeResult = await worker.close().resultToPromise();
598
+ * const closeResult = await worker.close();
593
599
  * if (closeResult.isOk()) {
594
600
  * console.log('Worker closed successfully');
595
601
  * }
596
602
  * ```
597
603
  */
598
604
  close() {
599
- return _swan_io_boxed.Future.all(Array.from(this.consumerTags).map((consumerTag) => this.amqpClient.cancel(consumerTag).mapErrorToResult((error) => {
605
+ const cancellations = Array.from(this.consumerTags).map((consumerTag) => this.amqpClient.cancel(consumerTag).orElse((error) => {
600
606
  this.logger?.warn("Failed to cancel consumer during close", {
601
607
  consumerTag,
602
608
  error
603
609
  });
604
- return _swan_io_boxed.Result.Ok(void 0);
605
- }))).map(_swan_io_boxed.Result.all).tapOk(() => {
610
+ return (0, neverthrow.ok)(void 0);
611
+ }));
612
+ return neverthrow.ResultAsync.combine(cancellations).andTee(() => {
606
613
  this.consumerTags.clear();
607
- }).flatMapOk(() => this.amqpClient.close()).mapOk(() => void 0);
614
+ }).andThen(() => this.amqpClient.close()).map(() => void 0);
608
615
  }
609
616
  /**
610
617
  * Start consuming for every entry in `contract.consumers` and `contract.rpcs`.
@@ -613,7 +620,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
613
620
  const consumerNames = Object.keys(this.contract.consumers ?? {});
614
621
  const rpcNames = Object.keys(this.contract.rpcs ?? {});
615
622
  const allNames = [...consumerNames, ...rpcNames];
616
- return _swan_io_boxed.Future.all(allNames.map((name) => this.consume(name))).map(_swan_io_boxed.Result.all).mapOk(() => void 0);
623
+ return neverthrow.ResultAsync.combine(allNames.map((name) => this.consume(name))).map(() => void 0);
617
624
  }
618
625
  waitForConnectionReady() {
619
626
  return this.amqpClient.waitForConnect();
@@ -634,9 +641,9 @@ var TypedAmqpWorker = class TypedAmqpWorker {
634
641
  validateSchema(schema, data, context) {
635
642
  const rawValidation = schema["~standard"].validate(data);
636
643
  const validationPromise = rawValidation instanceof Promise ? rawValidation : Promise.resolve(rawValidation);
637
- return _swan_io_boxed.Future.fromPromise(validationPromise).mapError((error) => new _amqp_contract_core.TechnicalError(`Error validating ${context.field}`, error)).mapOkToResult((result) => {
638
- if (result.issues) return _swan_io_boxed.Result.Error(new _amqp_contract_core.TechnicalError(`${context.field} validation failed`, new _amqp_contract_core.MessageValidationError(context.consumerName, result.issues)));
639
- return _swan_io_boxed.Result.Ok(result.value);
644
+ return neverthrow.ResultAsync.fromPromise(validationPromise, (error) => new _amqp_contract_core.TechnicalError(`Error validating ${context.field}`, error)).andThen((result) => {
645
+ if (result.issues) return (0, neverthrow.err)(new _amqp_contract_core.TechnicalError(`${context.field} validation failed`, new _amqp_contract_core.MessageValidationError(context.consumerName, result.issues)));
646
+ return (0, neverthrow.ok)(result.value);
640
647
  });
641
648
  }
642
649
  /**
@@ -648,18 +655,18 @@ var TypedAmqpWorker = class TypedAmqpWorker {
648
655
  */
649
656
  parseAndValidateMessage(msg, consumer, consumerName) {
650
657
  const context = { consumerName: String(consumerName) };
651
- const parsePayload = decompressBuffer(msg.content, msg.properties.contentEncoding).mapErrorToResult((error) => _swan_io_boxed.Result.Error(new _amqp_contract_core.TechnicalError("Failed to decompress message", error))).mapOkToResult((buffer) => _swan_io_boxed.Result.fromExecution(() => JSON.parse(buffer.toString())).mapError((error) => new _amqp_contract_core.TechnicalError("Failed to parse JSON", error))).flatMapOk((parsed) => this.validateSchema(consumer.message.payload, parsed, {
658
+ const parsePayload = decompressBuffer(msg.content, msg.properties.contentEncoding).andThen((buffer) => (0, _amqp_contract_core.safeJsonParse)(buffer, (error) => new _amqp_contract_core.TechnicalError("Failed to parse JSON", error))).andThen((parsed) => this.validateSchema(consumer.message.payload, parsed, {
652
659
  ...context,
653
660
  field: "payload"
654
661
  }));
655
662
  const parseHeaders = consumer.message.headers ? this.validateSchema(consumer.message.headers, msg.properties.headers ?? {}, {
656
663
  ...context,
657
664
  field: "headers"
658
- }) : _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
659
- return _swan_io_boxed.Future.allFromDict({
660
- payload: parsePayload,
661
- headers: parseHeaders
662
- }).map(_swan_io_boxed.Result.allFromDict);
665
+ }) : (0, neverthrow.okAsync)(void 0);
666
+ return neverthrow.ResultAsync.combine([parsePayload, parseHeaders]).map(([payload, headers]) => ({
667
+ payload,
668
+ headers
669
+ }));
663
670
  }
664
671
  /**
665
672
  * Validate an RPC handler's response and publish it back to the caller's reply
@@ -688,7 +695,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
688
695
  rpcName: String(rpcName),
689
696
  queueName
690
697
  });
691
- return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Error(new NonRetryableError(`RPC "${String(rpcName)}" received a message without replyTo; cannot deliver response`)));
698
+ return (0, neverthrow.errAsync)(new NonRetryableError(`RPC "${String(rpcName)}" received a message without replyTo; cannot deliver response`));
692
699
  }
693
700
  if (typeof correlationId !== "string" || correlationId.length === 0) {
694
701
  this.logger?.error("RPC handler returned a response but the incoming message has no correlationId", {
@@ -696,85 +703,127 @@ var TypedAmqpWorker = class TypedAmqpWorker {
696
703
  queueName,
697
704
  replyTo
698
705
  });
699
- return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Error(new NonRetryableError(`RPC "${String(rpcName)}" received a message without correlationId; cannot deliver response`)));
706
+ return (0, neverthrow.errAsync)(new NonRetryableError(`RPC "${String(rpcName)}" received a message without correlationId; cannot deliver response`));
700
707
  }
701
708
  let rawValidation;
702
709
  try {
703
710
  rawValidation = responseSchema["~standard"].validate(response);
704
711
  } catch (error) {
705
- return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Error(new NonRetryableError("RPC response schema validation threw", error)));
712
+ return (0, neverthrow.errAsync)(new NonRetryableError("RPC response schema validation threw", error));
706
713
  }
707
714
  const validationPromise = rawValidation instanceof Promise ? rawValidation : Promise.resolve(rawValidation);
708
- return _swan_io_boxed.Future.fromPromise(validationPromise).mapError((error) => new NonRetryableError("RPC response schema validation threw", error)).mapOkToResult((validation) => {
709
- if (validation.issues) return _swan_io_boxed.Result.Error(new NonRetryableError(`RPC response for "${String(rpcName)}" failed schema validation`, new _amqp_contract_core.MessageValidationError(String(rpcName), validation.issues)));
710
- return _swan_io_boxed.Result.Ok(validation.value);
711
- }).flatMapOk((validatedResponse) => this.amqpClient.publish("", replyTo, validatedResponse, {
715
+ return neverthrow.ResultAsync.fromPromise(validationPromise, (error) => new NonRetryableError("RPC response schema validation threw", error)).andThen((validation) => {
716
+ if (validation.issues) return (0, neverthrow.err)(new NonRetryableError(`RPC response for "${String(rpcName)}" failed schema validation`, new _amqp_contract_core.MessageValidationError(String(rpcName), validation.issues)));
717
+ return (0, neverthrow.ok)(validation.value);
718
+ }).andThen((validatedResponse) => this.amqpClient.publish("", replyTo, validatedResponse, {
712
719
  correlationId,
713
720
  contentType: "application/json"
714
- }).mapErrorToResult((error) => _swan_io_boxed.Result.Error(new NonRetryableError("Failed to publish RPC response", error))).mapOkToResult((published) => published ? _swan_io_boxed.Result.Ok(void 0) : _swan_io_boxed.Result.Error(new NonRetryableError("Failed to publish RPC response: channel buffer full"))));
721
+ }).mapErr((error) => new NonRetryableError("Failed to publish RPC response", error)).andThen((published) => published ? (0, neverthrow.ok)(void 0) : (0, neverthrow.err)(new NonRetryableError("Failed to publish RPC response: channel buffer full"))));
722
+ }
723
+ /**
724
+ * Parse and validate the message; on failure, nack(requeue=false) so the
725
+ * queue's DLX (if configured) receives the poison message and bypass the
726
+ * retry pipeline — a malformed payload is deterministic and retrying it
727
+ * would burn the queue's retry budget on a guaranteed failure.
728
+ */
729
+ parseAndValidateOrNack(msg, consumer, name) {
730
+ return this.parseAndValidateMessage(msg, consumer, name).orElse((parseError) => {
731
+ this.amqpClient.nack(msg, false, false);
732
+ return (0, neverthrow.errAsync)(parseError);
733
+ });
734
+ }
735
+ /**
736
+ * Invoke the handler and ack the message on success. Returns the handler's
737
+ * response (RPC) or `undefined` (regular consumer). Errors propagate as
738
+ * `HandlerError` for downstream RPC reply publishing or routing via
739
+ * {@link handleError}.
740
+ */
741
+ runHandler(handler, validatedMessage, msg) {
742
+ return handler(validatedMessage, msg);
743
+ }
744
+ /**
745
+ * For RPC handlers, validate and publish the reply on the caller's
746
+ * `replyTo` / `correlationId`. For non-RPC consumers, this is a no-op that
747
+ * resolves to `okAsync(undefined)`.
748
+ */
749
+ publishReplyIfRpc(msg, view, name, handlerResponse) {
750
+ if (!view.isRpc || !view.responseSchema) return (0, neverthrow.okAsync)(void 0);
751
+ const queueName = (0, _amqp_contract_contract.extractQueue)(view.consumer.queue).name;
752
+ return this.publishRpcResponse(msg, queueName, name, view.responseSchema, handlerResponse);
715
753
  }
716
754
  /**
717
755
  * Process a single consumed message: validate, invoke handler, optionally
718
- * publish the RPC response, record telemetry, and handle errors.
756
+ * publish the RPC response, record telemetry, and route errors.
757
+ *
758
+ * The caller-supplied `state` is mutated as the message is ack'd/nack'd so
759
+ * the consume callback's catch-all guard can tell whether a defensive nack
760
+ * is still needed (see {@link consumeSingle}).
761
+ *
762
+ * Success-vs-failure telemetry is data-driven: the chain resolves to
763
+ * `ok(undefined)` only on handler success (and reply-publish success for
764
+ * RPC). Handler failures — even when {@link handleError} routes them
765
+ * successfully to retry/DLQ — are classified as failures for metrics by
766
+ * re-failing the chain with a `TechnicalError` whose `cause` is the
767
+ * original `HandlerError`. The terminal `orTee` unwraps the cause before
768
+ * recording the span exception so traces keep the original
769
+ * `RetryableError` / `NonRetryableError` class as the exception type.
719
770
  */
720
- processMessage(msg, view, name, handler) {
721
- const { consumer, isRpc, responseSchema } = view;
771
+ processMessage(msg, view, name, handler, state) {
772
+ const { consumer } = view;
722
773
  const queueName = (0, _amqp_contract_contract.extractQueue)(consumer.queue).name;
723
774
  const startTime = Date.now();
724
775
  const span = (0, _amqp_contract_core.startConsumeSpan)(this.telemetry, queueName, String(name), { "messaging.rabbitmq.message.delivery_tag": msg.fields.deliveryTag });
725
- let messageHandled = false;
726
- let firstError;
727
- return this.parseAndValidateMessage(msg, consumer, name).flatMap((parseResult) => parseResult.match({
728
- Ok: (validatedMessage) => handler(validatedMessage, msg).flatMapOk((handlerResponse) => {
729
- if (isRpc && responseSchema) return this.publishRpcResponse(msg, queueName, name, responseSchema, handlerResponse).flatMapOk(() => {
730
- this.logger?.info("Message consumed successfully", {
731
- consumerName: String(name),
732
- queueName
733
- });
734
- this.amqpClient.ack(msg);
735
- messageHandled = true;
736
- return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
737
- });
738
- this.logger?.info("Message consumed successfully", {
739
- consumerName: String(name),
740
- queueName
741
- });
742
- this.amqpClient.ack(msg);
743
- messageHandled = true;
744
- return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
745
- }).flatMapError((handlerError) => {
746
- this.logger?.error("Error processing message", {
776
+ return this.parseAndValidateOrNack(msg, consumer, name).orTee((parseError) => {
777
+ this.logger?.error("Failed to parse/validate message; sending to DLQ", {
778
+ consumerName: String(name),
779
+ queueName,
780
+ error: parseError
781
+ });
782
+ state.messageHandled = true;
783
+ }).andThen((validatedMessage) => this.runHandler(handler, validatedMessage, msg).andThen((handlerResponse) => this.publishReplyIfRpc(msg, view, name, handlerResponse).andTee(() => {
784
+ this.logger?.info("Message consumed successfully", {
785
+ consumerName: String(name),
786
+ queueName
787
+ });
788
+ this.amqpClient.ack(msg);
789
+ state.messageHandled = true;
790
+ })).orElse((handlerError) => {
791
+ this.logger?.error("Error processing message", {
792
+ consumerName: String(name),
793
+ queueName,
794
+ errorType: handlerError.name,
795
+ retryCount: msg.properties.headers?.["x-delivery-count"] ?? msg.properties.headers?.["x-retry-count"] ?? 0,
796
+ error: handlerError.message
797
+ });
798
+ return handleError({
799
+ amqpClient: this.amqpClient,
800
+ logger: this.logger
801
+ }, handlerError, msg, String(name), consumer).andTee(() => {
802
+ state.messageHandled = true;
803
+ }).andThen(() => (0, neverthrow.errAsync)(new _amqp_contract_core.TechnicalError(`Handler "${String(name)}" failed: ${handlerError.message}`, handlerError)));
804
+ })).andTee(() => {
805
+ try {
806
+ (0, _amqp_contract_core.endSpanSuccess)(span);
807
+ (0, _amqp_contract_core.recordConsumeMetric)(this.telemetry, queueName, String(name), true, Date.now() - startTime);
808
+ } catch (telemetryError) {
809
+ this.logger?.warn("Telemetry recording threw; ignoring", {
747
810
  consumerName: String(name),
748
811
  queueName,
749
- errorType: handlerError.name,
750
- error: handlerError.message
812
+ error: telemetryError
751
813
  });
752
- firstError = handlerError;
753
- return handleError({
754
- amqpClient: this.amqpClient,
755
- logger: this.logger
756
- }, handlerError, msg, String(name), consumer);
757
- }),
758
- Error: (parseError) => {
759
- firstError = parseError;
760
- this.logger?.error("Failed to parse/validate message; sending to DLQ", {
814
+ }
815
+ }).orTee((error) => {
816
+ const reportedError = error.cause instanceof Error ? error.cause : error;
817
+ try {
818
+ (0, _amqp_contract_core.endSpanError)(span, reportedError);
819
+ (0, _amqp_contract_core.recordConsumeMetric)(this.telemetry, queueName, String(name), false, Date.now() - startTime);
820
+ } catch (telemetryError) {
821
+ this.logger?.warn("Telemetry recording threw; ignoring", {
761
822
  consumerName: String(name),
762
823
  queueName,
763
- error: parseError
824
+ error: telemetryError
764
825
  });
765
- this.amqpClient.nack(msg, false, false);
766
- return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Error(parseError));
767
- }
768
- })).map((result) => {
769
- const durationMs = Date.now() - startTime;
770
- if (messageHandled) {
771
- (0, _amqp_contract_core.endSpanSuccess)(span);
772
- (0, _amqp_contract_core.recordConsumeMetric)(this.telemetry, queueName, String(name), true, durationMs);
773
- } else {
774
- (0, _amqp_contract_core.endSpanError)(span, result.isError() ? result.error : firstError ?? /* @__PURE__ */ new Error("Unknown error"));
775
- (0, _amqp_contract_core.recordConsumeMetric)(this.telemetry, queueName, String(name), false, durationMs);
776
826
  }
777
- return result;
778
827
  });
779
828
  }
780
829
  /**
@@ -790,9 +839,18 @@ var TypedAmqpWorker = class TypedAmqpWorker {
790
839
  });
791
840
  return;
792
841
  }
842
+ const state = { messageHandled: false };
793
843
  try {
794
- await this.processMessage(msg, view, name, handler).toPromise();
844
+ await this.processMessage(msg, view, name, handler, state);
795
845
  } catch (error) {
846
+ if (state.messageHandled) {
847
+ this.logger?.error("Uncaught error in consume callback after message was already handled; not nacking", {
848
+ consumerName: String(name),
849
+ queueName,
850
+ error
851
+ });
852
+ return;
853
+ }
796
854
  this.logger?.error("Uncaught error in consume callback; nacking message", {
797
855
  consumerName: String(name),
798
856
  queueName,
@@ -800,64 +858,77 @@ var TypedAmqpWorker = class TypedAmqpWorker {
800
858
  });
801
859
  this.amqpClient.nack(msg, false, false);
802
860
  }
803
- }, this.consumerOptions[name]).tapOk((consumerTag) => {
861
+ }, this.consumerOptions[name]).andTee((consumerTag) => {
804
862
  this.consumerTags.add(consumerTag);
805
- }).mapError((error) => new _amqp_contract_core.TechnicalError(`Failed to start consuming for "${String(name)}"`, error)).mapOk(() => void 0);
863
+ }).map(() => void 0).mapErr((error) => new _amqp_contract_core.TechnicalError(`Failed to start consuming for "${String(name)}"`, error));
806
864
  }
807
865
  };
808
866
  //#endregion
809
867
  //#region src/handlers.ts
810
868
  /**
811
- * Validate that a consumer exists in the contract
869
+ * Build the list of available handler-target names — every key under
870
+ * `contract.consumers` plus every key under `contract.rpcs`.
812
871
  */
813
- function validateConsumerExists(contract, consumerName) {
872
+ function availableHandlerNames(contract) {
873
+ const consumers = contract.consumers ? Object.keys(contract.consumers) : [];
874
+ const rpcs = contract.rpcs ? Object.keys(contract.rpcs) : [];
875
+ return [...consumers, ...rpcs];
876
+ }
877
+ function formatAvailable(names) {
878
+ return names.length > 0 ? names.join(", ") : "none";
879
+ }
880
+ /**
881
+ * Validate that a name maps to a contract entry — either a `consumers` key
882
+ * or an `rpcs` key. The two name spaces are disjoint by contract definition.
883
+ */
884
+ function validateHandlerTargetExists(contract, name) {
814
885
  const consumers = contract.consumers;
815
- if (!consumers || !(consumerName in consumers)) {
816
- const availableConsumers = consumers ? Object.keys(consumers) : [];
817
- const available = availableConsumers.length > 0 ? availableConsumers.join(", ") : "none";
818
- throw new Error(`Consumer "${consumerName}" not found in contract. Available consumers: ${available}`);
886
+ const rpcs = contract.rpcs;
887
+ if (!(!!consumers && Object.hasOwn(consumers, name)) && !(!!rpcs && Object.hasOwn(rpcs, name))) {
888
+ const available = formatAvailable(availableHandlerNames(contract));
889
+ throw new Error(`Handler target "${name}" not found in contract. Available consumers and RPCs: ${available}`);
819
890
  }
820
891
  }
821
892
  /**
822
- * Validate that all handlers reference valid consumers
893
+ * Validate that every key in `handlers` maps to a contract entry —
894
+ * either a `consumers` key or an `rpcs` key.
823
895
  */
824
896
  function validateHandlers(contract, handlers) {
825
- const consumers = contract.consumers;
826
- const availableConsumers = Object.keys(consumers ?? {});
827
- const availableConsumerNames = availableConsumers.length > 0 ? availableConsumers.join(", ") : "none";
828
- for (const handlerName of Object.keys(handlers)) if (!consumers || !(handlerName in consumers)) throw new Error(`Consumer "${handlerName}" not found in contract. Available consumers: ${availableConsumerNames}`);
897
+ for (const handlerName of Object.keys(handlers)) validateHandlerTargetExists(contract, handlerName);
829
898
  }
830
- function defineHandler(contract, consumerName, handler, options) {
831
- validateConsumerExists(contract, String(consumerName));
899
+ function defineHandler(contract, name, handler, options) {
900
+ validateHandlerTargetExists(contract, String(name));
832
901
  if (options) return [handler, options];
833
902
  return handler;
834
903
  }
835
904
  /**
836
- * Define multiple type-safe handlers for consumers in a contract.
905
+ * Define multiple type-safe handlers for consumers and RPCs in a contract.
906
+ *
907
+ * **Recommended:** This function creates handlers that return
908
+ * `ResultAsync<void, HandlerError>` (consumers) or
909
+ * `ResultAsync<TResponse, HandlerError>` (RPCs), providing explicit error
910
+ * handling and better control over retry behavior.
837
911
  *
838
- * **Recommended:** This function creates handlers that return `Future<Result<void, HandlerError>>`,
839
- * providing explicit error handling and better control over retry behavior.
912
+ * The handlers object must contain exactly one entry per `consumers` and
913
+ * `rpcs` key in the contract see {@link WorkerInferHandlers}.
840
914
  *
841
915
  * @template TContract - The contract definition type
842
- * @param contract - The contract definition containing the consumers
843
- * @param handlers - An object with handler functions for each consumer
916
+ * @param contract - The contract definition containing the consumers and RPCs
917
+ * @param handlers - An object with handler functions for each consumer and RPC
844
918
  * @returns A type-safe handlers object that can be used with TypedAmqpWorker
845
919
  *
846
920
  * @example
847
921
  * ```typescript
848
922
  * import { defineHandlers, RetryableError } from '@amqp-contract/worker';
849
- * import { Future } from '@swan-io/boxed';
850
- * import { orderContract } from './contract';
923
+ * import { okAsync, ResultAsync } from 'neverthrow';
851
924
  *
852
925
  * const handlers = defineHandlers(orderContract, {
853
926
  * processOrder: ({ payload }) =>
854
- * Future.fromPromise(processPayment(payload))
855
- * .mapOk(() => undefined)
856
- * .mapError((error) => new RetryableError('Payment failed', error)),
857
- * notifyOrder: ({ payload }) =>
858
- * Future.fromPromise(sendNotification(payload))
859
- * .mapOk(() => undefined)
860
- * .mapError((error) => new RetryableError('Notification failed', error)),
927
+ * ResultAsync.fromPromise(
928
+ * processPayment(payload),
929
+ * (error) => new RetryableError('Payment failed', error),
930
+ * ).map(() => undefined),
931
+ * calculate: ({ payload }) => okAsync({ sum: payload.a + payload.b }),
861
932
  * });
862
933
  * ```
863
934
  */
@@ -866,6 +937,7 @@ function defineHandlers(contract, handlers) {
866
937
  return handlers;
867
938
  }
868
939
  //#endregion
940
+ exports.HandlerError = HandlerError;
869
941
  Object.defineProperty(exports, "MessageValidationError", {
870
942
  enumerable: true,
871
943
  get: function() {