@amqp-contract/worker 0.25.0 → 1.0.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 +18 -18
- package/dist/index.cjs +100 -99
- package/dist/index.d.cts +211 -64
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +211 -64
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +93 -91
- package/dist/index.mjs.map +1 -1
- package/docs/index.md +210 -583
- package/package.json +18 -15
package/README.md
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
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 AsyncResult/Result error handling.**
|
|
4
4
|
|
|
5
|
-
[](https://github.com/btravstack/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
8
|
[](https://www.typescriptlang.org/)
|
|
9
9
|
[](https://opensource.org/licenses/MIT)
|
|
10
10
|
|
|
11
|
-
📖 **[Full documentation →](https://
|
|
11
|
+
📖 **[Full documentation →](https://btravstack.github.io/amqp-contract/api/worker)**
|
|
12
12
|
|
|
13
13
|
## Installation
|
|
14
14
|
|
|
@@ -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 { fromPromise, type AsyncResult } from "unthrown";
|
|
35
35
|
import { contract } from "./contract";
|
|
36
36
|
|
|
37
37
|
// Optional: Create a logger implementation
|
|
@@ -51,7 +51,7 @@ const worker = (
|
|
|
51
51
|
console.log("Processing order:", payload.orderId);
|
|
52
52
|
|
|
53
53
|
// Your business logic here
|
|
54
|
-
return
|
|
54
|
+
return fromPromise(
|
|
55
55
|
Promise.all([processPayment(payload), updateInventory(payload)]),
|
|
56
56
|
(error) => new RetryableError("Order processing failed", error),
|
|
57
57
|
).map(() => undefined);
|
|
@@ -60,7 +60,7 @@ const worker = (
|
|
|
60
60
|
urls: ["amqp://localhost"],
|
|
61
61
|
logger, // Optional: logs message consumption and errors
|
|
62
62
|
})
|
|
63
|
-
).
|
|
63
|
+
).unwrap();
|
|
64
64
|
|
|
65
65
|
// Worker is already consuming messages
|
|
66
66
|
|
|
@@ -70,7 +70,7 @@ const worker = (
|
|
|
70
70
|
|
|
71
71
|
### Advanced Features
|
|
72
72
|
|
|
73
|
-
For advanced features like prefetch configuration and **automatic retry**, see the [Worker Usage Guide](https://
|
|
73
|
+
For advanced features like prefetch configuration and **automatic retry**, see the [Worker Usage Guide](https://btravstack.github.io/amqp-contract/guide/worker-usage).
|
|
74
74
|
|
|
75
75
|
#### Retry configuration
|
|
76
76
|
|
|
@@ -99,7 +99,7 @@ Then use `RetryableError` in your handlers:
|
|
|
99
99
|
|
|
100
100
|
```typescript
|
|
101
101
|
import { TypedAmqpWorker, RetryableError } from "@amqp-contract/worker";
|
|
102
|
-
import {
|
|
102
|
+
import { fromPromise, type AsyncResult } from "unthrown";
|
|
103
103
|
|
|
104
104
|
const worker = (
|
|
105
105
|
await TypedAmqpWorker.create({
|
|
@@ -107,39 +107,39 @@ const worker = (
|
|
|
107
107
|
handlers: {
|
|
108
108
|
processOrder: ({ payload }) =>
|
|
109
109
|
// If this fails with RetryableError, message is automatically retried
|
|
110
|
-
|
|
110
|
+
fromPromise(
|
|
111
111
|
processPayment(payload),
|
|
112
112
|
(error) => new RetryableError("Payment failed", error),
|
|
113
113
|
).map(() => undefined),
|
|
114
114
|
},
|
|
115
115
|
urls: ["amqp://localhost"],
|
|
116
116
|
})
|
|
117
|
-
).
|
|
117
|
+
).unwrap();
|
|
118
118
|
```
|
|
119
119
|
|
|
120
|
-
See the [Error Handling and Retry](https://
|
|
120
|
+
See the [Error Handling and Retry](https://btravstack.github.io/amqp-contract/guide/worker-usage#error-handling-and-retry) section in the guide for complete details.
|
|
121
121
|
|
|
122
122
|
## Defining Handlers Externally
|
|
123
123
|
|
|
124
|
-
You can define handlers outside of the worker creation using `defineHandler` and `defineHandlers` for better code organization. See the [Worker API documentation](https://
|
|
124
|
+
You can define handlers outside of the worker creation using `defineHandler` and `defineHandlers` for better code organization. See the [Worker API documentation](https://btravstack.github.io/amqp-contract/api/worker) for details.
|
|
125
125
|
|
|
126
126
|
## Error Handling
|
|
127
127
|
|
|
128
|
-
Worker handlers return `
|
|
128
|
+
Worker handlers return `AsyncResult<void, HandlerError>` for explicit error handling:
|
|
129
129
|
|
|
130
130
|
```typescript
|
|
131
131
|
import { RetryableError, NonRetryableError } from "@amqp-contract/worker";
|
|
132
|
-
import {
|
|
132
|
+
import { err, fromPromise, type AsyncResult } from "unthrown";
|
|
133
133
|
|
|
134
134
|
handlers: {
|
|
135
135
|
processOrder: ({ payload }) => {
|
|
136
136
|
// Validation errors - non-retryable
|
|
137
137
|
if (payload.amount <= 0) {
|
|
138
|
-
return
|
|
138
|
+
return err(new NonRetryableError("Invalid amount")).toAsync();
|
|
139
139
|
}
|
|
140
140
|
|
|
141
141
|
// Transient errors - retryable
|
|
142
|
-
return
|
|
142
|
+
return fromPromise(process(payload), (error) => new RetryableError("Processing failed", error))
|
|
143
143
|
.map(() => undefined);
|
|
144
144
|
},
|
|
145
145
|
}
|
|
@@ -156,11 +156,11 @@ Worker defines error classes:
|
|
|
156
156
|
|
|
157
157
|
## API
|
|
158
158
|
|
|
159
|
-
For complete API documentation, see the [Worker API Reference](https://
|
|
159
|
+
For complete API documentation, see the [Worker API Reference](https://btravstack.github.io/amqp-contract/api/worker).
|
|
160
160
|
|
|
161
161
|
## Documentation
|
|
162
162
|
|
|
163
|
-
📖 **[Read the full documentation →](https://
|
|
163
|
+
📖 **[Read the full documentation →](https://btravstack.github.io/amqp-contract)**
|
|
164
164
|
|
|
165
165
|
## License
|
|
166
166
|
|
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 unthrown = require("unthrown");
|
|
5
5
|
let node_zlib = require("node:zlib");
|
|
6
6
|
let node_util = require("node:util");
|
|
7
7
|
//#region src/decompression.ts
|
|
@@ -22,55 +22,57 @@ 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
|
|
25
|
+
* @returns An AsyncResult 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 (0,
|
|
30
|
+
if (!contentEncoding) return (0, unthrown.ok)(buffer).toAsync();
|
|
31
31
|
const normalizedEncoding = contentEncoding.toLowerCase();
|
|
32
|
-
if (!isSupportedEncoding(normalizedEncoding)) return (0,
|
|
32
|
+
if (!isSupportedEncoding(normalizedEncoding)) return (0, unthrown.err)(new _amqp_contract_core.TechnicalError(`Unsupported content-encoding: "${contentEncoding}". Supported encodings are: ${SUPPORTED_ENCODINGS.join(", ")}. Please check your publisher configuration.`)).toAsync();
|
|
33
33
|
switch (normalizedEncoding) {
|
|
34
|
-
case "gzip": return
|
|
35
|
-
case "deflate": return
|
|
34
|
+
case "gzip": return (0, unthrown.fromPromise)(gunzipAsync(buffer), (error) => new _amqp_contract_core.TechnicalError("Failed to decompress gzip", error));
|
|
35
|
+
case "deflate": return (0, unthrown.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
|
-
* Abstract base class for all handler-signalled errors.
|
|
42
|
-
*
|
|
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
|
-
*/
|
|
47
|
-
var HandlerError = class extends Error {
|
|
48
|
-
constructor(message, cause) {
|
|
49
|
-
super(message);
|
|
50
|
-
this.cause = cause;
|
|
51
|
-
const ErrorConstructor = Error;
|
|
52
|
-
if (typeof ErrorConstructor.captureStackTrace === "function") ErrorConstructor.captureStackTrace(this, this.constructor);
|
|
53
|
-
}
|
|
54
|
-
};
|
|
55
|
-
/**
|
|
56
41
|
* Retryable errors - transient failures that may succeed on retry
|
|
57
42
|
* Examples: network timeouts, rate limiting, temporary service unavailability
|
|
58
43
|
*
|
|
59
44
|
* Use this error type when the operation might succeed if retried.
|
|
60
45
|
* The worker will apply exponential backoff and retry the message.
|
|
46
|
+
*
|
|
47
|
+
* Built on unthrown's {@link TaggedError}, so it carries a namespaced `_tag` of
|
|
48
|
+
* `"@amqp-contract/RetryableError"` (to avoid colliding with other libraries'
|
|
49
|
+
* tags in a shared `matchTags`) for exhaustive dispatch; the `Error.name` is
|
|
50
|
+
* kept bare (`"RetryableError"`).
|
|
61
51
|
*/
|
|
62
|
-
var RetryableError = class extends
|
|
63
|
-
|
|
52
|
+
var RetryableError = class extends (0, unthrown.TaggedError)("@amqp-contract/RetryableError", { name: "RetryableError" }) {
|
|
53
|
+
constructor(message, cause) {
|
|
54
|
+
super({
|
|
55
|
+
message,
|
|
56
|
+
cause
|
|
57
|
+
});
|
|
58
|
+
}
|
|
64
59
|
};
|
|
65
60
|
/**
|
|
66
61
|
* Non-retryable errors - permanent failures that should not be retried
|
|
67
62
|
* Examples: invalid data, business rule violations, permanent external failures
|
|
68
63
|
*
|
|
69
64
|
* Use this error type when retrying would not help - the message will be
|
|
70
|
-
* immediately sent to the dead letter queue (DLQ) if configured.
|
|
65
|
+
* immediately sent to the dead letter queue (DLQ) if configured. Carries a
|
|
66
|
+
* namespaced `_tag` of `"@amqp-contract/NonRetryableError"`; the `Error.name` is
|
|
67
|
+
* kept bare (`"NonRetryableError"`).
|
|
71
68
|
*/
|
|
72
|
-
var NonRetryableError = class extends
|
|
73
|
-
|
|
69
|
+
var NonRetryableError = class extends (0, unthrown.TaggedError)("@amqp-contract/NonRetryableError", { name: "NonRetryableError" }) {
|
|
70
|
+
constructor(message, cause) {
|
|
71
|
+
super({
|
|
72
|
+
message,
|
|
73
|
+
cause
|
|
74
|
+
});
|
|
75
|
+
}
|
|
74
76
|
};
|
|
75
77
|
/**
|
|
76
78
|
* Type guard to check if an error is a RetryableError.
|
|
@@ -141,7 +143,7 @@ function isNonRetryableError(error) {
|
|
|
141
143
|
* ```
|
|
142
144
|
*/
|
|
143
145
|
function isHandlerError(error) {
|
|
144
|
-
return error instanceof
|
|
146
|
+
return error instanceof RetryableError || error instanceof NonRetryableError;
|
|
145
147
|
}
|
|
146
148
|
/**
|
|
147
149
|
* Create a RetryableError with less verbosity.
|
|
@@ -156,16 +158,16 @@ function isHandlerError(error) {
|
|
|
156
158
|
* @example
|
|
157
159
|
* ```typescript
|
|
158
160
|
* import { retryable } from '@amqp-contract/worker';
|
|
159
|
-
* import {
|
|
161
|
+
* import { fromPromise } from 'unthrown';
|
|
160
162
|
*
|
|
161
163
|
* const handler = ({ payload }) =>
|
|
162
|
-
*
|
|
164
|
+
* fromPromise(
|
|
163
165
|
* processPayment(payload),
|
|
164
166
|
* (e) => retryable('Payment service unavailable', e),
|
|
165
167
|
* ).map(() => undefined);
|
|
166
168
|
*
|
|
167
169
|
* // Equivalent to:
|
|
168
|
-
* //
|
|
170
|
+
* // fromPromise(processPayment(payload), (e) => new RetryableError('...', e))
|
|
169
171
|
* ```
|
|
170
172
|
*/
|
|
171
173
|
function retryable(message, cause) {
|
|
@@ -184,17 +186,17 @@ function retryable(message, cause) {
|
|
|
184
186
|
* @example
|
|
185
187
|
* ```typescript
|
|
186
188
|
* import { nonRetryable } from '@amqp-contract/worker';
|
|
187
|
-
* import {
|
|
189
|
+
* import { err, ok } from 'unthrown';
|
|
188
190
|
*
|
|
189
191
|
* const handler = ({ payload }) => {
|
|
190
192
|
* if (!isValidPayload(payload)) {
|
|
191
|
-
* return
|
|
193
|
+
* return err(nonRetryable('Invalid payload format')).toAsync();
|
|
192
194
|
* }
|
|
193
|
-
* return
|
|
195
|
+
* return ok(undefined).toAsync();
|
|
194
196
|
* };
|
|
195
197
|
*
|
|
196
198
|
* // Equivalent to:
|
|
197
|
-
* // return
|
|
199
|
+
* // return err(new NonRetryableError('Invalid payload format')).toAsync();
|
|
198
200
|
* ```
|
|
199
201
|
*/
|
|
200
202
|
function nonRetryable(message, cause) {
|
|
@@ -223,7 +225,7 @@ function nonRetryable(message, cause) {
|
|
|
223
225
|
function handleError(ctx, error, msg, consumerName, consumer) {
|
|
224
226
|
if (error instanceof NonRetryableError) {
|
|
225
227
|
sendToDLQ(ctx, msg, consumer);
|
|
226
|
-
return (0,
|
|
228
|
+
return (0, unthrown.ok)(void 0).toAsync();
|
|
227
229
|
}
|
|
228
230
|
const config = (0, _amqp_contract_contract.extractQueue)(consumer.queue).retry;
|
|
229
231
|
if (config.mode === "immediate-requeue") return handleErrorImmediateRequeue(ctx, error, msg, consumerName, consumer, config);
|
|
@@ -233,7 +235,7 @@ function handleError(ctx, error, msg, consumerName, consumer) {
|
|
|
233
235
|
queueName: (0, _amqp_contract_contract.extractQueue)(consumer.queue).name
|
|
234
236
|
});
|
|
235
237
|
sendToDLQ(ctx, msg, consumer);
|
|
236
|
-
return (0,
|
|
238
|
+
return (0, unthrown.ok)(void 0).toAsync();
|
|
237
239
|
}
|
|
238
240
|
/**
|
|
239
241
|
* Handle error by requeuing immediately.
|
|
@@ -256,7 +258,7 @@ function handleErrorImmediateRequeue(ctx, error, msg, consumerName, consumer, co
|
|
|
256
258
|
maxRetries: config.maxRetries
|
|
257
259
|
});
|
|
258
260
|
sendToDLQ(ctx, msg, consumer);
|
|
259
|
-
return (0,
|
|
261
|
+
return (0, unthrown.ok)(void 0).toAsync();
|
|
260
262
|
}
|
|
261
263
|
ctx.logger?.info("Retrying message (immediate-requeue mode)", {
|
|
262
264
|
consumerName,
|
|
@@ -266,7 +268,7 @@ function handleErrorImmediateRequeue(ctx, error, msg, consumerName, consumer, co
|
|
|
266
268
|
});
|
|
267
269
|
if (queue.type === "quorum") {
|
|
268
270
|
ctx.amqpClient.nack(msg, false, true);
|
|
269
|
-
return (0,
|
|
271
|
+
return (0, unthrown.ok)(void 0).toAsync();
|
|
270
272
|
} else return publishForRetry(ctx, {
|
|
271
273
|
msg,
|
|
272
274
|
exchange: msg.fields.exchange,
|
|
@@ -307,7 +309,7 @@ function handleErrorTtlBackoff(ctx, error, msg, consumerName, consumer, config)
|
|
|
307
309
|
consumerName,
|
|
308
310
|
queueName: consumer.queue.name
|
|
309
311
|
});
|
|
310
|
-
return (0,
|
|
312
|
+
return (0, unthrown.err)(new _amqp_contract_core.TechnicalError("Queue does not have TTL-backoff infrastructure")).toAsync();
|
|
311
313
|
}
|
|
312
314
|
const queueEntry = consumer.queue;
|
|
313
315
|
const queueName = (0, _amqp_contract_contract.extractQueue)(queueEntry).name;
|
|
@@ -320,7 +322,7 @@ function handleErrorTtlBackoff(ctx, error, msg, consumerName, consumer, config)
|
|
|
320
322
|
maxRetries: config.maxRetries
|
|
321
323
|
});
|
|
322
324
|
sendToDLQ(ctx, msg, consumer);
|
|
323
|
-
return (0,
|
|
325
|
+
return (0, unthrown.ok)(void 0).toAsync();
|
|
324
326
|
}
|
|
325
327
|
const delayMs = calculateRetryDelay(retryCount, config);
|
|
326
328
|
ctx.logger?.info("Retrying message (ttl-backoff mode)", {
|
|
@@ -393,14 +395,14 @@ function publishForRetry(ctx, { msg, exchange, routingKey, queueName, waitQueueN
|
|
|
393
395
|
"x-retry-queue": queueName
|
|
394
396
|
} : {}
|
|
395
397
|
}
|
|
396
|
-
}).
|
|
398
|
+
}).flatMap((published) => {
|
|
397
399
|
if (!published) {
|
|
398
400
|
ctx.logger?.error("Failed to publish message for retry (write buffer full)", {
|
|
399
401
|
queueName,
|
|
400
402
|
retryCount: newRetryCount,
|
|
401
403
|
...delayMs !== void 0 ? { delayMs } : {}
|
|
402
404
|
});
|
|
403
|
-
return (0,
|
|
405
|
+
return (0, unthrown.err)(new _amqp_contract_core.TechnicalError("Failed to publish message for retry (write buffer full)"));
|
|
404
406
|
}
|
|
405
407
|
ctx.amqpClient.ack(msg);
|
|
406
408
|
ctx.logger?.info("Message published for retry", {
|
|
@@ -408,7 +410,7 @@ function publishForRetry(ctx, { msg, exchange, routingKey, queueName, waitQueueN
|
|
|
408
410
|
retryCount: newRetryCount,
|
|
409
411
|
...delayMs !== void 0 ? { delayMs } : {}
|
|
410
412
|
});
|
|
411
|
-
return (0,
|
|
413
|
+
return (0, unthrown.ok)(void 0);
|
|
412
414
|
}).orElse((publishError) => {
|
|
413
415
|
ctx.logger?.error("Publish for retry failed; leaving original un-ack'd for redelivery", {
|
|
414
416
|
queueName,
|
|
@@ -416,7 +418,7 @@ function publishForRetry(ctx, { msg, exchange, routingKey, queueName, waitQueueN
|
|
|
416
418
|
...delayMs !== void 0 ? { delayMs } : {},
|
|
417
419
|
error: publishError
|
|
418
420
|
});
|
|
419
|
-
return (0,
|
|
421
|
+
return (0, unthrown.err)(publishError);
|
|
420
422
|
});
|
|
421
423
|
}
|
|
422
424
|
/**
|
|
@@ -453,7 +455,7 @@ function isHandlerTuple(entry) {
|
|
|
453
455
|
* ```typescript
|
|
454
456
|
* import { TypedAmqpWorker } from '@amqp-contract/worker';
|
|
455
457
|
* import { defineQueue, defineMessage, defineContract, defineConsumer } from '@amqp-contract/contract';
|
|
456
|
-
* import {
|
|
458
|
+
* import { ok } from 'unthrown';
|
|
457
459
|
* import { z } from 'zod';
|
|
458
460
|
*
|
|
459
461
|
* const orderQueue = defineQueue('order-processing');
|
|
@@ -473,20 +475,23 @@ function isHandlerTuple(entry) {
|
|
|
473
475
|
* handlers: {
|
|
474
476
|
* processOrder: ({ payload }) => {
|
|
475
477
|
* console.log('Processing order', payload.orderId);
|
|
476
|
-
* return
|
|
478
|
+
* return ok(undefined).toAsync();
|
|
477
479
|
* },
|
|
478
480
|
* },
|
|
479
481
|
* urls: ['amqp://localhost'],
|
|
480
482
|
* });
|
|
481
483
|
*
|
|
482
|
-
*
|
|
483
|
-
* const worker = result.value;
|
|
484
|
+
* const worker = result.unwrap();
|
|
484
485
|
*
|
|
485
486
|
* // Close when done
|
|
486
487
|
* await worker.close();
|
|
487
488
|
* ```
|
|
488
489
|
*/
|
|
489
490
|
var TypedAmqpWorker = class TypedAmqpWorker {
|
|
491
|
+
contract;
|
|
492
|
+
amqpClient;
|
|
493
|
+
defaultConsumerOptions;
|
|
494
|
+
logger;
|
|
490
495
|
/**
|
|
491
496
|
* Internal handler storage. Keyed by handler name (consumer or RPC); the
|
|
492
497
|
* stored function signature is widened so the dispatch loop can call it
|
|
@@ -559,14 +564,14 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
559
564
|
* Connections are automatically shared across clients and workers with the same
|
|
560
565
|
* URLs and connection options, following RabbitMQ best practices.
|
|
561
566
|
*
|
|
562
|
-
* @returns A
|
|
567
|
+
* @returns A AsyncResult that resolves to the worker or a TechnicalError.
|
|
563
568
|
*
|
|
564
569
|
* @example
|
|
565
570
|
* ```typescript
|
|
566
571
|
* const result = await TypedAmqpWorker.create({
|
|
567
572
|
* contract: myContract,
|
|
568
573
|
* handlers: {
|
|
569
|
-
* processOrder: ({ payload }) =>
|
|
574
|
+
* processOrder: ({ payload }) => ok(undefined).toAsync(),
|
|
570
575
|
* },
|
|
571
576
|
* urls: ['amqp://localhost'],
|
|
572
577
|
* });
|
|
@@ -578,14 +583,15 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
578
583
|
connectionOptions,
|
|
579
584
|
connectTimeoutMs
|
|
580
585
|
}), handlers, defaultConsumerOptions ?? {}, logger, telemetry);
|
|
581
|
-
const setup = worker.waitForConnectionReady().
|
|
582
|
-
return
|
|
586
|
+
const setup = worker.waitForConnectionReady().flatMap(() => worker.consumeAll());
|
|
587
|
+
return (0, unthrown.fromSafePromise)((async () => {
|
|
583
588
|
const setupResult = await setup;
|
|
584
|
-
if (setupResult.isOk())
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
+
if (!setupResult.isOk()) {
|
|
590
|
+
const closeResult = await worker.close();
|
|
591
|
+
if (closeResult.isErr()) logger?.warn("Failed to close worker after setup failure", { error: closeResult.error });
|
|
592
|
+
}
|
|
593
|
+
return setupResult.map(() => worker);
|
|
594
|
+
})()).flatMap((result) => result);
|
|
589
595
|
}
|
|
590
596
|
/**
|
|
591
597
|
* Close the AMQP channel and connection.
|
|
@@ -602,16 +608,15 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
602
608
|
* ```
|
|
603
609
|
*/
|
|
604
610
|
close() {
|
|
605
|
-
|
|
611
|
+
return (0, unthrown.allAsync)(Array.from(this.consumerTags).map((consumerTag) => this.amqpClient.cancel(consumerTag).orElse((error) => {
|
|
606
612
|
this.logger?.warn("Failed to cancel consumer during close", {
|
|
607
613
|
consumerTag,
|
|
608
614
|
error
|
|
609
615
|
});
|
|
610
|
-
return (0,
|
|
611
|
-
}))
|
|
612
|
-
return neverthrow.ResultAsync.combine(cancellations).andTee(() => {
|
|
616
|
+
return (0, unthrown.ok)(void 0);
|
|
617
|
+
}))).tap(() => {
|
|
613
618
|
this.consumerTags.clear();
|
|
614
|
-
}).
|
|
619
|
+
}).flatMap(() => this.amqpClient.close()).map(() => void 0);
|
|
615
620
|
}
|
|
616
621
|
/**
|
|
617
622
|
* Start consuming for every entry in `contract.consumers` and `contract.rpcs`.
|
|
@@ -619,8 +624,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
619
624
|
consumeAll() {
|
|
620
625
|
const consumerNames = Object.keys(this.contract.consumers ?? {});
|
|
621
626
|
const rpcNames = Object.keys(this.contract.rpcs ?? {});
|
|
622
|
-
|
|
623
|
-
return neverthrow.ResultAsync.combine(allNames.map((name) => this.consume(name))).map(() => void 0);
|
|
627
|
+
return (0, unthrown.allAsync)([...consumerNames, ...rpcNames].map((name) => this.consume(name))).map(() => void 0);
|
|
624
628
|
}
|
|
625
629
|
waitForConnectionReady() {
|
|
626
630
|
return this.amqpClient.waitForConnect();
|
|
@@ -640,10 +644,9 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
640
644
|
*/
|
|
641
645
|
validateSchema(schema, data, context) {
|
|
642
646
|
const rawValidation = schema["~standard"].validate(data);
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
return (0, neverthrow.ok)(result.value);
|
|
647
|
+
return (0, unthrown.fromPromise)(rawValidation instanceof Promise ? rawValidation : Promise.resolve(rawValidation), (error) => new _amqp_contract_core.TechnicalError(`Error validating ${context.field}`, error)).flatMap((result) => {
|
|
648
|
+
if (result.issues) return (0, unthrown.err)(new _amqp_contract_core.TechnicalError(`${context.field} validation failed`, new _amqp_contract_core.MessageValidationError(context.consumerName, result.issues)));
|
|
649
|
+
return (0, unthrown.ok)(result.value);
|
|
647
650
|
});
|
|
648
651
|
}
|
|
649
652
|
/**
|
|
@@ -655,15 +658,13 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
655
658
|
*/
|
|
656
659
|
parseAndValidateMessage(msg, consumer, consumerName) {
|
|
657
660
|
const context = { consumerName: String(consumerName) };
|
|
658
|
-
|
|
661
|
+
return (0, unthrown.allAsync)([decompressBuffer(msg.content, msg.properties.contentEncoding).flatMap((buffer) => (0, _amqp_contract_core.safeJsonParse)(buffer, (error) => new _amqp_contract_core.TechnicalError("Failed to parse JSON", error))).flatMap((parsed) => this.validateSchema(consumer.message.payload, parsed, {
|
|
659
662
|
...context,
|
|
660
663
|
field: "payload"
|
|
661
|
-
}))
|
|
662
|
-
const parseHeaders = consumer.message.headers ? this.validateSchema(consumer.message.headers, msg.properties.headers ?? {}, {
|
|
664
|
+
})), consumer.message.headers ? this.validateSchema(consumer.message.headers, msg.properties.headers ?? {}, {
|
|
663
665
|
...context,
|
|
664
666
|
field: "headers"
|
|
665
|
-
}) : (0,
|
|
666
|
-
return neverthrow.ResultAsync.combine([parsePayload, parseHeaders]).map(([payload, headers]) => ({
|
|
667
|
+
}) : (0, unthrown.ok)(void 0).toAsync()]).map(([payload, headers]) => ({
|
|
667
668
|
payload,
|
|
668
669
|
headers
|
|
669
670
|
}));
|
|
@@ -695,7 +696,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
695
696
|
rpcName: String(rpcName),
|
|
696
697
|
queueName
|
|
697
698
|
});
|
|
698
|
-
return (0,
|
|
699
|
+
return (0, unthrown.err)(new NonRetryableError(`RPC "${String(rpcName)}" received a message without replyTo; cannot deliver response`)).toAsync();
|
|
699
700
|
}
|
|
700
701
|
if (typeof correlationId !== "string" || correlationId.length === 0) {
|
|
701
702
|
this.logger?.error("RPC handler returned a response but the incoming message has no correlationId", {
|
|
@@ -703,22 +704,21 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
703
704
|
queueName,
|
|
704
705
|
replyTo
|
|
705
706
|
});
|
|
706
|
-
return (0,
|
|
707
|
+
return (0, unthrown.err)(new NonRetryableError(`RPC "${String(rpcName)}" received a message without correlationId; cannot deliver response`)).toAsync();
|
|
707
708
|
}
|
|
708
709
|
let rawValidation;
|
|
709
710
|
try {
|
|
710
711
|
rawValidation = responseSchema["~standard"].validate(response);
|
|
711
712
|
} catch (error) {
|
|
712
|
-
return (0,
|
|
713
|
+
return (0, unthrown.err)(new NonRetryableError("RPC response schema validation threw", error)).toAsync();
|
|
713
714
|
}
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
}).andThen((validatedResponse) => this.amqpClient.publish("", replyTo, validatedResponse, {
|
|
715
|
+
return (0, unthrown.fromPromise)(rawValidation instanceof Promise ? rawValidation : Promise.resolve(rawValidation), (error) => new NonRetryableError("RPC response schema validation threw", error)).flatMap((validation) => {
|
|
716
|
+
if (validation.issues) return (0, unthrown.err)(new NonRetryableError(`RPC response for "${String(rpcName)}" failed schema validation`, new _amqp_contract_core.MessageValidationError(String(rpcName), validation.issues)));
|
|
717
|
+
return (0, unthrown.ok)(validation.value);
|
|
718
|
+
}).flatMap((validatedResponse) => this.amqpClient.publish("", replyTo, validatedResponse, {
|
|
719
719
|
correlationId,
|
|
720
720
|
contentType: "application/json"
|
|
721
|
-
}).mapErr((error) => new NonRetryableError("Failed to publish RPC response", error)).
|
|
721
|
+
}).mapErr((error) => new NonRetryableError("Failed to publish RPC response", error)).flatMap((published) => published ? (0, unthrown.ok)(void 0) : (0, unthrown.err)(new NonRetryableError("Failed to publish RPC response: channel buffer full"))));
|
|
722
722
|
}
|
|
723
723
|
/**
|
|
724
724
|
* Parse and validate the message; on failure, nack(requeue=false) so the
|
|
@@ -729,7 +729,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
729
729
|
parseAndValidateOrNack(msg, consumer, name) {
|
|
730
730
|
return this.parseAndValidateMessage(msg, consumer, name).orElse((parseError) => {
|
|
731
731
|
this.amqpClient.nack(msg, false, false);
|
|
732
|
-
return (0,
|
|
732
|
+
return (0, unthrown.err)(parseError).toAsync();
|
|
733
733
|
});
|
|
734
734
|
}
|
|
735
735
|
/**
|
|
@@ -744,10 +744,10 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
744
744
|
/**
|
|
745
745
|
* For RPC handlers, validate and publish the reply on the caller's
|
|
746
746
|
* `replyTo` / `correlationId`. For non-RPC consumers, this is a no-op that
|
|
747
|
-
* resolves to `
|
|
747
|
+
* resolves to `ok(undefined).toAsync()`.
|
|
748
748
|
*/
|
|
749
749
|
publishReplyIfRpc(msg, view, name, handlerResponse) {
|
|
750
|
-
if (!view.isRpc || !view.responseSchema) return (0,
|
|
750
|
+
if (!view.isRpc || !view.responseSchema) return (0, unthrown.ok)(void 0).toAsync();
|
|
751
751
|
const queueName = (0, _amqp_contract_contract.extractQueue)(view.consumer.queue).name;
|
|
752
752
|
return this.publishRpcResponse(msg, queueName, name, view.responseSchema, handlerResponse);
|
|
753
753
|
}
|
|
@@ -773,14 +773,14 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
773
773
|
const queueName = (0, _amqp_contract_contract.extractQueue)(consumer.queue).name;
|
|
774
774
|
const startTime = Date.now();
|
|
775
775
|
const span = (0, _amqp_contract_core.startConsumeSpan)(this.telemetry, queueName, String(name), { "messaging.rabbitmq.message.delivery_tag": msg.fields.deliveryTag });
|
|
776
|
-
return this.parseAndValidateOrNack(msg, consumer, name).
|
|
776
|
+
return this.parseAndValidateOrNack(msg, consumer, name).tapErr((parseError) => {
|
|
777
777
|
this.logger?.error("Failed to parse/validate message; sending to DLQ", {
|
|
778
778
|
consumerName: String(name),
|
|
779
779
|
queueName,
|
|
780
780
|
error: parseError
|
|
781
781
|
});
|
|
782
782
|
state.messageHandled = true;
|
|
783
|
-
}).
|
|
783
|
+
}).flatMap((validatedMessage) => this.runHandler(handler, validatedMessage, msg).flatMap((handlerResponse) => this.publishReplyIfRpc(msg, view, name, handlerResponse).tap(() => {
|
|
784
784
|
this.logger?.info("Message consumed successfully", {
|
|
785
785
|
consumerName: String(name),
|
|
786
786
|
queueName
|
|
@@ -798,10 +798,10 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
798
798
|
return handleError({
|
|
799
799
|
amqpClient: this.amqpClient,
|
|
800
800
|
logger: this.logger
|
|
801
|
-
}, handlerError, msg, String(name), consumer).
|
|
801
|
+
}, handlerError, msg, String(name), consumer).tap(() => {
|
|
802
802
|
state.messageHandled = true;
|
|
803
|
-
}).
|
|
804
|
-
})).
|
|
803
|
+
}).flatMap(() => (0, unthrown.err)(new _amqp_contract_core.TechnicalError(`Handler "${String(name)}" failed: ${handlerError.message}`, handlerError)).toAsync());
|
|
804
|
+
})).tap(() => {
|
|
805
805
|
try {
|
|
806
806
|
(0, _amqp_contract_core.endSpanSuccess)(span);
|
|
807
807
|
(0, _amqp_contract_core.recordConsumeMetric)(this.telemetry, queueName, String(name), true, Date.now() - startTime);
|
|
@@ -812,7 +812,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
812
812
|
error: telemetryError
|
|
813
813
|
});
|
|
814
814
|
}
|
|
815
|
-
}).
|
|
815
|
+
}).tapErr((error) => {
|
|
816
816
|
const reportedError = error.cause instanceof Error ? error.cause : error;
|
|
817
817
|
try {
|
|
818
818
|
(0, _amqp_contract_core.endSpanError)(span, reportedError);
|
|
@@ -858,7 +858,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
858
858
|
});
|
|
859
859
|
this.amqpClient.nack(msg, false, false);
|
|
860
860
|
}
|
|
861
|
-
}, this.consumerOptions[name]).
|
|
861
|
+
}, this.consumerOptions[name]).tap((consumerTag) => {
|
|
862
862
|
this.consumerTags.add(consumerTag);
|
|
863
863
|
}).map(() => void 0).mapErr((error) => new _amqp_contract_core.TechnicalError(`Failed to start consuming for "${String(name)}"`, error));
|
|
864
864
|
}
|
|
@@ -884,7 +884,9 @@ function formatAvailable(names) {
|
|
|
884
884
|
function validateHandlerTargetExists(contract, name) {
|
|
885
885
|
const consumers = contract.consumers;
|
|
886
886
|
const rpcs = contract.rpcs;
|
|
887
|
-
|
|
887
|
+
const isConsumer = !!consumers && Object.hasOwn(consumers, name);
|
|
888
|
+
const isRpc = !!rpcs && Object.hasOwn(rpcs, name);
|
|
889
|
+
if (!isConsumer && !isRpc) {
|
|
888
890
|
const available = formatAvailable(availableHandlerNames(contract));
|
|
889
891
|
throw new Error(`Handler target "${name}" not found in contract. Available consumers and RPCs: ${available}`);
|
|
890
892
|
}
|
|
@@ -905,8 +907,8 @@ function defineHandler(contract, name, handler, options) {
|
|
|
905
907
|
* Define multiple type-safe handlers for consumers and RPCs in a contract.
|
|
906
908
|
*
|
|
907
909
|
* **Recommended:** This function creates handlers that return
|
|
908
|
-
* `
|
|
909
|
-
* `
|
|
910
|
+
* `AsyncResult<void, HandlerError>` (consumers) or
|
|
911
|
+
* `AsyncResult<TResponse, HandlerError>` (RPCs), providing explicit error
|
|
910
912
|
* handling and better control over retry behavior.
|
|
911
913
|
*
|
|
912
914
|
* The handlers object must contain exactly one entry per `consumers` and
|
|
@@ -920,15 +922,15 @@ function defineHandler(contract, name, handler, options) {
|
|
|
920
922
|
* @example
|
|
921
923
|
* ```typescript
|
|
922
924
|
* import { defineHandlers, RetryableError } from '@amqp-contract/worker';
|
|
923
|
-
* import {
|
|
925
|
+
* import { fromPromise, ok } from 'unthrown';
|
|
924
926
|
*
|
|
925
927
|
* const handlers = defineHandlers(orderContract, {
|
|
926
928
|
* processOrder: ({ payload }) =>
|
|
927
|
-
*
|
|
929
|
+
* fromPromise(
|
|
928
930
|
* processPayment(payload),
|
|
929
931
|
* (error) => new RetryableError('Payment failed', error),
|
|
930
932
|
* ).map(() => undefined),
|
|
931
|
-
* calculate: ({ payload }) =>
|
|
933
|
+
* calculate: ({ payload }) => ok({ sum: payload.a + payload.b }).toAsync(),
|
|
932
934
|
* });
|
|
933
935
|
* ```
|
|
934
936
|
*/
|
|
@@ -937,7 +939,6 @@ function defineHandlers(contract, handlers) {
|
|
|
937
939
|
return handlers;
|
|
938
940
|
}
|
|
939
941
|
//#endregion
|
|
940
|
-
exports.HandlerError = HandlerError;
|
|
941
942
|
Object.defineProperty(exports, "MessageValidationError", {
|
|
942
943
|
enumerable: true,
|
|
943
944
|
get: function() {
|