amqplib-envelop
Version:
A wrapper for working with RabbitMQ using the amqplib npm package
126 lines (108 loc) • 3.88 kB
JavaScript
const Command = require('./Command');
const CommandResult = require('./CommandResult');
const DEFAULT_TIMEOUT = 4000;
/**
* @class RpcClient
* @desc Sends {@link Command} requests to an RPC queue and resolves with the data from
* the matching {@link CommandResult} produced by an {@link RpcServer}. Built on top of an
* already-connected {@link RabbitMQ} instance.
*/
class RpcClient {
/**
* @param {RabbitMQ} rabbitmq - A connected RabbitMQ instance (see `initAndGetInstance`).
* @param {string} queue - Name of the request queue the RpcServer is listening on.
* @param {number} [timeout=4000] - Time (ms) to wait for a reply before rejecting.
*/
constructor(rabbitmq, queue, timeout = DEFAULT_TIMEOUT) {
if (!rabbitmq) {
throw new Error('RpcClient requires a connected RabbitMQ instance');
}
if (!queue) {
throw new Error('RpcClient requires a request queue name');
}
this.rabbitmq = rabbitmq;
this.queue = queue;
this.timeout = timeout;
this.cmdNumber = 0;
this.requests = new Map();
this.replyQueue = null;
this.#onReconnect = () => this.#createReplyQueue()
.catch((err) => console.error('RpcClient reconnect error:', err?.message));
this.rabbitmq.on('reconnect', this.#onReconnect);
}
#onReconnect;
/**
* Creates an exclusive reply queue and starts listening for replies.
* Must be called (and awaited) before {@link sendCommand}. Automatically re-run
* whenever the underlying RabbitMQ instance reconnects, since an exclusive queue
* does not survive a dropped connection.
* @returns {Promise<RpcClient>}
*/
async init() {
await this.#createReplyQueue();
return this;
}
async #createReplyQueue() {
const { queue } = await this.rabbitmq.channel.assertQueue('', { exclusive: true });
this.replyQueue = queue;
await this.rabbitmq.channel.consume(
this.replyQueue,
(msg) => this.#dispatchReply(msg),
{ noAck: true },
);
}
/**
* Stops listening for replies and removes the reconnect listener.
*/
close() {
this.rabbitmq.off('reconnect', this.#onReconnect);
}
#dispatchReply(msg) {
if (!msg) return;
const { correlationId } = msg.properties;
const context = this.requests.get(correlationId);
if (!context) return;
this.requests.delete(correlationId);
clearTimeout(context.timer);
try {
const result = CommandResult.fromBuffer(msg.content);
if (result.state === CommandResult.STATES.ERROR) {
context.reject(result.data);
} else {
context.resolve(result.data);
}
} catch (err) {
context.reject(err);
}
}
/**
* Sends a command to the RPC server and resolves with its result, or rejects on
* error / timeout.
* @param {string} command - Command name.
* @param {Array<*>} [args] - Arguments to pass to the RpcServer handler.
* @param {object} [messageOptions] - Additional sendToQueue() options.
* @returns {Promise<*>}
*/
sendCommand(command, args = [], messageOptions = {}) {
if (!this.replyQueue) {
return Promise.reject(new Error('RpcClient is not initialized, call init() first'));
}
const cmd = new Command(command, args);
const correlationId = String((this.cmdNumber += 1));
const properties = {
...messageOptions,
correlationId,
replyTo: this.replyQueue,
expiration: String(this.timeout),
};
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.requests.delete(correlationId);
reject(new Error(`RPC command "${command}" timed out after ${this.timeout}ms`));
}, this.timeout);
this.requests.set(correlationId, { resolve, reject, timer });
this.rabbitmq.channel.sendToQueue(this.queue, cmd.pack(), properties);
});
}
}
module.exports = RpcClient;