@amqp-contract/client 0.20.0 → 0.22.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,10 +1,11 @@
1
- Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let _amqp_contract_contract = require("@amqp-contract/contract");
2
3
  let _amqp_contract_core = require("@amqp-contract/core");
3
4
  let _swan_io_boxed = require("@swan-io/boxed");
5
+ let node_crypto = require("node:crypto");
4
6
  let node_zlib = require("node:zlib");
5
7
  let ts_pattern = require("ts-pattern");
6
8
  let node_util = require("node:util");
7
-
8
9
  //#region src/compression.ts
9
10
  const gzipAsync = (0, node_util.promisify)(node_zlib.gzip);
10
11
  const deflateAsync = (0, node_util.promisify)(node_zlib.deflate);
@@ -20,16 +21,74 @@ const deflateAsync = (0, node_util.promisify)(node_zlib.deflate);
20
21
  function compressBuffer(buffer, algorithm) {
21
22
  return (0, ts_pattern.match)(algorithm).with("gzip", () => _swan_io_boxed.Future.fromPromise(gzipAsync(buffer)).mapError((error) => new _amqp_contract_core.TechnicalError("Failed to compress with gzip", error))).with("deflate", () => _swan_io_boxed.Future.fromPromise(deflateAsync(buffer)).mapError((error) => new _amqp_contract_core.TechnicalError("Failed to compress with deflate", error))).exhaustive();
22
23
  }
23
-
24
+ //#endregion
25
+ //#region src/errors.ts
26
+ /**
27
+ * Captured `Error.captureStackTrace` shim — only present on Node.js.
28
+ */
29
+ function captureStack(target, ctor) {
30
+ const ErrorConstructor = Error;
31
+ if (typeof ErrorConstructor.captureStackTrace === "function") ErrorConstructor.captureStackTrace(target, ctor);
32
+ }
33
+ /**
34
+ * Returned from `TypedAmqpClient.call()` when the configured `timeoutMs` elapses
35
+ * before the RPC server publishes a reply with the matching `correlationId`.
36
+ *
37
+ * The pending call is removed from the in-memory correlation map; if a reply
38
+ * arrives after the timeout it is dropped (and a debug log is emitted by the
39
+ * client if a logger is configured).
40
+ */
41
+ var RpcTimeoutError = class extends Error {
42
+ constructor(rpcName, timeoutMs) {
43
+ super(`RPC call to "${rpcName}" timed out after ${timeoutMs}ms with no reply received`);
44
+ this.rpcName = rpcName;
45
+ this.timeoutMs = timeoutMs;
46
+ this.name = "RpcTimeoutError";
47
+ captureStack(this, this.constructor);
48
+ }
49
+ };
50
+ /**
51
+ * Returned from any in-flight RPC call when the client is closed before the
52
+ * reply is received. The correlation map is cleared on close and every pending
53
+ * caller's promise resolves with `Result.Error(RpcCancelledError)`.
54
+ */
55
+ var RpcCancelledError = class extends Error {
56
+ constructor(rpcName) {
57
+ super(`RPC call to "${rpcName}" was cancelled because the client was closed`);
58
+ this.rpcName = rpcName;
59
+ this.name = "RpcCancelledError";
60
+ captureStack(this, this.constructor);
61
+ }
62
+ };
24
63
  //#endregion
25
64
  //#region src/client.ts
26
65
  /**
66
+ * The RabbitMQ direct-reply-to pseudo-queue. Publishing with `replyTo` set to
67
+ * this value tells the server to deliver the response back to the consumer
68
+ * subscribed on this queue on the same channel — no real queue is created and
69
+ * no setup is required beyond consuming from it once with `noAck: true`.
70
+ *
71
+ * @see https://www.rabbitmq.com/docs/direct-reply-to
72
+ */
73
+ const DIRECT_REPLY_TO = "amq.rabbitmq.reply-to";
74
+ /**
27
75
  * Type-safe AMQP client for publishing messages
28
76
  */
