mqrpc
Version:
💫 Easy RPC over RabbitMQ
191 lines (190 loc) • 9 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const uuid = require("uuid/v4");
const logger_1 = require("./logger");
const AmqpClient_1 = require("./AmqpClient");
const errors_1 = require("./RpcClient/errors");
const promises_1 = require("./promises");
const Timer_1 = require("./Timer");
const deserializeServerError = (obj) => {
if (obj.cause)
return new errors_1.ProcedureFailed(obj.cause);
return new errors_1.ServerError(obj);
};
class RpcClient {
/**
* Instances a new RPC Client with the given config
*
* @param {RpcClientOptions} opts Config for this client, required.
* @param {AmqpClientOptions} opts.amqpClient Config for the underlying AMQP connection, required.
* @param {string} [opts.amqpClient.amqpUrl] URL for the AMQP broker.
* @param {object} [opts.amqpClient.socketOptions] Config for the AMQP connection.
* @param {object} [opts.amqpClient.connection] An open AMQP connection, for re-use.
* @param {object} [opts.amqpClient.channel] An open AMQP channel, for re-use.
* @param {number} [opts.amqpClient.prefetchCount] Global prefetch count when consuming messages. Default
* is 100.
* @param {RpcOptions} [opts.rpcClient] Config for the client itself.
* @param {string} [opts.rpcClient.rpcExchangeName] Exchange where calls are published. Default 'mqrpc'.
* Must match server.
* @param {number} [opts.rpcClient.ackTimeout] In ms, how long to wait for a server's ack. Default
* infinite (0).
* @param {number} [opts.rpcClient.idleTimeout] In ms, how long can a server be unresponsive. Default
* infinite (0).
* @param {number} [opts.rpcClient.callTimeout] In ms, how long overall to wait for a call's return.
* Default 15 minutes.
* @param {boolean} [opts.rpcClient.persistentMessages] Whether to use persistent messages.
* Default false.
* @param {StandardLogger} [opts.rpcClient.logger] Custom logger for client use.
*/
constructor(opts) {
this.rpcExchangeName = 'mqrpc';
this.ackTimeout = 0;
this.idleTimeout = 0;
this.callTimeout = 900000; // 15 minutes
this.log = logger_1.default;
this.persistentMessages = false;
this.amqpClient = new AmqpClient_1.default(opts.amqpClient);
this.calls = new Map();
if (opts.rpcClient) {
if (opts.rpcClient.rpcExchangeName)
this.rpcExchangeName = opts.rpcClient.rpcExchangeName;
if (opts.rpcClient.ackTimeout)
this.ackTimeout = opts.rpcClient.ackTimeout;
if (opts.rpcClient.idleTimeout)
this.idleTimeout = opts.rpcClient.idleTimeout;
if (opts.rpcClient.callTimeout)
this.callTimeout = opts.rpcClient.callTimeout;
if (opts.rpcClient.logger)
this.log = opts.rpcClient.logger;
if (typeof opts.rpcClient.persistentMessages !== 'undefined') {
this.persistentMessages = opts.rpcClient.persistentMessages;
}
}
this.callTimer = new Timer_1.default();
}
/**
* Starts the client by opening a channel to RabbitMq and listening to
* replies. If no connection was passed in the constructor, one is established
* here.
*/
async init() {
await this.amqpClient.init();
if (this.consumerTag)
return;
const { consumerTag } = await this.amqpClient.channel.consume('amq.rabbitmq.reply-to', this.makeReplyHandler(), { noAck: true });
this.consumerTag = consumerTag;
}
/**
* Tear down the client, optionally waiting for pending calls to resolve.
* Stops consuming replies, closes the channel and, if it owns the connection,
* closes it too.
*
* When calls are pending and the wait time expired or no wait time was given,
* the calls are rejected with a CallTerminated error.
*
* @param {number} [opts.waitForCalls] How long, in ms, to wait for pending
* calls. Give 0 for indefinitely.
*/
async term({ waitForCalls } = {}) {
if (typeof waitForCalls !== 'undefined' && this.calls.size > 0) {
let waited = 0;
const checkCallsInterval = setInterval(() => {
waited += 50;
if (this.calls.size === 0 || (waitForCalls > 0 && waited > waitForCalls)) {
clearInterval(checkCallsInterval);
return this.term();
}
}, 50);
return;
}
this.callTimer.clear();
this.calls.forEach(({ reject }) => reject(new errors_1.CallTerminated()));
this.calls.clear();
if (this.consumerTag) {
await this.amqpClient.channel.cancel(this.consumerTag);
delete this.consumerTag;
}
await this.amqpClient.term();
}
/**
* Calls the remote procedure with the given `procedure` and resolves its
* return, or rejects with errors.
*
* This will wait for a reply until the first timeout expires.
*
* @param {string} procedure The procedure's name.
* @param {any[]} ...args The args for the procedure.
* @return {Promise<any>} Whatever the procedure returns.
*/
async call(procedure, ...args) {
const [callPromise, callPromiseCallbacks] = promises_1.newPromiseAndCallbacks();
const correlationId = uuid();
this.calls.set(correlationId, callPromiseCallbacks);
// TODO: check for publish return, may need to flush
await this.amqpClient.channel.publish(this.rpcExchangeName, 'call', new Buffer(JSON.stringify(this.callPayload(procedure, ...args))), { persistent: this.persistentMessages, replyTo: 'amq.rabbitmq.reply-to', correlationId });
try {
return await Promise.race([
callPromise,
this.callTimer.addTimeouts(correlationId, ...this.callTimeouts())
]);
}
finally {
this.callTimer.remove(correlationId);
this.calls.delete(correlationId);
}
}
callTimeouts() {
const timeouts = [];
if (this.ackTimeout)
timeouts.push({ id: 'ackTimeout', length: this.ackTimeout });
if (this.callTimeout)
timeouts.push({ id: 'callTimeout', length: this.callTimeout });
return timeouts;
}
callPayload(procedure, ...args) {
const timeouts = {};
if (this.ackTimeout)
timeouts.ackTimeout = this.ackTimeout;
if (this.idleTimeout)
timeouts.idleTimeout = this.idleTimeout;
if (this.callTimeout)
timeouts.callTimeout = this.callTimeout;
return { procedure, args, timeouts };
}
makeReplyHandler() {
return (message) => {
if (!message)
return; // beats me, but it's in the type definition 🤷♂️
const correlationId = message.properties.correlationId;
const callbacks = this.calls.get(correlationId);
if (!callbacks) {
return this.log.warn('[RpcClient] Received reply to unknown call.', { correlationId });
}
let content;
try {
content = JSON.parse(message.content.toString());
}
catch (err) {
return callbacks.reject(new errors_1.UnparseableContent(message.content));
}
switch (content.type) {
case 'ack':
this.callTimer.removeTimeouts(correlationId, 'ackTimeout');
if (this.idleTimeout) {
this.callTimer.addTimeouts(correlationId, { id: 'idleTimeout', length: this.idleTimeout });
}
break;
case 'wait':
this.callTimer.restartTimeouts(correlationId, 'idleTimeout');
break;
case 'error':
return callbacks.reject(deserializeServerError(content.error));
case 'reply':
return callbacks.resolve(content.reply);
default:
callbacks.reject(new errors_1.UnknownReply(content));
}
};
}
}
exports.default = RpcClient;