Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
693 changes: 68 additions & 625 deletions package-lock.json

Large diffs are not rendered by default.

14 changes: 6 additions & 8 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,9 @@
],
"scripts": {
"prepublishOnly": "./node_modules/.bin/tsc",
"test": "./node_modules/.bin/tsc && ./node_modules/.bin/nyc mocha && ./node_modules/.bin/nyc report --reporter=text-lcov && npm run test-coverage",
"test": "./node_modules/.bin/tsc && ./node_modules/.bin/nyc mocha && ./node_modules/.bin/nyc report --reporter=text-lcov",
"test-fast": "./node_modules/.bin/tsc && ./node_modules/.bin/nyc mocha && /usr/bin/env node -e \"import('open').then(open => open.default('file://`pwd`/coverage/index.html', { wait: false }))\"",
"test-local": "export COVERALLS_REPO_TOKEN=$IMQ_RPC_COVERALLS_TOKEN && npm test && /usr/bin/env node -e \"import('open').then(open => open.default('https://coveralls.io/github/imqueue/imq-rpc', { wait: false }));\"",
"test-coverage": "cat ./coverage/lcov.info | CODECLIMATE_API_HOST=https://codebeat.co/webhooks/code_coverage CODECLIMATE_REPO_TOKEN=c2cc3954-6824-4cdf-8349-12bd9c31955a ./node_modules/.bin/codeclimate-test-reporter",
"clean-typedefs": "find . -name '*.d.ts' -not -wholename '*node_modules*' -not -wholename '*generator*' -type f -delete",
"clean-maps": "find . -name '*.js.map' -not -wholename '*node_modules*' -not -wholename '*generator*' -type f -delete",
"clean-js": "find . -name '*.js' -not -wholename '*node_modules*' -not -wholename '*generator*' -type f -delete",
Expand All @@ -38,22 +37,21 @@
"author": "imqueue.com <[email protected]> (https://imqueue.com)",
"license": "GPL-3.0-only",
"dependencies": {
"@imqueue/core": "^2.0.5",
"@types/node": "^24.0.8",
"@imqueue/core": "^2.0.6",
"@types/node": "^24.2.1",
"acorn": "^8.15.0",
"farmhash": "^4.0.2",
"node-machine-id": "^1.1.12",
"reflect-metadata": "^0.2.2",
"typescript": "^5.8.3"
"typescript": "^5.9.2"
},
"devDependencies": {
"@types/chai": "^5.2.2",
"@types/mocha": "^10.0.10",
"@types/mock-require": "^3.0.0",
"@types/sinon": "^17.0.4",
"chai": "^5.2.0",
"codeclimate-test-reporter": "^0.5.1",
"coveralls-next": "^4.2.1",
"coveralls-next": "^5.0.0",
"minimist": "^1.2.8",
"mocha": "^11.7.1",
"mocha-lcov-reporter": "^1.3.0",
Expand All @@ -63,7 +61,7 @@
"sinon": "^21.0.0",
"source-map-support": "^0.5.21",
"ts-node": "^10.9.2",
"typedoc": "^0.28.7"
"typedoc": "^0.28.10"
},
"main": "index.js",
"typescript": {
Expand Down
31 changes: 23 additions & 8 deletions src/IMQService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export class Description {
types: TypesDescription;
}

let serviceDescription: Description | null = null;
const serviceDescriptions: Map<string, Description> = new Map<string, Description>();

/**
* Returns collection of class methods metadata even those are inherited
Expand Down Expand Up @@ -188,10 +188,21 @@ export abstract class IMQService {
}

else if (!description.service.methods[method]) {
response.error = IMQError(
'IMQ_RPC_NO_ACCESS',
`Access to ${this.name}.${method}() denied!`,
new Error().stack, method, args);
// Allow calling runtime-attached methods (own props) even if
// they are not present in the exposed service description.
// Deny access for prototype (class) methods not decorated with @expose.
const isOwn = Object.prototype.hasOwnProperty.call(this, method);
const value: any = (this as any)[method];
const proto = Object.getPrototypeOf(this);
const protoValue = proto && proto[method];
const isSameAsProto = typeof protoValue === 'function' && value === protoValue;
// Allow only truly dynamic own-instance functions (not the same as prototype)
if (!(isOwn && typeof value === 'function' && !isSameAsProto)) {
response.error = IMQError(
'IMQ_RPC_NO_ACCESS',
`Access to ${this.name}.${method}() denied!`,
new Error().stack, method, args);
}
}

else if (!isValidArgsCount(
Expand Down Expand Up @@ -318,17 +329,21 @@ export abstract class IMQService {
*/
@expose()
public describe(): Description {
if (!serviceDescription) {
serviceDescription = {
let description = serviceDescriptions.get(this.name) || null;

if (!description) {
description = {
service: {
name: this.name,
methods: getClassMethods(this.constructor.name)
},
types: IMQRPCDescription.typesDescription
};

serviceDescriptions.set(this.name, description);
}

return serviceDescription;
return description;
}

}
Expand Down
24 changes: 18 additions & 6 deletions src/decorators/logged.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,6 @@ export function logged(options?: ILogger | LoggedDecoratorOptions) {
descriptor: TypedPropertyDescriptor<(...args: any[]) => any>,
) => {
const original = descriptor.value;
const logger = options && (options as LoggedDecoratorOptions).logger
? (options as LoggedDecoratorOptions).logger
: options && (options as ILogger).error ? options :
this?.logger || target?.logger || console;
const level: LoggedLogLevel = (
options &&
(options as LoggedDecoratorOptions).level
Expand All @@ -65,9 +61,25 @@ export function logged(options?: ILogger | LoggedDecoratorOptions) {

descriptor.value = async function<T>(...args: any[]): Promise<T|void> {
try {
return original && await original.apply(this || target, args);
const ctx = (typeof this !== 'undefined' && this !== null)
? this
: target;

if (original) {
return await original.apply(ctx, args);
}
} catch (err) {
logger[level](err);
const logger: ILogger = (options && (options as LoggedDecoratorOptions).logger)
? (options as LoggedDecoratorOptions).logger as ILogger
: (options && (options as any).error)
? options as ILogger
: (this && (this as any).logger)
? (this as any).logger as ILogger
: (target && (target as any).logger)
? (target as any).logger as ILogger
: console;

(logger as any)[level](err);

if (doThrow) {
throw err;
Expand Down
134 changes: 134 additions & 0 deletions test/IMQClient.console.logger.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/*!
* IMQClient console logger fallback branches coverage
*/
import './mocks';
import { expect } from 'chai';
import * as sinon from 'sinon';
import { IMQClient, IMQDelay, IMQMetadata, remote, AFTER_HOOK_ERROR, BEFORE_HOOK_ERROR } from '..';

class ConsoleClient extends IMQClient {
@remote()
public async ok(name?: string, meta?: IMQMetadata, delay?: IMQDelay) {
return this.remoteCall<string>(...arguments);
}
@remote()
public async boom() {
return this.remoteCall<any>(...arguments);
}
}

describe('IMQClient console logger fallbacks', () => {
let client: ConsoleClient;

afterEach(async () => {
try { await client?.destroy(); } catch { /* ignore */ }
sinon.restore();
});

it('should use console logger when BEFORE hook fails', async () => {
const warn = sinon.stub(console, 'warn' as any).callsFake(() => {});
client = new ConsoleClient({ beforeCall: async () => { throw new Error('before oops'); } });
await client.start();

const imq: any = (client as any).imq;
sinon.stub(imq, 'send').callsFake(async (_to: string, request: any) => {
const id = 'C1';
setImmediate(() => imq.emit('message', { to: id, request, data: 'good' }));
return id;
});

const res = await client.ok('x');
expect(res).to.equal('good');
expect(warn.called).to.equal(true);
expect(String(warn.firstCall.args[0])).to.contain(BEFORE_HOOK_ERROR);
});

it('should use console logger when AFTER hook fails (resolve and reject paths)', async () => {
const warn = sinon.stub(console, 'warn' as any).callsFake(() => {});
client = new ConsoleClient({ afterCall: async () => { throw new Error('after oops'); } });
await client.start();

const imq: any = (client as any).imq;
const send = sinon.stub(imq, 'send');

// success path
send.onFirstCall().callsFake(async (_to: string, request: any) => {
const id = 'C2';
setImmediate(() => imq.emit('message', { to: id, request, data: 'S' }));
return id;
});

// reject path
send.onSecondCall().callsFake(async (_to: string, request: any) => {
const id = 'C3';
setImmediate(() => imq.emit('message', { to: id, request, error: new Error('bad') }));
return id;
});

const ok = await client.ok('ok');
expect(ok).to.equal('S');

try { await client.boom(); } catch { /* expected */ }

expect(warn.callCount).to.be.greaterThan(0);
const messages = warn.getCalls().map(c => String(c.args[0])).join(' ');
expect(messages).to.contain(AFTER_HOOK_ERROR);
});

it('should use right-hand console branch in remoteCall when BEFORE hook fails', async () => {
const warn = sinon.stub(console, 'warn' as any).callsFake(() => {});
// Explicitly override default logger to be undefined to force `|| console` take the right branch
client = new ConsoleClient({ beforeCall: async () => { throw new Error('before oops'); }, logger: undefined as any });
await client.start();

const imq: any = (client as any).imq;
sinon.stub(imq, 'send').callsFake(async (_to: string, request: any) => {
const id = 'MB1';
setImmediate(() => imq.emit('message', { to: id, request, data: 'ok' }));
return id;
});

const res = await client.ok('x');
expect(res).to.equal('ok');
expect(warn.called).to.equal(true);
expect(String(warn.firstCall.args[0])).to.contain(BEFORE_HOOK_ERROR);
});

it('should use right-hand console branch in imqCallResolver when AFTER hook fails on resolve', async () => {
const warn = sinon.stub(console, 'warn' as any).callsFake(() => {});
client = new ConsoleClient({ afterCall: async () => { throw new Error('after oops'); }, logger: undefined as any });
await client.start();

const imq: any = (client as any).imq;
sinon.stub(imq, 'send').callsFake(async (_to: string, request: any) => {
const id = 'MB2';
setImmediate(() => imq.emit('message', { to: id, request, data: 'S' }));
return id;
});

const ok = await client.ok('ok');
expect(ok).to.equal('S');
expect(warn.callCount).to.be.greaterThan(0);
const messages = warn.getCalls().map(c => String(c.args[0])).join(' ');
expect(messages).to.contain(AFTER_HOOK_ERROR);
});

it('should use right-hand console branch in imqCallRejector when AFTER hook fails on reject', async () => {
const warn = sinon.stub(console, 'warn' as any).callsFake(() => {});
client = new ConsoleClient({ afterCall: async () => { throw new Error('after oops'); }, logger: undefined as any });
await client.start();

const imq: any = (client as any).imq;
sinon.stub(imq, 'send').callsFake(async (_to: string, request: any) => {
const id = 'MB3';
setImmediate(() => imq.emit('message', { to: id, request, error: new Error('bad') }));
return id;
});

try { await client.boom(); } catch { /* expected */ }

expect(warn.callCount).to.be.greaterThan(0);
const messages = warn.getCalls().map(c => String(c.args[0])).join(' ');
expect(messages).to.contain(AFTER_HOOK_ERROR);
});
});
101 changes: 101 additions & 0 deletions test/IMQClient.extra.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/*!
* IMQClient Extra Unit Tests (non-RPC, using send stubs)
*/
import './mocks';
import { logger } from './mocks';
import { expect } from 'chai';
import * as sinon from 'sinon';
import { IMQClient, IMQDelay, IMQMetadata, remote, AFTER_HOOK_ERROR, BEFORE_HOOK_ERROR } from '..';

class ExtraClient extends IMQClient {
@remote()
public async greet(name?: string, imqMetadata?: IMQMetadata, imqDelay?: IMQDelay) {
return this.remoteCall<string>(...arguments);
}
@remote()
public async fail(imqDelay?: IMQDelay) {
return this.remoteCall<any>(...arguments);
}
}

describe('IMQClient (extra branches without service)', () => {
let client: ExtraClient;

afterEach(async () => {
await client?.destroy();
sinon.restore();
});

it('should warn on BEFORE_HOOK_ERROR and continue call', async function() {
this.timeout(5000);
const warn = sinon.stub(logger, 'warn');
client = new ExtraClient({ logger, beforeCall: async () => { throw new Error('before'); } });
await client.start();
const imq: any = (client as any).imq;
sinon.stub(imq, 'send').callsFake(async (to: string, request: any, delay?: number) => {
const id = 'ID1';
setImmediate(() => imq.emit('message', { to: id, request, data: 'ok' }));
return id;
});
const res = await client.greet('imq');
expect(res).to.equal('ok');
expect(warn.called).to.equal(true);
expect(String(warn.firstCall.args[0])).to.contain(BEFORE_HOOK_ERROR);
});

it('should warn on AFTER_HOOK_ERROR for resolve and reject paths', async () => {
const warn = sinon.stub(logger, 'warn');
client = new ExtraClient({ logger, afterCall: async () => { throw new Error('after'); } });
await client.start();
const imq: any = (client as any).imq;
const send = sinon.stub(imq, 'send');
// success path
send.onFirstCall().callsFake(async (to: string, request: any) => {
const id = 'ID2';
setImmediate(() => imq.emit('message', { to: id, request, data: 'success' }));
return id;
});
// reject path
send.onSecondCall().callsFake(async (to: string, request: any) => {
const id = 'ID3';
setImmediate(() => imq.emit('message', { to: id, request, error: new Error('boom') }));
return id;
});
const ok = await client.greet('ok');
expect(ok).to.equal('success');
try { await client.fail(); } catch (e) { /* expected */ }
// both paths should warn due to afterCall throwing
expect(warn.callCount).to.be.greaterThan(0);
expect(warn.getCalls().map(c => String(c.args[0])).join(' ')).to.contain(AFTER_HOOK_ERROR);
});

it('should emit event when resolver is missing', async () => {
client = new ExtraClient({ logger });
await client.start();
const evt = sinon.spy();
client.on('greet', evt);
(client as any).imq.emit('message', {
to: 'unknown-id',
request: { method: 'greet' },
data: { foo: 'bar' },
});
expect(evt.calledOnce).to.equal(true);
});

it('should sanitize invalid IMQDelay and pass IMQMetadata through request', async () => {
client = new ExtraClient({ logger });
await client.start();
const imq: any = (client as any).imq;
const sendStub = sinon.stub(imq, 'send').callsFake(async (to: string, request: any, delay?: number) => {
expect(delay).to.equal(0);
expect(request.metadata).to.be.instanceOf(IMQMetadata);
const id = 'ID4';
setImmediate(() => imq.emit('message', { to: id, request, data: 'x' }));
return id;
});
const meta = new IMQMetadata({ a: 1 } as any);
const res = await client.greet('z', meta as any, new IMQDelay(-100) as any);
expect(res).to.equal('x');
expect(sendStub.calledOnce).to.equal(true);
});
});
Loading