@amqp-contract/worker 0.23.0 → 0.24.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/README.md +41 -36
- package/dist/index.cjs +125 -120
- package/dist/index.d.cts +69 -64
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +69 -64
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +125 -120
- package/dist/index.mjs.map +1 -1
- package/docs/index.md +109 -102
- package/package.json +7 -7
package/README.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# @amqp-contract/worker
|
|
2
2
|
|
|
3
|
-
**Type-safe AMQP worker for consuming messages using amqp-contract with
|
|
3
|
+
**Type-safe AMQP worker for consuming messages using amqp-contract with ResultAsync/Result error handling.**
|
|
4
4
|
|
|
5
5
|
[](https://github.com/btravers/amqp-contract/actions/workflows/ci.yml)
|
|
6
6
|
[](https://www.npmjs.com/package/@amqp-contract/worker)
|
|
7
7
|
[](https://www.npmjs.com/package/@amqp-contract/worker)
|
|
8
|
-
[](https://www.typescriptlang.org/)
|
|
9
9
|
[](https://opensource.org/licenses/MIT)
|
|
10
10
|
|
|
11
11
|
📖 **[Full documentation →](https://btravers.github.io/amqp-contract/api/worker)**
|
|
@@ -31,7 +31,7 @@ pnpm add @amqp-contract/worker
|
|
|
31
31
|
```typescript
|
|
32
32
|
import { TypedAmqpWorker, RetryableError } from "@amqp-contract/worker";
|
|
33
33
|
import type { Logger } from "@amqp-contract/core";
|
|
34
|
-
import {
|
|
34
|
+
import { ResultAsync } from "neverthrow";
|
|
35
35
|
import { contract } from "./contract";
|
|
36
36
|
|
|
37
37
|
// Optional: Create a logger implementation
|
|
@@ -43,21 +43,24 @@ const logger: Logger = {
|
|
|
43
43
|
};
|
|
44
44
|
|
|
45
45
|
// Create worker from contract with handlers (automatically connects and starts consuming)
|
|
46
|
-
const worker =
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
.
|
|
55
|
-
|
|
46
|
+
const worker = (
|
|
47
|
+
await TypedAmqpWorker.create({
|
|
48
|
+
contract,
|
|
49
|
+
handlers: {
|
|
50
|
+
processOrder: ({ payload }) => {
|
|
51
|
+
console.log("Processing order:", payload.orderId);
|
|
52
|
+
|
|
53
|
+
// Your business logic here
|
|
54
|
+
return ResultAsync.fromPromise(
|
|
55
|
+
Promise.all([processPayment(payload), updateInventory(payload)]),
|
|
56
|
+
(error) => new RetryableError("Order processing failed", error),
|
|
57
|
+
).map(() => undefined);
|
|
58
|
+
},
|
|
56
59
|
},
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
60
|
+
urls: ["amqp://localhost"],
|
|
61
|
+
logger, // Optional: logs message consumption and errors
|
|
62
|
+
})
|
|
63
|
+
)._unsafeUnwrap();
|
|
61
64
|
|
|
62
65
|
// Worker is already consuming messages
|
|
63
66
|
|
|
@@ -96,19 +99,22 @@ Then use `RetryableError` in your handlers:
|
|
|
96
99
|
|
|
97
100
|
```typescript
|
|
98
101
|
import { TypedAmqpWorker, RetryableError } from "@amqp-contract/worker";
|
|
99
|
-
import {
|
|
100
|
-
|
|
101
|
-
const worker =
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
.
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
}
|
|
102
|
+
import { ResultAsync } from "neverthrow";
|
|
103
|
+
|
|
104
|
+
const worker = (
|
|
105
|
+
await TypedAmqpWorker.create({
|
|
106
|
+
contract,
|
|
107
|
+
handlers: {
|
|
108
|
+
processOrder: ({ payload }) =>
|
|
109
|
+
// If this fails with RetryableError, message is automatically retried
|
|
110
|
+
ResultAsync.fromPromise(
|
|
111
|
+
processPayment(payload),
|
|
112
|
+
(error) => new RetryableError("Payment failed", error),
|
|
113
|
+
).map(() => undefined),
|
|
114
|
+
},
|
|
115
|
+
urls: ["amqp://localhost"],
|
|
116
|
+
})
|
|
117
|
+
)._unsafeUnwrap();
|
|
112
118
|
```
|
|
113
119
|
|
|
114
120
|
See the [Error Handling and Retry](https://btravers.github.io/amqp-contract/guide/worker-usage#error-handling-and-retry) section in the guide for complete details.
|
|
@@ -119,23 +125,22 @@ You can define handlers outside of the worker creation using `defineHandler` and
|
|
|
119
125
|
|
|
120
126
|
## Error Handling
|
|
121
127
|
|
|
122
|
-
Worker handlers return `
|
|
128
|
+
Worker handlers return `ResultAsync<void, HandlerError>` for explicit error handling:
|
|
123
129
|
|
|
124
130
|
```typescript
|
|
125
131
|
import { RetryableError, NonRetryableError } from "@amqp-contract/worker";
|
|
126
|
-
import {
|
|
132
|
+
import { errAsync, ResultAsync } from "neverthrow";
|
|
127
133
|
|
|
128
134
|
handlers: {
|
|
129
135
|
processOrder: ({ payload }) => {
|
|
130
136
|
// Validation errors - non-retryable
|
|
131
137
|
if (payload.amount <= 0) {
|
|
132
|
-
return
|
|
138
|
+
return errAsync(new NonRetryableError("Invalid amount"));
|
|
133
139
|
}
|
|
134
140
|
|
|
135
141
|
// Transient errors - retryable
|
|
136
|
-
return
|
|
137
|
-
.
|
|
138
|
-
.mapError((error) => new RetryableError("Processing failed", error));
|
|
142
|
+
return ResultAsync.fromPromise(process(payload), (error) => new RetryableError("Processing failed", error))
|
|
143
|
+
.map(() => undefined);
|
|
139
144
|
},
|
|
140
145
|
}
|
|
141
146
|
```
|
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
|
|
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,17 +22,17 @@ 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
|
|
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
|
|
30
|
+
if (!contentEncoding) return (0, neverthrow.okAsync)(buffer);
|
|
31
31
|
const normalizedEncoding = contentEncoding.toLowerCase();
|
|
32
|
-
if (!isSupportedEncoding(normalizedEncoding)) return
|
|
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
|
|
35
|
-
case "deflate": return
|
|
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
|
|
@@ -153,15 +153,16 @@ function isHandlerError(error) {
|
|
|
153
153
|
* @example
|
|
154
154
|
* ```typescript
|
|
155
155
|
* import { retryable } from '@amqp-contract/worker';
|
|
156
|
-
* import {
|
|
156
|
+
* import { ResultAsync } from 'neverthrow';
|
|
157
157
|
*
|
|
158
158
|
* const handler = ({ payload }) =>
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
159
|
+
* ResultAsync.fromPromise(
|
|
160
|
+
* processPayment(payload),
|
|
161
|
+
* (e) => retryable('Payment service unavailable', e),
|
|
162
|
+
* ).map(() => undefined);
|
|
162
163
|
*
|
|
163
164
|
* // Equivalent to:
|
|
164
|
-
* // .
|
|
165
|
+
* // ResultAsync.fromPromise(processPayment(payload), (e) => new RetryableError('...', e))
|
|
165
166
|
* ```
|
|
166
167
|
*/
|
|
167
168
|
function retryable(message, cause) {
|
|
@@ -180,17 +181,17 @@ function retryable(message, cause) {
|
|
|
180
181
|
* @example
|
|
181
182
|
* ```typescript
|
|
182
183
|
* import { nonRetryable } from '@amqp-contract/worker';
|
|
183
|
-
* import {
|
|
184
|
+
* import { errAsync, okAsync } from 'neverthrow';
|
|
184
185
|
*
|
|
185
186
|
* const handler = ({ payload }) => {
|
|
186
187
|
* if (!isValidPayload(payload)) {
|
|
187
|
-
* return
|
|
188
|
+
* return errAsync(nonRetryable('Invalid payload format'));
|
|
188
189
|
* }
|
|
189
|
-
* return
|
|
190
|
+
* return okAsync(undefined);
|
|
190
191
|
* };
|
|
191
192
|
*
|
|
192
193
|
* // Equivalent to:
|
|
193
|
-
* // return
|
|
194
|
+
* // return errAsync(new NonRetryableError('Invalid payload format'));
|
|
194
195
|
* ```
|
|
195
196
|
*/
|
|
196
197
|
function nonRetryable(message, cause) {
|
|
@@ -224,7 +225,7 @@ function handleError(ctx, error, msg, consumerName, consumer) {
|
|
|
224
225
|
error: error.message
|
|
225
226
|
});
|
|
226
227
|
sendToDLQ(ctx, msg, consumer);
|
|
227
|
-
return
|
|
228
|
+
return (0, neverthrow.okAsync)(void 0);
|
|
228
229
|
}
|
|
229
230
|
const config = (0, _amqp_contract_contract.extractQueue)(consumer.queue).retry;
|
|
230
231
|
if (config.mode === "immediate-requeue") return handleErrorImmediateRequeue(ctx, error, msg, consumerName, consumer, config);
|
|
@@ -234,7 +235,7 @@ function handleError(ctx, error, msg, consumerName, consumer) {
|
|
|
234
235
|
error: error.message
|
|
235
236
|
});
|
|
236
237
|
sendToDLQ(ctx, msg, consumer);
|
|
237
|
-
return
|
|
238
|
+
return (0, neverthrow.okAsync)(void 0);
|
|
238
239
|
}
|
|
239
240
|
/**
|
|
240
241
|
* Handle error by requeuing immediately.
|
|
@@ -258,7 +259,7 @@ function handleErrorImmediateRequeue(ctx, error, msg, consumerName, consumer, co
|
|
|
258
259
|
error: error.message
|
|
259
260
|
});
|
|
260
261
|
sendToDLQ(ctx, msg, consumer);
|
|
261
|
-
return
|
|
262
|
+
return (0, neverthrow.okAsync)(void 0);
|
|
262
263
|
}
|
|
263
264
|
ctx.logger?.warn("Retrying message (immediate-requeue mode)", {
|
|
264
265
|
consumerName,
|
|
@@ -269,7 +270,7 @@ function handleErrorImmediateRequeue(ctx, error, msg, consumerName, consumer, co
|
|
|
269
270
|
});
|
|
270
271
|
if (queue.type === "quorum") {
|
|
271
272
|
ctx.amqpClient.nack(msg, false, true);
|
|
272
|
-
return
|
|
273
|
+
return (0, neverthrow.okAsync)(void 0);
|
|
273
274
|
} else return publishForRetry(ctx, {
|
|
274
275
|
msg,
|
|
275
276
|
exchange: msg.fields.exchange,
|
|
@@ -310,7 +311,7 @@ function handleErrorTtlBackoff(ctx, error, msg, consumerName, consumer, config)
|
|
|
310
311
|
consumerName,
|
|
311
312
|
queueName: consumer.queue.name
|
|
312
313
|
});
|
|
313
|
-
return
|
|
314
|
+
return (0, neverthrow.errAsync)(new _amqp_contract_core.TechnicalError("Queue does not have TTL-backoff infrastructure"));
|
|
314
315
|
}
|
|
315
316
|
const queueEntry = consumer.queue;
|
|
316
317
|
const queueName = (0, _amqp_contract_contract.extractQueue)(queueEntry).name;
|
|
@@ -324,7 +325,7 @@ function handleErrorTtlBackoff(ctx, error, msg, consumerName, consumer, config)
|
|
|
324
325
|
error: error.message
|
|
325
326
|
});
|
|
326
327
|
sendToDLQ(ctx, msg, consumer);
|
|
327
|
-
return
|
|
328
|
+
return (0, neverthrow.okAsync)(void 0);
|
|
328
329
|
}
|
|
329
330
|
const delayMs = calculateRetryDelay(retryCount, config);
|
|
330
331
|
ctx.logger?.warn("Retrying message (ttl-backoff mode)", {
|
|
@@ -371,10 +372,10 @@ function parseMessageContentForRetry(ctx, msg, queueName) {
|
|
|
371
372
|
if (!(contentType === void 0 || contentType === "application/json" || contentType.startsWith("application/json;") || contentType.endsWith("+json"))) return msg.content;
|
|
372
373
|
try {
|
|
373
374
|
return JSON.parse(msg.content.toString());
|
|
374
|
-
} catch (
|
|
375
|
+
} catch (parseErr) {
|
|
375
376
|
ctx.logger?.warn("Failed to parse JSON message for retry, using original buffer", {
|
|
376
377
|
queueName,
|
|
377
|
-
error:
|
|
378
|
+
error: parseErr
|
|
378
379
|
});
|
|
379
380
|
return msg.content;
|
|
380
381
|
}
|
|
@@ -399,21 +400,21 @@ function publishForRetry(ctx, { msg, exchange, routingKey, queueName, waitQueueN
|
|
|
399
400
|
"x-retry-queue": queueName
|
|
400
401
|
} : {}
|
|
401
402
|
}
|
|
402
|
-
}).
|
|
403
|
+
}).andThen((published) => {
|
|
403
404
|
if (!published) {
|
|
404
405
|
ctx.logger?.error("Failed to publish message for retry (write buffer full)", {
|
|
405
406
|
queueName,
|
|
406
407
|
retryCount: newRetryCount,
|
|
407
408
|
...delayMs !== void 0 ? { delayMs } : {}
|
|
408
409
|
});
|
|
409
|
-
return
|
|
410
|
+
return (0, neverthrow.err)(new _amqp_contract_core.TechnicalError("Failed to publish message for retry (write buffer full)"));
|
|
410
411
|
}
|
|
411
412
|
ctx.logger?.info("Message published for retry", {
|
|
412
413
|
queueName,
|
|
413
414
|
retryCount: newRetryCount,
|
|
414
415
|
...delayMs !== void 0 ? { delayMs } : {}
|
|
415
416
|
});
|
|
416
|
-
return
|
|
417
|
+
return (0, neverthrow.ok)(void 0);
|
|
417
418
|
});
|
|
418
419
|
}
|
|
419
420
|
/**
|
|
@@ -450,6 +451,7 @@ function isHandlerTuple(entry) {
|
|
|
450
451
|
* ```typescript
|
|
451
452
|
* import { TypedAmqpWorker } from '@amqp-contract/worker';
|
|
452
453
|
* import { defineQueue, defineMessage, defineContract, defineConsumer } from '@amqp-contract/contract';
|
|
454
|
+
* import { okAsync } from 'neverthrow';
|
|
453
455
|
* import { z } from 'zod';
|
|
454
456
|
*
|
|
455
457
|
* const orderQueue = defineQueue('order-processing');
|
|
@@ -464,19 +466,22 @@ function isHandlerTuple(entry) {
|
|
|
464
466
|
* }
|
|
465
467
|
* });
|
|
466
468
|
*
|
|
467
|
-
* const
|
|
469
|
+
* const result = await TypedAmqpWorker.create({
|
|
468
470
|
* contract,
|
|
469
471
|
* handlers: {
|
|
470
|
-
* processOrder:
|
|
471
|
-
* console.log('Processing order',
|
|
472
|
-
*
|
|
473
|
-
* }
|
|
472
|
+
* processOrder: ({ payload }) => {
|
|
473
|
+
* console.log('Processing order', payload.orderId);
|
|
474
|
+
* return okAsync(undefined);
|
|
475
|
+
* },
|
|
474
476
|
* },
|
|
475
|
-
* urls: ['amqp://localhost']
|
|
476
|
-
* })
|
|
477
|
+
* urls: ['amqp://localhost'],
|
|
478
|
+
* });
|
|
479
|
+
*
|
|
480
|
+
* if (result.isErr()) throw result.error;
|
|
481
|
+
* const worker = result.value;
|
|
477
482
|
*
|
|
478
483
|
* // Close when done
|
|
479
|
-
* await worker.close()
|
|
484
|
+
* await worker.close();
|
|
480
485
|
* ```
|
|
481
486
|
*/
|
|
482
487
|
var TypedAmqpWorker = class TypedAmqpWorker {
|
|
@@ -552,18 +557,17 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
552
557
|
* Connections are automatically shared across clients and workers with the same
|
|
553
558
|
* URLs and connection options, following RabbitMQ best practices.
|
|
554
559
|
*
|
|
555
|
-
* @
|
|
556
|
-
* @returns A Future that resolves to a Result containing the worker or an error
|
|
560
|
+
* @returns A ResultAsync that resolves to the worker or a TechnicalError.
|
|
557
561
|
*
|
|
558
562
|
* @example
|
|
559
563
|
* ```typescript
|
|
560
|
-
* const
|
|
564
|
+
* const result = await TypedAmqpWorker.create({
|
|
561
565
|
* contract: myContract,
|
|
562
566
|
* handlers: {
|
|
563
|
-
* processOrder:
|
|
567
|
+
* processOrder: ({ payload }) => okAsync(undefined),
|
|
564
568
|
* },
|
|
565
|
-
* urls: ['amqp://localhost']
|
|
566
|
-
* })
|
|
569
|
+
* urls: ['amqp://localhost'],
|
|
570
|
+
* });
|
|
567
571
|
* ```
|
|
568
572
|
*/
|
|
569
573
|
static create({ contract, handlers, urls, connectionOptions, defaultConsumerOptions, logger, telemetry, connectTimeoutMs }) {
|
|
@@ -572,12 +576,14 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
572
576
|
connectionOptions,
|
|
573
577
|
connectTimeoutMs
|
|
574
578
|
}), handlers, defaultConsumerOptions ?? {}, logger, telemetry);
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
579
|
+
const setup = worker.waitForConnectionReady().andThen(() => worker.consumeAll());
|
|
580
|
+
return new neverthrow.ResultAsync((async () => {
|
|
581
|
+
const setupResult = await setup;
|
|
582
|
+
if (setupResult.isOk()) return (0, neverthrow.ok)(worker);
|
|
583
|
+
const closeResult = await worker.close();
|
|
584
|
+
if (closeResult.isErr()) logger?.warn("Failed to close worker after setup failure", { error: closeResult.error });
|
|
585
|
+
return (0, neverthrow.err)(setupResult.error);
|
|
586
|
+
})());
|
|
581
587
|
}
|
|
582
588
|
/**
|
|
583
589
|
* Close the AMQP channel and connection.
|
|
@@ -585,26 +591,25 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
585
591
|
* This gracefully closes the connection to the AMQP broker,
|
|
586
592
|
* stopping all message consumption and cleaning up resources.
|
|
587
593
|
*
|
|
588
|
-
* @returns A Future that resolves to a Result indicating success or failure
|
|
589
|
-
*
|
|
590
594
|
* @example
|
|
591
595
|
* ```typescript
|
|
592
|
-
* const closeResult = await worker.close()
|
|
596
|
+
* const closeResult = await worker.close();
|
|
593
597
|
* if (closeResult.isOk()) {
|
|
594
598
|
* console.log('Worker closed successfully');
|
|
595
599
|
* }
|
|
596
600
|
* ```
|
|
597
601
|
*/
|
|
598
602
|
close() {
|
|
599
|
-
|
|
603
|
+
const cancellations = Array.from(this.consumerTags).map((consumerTag) => this.amqpClient.cancel(consumerTag).orElse((error) => {
|
|
600
604
|
this.logger?.warn("Failed to cancel consumer during close", {
|
|
601
605
|
consumerTag,
|
|
602
606
|
error
|
|
603
607
|
});
|
|
604
|
-
return
|
|
605
|
-
}))
|
|
608
|
+
return (0, neverthrow.ok)(void 0);
|
|
609
|
+
}));
|
|
610
|
+
return neverthrow.ResultAsync.combine(cancellations).andTee(() => {
|
|
606
611
|
this.consumerTags.clear();
|
|
607
|
-
}).
|
|
612
|
+
}).andThen(() => this.amqpClient.close()).map(() => void 0);
|
|
608
613
|
}
|
|
609
614
|
/**
|
|
610
615
|
* Start consuming for every entry in `contract.consumers` and `contract.rpcs`.
|
|
@@ -613,7 +618,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
613
618
|
const consumerNames = Object.keys(this.contract.consumers ?? {});
|
|
614
619
|
const rpcNames = Object.keys(this.contract.rpcs ?? {});
|
|
615
620
|
const allNames = [...consumerNames, ...rpcNames];
|
|
616
|
-
return
|
|
621
|
+
return neverthrow.ResultAsync.combine(allNames.map((name) => this.consume(name))).map(() => void 0);
|
|
617
622
|
}
|
|
618
623
|
waitForConnectionReady() {
|
|
619
624
|
return this.amqpClient.waitForConnect();
|
|
@@ -634,9 +639,9 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
634
639
|
validateSchema(schema, data, context) {
|
|
635
640
|
const rawValidation = schema["~standard"].validate(data);
|
|
636
641
|
const validationPromise = rawValidation instanceof Promise ? rawValidation : Promise.resolve(rawValidation);
|
|
637
|
-
return
|
|
638
|
-
if (result.issues) return
|
|
639
|
-
return
|
|
642
|
+
return neverthrow.ResultAsync.fromPromise(validationPromise, (error) => new _amqp_contract_core.TechnicalError(`Error validating ${context.field}`, error)).andThen((result) => {
|
|
643
|
+
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)));
|
|
644
|
+
return (0, neverthrow.ok)(result.value);
|
|
640
645
|
});
|
|
641
646
|
}
|
|
642
647
|
/**
|
|
@@ -648,18 +653,18 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
648
653
|
*/
|
|
649
654
|
parseAndValidateMessage(msg, consumer, consumerName) {
|
|
650
655
|
const context = { consumerName: String(consumerName) };
|
|
651
|
-
const parsePayload = decompressBuffer(msg.content, msg.properties.contentEncoding).
|
|
656
|
+
const parsePayload = decompressBuffer(msg.content, msg.properties.contentEncoding).andThen((buffer) => neverthrow.Result.fromThrowable(() => JSON.parse(buffer.toString()), (error) => new _amqp_contract_core.TechnicalError("Failed to parse JSON", error))()).andThen((parsed) => this.validateSchema(consumer.message.payload, parsed, {
|
|
652
657
|
...context,
|
|
653
658
|
field: "payload"
|
|
654
659
|
}));
|
|
655
660
|
const parseHeaders = consumer.message.headers ? this.validateSchema(consumer.message.headers, msg.properties.headers ?? {}, {
|
|
656
661
|
...context,
|
|
657
662
|
field: "headers"
|
|
658
|
-
}) :
|
|
659
|
-
return
|
|
660
|
-
payload
|
|
661
|
-
headers
|
|
662
|
-
})
|
|
663
|
+
}) : (0, neverthrow.okAsync)(void 0);
|
|
664
|
+
return neverthrow.ResultAsync.combine([parsePayload, parseHeaders]).map(([payload, headers]) => ({
|
|
665
|
+
payload,
|
|
666
|
+
headers
|
|
667
|
+
}));
|
|
663
668
|
}
|
|
664
669
|
/**
|
|
665
670
|
* Validate an RPC handler's response and publish it back to the caller's reply
|
|
@@ -688,7 +693,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
688
693
|
rpcName: String(rpcName),
|
|
689
694
|
queueName
|
|
690
695
|
});
|
|
691
|
-
return
|
|
696
|
+
return (0, neverthrow.errAsync)(new NonRetryableError(`RPC "${String(rpcName)}" received a message without replyTo; cannot deliver response`));
|
|
692
697
|
}
|
|
693
698
|
if (typeof correlationId !== "string" || correlationId.length === 0) {
|
|
694
699
|
this.logger?.error("RPC handler returned a response but the incoming message has no correlationId", {
|
|
@@ -696,22 +701,22 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
696
701
|
queueName,
|
|
697
702
|
replyTo
|
|
698
703
|
});
|
|
699
|
-
return
|
|
704
|
+
return (0, neverthrow.errAsync)(new NonRetryableError(`RPC "${String(rpcName)}" received a message without correlationId; cannot deliver response`));
|
|
700
705
|
}
|
|
701
706
|
let rawValidation;
|
|
702
707
|
try {
|
|
703
708
|
rawValidation = responseSchema["~standard"].validate(response);
|
|
704
709
|
} catch (error) {
|
|
705
|
-
return
|
|
710
|
+
return (0, neverthrow.errAsync)(new NonRetryableError("RPC response schema validation threw", error));
|
|
706
711
|
}
|
|
707
712
|
const validationPromise = rawValidation instanceof Promise ? rawValidation : Promise.resolve(rawValidation);
|
|
708
|
-
return
|
|
709
|
-
if (validation.issues) return
|
|
710
|
-
return
|
|
711
|
-
}).
|
|
713
|
+
return neverthrow.ResultAsync.fromPromise(validationPromise, (error) => new NonRetryableError("RPC response schema validation threw", error)).andThen((validation) => {
|
|
714
|
+
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)));
|
|
715
|
+
return (0, neverthrow.ok)(validation.value);
|
|
716
|
+
}).andThen((validatedResponse) => this.amqpClient.publish("", replyTo, validatedResponse, {
|
|
712
717
|
correlationId,
|
|
713
718
|
contentType: "application/json"
|
|
714
|
-
}).
|
|
719
|
+
}).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"))));
|
|
715
720
|
}
|
|
716
721
|
/**
|
|
717
722
|
* Process a single consumed message: validate, invoke handler, optionally
|
|
@@ -724,58 +729,56 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
724
729
|
const span = (0, _amqp_contract_core.startConsumeSpan)(this.telemetry, queueName, String(name), { "messaging.rabbitmq.message.delivery_tag": msg.fields.deliveryTag });
|
|
725
730
|
let messageHandled = false;
|
|
726
731
|
let firstError;
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
732
|
+
const inner = this.parseAndValidateMessage(msg, consumer, name).orElse((parseError) => {
|
|
733
|
+
firstError = parseError;
|
|
734
|
+
this.logger?.error("Failed to parse/validate message; sending to DLQ", {
|
|
735
|
+
consumerName: String(name),
|
|
736
|
+
queueName,
|
|
737
|
+
error: parseError
|
|
738
|
+
});
|
|
739
|
+
this.amqpClient.nack(msg, false, false);
|
|
740
|
+
return (0, neverthrow.errAsync)(parseError);
|
|
741
|
+
}).andThen((validatedMessage) => handler(validatedMessage, msg).andThen((handlerResponse) => {
|
|
742
|
+
if (isRpc && responseSchema) return this.publishRpcResponse(msg, queueName, name, responseSchema, handlerResponse).map(() => {
|
|
738
743
|
this.logger?.info("Message consumed successfully", {
|
|
739
744
|
consumerName: String(name),
|
|
740
745
|
queueName
|
|
741
746
|
});
|
|
742
747
|
this.amqpClient.ack(msg);
|
|
743
748
|
messageHandled = true;
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
}
|
|
768
|
-
})).map((result) => {
|
|
749
|
+
});
|
|
750
|
+
this.logger?.info("Message consumed successfully", {
|
|
751
|
+
consumerName: String(name),
|
|
752
|
+
queueName
|
|
753
|
+
});
|
|
754
|
+
this.amqpClient.ack(msg);
|
|
755
|
+
messageHandled = true;
|
|
756
|
+
return (0, neverthrow.okAsync)(void 0);
|
|
757
|
+
}).orElse((handlerError) => {
|
|
758
|
+
this.logger?.error("Error processing message", {
|
|
759
|
+
consumerName: String(name),
|
|
760
|
+
queueName,
|
|
761
|
+
errorType: handlerError.name,
|
|
762
|
+
error: handlerError.message
|
|
763
|
+
});
|
|
764
|
+
firstError = handlerError;
|
|
765
|
+
return handleError({
|
|
766
|
+
amqpClient: this.amqpClient,
|
|
767
|
+
logger: this.logger
|
|
768
|
+
}, handlerError, msg, String(name), consumer);
|
|
769
|
+
}));
|
|
770
|
+
return new neverthrow.ResultAsync((async () => {
|
|
771
|
+
const result = await inner;
|
|
769
772
|
const durationMs = Date.now() - startTime;
|
|
770
773
|
if (messageHandled) {
|
|
771
774
|
(0, _amqp_contract_core.endSpanSuccess)(span);
|
|
772
775
|
(0, _amqp_contract_core.recordConsumeMetric)(this.telemetry, queueName, String(name), true, durationMs);
|
|
773
776
|
} else {
|
|
774
|
-
(0, _amqp_contract_core.endSpanError)(span, result.
|
|
777
|
+
(0, _amqp_contract_core.endSpanError)(span, result.isErr() ? result.error : firstError ?? /* @__PURE__ */ new Error("Unknown error"));
|
|
775
778
|
(0, _amqp_contract_core.recordConsumeMetric)(this.telemetry, queueName, String(name), false, durationMs);
|
|
776
779
|
}
|
|
777
780
|
return result;
|
|
778
|
-
});
|
|
781
|
+
})());
|
|
779
782
|
}
|
|
780
783
|
/**
|
|
781
784
|
* Consume messages one at a time.
|
|
@@ -791,7 +794,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
791
794
|
return;
|
|
792
795
|
}
|
|
793
796
|
try {
|
|
794
|
-
await this.processMessage(msg, view, name, handler)
|
|
797
|
+
await this.processMessage(msg, view, name, handler);
|
|
795
798
|
} catch (error) {
|
|
796
799
|
this.logger?.error("Uncaught error in consume callback; nacking message", {
|
|
797
800
|
consumerName: String(name),
|
|
@@ -800,9 +803,9 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
800
803
|
});
|
|
801
804
|
this.amqpClient.nack(msg, false, false);
|
|
802
805
|
}
|
|
803
|
-
}, this.consumerOptions[name]).
|
|
806
|
+
}, this.consumerOptions[name]).andTee((consumerTag) => {
|
|
804
807
|
this.consumerTags.add(consumerTag);
|
|
805
|
-
}).
|
|
808
|
+
}).map(() => void 0).mapErr((error) => new _amqp_contract_core.TechnicalError(`Failed to start consuming for "${String(name)}"`, error));
|
|
806
809
|
}
|
|
807
810
|
};
|
|
808
811
|
//#endregion
|
|
@@ -835,7 +838,7 @@ function defineHandler(contract, consumerName, handler, options) {
|
|
|
835
838
|
/**
|
|
836
839
|
* Define multiple type-safe handlers for consumers in a contract.
|
|
837
840
|
*
|
|
838
|
-
* **Recommended:** This function creates handlers that return `
|
|
841
|
+
* **Recommended:** This function creates handlers that return `ResultAsync<void, HandlerError>`,
|
|
839
842
|
* providing explicit error handling and better control over retry behavior.
|
|
840
843
|
*
|
|
841
844
|
* @template TContract - The contract definition type
|
|
@@ -846,18 +849,20 @@ function defineHandler(contract, consumerName, handler, options) {
|
|
|
846
849
|
* @example
|
|
847
850
|
* ```typescript
|
|
848
851
|
* import { defineHandlers, RetryableError } from '@amqp-contract/worker';
|
|
849
|
-
* import {
|
|
852
|
+
* import { ResultAsync } from 'neverthrow';
|
|
850
853
|
* import { orderContract } from './contract';
|
|
851
854
|
*
|
|
852
855
|
* const handlers = defineHandlers(orderContract, {
|
|
853
856
|
* processOrder: ({ payload }) =>
|
|
854
|
-
*
|
|
855
|
-
*
|
|
856
|
-
*
|
|
857
|
+
* ResultAsync.fromPromise(
|
|
858
|
+
* processPayment(payload),
|
|
859
|
+
* (error) => new RetryableError('Payment failed', error),
|
|
860
|
+
* ).map(() => undefined),
|
|
857
861
|
* notifyOrder: ({ payload }) =>
|
|
858
|
-
*
|
|
859
|
-
*
|
|
860
|
-
*
|
|
862
|
+
* ResultAsync.fromPromise(
|
|
863
|
+
* sendNotification(payload),
|
|
864
|
+
* (error) => new RetryableError('Notification failed', error),
|
|
865
|
+
* ).map(() => undefined),
|
|
861
866
|
* });
|
|
862
867
|
* ```
|
|
863
868
|
*/
|