@amqp-contract/client 0.21.0 → 0.23.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.d.mts CHANGED
@@ -1,6 +1,6 @@
1
+ import { CompressionAlgorithm, ContractDefinition, InferPublisherNames, InferRpcNames, MessageDefinition, PublisherEntry, RpcDefinition } from "@amqp-contract/contract";
1
2
  import { Logger, MessageValidationError, PublishOptions as PublishOptions$1, TechnicalError, TelemetryProvider } from "@amqp-contract/core";
2
3
  import { Future, Result } from "@swan-io/boxed";
3
- import { CompressionAlgorithm, ContractDefinition, InferPublisherNames, PublisherEntry } from "@amqp-contract/contract";
4
4
  import * as amqp from "amqplib";
5
5
  import { TcpSocketConnectOpts } from "net";
6
6
  import { ConnectionOptions } from "tls";
@@ -47,11 +47,39 @@ interface AmqpConnectionManagerOptions {
47
47
  connectionOptions?: AmqpConnectionOptions;
48
48
  }
49
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
50
74
  //#region src/types.d.ts
51
75
  /**
52
- * Infer the TypeScript type from a schema
76
+ * Infer the TypeScript type from a schema (input side, used for publish payloads).
53
77
  */
54
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;
55
83
  /**
56
84
  * Infer publisher message input type.
57
85
  * Works with both PublisherDefinition and EventPublisherConfig since both have
@@ -62,18 +90,22 @@ type PublisherInferInput<TPublisher extends PublisherEntry> = TPublisher extends
62
90
  payload: StandardSchemaV1;
63
91
  };
64
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];
65
95
  /**
66
- * Infer all publishers from contract
96
+ * Input type accepted by `client.publish(name, ...)` for a specific publisher.
67
97
  */
68
- 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];
69
101
  /**
70
- * Get specific publisher definition from contract
102
+ * Input type accepted by `client.call(name, request, ...)`.
71
103
  */
72
- 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;
73
105
  /**
74
- * Infer publisher input type (message payload) for a specific publisher in a contract
106
+ * Output (validated) response type returned by `client.call(name, ...)`.
75
107
  */
76
- 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;
77
109
  //#endregion
78
110
  //#region src/client.d.ts
79
111
  /**
@@ -107,6 +139,31 @@ type CreateClientOptions<TContract extends ContractDefinition> = {
107
139
  * By default, persistent is set to true for message durability.
108
140
  */
