UNPKG

mqrpc

Version:
191 lines (190 loc) 9 kB
"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;