UNPKG

@imqueue/rpc

Version:

RPC-like client-service implementation over messaging queue

546 lines (545 loc) 19.7 kB
"use strict"; var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; var __metadata = (this && this.__metadata) || function (k, v) { if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v); }; Object.defineProperty(exports, "__esModule", { value: true }); exports.IMQClient = void 0; exports.imqCallResolver = imqCallResolver; exports.imqCallRejector = imqCallRejector; /*! * IMQClient implementation * * I'm Queue Software Project * Copyright (C) 2025 imqueue.com <support@imqueue.com> * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <https://www.gnu.org/licenses/>. * * If you want to use this code in a closed source (commercial) project, you can * purchase a proprietary commercial license. Please contact us at * <support@imqueue.com> to get commercial licensing options. */ const core_1 = require("@imqueue/core"); const _1 = require("."); const ts = require("typescript"); const events_1 = require("events"); const vm = require("vm"); process.setMaxListeners(10000); const tsOptions = require('../tsconfig.json').compilerOptions; const RX_SEMICOLON = /;+$/g; /** * Class IMQClient - base abstract class for service clients. */ class IMQClient extends events_1.EventEmitter { // noinspection TypeScriptAbstractClassConstructorCanBeMadeProtected /** * Class constructor * * @constructor * @param {Partial<IMQClientOptions>} options * @param {string} serviceName * @param {string} name */ constructor(options, serviceName, name) { var _a; super(); this.resolvers = {}; const baseName = name || this.constructor.name; this.baseName = baseName; if (this.constructor.name === 'IMQClient') { throw new TypeError('IMQClient class is abstract and cannot ' + 'be instantiated directly!'); } this.options = Object.assign(Object.assign({}, _1.DEFAULT_IMQ_CLIENT_OPTIONS), options); this.id = (0, _1.pid)(baseName); this.logger = this.options.logger || /* istanbul ignore next */ console; this.hostName = ((_a = IMQClient.singleImq) === null || _a === void 0 ? void 0 : _a.name) || `${(0, _1.osUuid)()}-${this.id}:client`; this.name = `${baseName}-${this.hostName}`; this.serviceName = serviceName || baseName.replace(/Client$/, ''); this.queueName = this.options.singleQueue ? this.hostName : this.name; this.imq = this.createImq(); this.subscriptionImq = this.createSubscriptionImq(); _1.SIGNALS.forEach((signal) => process.on(signal, async () => { this.destroy().catch(this.logger.error); // istanbul ignore next setTimeout(() => process.exit(0), core_1.IMQ_SHUTDOWN_TIMEOUT); })); } createImq() { if (!this.options.singleQueue) { return core_1.default.create(this.queueName, this.options); } if (!IMQClient.singleImq) { IMQClient.singleImq = core_1.default.create(this.queueName, this.options); } return IMQClient.singleImq; } createSubscriptionImq() { if (!this.options.singleQueue) { return this.imq; } return core_1.default.create(this.name, this.options); } /** * Sends call to remote service method * * @access protected * @param {...any[]} args * @template T * @returns {Promise<T>} */ async remoteCall(...args) { const logger = this.options.logger || console; const method = args.pop(); const from = this.queueName; const to = this.serviceName; let delay = 0; let metadata; if (args[args.length - 1] instanceof _1.IMQDelay) { // noinspection TypeScriptUnresolvedVariable delay = args.pop().ms; // istanbul ignore if if (!isFinite(delay) || isNaN(delay) || delay < 0) { delay = 0; } } if (args[args.length - 1] instanceof _1.IMQMetadata) { metadata = args.pop(); } const request = Object.assign({ from, method, args }, (metadata ? { metadata } : {})); if (typeof this.options.beforeCall === 'function') { const beforeCall = this.options.beforeCall.bind(this); try { await beforeCall(request); } catch (err) { logger.warn(_1.BEFORE_HOOK_ERROR, err); } } return new Promise(async (resolve, reject) => { try { const id = await this.imq.send(to, request, delay, reject); this.resolvers[id] = [ imqCallResolver(resolve, request, this), imqCallRejector(reject, request, this), ]; } catch (err) { // istanbul ignore next imqCallRejector(reject, request, this)(err); } }); } // noinspection JSUnusedGlobalSymbols /** * Adds subscription to service event channel * * @param {(data: JsonObject) => any} handler * @return {Promise<void>} */ async subscribe(handler) { return this.subscriptionImq.subscribe(this.serviceName, handler); } // noinspection JSUnusedGlobalSymbols /** * Destroys subscription channel to service * * @return {Promise<void>} */ async unsubscribe() { return this.subscriptionImq.unsubscribe(); } // noinspection JSUnusedGlobalSymbols /** * Broadcasts given payload to all other service clients subscribed. * So this is like client-to-clients publishing. * * @param {JsonObject} payload * @return {Promise<void>} */ async broadcast(payload) { return this.imq.publish(payload, this.queueName); } /** * Initializes client work * * @returns {Promise<void>} */ async start() { this.imq.on('message', (message) => { // the following condition below is hard to test with the // current redis mock, BTW it was tested manually on real // redis run // istanbul ignore if if (!this.resolvers[message.to]) { // when there is no resolvers it means // we have message in queue which was initiated // by some process which is broken. So we provide an // ability to handle enqueued messages via EventEmitter // interface this.emit(message.request.method, message); } const [resolve, reject] = this.resolvers[message.to] || []; // make sure no memory leaking delete this.resolvers[message.to]; if (message.error) { return reject && reject(message.error, message); } resolve && resolve(message.data, message); }); if (this.imq) { await this.imq.start(); } } // noinspection JSUnusedGlobalSymbols /** * Stops client work * * @returns {Promise<void>} */ async stop() { await this.imq.stop(); } /** * Destroys client * * @returns {Promise<void>} */ async destroy() { await this.imq.unsubscribe(); (0, _1.forgetPid)(this.baseName, this.id, this.logger); this.removeAllListeners(); await this.imq.destroy(); } /** * Returns service description metadata. * * @param {IMQDelay} delay * @returns {Promise<Description>} */ async describe(delay) { return await this.remoteCall(...arguments); } /** * Creates client for a service with the given name * * @param {string} name * @param {Partial<IMQServiceOptions>} options * @returns {IMQClient} */ static async create(name, options) { const clientOptions = Object.assign(Object.assign({}, _1.DEFAULT_IMQ_CLIENT_OPTIONS), options); return await generator(name, clientOptions); } } exports.IMQClient = IMQClient; __decorate([ (0, _1.remote)(), __metadata("design:type", Function), __metadata("design:paramtypes", [_1.IMQDelay]), __metadata("design:returntype", Promise) ], IMQClient.prototype, "describe", null); /** * Builds and returns call resolver, which supports after call optional hook * * @param {(...args: any[]) => void} resolve - source promise like resolver * @param {IMQRPCRequest} req - request message * @param {IMQClient} client - imq client * @return {(data: any, res: IMQRPCResponse) => void} - hook-supported resolve */ function imqCallResolver(resolve, req, client) { return async (data, res) => { const logger = client.options.logger || console; resolve(data); if (typeof client.options.afterCall === 'function') { const afterCall = client.options.afterCall.bind(client); try { await afterCall(req, res); } catch (err) { logger.warn(_1.AFTER_HOOK_ERROR, err); } } }; } /** * Builds and returns call rejector, which supports after call optional hook * * @param {(err: any) => void} reject - source promise like rejector * @param {IMQRPCRequest} req - call request * @param {IMQClient} client - imq client * @return {(err: any) => void} - hook-supported reject */ function imqCallRejector(reject, req, client) { return async (err, res) => { const logger = client.options.logger || console; reject(err); if (typeof client.options.afterCall === 'function') { const afterCall = client.options.afterCall.bind(client); try { await afterCall(req, res); } catch (err) { logger.warn(_1.AFTER_HOOK_ERROR, err); } } }; } /** * Class GeneratorClient - generator helper class implementation * @access private */ class GeneratorClient extends IMQClient { } /** * Fetches and returns service description using the timeout (to handle * situations when the service is not started) * * @access private * @param {string} name * @param {IMQClientOptions} options * @returns {Promise<Description>} */ async function getDescription(name, options) { return new Promise(async (resolve, reject) => { const client = new GeneratorClient(options, name, `${name}Client`); await client.start(); const timeout = setTimeout(async () => { await client.destroy(); timeout && clearTimeout(timeout); reject(new EvalError('Generate client error: service remote ' + `call timed-out! Is service "${name}" running?`)); }, options.timeout); const description = await client.describe(); timeout && clearTimeout(timeout); await client.destroy(); resolve(description); }); } // codebeat:disable[LOC,ABC] /** * Client generator helper function * * @access private * @param {string} name * @param {IMQClientOptions} options * @returns {Promise<string>} */ async function generator(name, options) { const description = await getDescription(name, options); const serviceName = description.service.name; const clientName = serviceName.replace(/Service$|$/, 'Client'); const namespaceName = serviceName.charAt(0).toLowerCase() + serviceName.substr(1); let src = `/*! * IMQ-RPC Service Client: ${description.service.name} * * I'm Queue Software Project * Copyright (C) ${new Date().getFullYear()} imqueue.com <support@imqueue.com> * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <https://www.gnu.org/licenses/>. * * If you want to use this code in a closed source (commercial) project, you can * purchase a proprietary commercial license. Please contact us at * <support@imqueue.com> to get commercial licensing options. */ import { IMQClient, IMQDelay, IMQMetadata, remote, profile, } from '@imqueue/rpc'; export namespace ${namespaceName} {\n`; for (let typeName of Object.keys(description.types)) { src += ` export interface ${typeName} ${description.types[typeName].inherits && description.types[description.types[typeName].inherits] ? `extends ${description.types[typeName].inherits}` : ''} {\n`; const indexType = description.types[typeName].indexType; if (indexType) { src += ' '.repeat(8); src += `${indexType.trim().replace(RX_SEMICOLON, '').trim()};\n`; } for (const propertyName of Object.keys(description.types[typeName].properties)) { const { type, isOptional } = description.types[typeName].properties[propertyName]; src += ' '.repeat(8); src += `${propertyName}${isOptional ? '?' : ''}: ${type};\n`; } src += ' }\n\n'; } src += ` export class ${clientName} extends IMQClient {\n\n`; const methods = description.service.methods; for (const methodName of Object.keys(methods)) { if (methodName === 'describe') { continue; // do not create inherited method - no need } const args = methods[methodName].arguments; const description = methods[methodName].description; const ret = methods[methodName].returns; const addArgs = [{ description: 'if passed, will deliver given metadata to ' + 'service, and will initiate trace handler calls', name: 'imqMetadata', type: 'IMQMetadata', tsType: 'IMQMetadata', isOptional: true, }, { description: 'if passed the method will be called with ' + 'the specified delay over message queue', name: 'imqDelay', type: 'IMQDelay', tsType: 'IMQDelay', isOptional: true }]; let retType = ret.tsType.replace(/\r?\n/g, ' ').replace(/\s{2,}/g, ' '); for (let i = 1; i <= 2; i++) { const arg = args[args.length - i]; if (arg && ~['IMQDelay', 'IMQMetadata'].indexOf(arg.type)) { args.pop(); // remove it } } args.push(...addArgs); // make sure client expect them // istanbul ignore if if (retType === 'Promise') { retType = 'Promise<any>'; } src += ' /**\n'; // istanbul ignore next src += description ? description.split(/\r?\n/) .map(line => ` * ${line}`) .join('\n') + '\n *\n' : ''; for (let i = 0, s = args.length; i < s; i++) { const arg = args[i]; src += ` * @param {${toComment(arg.tsType)}} `; src += arg.isOptional ? `[${arg.name}]` : arg.name; src += arg.description ? ' - ' + arg.description : ''; src += '\n'; } src += ` * @return {${toComment(ret.tsType, true)}}\n`; src += ' */\n'; src += ' @profile()\n'; src += ' @remote()\n'; src += ` public async ${methodName}(`; for (let i = 0, s = args.length; i < s; i++) { const arg = args[i]; src += arg.name + (arg.isOptional ? '?' : '') + ': ' + arg.tsType.replace(/\s{2,}/g, ' ') + (i === s - 1 ? '' : ', '); } src += `): ${promisedType(retType)} {\n`; src += ' '.repeat(12); src += `return await this.remoteCall<${cleanType(retType)}>(...arguments);`; src += '\n }\n\n'; } src += ' }\n}\n'; const module = await compile(name, src, options); return module ? module[namespaceName] : /* istanbul ignore next */ null; } // codebeat:enable[LOC,ABC] /** * Return promised typedef of a given type if its missing * *c @access private * @param {string} typedef * @returns {string} */ function promisedType(typedef) { // istanbul ignore next if (!/^Promise</.test(typedef)) { typedef = `Promise<${typedef}>`; } return typedef; } /** * Removes Promise from type definition if any * * @access private * @param {string} typedef * @returns {string} */ function cleanType(typedef) { return typedef.replace(/^Promise<([\s\S]+?)>$/, '$1'); } /** * Type to comment * * @access private * @param {string} typedef * @param {boolean} [promised] * @returns {string} */ function toComment(typedef, promised = false) { if (promised) { typedef = promisedType(typedef); } // istanbul ignore next return typedef.split(/\r?\n/) .map((line, lineNum) => (lineNum ? ' * ' : '') + line) .join('\n'); } /** * Compiles client source code and returns loaded module * * @access private * @param {string} name * @param {string} src * @param {IMQClientOptions} options * @returns {any} */ async function compile(name, src, options) { const path = options.path; const srcFile = `${path}/${name}.ts`; const jsFile = `${path}/${name}.js`; const js = ts.transpile(src, tsOptions); if (options.write) { // istanbul ignore else if (!await (0, _1.fileExists)(path)) { await (0, _1.mkdir)(path); } await Promise.all([ (0, _1.writeFile)(srcFile, src), (0, _1.writeFile)(jsFile, js), ]); } // istanbul ignore else if (options.compile) { const script = new vm.Script(js); const context = { exports: {}, require }; script.runInNewContext(context, { filename: jsFile }); return context.exports; } // istanbul ignore next return null; } //# sourceMappingURL=IMQClient.js.map