29
77
  var TypedAmqpClient = class TypedAmqpClient {
30
- constructor(contract, amqpClient, logger, telemetry = _amqp_contract_core.defaultTelemetryProvider) {
78
+ /**
79
+ * In-flight RPC calls keyed by `correlationId`. Cleared when a reply is
80
+ * received, when the call times out, or when the client is closed.
81
+ */
82
+ pendingCalls = /* @__PURE__ */ new Map();
83
+ /**
84
+ * Consumer tag of the reply consumer subscribed on `amq.rabbitmq.reply-to`.
85
+ * Set when the contract has at least one entry in `rpcs`; undefined otherwise.
86
+ */
87
+ replyConsumerTag;
88
+ constructor(contract, amqpClient, defaultPublishOptions, logger, telemetry = _amqp_contract_core.defaultTelemetryProvider) {
31
89
  this.contract = contract;
32
90
  this.amqpClient = amqpClient;
91
+ this.defaultPublishOptions = defaultPublishOptions;
33
92
  this.logger = logger;
34
93
  this.telemetry = telemetry;
35
94
  }
@@ -43,12 +102,77 @@ var TypedAmqpClient = class TypedAmqpClient {
43
102
  * Connections are automatically shared across clients with the same URLs and
44
103
  * connection options, following RabbitMQ best practices.
45
104
  */
46
- static create({ contract, urls, connectionOptions, logger, telemetry }) {
105
+ static create({ contract, urls, connectionOptions, defaultPublishOptions, logger, telemetry, connectTimeoutMs }) {
47
106
  const client = new TypedAmqpClient(contract, new _amqp_contract_core.AmqpClient(contract, {
48
107
  urls,
49
- connectionOptions
50
- }), logger, telemetry ?? _amqp_contract_core.defaultTelemetryProvider);
51
- return client.waitForConnectionReady().mapOk(() => client);
108
+ connectionOptions,
109
+ connectTimeoutMs
110
+ }), {
111
+ persistent: true,
112
+ ...defaultPublishOptions
113
+ }, logger, telemetry ?? _amqp_contract_core.defaultTelemetryProvider);
114
+ return client.waitForConnectionReady().flatMapOk(() => client.setupReplyConsumerIfNeeded()).flatMap((result) => result.match({
115
+ Ok: () => _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(client)),
116
+ Error: (error) => client.close().tapError((closeError) => {
117
+ logger?.warn("Failed to close client after connection failure", { error: closeError });
118
+ }).map(() => _swan_io_boxed.Result.Error(error))
119
+ }));
120
+ }
121
+ /**
122
+ * If the contract has any RPC entry, subscribe to `amq.rabbitmq.reply-to`
123
+ * once. Replies for every in-flight call arrive on this single consumer and
124
+ * are demultiplexed by `correlationId`.
125
+ */
126
+ setupReplyConsumerIfNeeded() {
127
+ const rpcs = this.contract.rpcs ?? {};
128
+ if (Object.keys(rpcs).length === 0) return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
129
+ return this.amqpClient.consume(DIRECT_REPLY_TO, (msg) => this.handleRpcReply(msg), { noAck: true }).tapOk((tag) => {
130
+ this.replyConsumerTag = tag;
131
+ }).mapOk(() => void 0);
132
+ }
133
+ /**
134
+ * Demultiplex an RPC reply by `correlationId`, validate the body against the
135
+ * call's response schema, and resolve the awaiting caller. Replies with no
136
+ * matching pending call (e.g. arriving after the call timed out) are dropped
137
+ * with a debug log.
138
+ */
139
+ handleRpcReply(msg) {
140
+ if (!msg) return;
141
+ const correlationId = msg.properties.correlationId;
142
+ if (typeof correlationId !== "string") {
143
+ this.logger?.warn("Received RPC reply without correlationId; dropping", { deliveryTag: msg.fields.deliveryTag });
144
+ return;
145
+ }
146
+ const pending = this.pendingCalls.get(correlationId);
147
+ if (!pending) {
148
+ this.logger?.debug("Received RPC reply for unknown correlationId", { correlationId });
149
+ return;
150
+ }
151
+ this.pendingCalls.delete(correlationId);
152
+ clearTimeout(pending.timer);
153
+ let parsed;
154
+ try {
155
+ parsed = JSON.parse(msg.content.toString());
156
+ } catch (error) {
157
+ pending.resolve(_swan_io_boxed.Result.Error(new _amqp_contract_core.TechnicalError(`Failed to parse RPC reply JSON for "${pending.rpcName}"`, error)));
158
+ return;
159
+ }
160
+ let rawValidation;
161
+ try {
162
+ rawValidation = pending.responseSchema["~standard"].validate(parsed);
163
+ } catch (error) {
164
+ pending.resolve(_swan_io_boxed.Result.Error(new _amqp_contract_core.TechnicalError(`RPC reply validation threw for "${pending.rpcName}"`, error)));
165
+ return;
166
+ }
167
+ (rawValidation instanceof Promise ? rawValidation : Promise.resolve(rawValidation)).then((validation) => {
168
+ if (validation.issues) {
169
+ pending.resolve(_swan_io_boxed.Result.Error(new _amqp_contract_core.MessageValidationError(pending.rpcName, validation.issues)));
170
+ return;
171
+ }
172
+ pending.resolve(_swan_io_boxed.Result.Ok(validation.value));
173
+ }, (error) => {
174
+ pending.resolve(_swan_io_boxed.Result.Error(new _amqp_contract_core.TechnicalError(`RPC reply validation threw for "${pending.rpcName}"`, error)));
175
+ });
52
176
  }