109
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>`. Defaults to 30s
145
+ * (the {@link AmqpClient}'s `DEFAULT_CONNECT_TIMEOUT_MS`). Pass `null` to
146
+ * disable the timeout and let amqp-connection-manager retry indefinitely.
147
+ */
148
+ connectTimeoutMs?: number | null | 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">;
110
167
  };
111
168
  /**
112
169
  * Type-safe AMQP client for publishing messages
@@ -117,6 +174,16 @@ declare class TypedAmqpClient<TContract extends ContractDefinition> {
117
174
  private readonly defaultPublishOptions;
118
175
  private readonly logger?;
119
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?;
120
187
  private constructor();
121
188
  /**
122
189
  * Create a type-safe AMQP client from a contract.
@@ -134,8 +201,25 @@ declare class TypedAmqpClient<TContract extends ContractDefinition> {
134
201
  connectionOptions,
135
202
  defaultPublishOptions,
136
203
  logger,
137
- telemetry
204
+ telemetry,
205
+ connectTimeoutMs
138
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 (the call already timed out, was cancelled, or the
217
+ * correlationId is unknown) are logged at warn — a non-zero rate of these
218
+ * usually indicates a tuning problem (handler latency exceeds caller
219
+ * timeout). The `messaging.rpc.late_reply` counter lets dashboards alert on
220
+ * sustained drift without parsing logs.
221
+ */
222
+ private handleRpcReply;
139
223
  /**
140
224
  * Publish a message using a defined publisher
141
225
  *
@@ -156,11 +240,39 @@ declare class TypedAmqpClient<TContract extends ContractDefinition> {
156
240
  */
157
241
  publish<TName extends InferPublisherNames<TContract>>(publisherName: TName, message: ClientInferPublisherInput<TContract, TName>, options?: PublishOptions): Future<Result<void, TechnicalError | MessageValidationError>>;
158
242
  /**
159
- * 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<TName extends InferRpcNames<TContract>>(rpcName: TName, request: ClientInferRpcRequestInput<TContract, TName>, options: CallOptions): Future<Result<ClientInferRpcResponseOutput<TContract, TName>, TechnicalError | MessageValidationError | RpcTimeoutError | RpcCancelledError>>;
269
+ /**
270
+ * Close the channel and connection. Cancels the reply consumer (if any) and
271
+ * rejects every in-flight RPC call with `RpcCancelledError`.
160
272
  */
161
273
  close(): Future<Result<void, TechnicalError>>;
162
274
  private waitForConnectionReady;
163
275
  }
164
276
  //#endregion
165
- export { type ClientInferPublisherInput, type CreateClientOptions, MessageValidationError, type PublishOptions, TypedAmqpClient };
277
+ export { type CallOptions, type ClientInferPublisherInput, type ClientInferRpcRequestInput, type ClientInferRpcResponseOutput, type CreateClientOptions, MessageValidationError, type PublishOptions, RpcCancelledError, RpcTimeoutError, TypedAmqpClient };
166
278
  //# sourceMappingURL=index.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","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/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;EATTJ;EAWJM,0BAAAA;EATIJ;;;;EAcJK,sBAAAA;EAVoBd;;;AAGxB;;;;EAeIe,WAAAA,KAAgBE,QAAAA,GAAWD,IAAAA,EAAM7B,aAAAA,GAAgBA,aAAAA,+BAA4CyB,OAAAA,CAAQzB,aAAAA,GAAgBA,aAAAA;EAAhBA;EAErGK,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,gBAAA;EFtBJ;;;;;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;EF5B2CA;;;;;EEkCvD,qBAAA,GAAwB,cAAA;AAAA;;;;cAMb,eAAA,mBAAkC,kBAAA;EAAA,iBAE1B,QAAA;EAAA,iBACA,UAAA;EAAA,iBACA,qBAAA;EAAA,iBACA,MAAA;EAAA,iBACA,SAAA;EAAA,QALZ,WAAA,CAAA;EFzBQ4B;;;;;;;;;;EAAAA,OE2CR,MAAA,mBAAyB,kBAAA,CAAA,CAAA;IAC9B,QAAA;IACA,IAAA;IACA,iBAAA;IACA,qBAAA;IACA,MAAA;IACA;EAAA,GACC,mBAAA,CAAoB,SAAA,IAAa,MAAA,CAAO,MAAA,CAAO,eAAA,CAAgB,SAAA,GAAY,cAAA;EFhD5EE;;;;;;;;;;;;;;;;;AClC0D;ECgH5D,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;ED/GrB;;;EC4MnB,KAAA,CAAA,GAAS,MAAA,CAAO,MAAA,OAAa,cAAA;EAAA,QAIrB,sBAAA;AAAA"}
1
+ {"version":3,"file":"index.d.mts","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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"}
package/dist/index.mjs CHANGED
@@ -1,5 +1,7 @@
1
- import { AmqpClient, MessageValidationError, MessagingSemanticConventions, TechnicalError, defaultTelemetryProvider, endSpanError, endSpanSuccess, recordPublishMetric, startPublishSpan } from "@amqp-contract/core";
1
+ import { extractQueue } from "@amqp-contract/contract";
2
+ import { AmqpClient, MessageValidationError, MessagingSemanticConventions, TechnicalError, defaultTelemetryProvider, endSpanError, endSpanSuccess, recordLateRpcReply, recordPublishMetric, startPublishSpan } from "@amqp-contract/core";
2
3
  import { Future, Result } from "@swan-io/boxed";
4
+ import { randomUUID } from "node:crypto";
3
5
  import { deflate, gzip } from "node:zlib";
4
6
  import { match } from "ts-pattern";
5
7
  import { promisify } from "node:util";
@@ -19,11 +21,69 @@ function compressBuffer(buffer, algorithm) {
19
21
  return match(algorithm).with("gzip", () => Future.fromPromise(gzipAsync(buffer)).mapError((error) => new TechnicalError("Failed to compress with gzip", error))).with("deflate", () => Future.fromPromise(deflateAsync(buffer)).mapError((error) => new TechnicalError("Failed to compress with deflate", error))).exhaustive();
20
22
  }
21
23
  //#endregion
24
+ //#region src/errors.ts
25
+ /**
26
+ * Captured `Error.captureStackTrace` shim — only present on Node.js.
27
+ */
28
+ function captureStack(target, ctor) {
29
+ const ErrorConstructor = Error;
30
+ if (typeof ErrorConstructor.captureStackTrace === "function") ErrorConstructor.captureStackTrace(target, ctor);
31
+ }
32
+ /**
33
+ * Returned from `TypedAmqpClient.call()` when the configured `timeoutMs` elapses
34
+ * before the RPC server publishes a reply with the matching `correlationId`.
35
+ *
36
+ * The pending call is removed from the in-memory correlation map; if a reply
37
+ * arrives after the timeout it is dropped (and a debug log is emitted by the
38
+ * client if a logger is configured).
39
+ */
40
+ var RpcTimeoutError = class extends Error {
41
+ constructor(rpcName, timeoutMs) {
42
+ super(`RPC call to "${rpcName}" timed out after ${timeoutMs}ms with no reply received`);
43
+ this.rpcName = rpcName;
44
+ this.timeoutMs = timeoutMs;
45
+ this.name = "RpcTimeoutError";
46
+ captureStack(this, this.constructor);
47
+ }
48
+ };
49
+ /**
50
+ * Returned from any in-flight RPC call when the client is closed before the
51
+ * reply is received. The correlation map is cleared on close and every pending
52
+ * caller's promise resolves with `Result.Error(RpcCancelledError)`.
53
+ */
54
+ var RpcCancelledError = class extends Error {
55
+ constructor(rpcName) {
56
+ super(`RPC call to "${rpcName}" was cancelled because the client was closed`);
57
+ this.rpcName = rpcName;
58
+ this.name = "RpcCancelledError";
59
+ captureStack(this, this.constructor);
60
+ }
61
+ };
62
+ //#endregion
22
63
  //#region src/client.ts
23
64
  /**
65
+ * The RabbitMQ direct-reply-to pseudo-queue. Publishing with `replyTo` set to
66
+ * this value tells the server to deliver the response back to the consumer
67
+ * subscribed on this queue on the same channel — no real queue is created and
68
+ * no setup is required beyond consuming from it once with `noAck: true`.
69
+ *
70
+ * @see https://www.rabbitmq.com/docs/direct-reply-to
71
+ */
72
+ const DIRECT_REPLY_TO = "amq.rabbitmq.reply-to";
73
+ /**
24
74
  * Type-safe AMQP client for publishing messages
25
75
  */
26
76
  var TypedAmqpClient = class TypedAmqpClient {
77
+ /**
78
+ * In-flight RPC calls keyed by `correlationId`. Cleared when a reply is
79
+ * received, when the call times out, or when the client is closed.
80
+ */
81
+ pendingCalls = /* @__PURE__ */ new Map();
82
+ /**
83
+ * Consumer tag of the reply consumer subscribed on `amq.rabbitmq.reply-to`.
84
+ * Set when the contract has at least one entry in `rpcs`; undefined otherwise.
85
+ */
86
+ replyConsumerTag;
27
87
  constructor(contract, amqpClient, defaultPublishOptions, logger, telemetry = defaultTelemetryProvider) {
28
88
  this.contract = contract;
29
89
  this.amqpClient = amqpClient;
@@ -41,15 +101,82 @@ var TypedAmqpClient = class TypedAmqpClient {
41
101
  * Connections are automatically shared across clients with the same URLs and
42
102
  * connection options, following RabbitMQ best practices.
43
103
  */
44
- static create({ contract, urls, connectionOptions, defaultPublishOptions, logger, telemetry }) {
104
+ static create({ contract, urls, connectionOptions, defaultPublishOptions, logger, telemetry, connectTimeoutMs }) {
45
105
  const client = new TypedAmqpClient(contract, new AmqpClient(contract, {
46
106
  urls,
47
- connectionOptions
107
+ connectionOptions,
108
+ connectTimeoutMs
48
109
  }), {
49
110
  persistent: true,
50
111
  ...defaultPublishOptions
51
112
  }, logger, telemetry ?? defaultTelemetryProvider);
52
- return client.waitForConnectionReady().mapOk(() => client);
113
+ return client.waitForConnectionReady().flatMapOk(() => client.setupReplyConsumerIfNeeded()).flatMap((result) => result.match({
114
+ Ok: () => Future.value(Result.Ok(client)),
115
+ Error: (error) => client.close().tapError((closeError) => {
116
+ logger?.warn("Failed to close client after connection failure", { error: closeError });
117
+ }).map(() => Result.Error(error))
118
+ }));
119
+ }
120
+ /**
121
+ * If the contract has any RPC entry, subscribe to `amq.rabbitmq.reply-to`
122
+ * once. Replies for every in-flight call arrive on this single consumer and
123
+ * are demultiplexed by `correlationId`.
124
+ */
125
+ setupReplyConsumerIfNeeded() {
126
+ const rpcs = this.contract.rpcs ?? {};
127
+ if (Object.keys(rpcs).length === 0) return Future.value(Result.Ok(void 0));
128
+ return this.amqpClient.consume(DIRECT_REPLY_TO, (msg) => this.handleRpcReply(msg), { noAck: true }).tapOk((tag) => {
129
+ this.replyConsumerTag = tag;
130
+ }).mapOk(() => void 0);
131
+ }
132
+ /**
133
+ * Demultiplex an RPC reply by `correlationId`, validate the body against the
134
+ * call's response schema, and resolve the awaiting caller. Replies with no
135
+ * matching pending call (the call already timed out, was cancelled, or the
136
+ * correlationId is unknown) are logged at warn — a non-zero rate of these
137
+ * usually indicates a tuning problem (handler latency exceeds caller
138
+ * timeout). The `messaging.rpc.late_reply` counter lets dashboards alert on
139
+ * sustained drift without parsing logs.
140
+ */
141
+ handleRpcReply(msg) {
142
+ if (!msg) return;
143
+ const correlationId = msg.properties.correlationId;
144
+ if (typeof correlationId !== "string") {
145
+ this.logger?.warn("Received RPC reply without correlationId; dropping", { deliveryTag: msg.fields.deliveryTag });
146
+ recordLateRpcReply(this.telemetry, "missing-correlation-id");
147
+ return;
148
+ }
149
+ const pending = this.pendingCalls.get(correlationId);
150
+ if (!pending) {
151
+ this.logger?.warn("Received RPC reply for unknown correlationId (caller already timed out or cancelled)", { correlationId });
152
+ recordLateRpcReply(this.telemetry, "unknown-correlation-id");
153
+ return;
154
+ }
155
+ this.pendingCalls.delete(correlationId);
156
+ clearTimeout(pending.timer);
157
+ let parsed;
158
+ try {
159
+ parsed = JSON.parse(msg.content.toString());
160
+ } catch (error) {
161
+ pending.resolve(Result.Error(new TechnicalError(`Failed to parse RPC reply JSON for "${pending.rpcName}"`, error)));
162
+ return;
163
+ }
164
+ let rawValidation;
165
+ try {
166
+ rawValidation = pending.responseSchema["~standard"].validate(parsed);
167
+ } catch (error) {
168
+ pending.resolve(Result.Error(new TechnicalError(`RPC reply validation threw for "${pending.rpcName}"`, error)));
169
+ return;
170
+ }
171
+ (rawValidation instanceof Promise ? rawValidation : Promise.resolve(rawValidation)).then((validation) => {
172
+ if (validation.issues) {
173
+ pending.resolve(Result.Error(new MessageValidationError(pending.rpcName, validation.issues)));
174
+ return;
175
+ }
176
+ pending.resolve(Result.Ok(validation.value));
177
+ }, (error) => {
178
+ pending.resolve(Result.Error(new TechnicalError(`RPC reply validation threw for "${pending.rpcName}"`, error)));
179
+ });
53
180
  }
54
181
  /**
55
182
  * Publish a message using a defined publisher
@@ -117,16 +244,114 @@ var TypedAmqpClient = class TypedAmqpClient {
117
244
  });
118
245
  }
119
246
  /**
120
- * Close the channel and connection
247
+ * Invoke an RPC defined via `defineRpc` and await the typed response.
248
+ *
249
+ * The request payload is validated against the RPC's request schema, then
250
+ * published to the AMQP default exchange with the server's queue name as
251
+ * routing key, `replyTo` set to `amq.rabbitmq.reply-to`, and a fresh UUID
252
+ * `correlationId`. The returned Future resolves once a matching reply
253
+ * arrives and validates against the response schema, or once `timeoutMs`
254
+ * elapses (whichever comes first).
255
+ *
256
+ * @typeParam TName - An RPC name from `contract.rpcs`.
257
+ * @param rpcName - The RPC name from the contract.
258
+ * @param request - The request payload, validated against the request schema.
259
+ * @param options - Per-call options. `timeoutMs` is required.
260
+ *
261
+ * @returns `Result.Ok(response)` on a successful round-trip; `Result.Error`
262
+ * on validation, transport, timeout, or cancel.
263
+ *
264
+ * @example
265
+ * ```typescript
266
+ * const result = await client
267
+ * .call('calculate', { a: 1, b: 2 }, { timeoutMs: 5_000 })
268
+ * .toPromise();
269
+ * if (result.isOk()) console.log(result.value.sum); // 3
270
+ * ```
271
+ */
272
+ call(rpcName, request, options) {
273
+ const TIMEOUT_MAX_MS = 2147483647;
274
+ if (typeof options.timeoutMs !== "number" || !Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0 || options.timeoutMs > TIMEOUT_MAX_MS) return Future.value(Result.Error(new TechnicalError(`Invalid timeoutMs for RPC call to "${String(rpcName)}": expected a finite positive number ≤ ${TIMEOUT_MAX_MS}, got ${String(options.timeoutMs)}`)));
275
+ const startTime = Date.now();
276
+ const rpc = this.contract.rpcs[rpcName];
277
+ const requestSchema = rpc.request.payload;
278
+ const responseSchema = rpc.response.payload;
279
+ const queueName = extractQueue(rpc.queue).name;
280
+ const span = startPublishSpan(this.telemetry, "", queueName, { [MessagingSemanticConventions.AMQP_PUBLISHER_NAME]: String(rpcName) });
281
+ const correlationId = randomUUID();
282
+ const callFuture = Future.make((resolve) => {
283
+ const timer = setTimeout(() => {
284
+ if (!this.pendingCalls.get(correlationId)) return;
285
+ this.pendingCalls.delete(correlationId);
286
+ resolve(Result.Error(new RpcTimeoutError(String(rpcName), options.timeoutMs)));
287
+ }, options.timeoutMs);
288
+ this.pendingCalls.set(correlationId, {
289
+ rpcName: String(rpcName),
290
+ responseSchema,
291
+ resolve,
292
+ timer
293
+ });
294
+ });
295
+ const validateRequest = () => {
296
+ let rawValidation;
297
+ try {
298
+ rawValidation = requestSchema["~standard"].validate(request);
299
+ } catch (error) {
300
+ return Future.value(Result.Error(new TechnicalError("RPC request validation threw", error)));
301
+ }
302
+ const validationPromise = rawValidation instanceof Promise ? rawValidation : Promise.resolve(rawValidation);
303
+ return Future.fromPromise(validationPromise).mapError((error) => new TechnicalError("RPC request validation threw", error)).mapOkToResult((validation) => validation.issues ? Result.Error(new MessageValidationError(String(rpcName), validation.issues)) : Result.Ok(validation.value));
304
+ };
305
+ const publishRequest = (validatedRequest) => {
306
+ const { compression: _ignoredCompression, ...defaultsWithoutCompression } = this.defaultPublishOptions;
307
+ const publishOptions = {
308
+ ...defaultsWithoutCompression,
309
+ ...options.publishOptions,
310
+ replyTo: DIRECT_REPLY_TO,
311
+ correlationId,
312
+ contentType: "application/json"
313
+ };
314
+ return this.amqpClient.publish("", queueName, validatedRequest, publishOptions).mapOkToResult((published) => published ? Result.Ok(void 0) : Result.Error(new TechnicalError(`Failed to publish RPC request for "${String(rpcName)}": channel buffer full`)));
315
+ };
316
+ return validateRequest().flatMapOk((validated) => publishRequest(validated)).flatMap((preflight) => {
317
+ if (preflight.isError()) {
318
+ const pending = this.pendingCalls.get(correlationId);
319
+ if (pending) {
320
+ clearTimeout(pending.timer);
321
+ this.pendingCalls.delete(correlationId);
322
+ }
323
+ return Future.value(Result.Error(preflight.error));
324
+ }
325
+ return callFuture;
326
+ }).tapOk(() => {
327
+ const durationMs = Date.now() - startTime;
328
+ endSpanSuccess(span);
329
+ recordPublishMetric(this.telemetry, "", queueName, true, durationMs);
330
+ }).tapError((error) => {
331
+ const durationMs = Date.now() - startTime;
332
+ endSpanError(span, error);
333
+ recordPublishMetric(this.telemetry, "", queueName, false, durationMs);
334
+ });
335
+ }
336
+ /**
337
+ * Close the channel and connection. Cancels the reply consumer (if any) and
338
+ * rejects every in-flight RPC call with `RpcCancelledError`.
121
339
  */
122
340
  close() {
123
- return this.amqpClient.close().mapOk(() => void 0);
341
+ for (const [, pending] of this.pendingCalls) {
342
+ clearTimeout(pending.timer);
343
+ pending.resolve(Result.Error(new RpcCancelledError(pending.rpcName)));
344
+ }
345
+ this.pendingCalls.clear();
346
+ return (this.replyConsumerTag ? this.amqpClient.cancel(this.replyConsumerTag).tapError((error) => {
347
+ this.logger?.warn("Failed to cancel RPC reply consumer during close", { error });
348
+ }) : Future.value(Result.Ok(void 0))).flatMap(() => this.amqpClient.close()).mapOk(() => void 0);
124
349
  }
125
350
  waitForConnectionReady() {
126
351
  return this.amqpClient.waitForConnect();
127
352
  }
128
353
  };
129
354
  //#endregion
130
- export { MessageValidationError, TypedAmqpClient };
355
+ export { MessageValidationError, RpcCancelledError, RpcTimeoutError, TypedAmqpClient };
131
356
 
132
357
  //# sourceMappingURL=index.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":[],"sources":["../src/compression.ts","../src/client.ts"],"sourcesContent":["import { Future, Result } from \"@swan-io/boxed\";\nimport { deflate, gzip } from \"node:zlib\";\nimport type { CompressionAlgorithm } from \"@amqp-contract/contract\";\nimport { TechnicalError } from \"@amqp-contract/core\";\nimport { match } from \"ts-pattern\";\nimport { promisify } from \"node:util\";\n\nconst gzipAsync = promisify(gzip);\nconst deflateAsync = promisify(deflate);\n\n/**\n * Compress a buffer using the specified compression algorithm.\n *\n * @param buffer - The buffer to compress\n * @param algorithm - The compression algorithm to use\n * @returns A Future with the compressed buffer or a TechnicalError\n *\n * @internal\n */\nexport function compressBuffer(\n buffer: Buffer,\n algorithm: CompressionAlgorithm,\n): Future<Result<Buffer, TechnicalError>> {\n return match(algorithm)\n .with(\"gzip\", () =>\n Future.fromPromise(gzipAsync(buffer)).mapError(\n (error) => new TechnicalError(\"Failed to compress with gzip\", error),\n ),\n )\n .with(\"deflate\", () =>\n Future.fromPromise(deflateAsync(buffer)).mapError(\n (error) => new TechnicalError(\"Failed to compress with deflate\", error),\n ),\n )\n .exhaustive();\n}\n","import type {\n CompressionAlgorithm,\n ContractDefinition,\n InferPublisherNames,\n} from \"@amqp-contract/contract\";\nimport {\n AmqpClient,\n PublishOptions as AmqpClientPublishOptions,\n type Logger,\n MessagingSemanticConventions,\n TechnicalError,\n type TelemetryProvider,\n defaultTelemetryProvider,\n endSpanError,\n endSpanSuccess,\n recordPublishMetric,\n startPublishSpan,\n} from \"@amqp-contract/core\";\nimport { Future, Result } from \"@swan-io/boxed\";\nimport type { AmqpConnectionManagerOptions, ConnectionUrl } from \"amqp-connection-manager\";\nimport { compressBuffer } from \"./compression.js\";\nimport { MessageValidationError } from \"./errors.js\";\nimport type { ClientInferPublisherInput } from \"./types.js\";\n\n/**\n * Publish options that extend amqp-client's PublishOptions with optional compression support.\n */\nexport type PublishOptions = AmqpClientPublishOptions & {\n /**\n * Optional compression algorithm to use for the message payload.\n * When specified, the message will be compressed using the chosen algorithm\n * and the contentEncoding header will be set automatically.\n */\n compression?: CompressionAlgorithm | undefined;\n};\n\n/**\n * Options for creating a client\n */\nexport type CreateClientOptions<TContract extends ContractDefinition> = {\n contract: TContract;\n urls: ConnectionUrl[];\n connectionOptions?: AmqpConnectionManagerOptions | undefined;\n logger?: Logger | undefined;\n /**\n * Optional telemetry provider for tracing and metrics.\n * If not provided, uses the default provider which attempts to load OpenTelemetry.\n * OpenTelemetry instrumentation is automatically enabled if @opentelemetry/api is installed.\n */\n telemetry?: TelemetryProvider | undefined;\n /**\n * Default publish options that will be applied to all publish operations.\n * These can be overridden by options passed to the publish method.\n * By default, persistent is set to true for message durability.\n */\n defaultPublishOptions?: PublishOptions | undefined;\n};\n\n/**\n * Type-safe AMQP client for publishing messages\n */\nexport class TypedAmqpClient<TContract extends ContractDefinition> {\n private constructor(\n private readonly contract: TContract,\n private readonly amqpClient: AmqpClient,\n private readonly defaultPublishOptions: PublishOptions,\n private readonly logger?: Logger,\n private readonly telemetry: TelemetryProvider = defaultTelemetryProvider,\n ) {}\n\n /**\n * Create a type-safe AMQP client from a contract.\n *\n * Connection management (including automatic reconnection) is handled internally\n * by amqp-connection-manager via the {@link AmqpClient}. The client establishes\n * infrastructure asynchronously in the background once the connection is ready.\n *\n * Connections are automatically shared across clients with the same URLs and\n * connection options, following RabbitMQ best practices.\n */\n static create<TContract extends ContractDefinition>({\n contract,\n urls,\n connectionOptions,\n defaultPublishOptions,\n logger,\n telemetry,\n }: CreateClientOptions<TContract>): Future<Result<TypedAmqpClient<TContract>, TechnicalError>> {\n const client = new TypedAmqpClient(\n contract,\n new AmqpClient(contract, { urls, connectionOptions }),\n { persistent: true, ...defaultPublishOptions },\n logger,\n telemetry ?? defaultTelemetryProvider,\n );\n\n return client.waitForConnectionReady().mapOk(() => client);\n }\n\n /**\n * Publish a message using a defined publisher\n *\n * @param publisherName - The name of the publisher to use\n * @param message - The message to publish\n * @param options - Optional publish options including compression, headers, priority, etc.\n *\n * @remarks\n * If `options.compression` is specified, the message will be compressed before publishing\n * and the `contentEncoding` property will be set automatically. Any `contentEncoding`\n * value already in options will be overwritten by the compression algorithm.\n *\n * @returns Result.Ok(void) on success, or Result.Error with specific error on failure\n */\n /**\n * Publish a message using a defined publisher.\n * TypeScript guarantees publisher exists for valid publisher names.\n */\n publish<TName extends InferPublisherNames<TContract>>(\n publisherName: TName,\n message: ClientInferPublisherInput<TContract, TName>,\n options?: PublishOptions,\n ): Future<Result<void, TechnicalError | MessageValidationError>> {\n const startTime = Date.now();\n // Non-null assertions safe: TypeScript guarantees these exist for valid TName\n const publisher = this.contract.publishers![publisherName as string]!;\n const { exchange, routingKey } = publisher;\n\n // Start telemetry span\n const span = startPublishSpan(this.telemetry, exchange.name, routingKey, {\n [MessagingSemanticConventions.AMQP_PUBLISHER_NAME]: String(publisherName),\n });\n\n const validateMessage = () => {\n const validationResult = publisher.message.payload[\"~standard\"].validate(message);\n return Future.fromPromise(\n validationResult instanceof Promise ? validationResult : Promise.resolve(validationResult),\n )\n .mapError((error) => new TechnicalError(`Validation failed`, error))\n .mapOkToResult((validation) => {\n if (validation.issues) {\n return Result.Error(\n new MessageValidationError(String(publisherName), validation.issues),\n );\n }\n\n return Result.Ok(validation.value);\n });\n };\n\n const publishMessage = (validatedMessage: unknown): Future<Result<void, TechnicalError>> => {\n // Merge default options with provided options\n const mergedOptions = { ...this.defaultPublishOptions, ...options };\n\n // Extract compression from merged options and create publish options without it\n const { compression, ...restOptions } = mergedOptions;\n const publishOptions: AmqpClientPublishOptions = { ...restOptions };\n\n // Prepare payload and options based on compression configuration\n const preparePayload = (): Future<Result<Buffer | unknown, TechnicalError>> => {\n if (compression) {\n // Compress the message payload\n const messageBuffer = Buffer.from(JSON.stringify(validatedMessage));\n publishOptions.contentEncoding = compression;\n\n return compressBuffer(messageBuffer, compression);\n }\n\n // No compression: use the channel's built-in JSON serialization\n return Future.value(Result.Ok(validatedMessage));\n };\n\n // Publish the prepared payload\n return preparePayload().flatMapOk((payload) =>\n this.amqpClient\n .publish(publisher.exchange.name, publisher.routingKey ?? \"\", payload, publishOptions)\n .mapOkToResult((published) => {\n if (!published) {\n return Result.Error(\n new TechnicalError(\n `Failed to publish message for publisher \"${String(publisherName)}\": Channel rejected the message (buffer full or other channel issue)`,\n ),\n );\n }\n\n this.logger?.info(\"Message published successfully\", {\n publisherName: String(publisherName),\n exchange: publisher.exchange.name,\n routingKey: publisher.routingKey,\n compressed: !!compression,\n });\n\n return Result.Ok(undefined);\n }),\n );\n };\n\n // Validate message using schema\n return validateMessage()\n .flatMapOk((validatedMessage) => publishMessage(validatedMessage))\n .tapOk(() => {\n const durationMs = Date.now() - startTime;\n endSpanSuccess(span);\n recordPublishMetric(this.telemetry, exchange.name, routingKey, true, durationMs);\n })\n .tapError((error) => {\n const durationMs = Date.now() - startTime;\n endSpanError(span, error);\n recordPublishMetric(this.telemetry, exchange.name, routingKey, false, durationMs);\n });\n }\n\n /**\n * Close the channel and connection\n */\n close(): Future<Result<void, TechnicalError>> {\n return this.amqpClient.close().mapOk(() => undefined);\n }\n\n private waitForConnectionReady(): Future<Result<void, TechnicalError>> {\n return this.amqpClient.waitForConnect();\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../src/compression.ts","../src/errors.ts","../src/client.ts"],"sourcesContent":["import { Future, Result } from \"@swan-io/boxed\";\nimport { deflate, gzip } from \"node:zlib\";\nimport type { CompressionAlgorithm } from \"@amqp-contract/contract\";\nimport { TechnicalError } from \"@amqp-contract/core\";\nimport { match } from \"ts-pattern\";\nimport { promisify } from \"node:util\";\n\nconst gzipAsync = promisify(gzip);\nconst deflateAsync = promisify(deflate);\n\n/**\n * Compress a buffer using the specified compression algorithm.\n *\n * @param buffer - The buffer to compress\n * @param algorithm - The compression algorithm to use\n * @returns A Future with the compressed buffer or a TechnicalError\n *\n * @internal\n */\nexport function compressBuffer(\n buffer: Buffer,\n algorithm: CompressionAlgorithm,\n): Future<Result<Buffer, TechnicalError>> {\n return match(algorithm)\n .with(\"gzip\", () =>\n Future.fromPromise(gzipAsync(buffer)).mapError(\n (error) => new TechnicalError(\"Failed to compress with gzip\", error),\n ),\n )\n .with(\"deflate\", () =>\n Future.fromPromise(deflateAsync(buffer)).mapError(\n (error) => new TechnicalError(\"Failed to compress with deflate\", error),\n ),\n )\n .exhaustive();\n}\n","export { MessageValidationError } from \"@amqp-contract/core\";\n\n/**\n * Captured `Error.captureStackTrace` shim — only present on Node.js.\n */\nfunction captureStack(target: object, ctor: Function): void {\n const ErrorConstructor = Error as unknown as {\n captureStackTrace?: (target: object, constructor: Function) => void;\n };\n if (typeof ErrorConstructor.captureStackTrace === \"function\") {\n ErrorConstructor.captureStackTrace(target, ctor);\n }\n}\n\n/**\n * Returned from `TypedAmqpClient.call()` when the configured `timeoutMs` elapses\n * before the RPC server publishes a reply with the matching `correlationId`.\n *\n * The pending call is removed from the in-memory correlation map; if a reply\n * arrives after the timeout it is dropped (and a debug log is emitted by the\n * client if a logger is configured).\n */\nexport class RpcTimeoutError extends Error {\n constructor(\n public readonly rpcName: string,\n public readonly timeoutMs: number,\n ) {\n super(`RPC call to \"${rpcName}\" timed out after ${timeoutMs}ms with no reply received`);\n this.name = \"RpcTimeoutError\";\n captureStack(this, this.constructor);\n }\n}\n\n/**\n * Returned from any in-flight RPC call when the client is closed before the\n * reply is received. The correlation map is cleared on close and every pending\n * caller's promise resolves with `Result.Error(RpcCancelledError)`.\n */\nexport class RpcCancelledError extends Error {\n constructor(public readonly rpcName: string) {\n super(`RPC call to \"${rpcName}\" was cancelled because the client was closed`);\n this.name = \"RpcCancelledError\";\n captureStack(this, this.constructor);\n }\n}\n","import {\n extractQueue,\n type CompressionAlgorithm,\n type ContractDefinition,\n type InferPublisherNames,\n type InferRpcNames,\n} from \"@amqp-contract/contract\";\nimport {\n AmqpClient,\n PublishOptions as AmqpClientPublishOptions,\n type Logger,\n MessagingSemanticConventions,\n TechnicalError,\n type TelemetryProvider,\n defaultTelemetryProvider,\n endSpanError,\n endSpanSuccess,\n recordLateRpcReply,\n recordPublishMetric,\n startPublishSpan,\n} from \"@amqp-contract/core\";\nimport type { StandardSchemaV1 } from \"@standard-schema/spec\";\nimport { Future, Result } from \"@swan-io/boxed\";\nimport type { AmqpConnectionManagerOptions, ConnectionUrl } from \"amqp-connection-manager\";\nimport { randomUUID } from \"node:crypto\";\nimport { compressBuffer } from \"./compression.js\";\nimport { MessageValidationError, RpcCancelledError, RpcTimeoutError } from \"./errors.js\";\nimport type {\n ClientInferPublisherInput,\n ClientInferRpcRequestInput,\n ClientInferRpcResponseOutput,\n} from \"./types.js\";\n\n/**\n * The RabbitMQ direct-reply-to pseudo-queue. Publishing with `replyTo` set to\n * this value tells the server to deliver the response back to the consumer\n * subscribed on this queue on the same channel — no real queue is created and\n * no setup is required beyond consuming from it once with `noAck: true`.\n *\n * @see https://www.rabbitmq.com/docs/direct-reply-to\n */\nconst DIRECT_REPLY_TO = \"amq.rabbitmq.reply-to\";\n\n/**\n * In-flight RPC call tracked by `TypedAmqpClient`. The reply consumer\n * looks up entries by `correlationId` when responses arrive.\n */\ntype PendingCall = {\n rpcName: string;\n responseSchema: StandardSchemaV1;\n resolve: (\n result: Result<\n unknown,\n TechnicalError | MessageValidationError | RpcTimeoutError | RpcCancelledError\n >,\n ) => void;\n timer: ReturnType<typeof setTimeout>;\n};\n\n/**\n * Publish options that extend amqp-client's PublishOptions with optional compression support.\n */\nexport type PublishOptions = AmqpClientPublishOptions & {\n /**\n * Optional compression algorithm to use for the message payload.\n * When specified, the message will be compressed using the chosen algorithm\n * and the contentEncoding header will be set automatically.\n */\n compression?: CompressionAlgorithm | undefined;\n};\n\n/**\n * Options for creating a client\n */\nexport type CreateClientOptions<TContract extends ContractDefinition> = {\n contract: TContract;\n urls: ConnectionUrl[];\n connectionOptions?: AmqpConnectionManagerOptions | undefined;\n logger?: Logger | undefined;\n /**\n * Optional telemetry provider for tracing and metrics.\n * If not provided, uses the default provider which attempts to load OpenTelemetry.\n * OpenTelemetry instrumentation is automatically enabled if @opentelemetry/api is installed.\n */\n telemetry?: TelemetryProvider | undefined;\n /**\n * Default publish options that will be applied to all publish operations.\n * These can be overridden by options passed to the publish method.\n * By default, persistent is set to true for message durability.\n */\n defaultPublishOptions?: PublishOptions | undefined;\n /**\n * Maximum time in ms to wait for the AMQP connection to become ready before\n * `create()` resolves to `Result.Error<TechnicalError>`. Defaults to 30s\n * (the {@link AmqpClient}'s `DEFAULT_CONNECT_TIMEOUT_MS`). Pass `null` to\n * disable the timeout and let amqp-connection-manager retry indefinitely.\n */\n connectTimeoutMs?: number | null | undefined;\n};\n\n/**\n * Per-call options for `client.call()`.\n */\nexport type CallOptions = {\n /**\n * Maximum time in ms to wait for an RPC reply. If exceeded, the call resolves\n * to `Result.Error<RpcTimeoutError>` and the in-memory correlation entry is\n * cleared. A late reply arriving after the timeout is silently dropped.\n *\n * Required: RPC without a timeout is a footgun.\n */\n timeoutMs: number;\n\n /**\n * Optional AMQP message properties to merge into the request. `replyTo` and\n * `correlationId` are managed by the client and cannot be overridden.\n */\n publishOptions?: Omit<AmqpClientPublishOptions, \"replyTo\" | \"correlationId\">;\n};\n\n/**\n * Type-safe AMQP client for publishing messages\n */\nexport class TypedAmqpClient<TContract extends ContractDefinition> {\n /**\n * In-flight RPC calls keyed by `correlationId`. Cleared when a reply is\n * received, when the call times out, or when the client is closed.\n */\n private readonly pendingCalls = new Map<string, PendingCall>();\n\n /**\n * Consumer tag of the reply consumer subscribed on `amq.rabbitmq.reply-to`.\n * Set when the contract has at least one entry in `rpcs`; undefined otherwise.\n */\n private replyConsumerTag?: string;\n\n private constructor(\n private readonly contract: TContract,\n private readonly amqpClient: AmqpClient,\n private readonly defaultPublishOptions: PublishOptions,\n private readonly logger?: Logger,\n private readonly telemetry: TelemetryProvider = defaultTelemetryProvider,\n ) {}\n\n /**\n * Create a type-safe AMQP client from a contract.\n *\n * Connection management (including automatic reconnection) is handled internally\n * by amqp-connection-manager via the {@link AmqpClient}. The client establishes\n * infrastructure asynchronously in the background once the connection is ready.\n *\n * Connections are automatically shared across clients with the same URLs and\n * connection options, following RabbitMQ best practices.\n */\n static create<TContract extends ContractDefinition>({\n contract,\n urls,\n connectionOptions,\n defaultPublishOptions,\n logger,\n telemetry,\n connectTimeoutMs,\n }: CreateClientOptions<TContract>): Future<Result<TypedAmqpClient<TContract>, TechnicalError>> {\n const client = new TypedAmqpClient(\n contract,\n new AmqpClient(contract, { urls, connectionOptions, connectTimeoutMs }),\n { persistent: true, ...defaultPublishOptions },\n logger,\n telemetry ?? defaultTelemetryProvider,\n );\n\n return client\n .waitForConnectionReady()\n .flatMapOk(() => client.setupReplyConsumerIfNeeded())\n .flatMap((result) =>\n result.match({\n Ok: () => Future.value(Result.Ok<TypedAmqpClient<TContract>, TechnicalError>(client)),\n // Release the AmqpClient's connection ref-count so a failed create() does not leak.\n Error: (error) =>\n client\n .close()\n .tapError((closeError) => {\n logger?.warn(\"Failed to close client after connection failure\", {\n error: closeError,\n });\n })\n .map(() => Result.Error<TypedAmqpClient<TContract>, TechnicalError>(error)),\n }),\n );\n }\n\n /**\n * If the contract has any RPC entry, subscribe to `amq.rabbitmq.reply-to`\n * once. Replies for every in-flight call arrive on this single consumer and\n * are demultiplexed by `correlationId`.\n */\n private setupReplyConsumerIfNeeded(): Future<Result<void, TechnicalError>> {\n const rpcs = this.contract.rpcs ?? {};\n if (Object.keys(rpcs).length === 0) {\n return Future.value(Result.Ok(undefined));\n }\n\n return this.amqpClient\n .consume(DIRECT_REPLY_TO, (msg) => this.handleRpcReply(msg), { noAck: true })\n .tapOk((tag) => {\n this.replyConsumerTag = tag;\n })\n .mapOk(() => undefined);\n }\n\n /**\n * Demultiplex an RPC reply by `correlationId`, validate the body against the\n * call's response schema, and resolve the awaiting caller. Replies with no\n * matching pending call (the call already timed out, was cancelled, or the\n * correlationId is unknown) are logged at warn — a non-zero rate of these\n * usually indicates a tuning problem (handler latency exceeds caller\n * timeout). The `messaging.rpc.late_reply` counter lets dashboards alert on\n * sustained drift without parsing logs.\n */\n private handleRpcReply(msg: Parameters<Parameters<AmqpClient[\"consume\"]>[1]>[0]): void {\n if (!msg) return;\n const correlationId = msg.properties.correlationId;\n if (typeof correlationId !== \"string\") {\n this.logger?.warn(\"Received RPC reply without correlationId; dropping\", {\n deliveryTag: msg.fields.deliveryTag,\n });\n recordLateRpcReply(this.telemetry, \"missing-correlation-id\");\n return;\n }\n const pending = this.pendingCalls.get(correlationId);\n if (!pending) {\n this.logger?.warn(\n \"Received RPC reply for unknown correlationId (caller already timed out or cancelled)\",\n { correlationId },\n );\n recordLateRpcReply(this.telemetry, \"unknown-correlation-id\");\n return;\n }\n this.pendingCalls.delete(correlationId);\n clearTimeout(pending.timer);\n\n let parsed: unknown;\n try {\n parsed = JSON.parse(msg.content.toString());\n } catch (error: unknown) {\n pending.resolve(\n Result.Error(\n new TechnicalError(`Failed to parse RPC reply JSON for \"${pending.rpcName}\"`, error),\n ),\n );\n return;\n }\n\n // Wrap the validate call itself — a Standard Schema implementation may\n // throw synchronously, and the throw would otherwise escape the consume\n // callback and could crash the reply consumer.\n let rawValidation: ReturnType<StandardSchemaV1[\"~standard\"][\"validate\"]>;\n try {\n rawValidation = pending.responseSchema[\"~standard\"].validate(parsed);\n } catch (error: unknown) {\n pending.resolve(\n Result.Error(\n new TechnicalError(`RPC reply validation threw for \"${pending.rpcName}\"`, error),\n ),\n );\n return;\n }\n const validationPromise =\n rawValidation instanceof Promise ? rawValidation : Promise.resolve(rawValidation);\n\n validationPromise.then(\n (validation) => {\n if (validation.issues) {\n pending.resolve(\n Result.Error(new MessageValidationError(pending.rpcName, validation.issues)),\n );\n return;\n }\n pending.resolve(Result.Ok(validation.value));\n },\n (error: unknown) => {\n pending.resolve(\n Result.Error(\n new TechnicalError(`RPC reply validation threw for \"${pending.rpcName}\"`, error),\n ),\n );\n },\n );\n }\n\n /**\n * Publish a message using a defined publisher\n *\n * @param publisherName - The name of the publisher to use\n * @param message - The message to publish\n * @param options - Optional publish options including compression, headers, priority, etc.\n *\n * @remarks\n * If `options.compression` is specified, the message will be compressed before publishing\n * and the `contentEncoding` property will be set automatically. Any `contentEncoding`\n * value already in options will be overwritten by the compression algorithm.\n *\n * @returns Result.Ok(void) on success, or Result.Error with specific error on failure\n */\n /**\n * Publish a message using a defined publisher.\n * TypeScript guarantees publisher exists for valid publisher names.\n */\n publish<TName extends InferPublisherNames<TContract>>(\n publisherName: TName,\n message: ClientInferPublisherInput<TContract, TName>,\n options?: PublishOptions,\n ): Future<Result<void, TechnicalError | MessageValidationError>> {\n const startTime = Date.now();\n // Non-null assertions safe: TypeScript guarantees these exist for valid TName\n const publisher = this.contract.publishers![publisherName as string]!;\n const { exchange, routingKey } = publisher;\n\n // Start telemetry span\n const span = startPublishSpan(this.telemetry, exchange.name, routingKey, {\n [MessagingSemanticConventions.AMQP_PUBLISHER_NAME]: String(publisherName),\n });\n\n const validateMessage = () => {\n const validationResult = publisher.message.payload[\"~standard\"].validate(message);\n return Future.fromPromise(\n validationResult instanceof Promise ? validationResult : Promise.resolve(validationResult),\n )\n .mapError((error) => new TechnicalError(`Validation failed`, error))\n .mapOkToResult((validation) => {\n if (validation.issues) {\n return Result.Error(\n new MessageValidationError(String(publisherName), validation.issues),\n );\n }\n\n return Result.Ok(validation.value);\n });\n };\n\n const publishMessage = (validatedMessage: unknown): Future<Result<void, TechnicalError>> => {\n // Merge default options with provided options\n const mergedOptions = { ...this.defaultPublishOptions, ...options };\n\n // Extract compression from merged options and create publish options without it\n const { compression, ...restOptions } = mergedOptions;\n const publishOptions: AmqpClientPublishOptions = { ...restOptions };\n\n // Prepare payload and options based on compression configuration\n const preparePayload = (): Future<Result<Buffer | unknown, TechnicalError>> => {\n if (compression) {\n // Compress the message payload\n const messageBuffer = Buffer.from(JSON.stringify(validatedMessage));\n publishOptions.contentEncoding = compression;\n\n return compressBuffer(messageBuffer, compression);\n }\n\n // No compression: use the channel's built-in JSON serialization\n return Future.value(Result.Ok(validatedMessage));\n };\n\n // Publish the prepared payload\n return preparePayload().flatMapOk((payload) =>\n this.amqpClient\n .publish(publisher.exchange.name, publisher.routingKey ?? \"\", payload, publishOptions)\n .mapOkToResult((published) => {\n if (!published) {\n return Result.Error(\n new TechnicalError(\n `Failed to publish message for publisher \"${String(publisherName)}\": Channel rejected the message (buffer full or other channel issue)`,\n ),\n );\n }\n\n this.logger?.info(\"Message published successfully\", {\n publisherName: String(publisherName),\n exchange: publisher.exchange.name,\n routingKey: publisher.routingKey,\n compressed: !!compression,\n });\n\n return Result.Ok(undefined);\n }),\n );\n };\n\n // Validate message using schema\n return validateMessage()\n .flatMapOk((validatedMessage) => publishMessage(validatedMessage))\n .tapOk(() => {\n const durationMs = Date.now() - startTime;\n endSpanSuccess(span);\n recordPublishMetric(this.telemetry, exchange.name, routingKey, true, durationMs);\n })\n .tapError((error) => {\n const durationMs = Date.now() - startTime;\n endSpanError(span, error);\n recordPublishMetric(this.telemetry, exchange.name, routingKey, false, durationMs);\n });\n }\n\n /**\n * Invoke an RPC defined via `defineRpc` and await the typed response.\n *\n * The request payload is validated against the RPC's request schema, then\n * published to the AMQP default exchange with the server's queue name as\n * routing key, `replyTo` set to `amq.rabbitmq.reply-to`, and a fresh UUID\n * `correlationId`. The returned Future resolves once a matching reply\n * arrives and validates against the response schema, or once `timeoutMs`\n * elapses (whichever comes first).\n *\n * @typeParam TName - An RPC name from `contract.rpcs`.\n * @param rpcName - The RPC name from the contract.\n * @param request - The request payload, validated against the request schema.\n * @param options - Per-call options. `timeoutMs` is required.\n *\n * @returns `Result.Ok(response)` on a successful round-trip; `Result.Error`\n * on validation, transport, timeout, or cancel.\n *\n * @example\n * ```typescript\n * const result = await client\n * .call('calculate', { a: 1, b: 2 }, { timeoutMs: 5_000 })\n * .toPromise();\n * if (result.isOk()) console.log(result.value.sum); // 3\n * ```\n */\n call<TName extends InferRpcNames<TContract>>(\n rpcName: TName,\n request: ClientInferRpcRequestInput<TContract, TName>,\n options: CallOptions,\n ): Future<\n Result<\n ClientInferRpcResponseOutput<TContract, TName>,\n TechnicalError | MessageValidationError | RpcTimeoutError | RpcCancelledError\n >\n > {\n type CallResult = Result<\n ClientInferRpcResponseOutput<TContract, TName>,\n TechnicalError | MessageValidationError | RpcTimeoutError | RpcCancelledError\n >;\n\n // setTimeout truncates fractional ms and clamps anything outside the\n // 32-bit signed integer range (~24.8 days) to 1ms, so reject those up\n // front as user errors rather than producing surprising behavior.\n const TIMEOUT_MAX_MS = 2_147_483_647;\n if (\n typeof options.timeoutMs !== \"number\" ||\n !Number.isFinite(options.timeoutMs) ||\n options.timeoutMs <= 0 ||\n options.timeoutMs > TIMEOUT_MAX_MS\n ) {\n return Future.value(\n Result.Error(\n new TechnicalError(\n `Invalid timeoutMs for RPC call to \"${String(rpcName)}\": expected a finite positive number ≤ ${TIMEOUT_MAX_MS}, got ${String(options.timeoutMs)}`,\n ),\n ) as CallResult,\n );\n }\n\n const startTime = Date.now();\n // Non-null assertion safe: TName is constrained to RPC names in the contract.\n const rpc = this.contract.rpcs![rpcName as string]!;\n const requestSchema = rpc.request.payload;\n const responseSchema = rpc.response.payload;\n const queueName = extractQueue(rpc.queue).name;\n\n // RPC publishes to the default exchange with the queue name as routing key.\n const span = startPublishSpan(this.telemetry, \"\", queueName, {\n [MessagingSemanticConventions.AMQP_PUBLISHER_NAME]: String(rpcName),\n });\n\n const correlationId = randomUUID();\n const callFuture = Future.make<CallResult>((resolve) => {\n const timer = setTimeout(() => {\n const pending = this.pendingCalls.get(correlationId);\n if (!pending) return;\n this.pendingCalls.delete(correlationId);\n resolve(Result.Error(new RpcTimeoutError(String(rpcName), options.timeoutMs)));\n }, options.timeoutMs);\n\n this.pendingCalls.set(correlationId, {\n rpcName: String(rpcName),\n responseSchema,\n resolve: resolve as PendingCall[\"resolve\"],\n timer,\n });\n });\n\n const validateRequest = (): Future<\n Result<unknown, TechnicalError | MessageValidationError>\n > => {\n // Wrap the validate call — a Standard Schema implementation may throw\n // synchronously, and that throw would otherwise escape the Future chain\n // and leave the pending-call entry/timer dangling until timeout.\n let rawValidation: ReturnType<StandardSchemaV1[\"~standard\"][\"validate\"]>;\n try {\n rawValidation = requestSchema[\"~standard\"].validate(request);\n } catch (error: unknown) {\n return Future.value(\n Result.Error<unknown, TechnicalError | MessageValidationError>(\n new TechnicalError(\"RPC request validation threw\", error),\n ),\n );\n }\n const validationPromise =\n rawValidation instanceof Promise ? rawValidation : Promise.resolve(rawValidation);\n return Future.fromPromise(validationPromise)\n .mapError((error) => new TechnicalError(\"RPC request validation threw\", error))\n .mapOkToResult((validation) =>\n validation.issues\n ? Result.Error<unknown, TechnicalError | MessageValidationError>(\n new MessageValidationError(String(rpcName), validation.issues),\n )\n : Result.Ok<unknown, TechnicalError | MessageValidationError>(validation.value),\n );\n };\n\n const publishRequest = (validatedRequest: unknown): Future<Result<void, TechnicalError>> => {\n // Merge `defaultPublishOptions` (persistent, priority, headers, …) with\n // the per-call options, then layer the RPC-managed fields on top so they\n // cannot be overridden. `compression` is intentionally dropped: RPC v1\n // does not implement reply-side decompression, so request-side\n // compression would break the round-trip.\n const { compression: _ignoredCompression, ...defaultsWithoutCompression } =\n this.defaultPublishOptions;\n const publishOptions: AmqpClientPublishOptions = {\n ...defaultsWithoutCompression,\n ...options.publishOptions,\n replyTo: DIRECT_REPLY_TO,\n correlationId,\n contentType: \"application/json\",\n };\n return this.amqpClient\n .publish(\"\", queueName, validatedRequest, publishOptions)\n .mapOkToResult((published) =>\n published\n ? Result.Ok<void, TechnicalError>(undefined)\n : Result.Error<void, TechnicalError>(\n new TechnicalError(\n `Failed to publish RPC request for \"${String(rpcName)}\": channel buffer full`,\n ),\n ),\n );\n };\n\n // Validate the request, publish it, and await the reply (or timeout).\n return validateRequest()\n .flatMapOk((validated) => publishRequest(validated))\n .flatMap((preflight) => {\n if (preflight.isError()) {\n // Publish/validation failed before the request hit the broker — clean\n // up the pending entry so the timer never fires.\n const pending = this.pendingCalls.get(correlationId);\n if (pending) {\n clearTimeout(pending.timer);\n this.pendingCalls.delete(correlationId);\n }\n return Future.value(Result.Error(preflight.error) as CallResult);\n }\n return callFuture;\n })\n .tapOk(() => {\n const durationMs = Date.now() - startTime;\n endSpanSuccess(span);\n recordPublishMetric(this.telemetry, \"\", queueName, true, durationMs);\n })\n .tapError((error) => {\n const durationMs = Date.now() - startTime;\n endSpanError(span, error);\n recordPublishMetric(this.telemetry, \"\", queueName, false, durationMs);\n });\n }\n\n /**\n * Close the channel and connection. Cancels the reply consumer (if any) and\n * rejects every in-flight RPC call with `RpcCancelledError`.\n */\n close(): Future<Result<void, TechnicalError>> {\n // Reject pending calls first — once close() runs, no reply will arrive.\n for (const [, pending] of this.pendingCalls) {\n clearTimeout(pending.timer);\n pending.resolve(Result.Error(new RpcCancelledError(pending.rpcName)));\n }\n this.pendingCalls.clear();\n\n const cancelReply = this.replyConsumerTag\n ? this.amqpClient.cancel(this.replyConsumerTag).tapError((error) => {\n this.logger?.warn(\"Failed to cancel RPC reply consumer during close\", { error });\n })\n : Future.value(Result.Ok<void, TechnicalError>(undefined));\n\n return cancelReply.flatMap(() => this.amqpClient.close()).mapOk(() => undefined);\n }\n\n private waitForConnectionReady(): Future<Result<void, TechnicalError>> {\n return this.amqpClient.waitForConnect();\n }\n}\n"],"mappings":"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"}