@imqueue/rpc
Version:
RPC-like client-service implementation over messaging queue
546 lines (545 loc) • 19.6 kB
JavaScript
;
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.name, 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