53
177
  /**
54
178
  * Publish a message using a defined publisher
@@ -81,7 +205,10 @@ var TypedAmqpClient = class TypedAmqpClient {
81
205
  });
82
206
  };
83
207
  const publishMessage = (validatedMessage) => {
84
- const { compression, ...restOptions } = options ?? {};
208
+ const { compression, ...restOptions } = {
209
+ ...this.defaultPublishOptions,
210
+ ...options
211
+ };
85
212
  const publishOptions = { ...restOptions };
86
213
  const preparePayload = () => {
87
214
  if (compression) {
@@ -113,21 +240,120 @@ var TypedAmqpClient = class TypedAmqpClient {
113
240
  });
114
241
  }
115
242
  /**
116
- * Close the channel and connection
243
+ * Invoke an RPC defined via `defineRpc` and await the typed response.
244
+ *
245
+ * The request payload is validated against the RPC's request schema, then
246
+ * published to the AMQP default exchange with the server's queue name as
247
+ * routing key, `replyTo` set to `amq.rabbitmq.reply-to`, and a fresh UUID
248
+ * `correlationId`. The returned Future resolves once a matching reply
249
+ * arrives and validates against the response schema, or once `timeoutMs`
250
+ * elapses (whichever comes first).
251
+ *
252
+ * @typeParam TName - An RPC name from `contract.rpcs`.
253
+ * @param rpcName - The RPC name from the contract.
254
+ * @param request - The request payload, validated against the request schema.
255
+ * @param options - Per-call options. `timeoutMs` is required.
256
+ *
257
+ * @returns `Result.Ok(response)` on a successful round-trip; `Result.Error`
258
+ * on validation, transport, timeout, or cancel.
259
+ *
260
+ * @example
261
+ * ```typescript
262
+ * const result = await client
263
+ * .call('calculate', { a: 1, b: 2 }, { timeoutMs: 5_000 })
264
+ * .toPromise();
265
+ * if (result.isOk()) console.log(result.value.sum); // 3
266
+ * ```
267
+ */
268
+ call(rpcName, request, options) {
269
+ const TIMEOUT_MAX_MS = 2147483647;
270
+ if (typeof options.timeoutMs !== "number" || !Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0 || options.timeoutMs > TIMEOUT_MAX_MS) return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Error(new _amqp_contract_core.TechnicalError(`Invalid timeoutMs for RPC call to "${String(rpcName)}": expected a finite positive number ≤ ${TIMEOUT_MAX_MS}, got ${String(options.timeoutMs)}`)));
271
+ const startTime = Date.now();
272
+ const rpc = this.contract.rpcs[rpcName];
273
+ const requestSchema = rpc.request.payload;
274
+ const responseSchema = rpc.response.payload;
275
+ const queueName = (0, _amqp_contract_contract.extractQueue)(rpc.queue).name;
276
+ const span = (0, _amqp_contract_core.startPublishSpan)(this.telemetry, "", queueName, { [_amqp_contract_core.MessagingSemanticConventions.AMQP_PUBLISHER_NAME]: String(rpcName) });
277
+ const correlationId = (0, node_crypto.randomUUID)();
278
+ const callFuture = _swan_io_boxed.Future.make((resolve) => {
279
+ const timer = setTimeout(() => {
280
+ if (!this.pendingCalls.get(correlationId)) return;
281
+ this.pendingCalls.delete(correlationId);
282
+ resolve(_swan_io_boxed.Result.Error(new RpcTimeoutError(String(rpcName), options.timeoutMs)));
283
+ }, options.timeoutMs);
284
+ this.pendingCalls.set(correlationId, {
285
+ rpcName: String(rpcName),
286
+ responseSchema,
287
+ resolve,
288
+ timer
289
+ });
290
+ });
291
+ const validateRequest = () => {
292
+ let rawValidation;
293
+ try {
294
+ rawValidation = requestSchema["~standard"].validate(request);
295
+ } catch (error) {
296
+ return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Error(new _amqp_contract_core.TechnicalError("RPC request validation threw", error)));
297
+ }
298
+ const validationPromise = rawValidation instanceof Promise ? rawValidation : Promise.resolve(rawValidation);
299
+ return _swan_io_boxed.Future.fromPromise(validationPromise).mapError((error) => new _amqp_contract_core.TechnicalError("RPC request validation threw", error)).mapOkToResult((validation) => validation.issues ? _swan_io_boxed.Result.Error(new _amqp_contract_core.MessageValidationError(String(rpcName), validation.issues)) : _swan_io_boxed.Result.Ok(validation.value));
300
+ };
301
+ const publishRequest = (validatedRequest) => {
302
+ const { compression: _ignoredCompression, ...defaultsWithoutCompression } = this.defaultPublishOptions;
303
+ const publishOptions = {
304
+ ...defaultsWithoutCompression,
305
+ ...options.publishOptions,
306
+ replyTo: DIRECT_REPLY_TO,
307
+ correlationId,
308
+ contentType: "application/json"
309
+ };
310
+ return this.amqpClient.publish("", queueName, validatedRequest, publishOptions).mapOkToResult((published) => published ? _swan_io_boxed.Result.Ok(void 0) : _swan_io_boxed.Result.Error(new _amqp_contract_core.TechnicalError(`Failed to publish RPC request for "${String(rpcName)}": channel buffer full`)));
311
+ };
312
+ return validateRequest().flatMapOk((validated) => publishRequest(validated)).flatMap((preflight) => {
313
+ if (preflight.isError()) {
314
+ const pending = this.pendingCalls.get(correlationId);
315
+ if (pending) {
316
+ clearTimeout(pending.timer);
317
+ this.pendingCalls.delete(correlationId);
318
+ }
319
+ return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Error(preflight.error));
320
+ }
321
+ return callFuture;
322
+ }).tapOk(() => {
323
+ const durationMs = Date.now() - startTime;
324
+ (0, _amqp_contract_core.endSpanSuccess)(span);
325
+ (0, _amqp_contract_core.recordPublishMetric)(this.telemetry, "", queueName, true, durationMs);
326
+ }).tapError((error) => {
327
+ const durationMs = Date.now() - startTime;
328
+ (0, _amqp_contract_core.endSpanError)(span, error);
329
+ (0, _amqp_contract_core.recordPublishMetric)(this.telemetry, "", queueName, false, durationMs);
330
+ });
331
+ }
332
+ /**
333
+ * Close the channel and connection. Cancels the reply consumer (if any) and
334
+ * rejects every in-flight RPC call with `RpcCancelledError`.
117
335
  */
118
336
  close() {
119
- return this.amqpClient.close().mapOk(() => void 0);
337
+ for (const [, pending] of this.pendingCalls) {
338
+ clearTimeout(pending.timer);
339
+ pending.resolve(_swan_io_boxed.Result.Error(new RpcCancelledError(pending.rpcName)));
340
+ }
341
+ this.pendingCalls.clear();
342
+ return (this.replyConsumerTag ? this.amqpClient.cancel(this.replyConsumerTag).tapError((error) => {
343
+ this.logger?.warn("Failed to cancel RPC reply consumer during close", { error });
344
+ }) : _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0))).flatMap(() => this.amqpClient.close()).mapOk(() => void 0);
120
345
  }
121
346
  waitForConnectionReady() {
122
347
  return this.amqpClient.waitForConnect();
123
348
  }
124
349
  };
125
-
126
350
  //#endregion
127
- Object.defineProperty(exports, 'MessageValidationError', {
128
- enumerable: true,
129
- get: function () {
130
- return _amqp_contract_core.MessageValidationError;
131
- }
351
+ Object.defineProperty(exports, "MessageValidationError", {
352
+ enumerable: true,
353
+ get: function() {
354
+ return _amqp_contract_core.MessageValidationError;
355
+ }
132
356
  });
133
- exports.TypedAmqpClient = TypedAmqpClient;
357
+ exports.RpcCancelledError = RpcCancelledError;
358
+ exports.RpcTimeoutError = RpcTimeoutError;
359
+ exports.TypedAmqpClient = TypedAmqpClient;
package/dist/index.d.cts CHANGED
@@ -1,13 +1,12 @@
1
- import { Logger, MessageValidationError, TechnicalError, TelemetryProvider } from "@amqp-contract/core";
1
+ import { CompressionAlgorithm, ContractDefinition, InferPublisherNames, InferRpcNames, MessageDefinition, PublisherEntry, RpcDefinition } from "@amqp-contract/contract";
2
+ import { Logger, MessageValidationError, PublishOptions as PublishOptions$1, TechnicalError, TelemetryProvider } from "@amqp-contract/core";
3
+ import { Future, Result } from "@swan-io/boxed";
2
4
  import * as amqp from "amqplib";
3
- import { Options } from "amqplib";
4
5
  import { TcpSocketConnectOpts } from "net";
5
6
  import { ConnectionOptions } from "tls";
6
- import { CompressionAlgorithm, ContractDefinition, InferPublisherNames, PublisherEntry } from "@amqp-contract/contract";
7
- import { Future, Result } from "@swan-io/boxed";
8
7
  import { StandardSchemaV1 } from "@standard-schema/spec";
9
8
 
10
- //#region ../../node_modules/.pnpm/amqp-connection-manager@5.0.0_amqplib@0.10.9/node_modules/amqp-connection-manager/dist/types/AmqpConnectionManager.d.ts
9
+ //#region ../../node_modules/.pnpm/amqp-connection-manager@5.0.0_amqplib@1.0.3/node_modules/amqp-connection-manager/dist/types/AmqpConnectionManager.d.ts
11
10
  type ConnectionUrl = string | amqp.Options.Connect | {
12
11
  url: string;
13
12
  connectionOptions?: AmqpConnectionOptions;
@@ -48,11 +47,39 @@ interface AmqpConnectionManagerOptions {
48
47
  connectionOptions?: AmqpConnectionOptions;
49
48
  }
50
49
  //#endregion
50
+ //#region src/errors.d.ts
51
+ /**
52
+ * Returned from `TypedAmqpClient.call()` when the configured `timeoutMs` elapses
53
+ * before the RPC server publishes a reply with the matching `correlationId`.
54
+ *
55
+ * The pending call is removed from the in-memory correlation map; if a reply
56
+ * arrives after the timeout it is dropped (and a debug log is emitted by the
57
+ * client if a logger is configured).
58
+ */
59
+ declare class RpcTimeoutError extends Error {
60
+ readonly rpcName: string;
61
+ readonly timeoutMs: number;
62
+ constructor(rpcName: string, timeoutMs: number);
63
+ }
64
+ /**
65
+ * Returned from any in-flight RPC call when the client is closed before the
66
+ * reply is received. The correlation map is cleared on close and every pending
67
+ * caller's promise resolves with `Result.Error(RpcCancelledError)`.
68
+ */
69
+ declare class RpcCancelledError extends Error {
70
+ readonly rpcName: string;
71
+ constructor(rpcName: string);
72
+ }
73
+ //#endregion
51
74
  //#region src/types.d.ts
52
75
  /**
53
- * Infer the TypeScript type from a schema
76
+ * Infer the TypeScript type from a schema (input side, used for publish payloads).
54
77
  */
55
78
  type InferSchemaInput<TSchema extends StandardSchemaV1> = TSchema extends StandardSchemaV1<infer TInput> ? TInput : never;
79
+ /**
80
+ * Infer the TypeScript type from a schema (output side, used for validated responses).
81
+ */
82
+ type InferSchemaOutput<TSchema extends StandardSchemaV1> = TSchema extends StandardSchemaV1<infer _TInput, infer TOutput> ? TOutput : never;
56
83
  /**
57
84
  * Infer publisher message input type.
58
85
  * Works with both PublisherDefinition and EventPublisherConfig since both have
@@ -63,24 +90,28 @@ type PublisherInferInput<TPublisher extends PublisherEntry> = TPublisher extends
63
90
  payload: StandardSchemaV1;
64
91
  };
65
92
  } ? InferSchemaInput<TPublisher["message"]["payload"]> : never;
93
+ type InferPublishers<TContract extends ContractDefinition> = NonNullable<TContract["publishers"]>;
94
+ type InferPublisher<TContract extends ContractDefinition, TName extends InferPublisherNames<TContract>> = InferPublishers<TContract>[TName];
66
95
  /**
67
- * Infer all publishers from contract
96
+ * Input type accepted by `client.publish(name, ...)` for a specific publisher.
68
97
  */
69
- type InferPublishers<TContract extends ContractDefinition> = NonNullable<TContract["publishers"]>;
98
+ type ClientInferPublisherInput<TContract extends ContractDefinition, TName extends InferPublisherNames<TContract>> = PublisherInferInput<InferPublisher<TContract, TName>>;
99
+ type InferRpcs<TContract extends ContractDefinition> = NonNullable<TContract["rpcs"]>;
100
+ type InferRpc<TContract extends ContractDefinition, TName extends InferRpcNames<TContract>> = InferRpcs<TContract>[TName];
70
101
  /**
71
- * Get specific publisher definition from contract
102
+ * Input type accepted by `client.call(name, request, ...)`.
72
103
  */
73
- type InferPublisher<TContract extends ContractDefinition, TName extends InferPublisherNames<TContract>> = InferPublishers<TContract>[TName];
104
+ type ClientInferRpcRequestInput<TContract extends ContractDefinition, TName extends InferRpcNames<TContract>> = InferRpc<TContract, TName> extends RpcDefinition<infer TRequest, MessageDefinition> ? TRequest extends MessageDefinition ? InferSchemaInput<TRequest["payload"]> : never : never;
74
105
  /**
75
- * Infer publisher input type (message payload) for a specific publisher in a contract
106
+ * Output (validated) response type returned by `client.call(name, ...)`.
76
107
  */
77
- type ClientInferPublisherInput<TContract extends ContractDefinition, TName extends InferPublisherNames<TContract>> = PublisherInferInput<InferPublisher<TContract, TName>>;
108
+ type ClientInferRpcResponseOutput<TContract extends ContractDefinition, TName extends InferRpcNames<TContract>> = InferRpc<TContract, TName> extends RpcDefinition<MessageDefinition, infer TResponse> ? TResponse extends MessageDefinition ? InferSchemaOutput<TResponse["payload"]> : never : never;
78
109
  //#endregion
79
110
  //#region src/client.d.ts
80
111
  /**
81
- * Publish options that extend amqplib's Options.Publish with optional compression support.
112
+ * Publish options that extend amqp-client's PublishOptions with optional compression support.
82
113
  */
83
- type PublishOptions = Options.Publish & {
114
+ type PublishOptions = PublishOptions$1 & {
84
115
  /**
85
116
  * Optional compression algorithm to use for the message payload.
86
117
  * When specified, the message will be compressed using the chosen algorithm
@@ -102,6 +133,37 @@ type CreateClientOptions<TContract extends ContractDefinition> = {
102
133
  * OpenTelemetry instrumentation is automatically enabled if @opentelemetry/api is installed.
103
134
  */
104
135
  telemetry?: TelemetryProvider | undefined;
136
+ /**
137
+ * Default publish options that will be applied to all publish operations.
138
+ * These can be overridden by options passed to the publish method.
139
+ * By default, persistent is set to true for message durability.
140
+ */
141
+ defaultPublishOptions?: PublishOptions | undefined;
142
+ /**
143
+ * Maximum time in ms to wait for the AMQP connection to become ready before
144
+ * `create()` resolves to `Result.Error<TechnicalError>`. Without this option,
145
+ * `create()` waits forever — the underlying amqp-connection-manager retries
146
+ * indefinitely.
147
+ */
148
+ connectTimeoutMs?: number | undefined;
149
+ };
150
+ /**
151
+ * Per-call options for `client.call()`.
152
+ */
153
+ type CallOptions = {
154
+ /**
155
+ * Maximum time in ms to wait for an RPC reply. If exceeded, the call resolves
156
+ * to `Result.Error<RpcTimeoutError>` and the in-memory correlation entry is
157
+ * cleared. A late reply arriving after the timeout is silently dropped.
158
+ *
159
+ * Required: RPC without a timeout is a footgun.
160
+ */
161
+ timeoutMs: number;
162
+ /**
163
+ * Optional AMQP message properties to merge into the request. `replyTo` and
164
+ * `correlationId` are managed by the client and cannot be overridden.
165
+ */
166
+ publishOptions?: Omit<PublishOptions$1, "replyTo" | "correlationId">;
105
167
  };
106
168
  /**
107
169
  * Type-safe AMQP client for publishing messages
@@ -109,8 +171,19 @@ type CreateClientOptions<TContract extends ContractDefinition> = {
109
171
  declare class TypedAmqpClient<TContract extends ContractDefinition> {
110
172
  private readonly contract;
111
173
  private readonly amqpClient;
174
+ private readonly defaultPublishOptions;
112
175
  private readonly logger?;
113
176
  private readonly telemetry;
177
+ /**
178
+ * In-flight RPC calls keyed by `correlationId`. Cleared when a reply is
179
+ * received, when the call times out, or when the client is closed.
180
+ */
181
+ private readonly pendingCalls;
182
+ /**
183
+ * Consumer tag of the reply consumer subscribed on `amq.rabbitmq.reply-to`.
184
+ * Set when the contract has at least one entry in `rpcs`; undefined otherwise.
185
+ */
186
+ private replyConsumerTag?;
114
187
  private constructor();
115
188
  /**
116
189
  * Create a type-safe AMQP client from a contract.
@@ -126,9 +199,24 @@ declare class TypedAmqpClient<TContract extends ContractDefinition> {
126
199
  contract,
127
200
  urls,
128
201
  connectionOptions,
202
+ defaultPublishOptions,
129
203
  logger,
130
- telemetry
204
+ telemetry,
205
+ connectTimeoutMs
131
206
  }: CreateClientOptions<TContract>): Future<Result<TypedAmqpClient<TContract>, TechnicalError>>;
207
+ /**
208
+ * If the contract has any RPC entry, subscribe to `amq.rabbitmq.reply-to`
209
+ * once. Replies for every in-flight call arrive on this single consumer and
210
+ * are demultiplexed by `correlationId`.
211
+ */
212
+ private setupReplyConsumerIfNeeded;
213
+ /**
214
+ * Demultiplex an RPC reply by `correlationId`, validate the body against the
215
+ * call's response schema, and resolve the awaiting caller. Replies with no
216
+ * matching pending call (e.g. arriving after the call timed out) are dropped
217
+ * with a debug log.
218
+ */
219
+ private handleRpcReply;
132
220
  /**
133
221
  * Publish a message using a defined publisher
134
222
  *
@@ -149,11 +237,39 @@ declare class TypedAmqpClient<TContract extends ContractDefinition> {
149
237
  */
150
238
  publish<TName extends InferPublisherNames<TContract>>(publisherName: TName, message: ClientInferPublisherInput<TContract, TName>, options?: PublishOptions): Future<Result<void, TechnicalError | MessageValidationError>>;
151
239
  /**
152
- * Close the channel and connection
240
+ * Invoke an RPC defined via `defineRpc` and await the typed response.
241
+ *
242
+ * The request payload is validated against the RPC's request schema, then
243
+ * published to the AMQP default exchange with the server's queue name as
244
+ * routing key, `replyTo` set to `amq.rabbitmq.reply-to`, and a fresh UUID
245
+ * `correlationId`. The returned Future resolves once a matching reply
246
+ * arrives and validates against the response schema, or once `timeoutMs`
247
+ * elapses (whichever comes first).
248
+ *
249
+ * @typeParam TName - An RPC name from `contract.rpcs`.
250
+ * @param rpcName - The RPC name from the contract.
251
+ * @param request - The request payload, validated against the request schema.
252
+ * @param options - Per-call options. `timeoutMs` is required.
253
+ *
254
+ * @returns `Result.Ok(response)` on a successful round-trip; `Result.Error`
255
+ * on validation, transport, timeout, or cancel.
256
+ *
257
+ * @example
258
+ * ```typescript
259
+ * const result = await client
260
+ * .call('calculate', { a: 1, b: 2 }, { timeoutMs: 5_000 })
261
+ * .toPromise();
262
+ * if (result.isOk()) console.log(result.value.sum); // 3
263
+ * ```
264
+ */
265
+ call<TName extends InferRpcNames<TContract>>(rpcName: TName, request: ClientInferRpcRequestInput<TContract, TName>, options: CallOptions): Future<Result<ClientInferRpcResponseOutput<TContract, TName>, TechnicalError | MessageValidationError | RpcTimeoutError | RpcCancelledError>>;
266
+ /**
267
+ * Close the channel and connection. Cancels the reply consumer (if any) and
268
+ * rejects every in-flight RPC call with `RpcCancelledError`.
153
269
  */
154
270
  close(): Future<Result<void, TechnicalError>>;
155
271
  private waitForConnectionReady;
156
272
  }
157
273
  //#endregion
158
- export { type ClientInferPublisherInput, type CreateClientOptions, MessageValidationError, type PublishOptions, TypedAmqpClient };
274
+ export { type CallOptions, type ClientInferPublisherInput, type ClientInferRpcRequestInput, type ClientInferRpcResponseOutput, type CreateClientOptions, MessageValidationError, type PublishOptions, RpcCancelledError, RpcTimeoutError, TypedAmqpClient };
159
275
  //# sourceMappingURL=index.d.cts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.cts","names":["amqp","EventEmitter","TcpSocketConnectOpts","ConnectionOptions","ChannelWrapper","CreateChannelOpts","ConnectionUrl","Options","Connect","AmqpConnectionOptions","url","connectionOptions","ConnectListener","Connection","connection","arg","ConnectFailedListener","Error","err","Buffer","noDelay","timeout","keepAlive","keepAliveDelay","clientProperties","credentials","mechanism","username","password","response","AmqpConnectionManagerOptions","Promise","heartbeatIntervalInSeconds","reconnectTimeInSeconds","findServers","urls","callback","IAmqpConnectionManager","Function","ChannelModel","addListener","event","args","listener","reason","listeners","eventName","on","once","prependListener","prependOnceListener","removeListener","connect","options","reconnect","createChannel","close","isConnected","channelCount","AmqpConnectionManager","_channels","_currentUrl","_closed","_cancelRetriesHandler","_connectPromise","_currentConnection","_findServers","_urls","constructor","_connect","default"],"sources":["../../../node_modules/.pnpm/amqp-connection-manager@5.0.0_amqplib@0.10.9/node_modules/amqp-connection-manager/dist/types/AmqpConnectionManager.d.ts","../src/types.ts","../src/client.ts"],"x_google_ignoreList":[0],"mappings":";;;;;;;;;;KAKYM,aAAAA,YAAyBN,IAAAA,CAAKO,OAAAA,CAAQC,OAAAA;EAC9CE,GAAAA;EACAC,iBAAAA,GAAoBF,qBAAAA;AAAAA;AAAAA,KAcZA,qBAAAA,IAAyBN,iBAAAA,GAAoBD,oBAAAA;EACrDkB,OAAAA;EACAC,OAAAA;EACAC,SAAAA;EACAC,cAAAA;EACAC,gBAAAA;EACAC,WAAAA;IACIC,SAAAA;IACAC,QAAAA;IACAC,QAAAA;IACAC,QAAAA,QAAgBV,MAAAA;EAAAA;IAEhBO,SAAAA;IACAG,QAAAA,QAAgBV,MAAAA;EAAAA;AAAAA;AAAAA,UAGPW,4BAAAA;EAVbL;EAYAO,0BAAAA;EAVIL;;;;EAeJM,sBAAAA;EAVIJ;;;;AAGR;;;EAeIK,WAAAA,KAAgBE,QAAAA,GAAWD,IAAAA,EAAM7B,aAAAA,GAAgBA,aAAAA,+BAA4CyB,OAAAA,CAAQzB,aAAAA,GAAgBA,aAAAA;EAApEA;EAEjDK,iBAAAA,GAAoBF,qBAAAA;AAAAA;;;;;;KC5CnB,gBAAA,iBAAiC,gBAAA,IACpC,OAAA,SAAgB,gBAAA,iBAAiC,MAAA;;;;;ADNnD;KCaK,mBAAA,oBAAuC,cAAA,IAAkB,UAAA;EAC5D,OAAA;IAAW,OAAA,EAAS,gBAAA;EAAA;AAAA,IAElB,gBAAA,CAAiB,UAAA;;;;KAMhB,eAAA,mBAAkC,kBAAA,IAAsB,WAAA,CAAY,SAAA;;;ADNzE;KCWK,cAAA,mBACe,kBAAA,gBACJ,mBAAA,CAAoB,SAAA,KAChC,eAAA,CAAgB,SAAA,EAAW,KAAA;;;;KAKnB,yBAAA,mBACQ,kBAAA,gBACJ,mBAAA,CAAoB,SAAA,KAChC,mBAAA,CAAoB,cAAA,CAAe,SAAA,EAAW,KAAA;;;;;;KChBtC,cAAA,GAAiB,OAAA,CAAQ,OAAA;EFtBZ;;;;;EE4BvB,WAAA,GAAc,oBAAA;AAAA;;;;KAMJ,mBAAA,mBAAsC,kBAAA;EAChD,QAAA,EAAU,SAAA;EACV,IAAA,EAAM,aAAA;EACN,iBAAA,GAAoB,4BAAA;EACpB,MAAA,GAAS,MAAA;EFtB8CP;;;;;EE4BvD,SAAA,GAAY,iBAAA;AAAA;;;;cAMD,eAAA,mBAAkC,kBAAA;EAAA,iBAE1B,QAAA;EAAA,iBACA,UAAA;EAAA,iBACA,MAAA;EAAA,iBACA,SAAA;EAAA,QAJZ,WAAA,CAAA;EFzBD2B;;;;;;;AAMR;;;EANQA,OE0CC,MAAA,mBAAyB,kBAAA,CAAA,CAAA;IAC9B,QAAA;IACA,IAAA;IACA,iBAAA;IACA,MAAA;IACA;EAAA,GACC,mBAAA,CAAoB,SAAA,IAAa,MAAA,CAAO,MAAA,CAAO,eAAA,CAAgB,SAAA,GAAY,cAAA;EF3B3BvB;;;;;;;;;;;;;;EAA4CyB;;;;EEwD/F,OAAA,eAAsB,mBAAA,CAAoB,SAAA,EAAA,CACxC,aAAA,EAAe,KAAA,EACf,OAAA,EAAS,yBAAA,CAA0B,SAAA,EAAW,KAAA,GAC9C,OAAA,GAAU,cAAA,GACT,MAAA,CAAO,MAAA,OAAa,cAAA,GAAiB,sBAAA;EF1DG;;;EEoJ3C,KAAA,CAAA,GAAS,MAAA,CAAO,MAAA,OAAa,cAAA;EAAA,QAIrB,sBAAA;AAAA"}
1
+ {"version":3,"file":"index.d.cts","names":["amqp","EventEmitter","TcpSocketConnectOpts","ConnectionOptions","ChannelWrapper","CreateChannelOpts","ConnectionUrl","Options","Connect","AmqpConnectionOptions","url","connectionOptions","ConnectListener","Connection","connection","arg","ConnectFailedListener","Error","err","Buffer","noDelay","timeout","keepAlive","keepAliveDelay","clientProperties","credentials","mechanism","username","password","response","AmqpConnectionManagerOptions","Promise","heartbeatIntervalInSeconds","reconnectTimeInSeconds","findServers","urls","callback","IAmqpConnectionManager","Function","ChannelModel","addListener","event","args","listener","reason","listeners","eventName","on","once","prependListener","prependOnceListener","removeListener","connect","options","reconnect","createChannel","close","isConnected","channelCount","AmqpConnectionManager","_channels","_currentUrl","_closed","_cancelRetriesHandler","_connectPromise","_currentConnection","_findServers","_urls","constructor","_connect","default"],"sources":["../../../node_modules/.pnpm/amqp-connection-manager@5.0.0_amqplib@1.0.3/node_modules/amqp-connection-manager/dist/types/AmqpConnectionManager.d.ts","../src/errors.ts","../src/types.ts","../src/client.ts"],"x_google_ignoreList":[0],"mappings":"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"}