jsonbird
Version:
JSON-RPC 2.0 client/server/peer for any reliable transport. Inter-process communication. REST. WebSocket. WebWorker. Out of order messaging or in-order byte streams
1,397 lines (1,226 loc) • 54.8 kB
JavaScript
'use strict';
const {Duplex} = require('readable-stream');
const shortid = require('shortid');
const JSONParser = require('jsonparse');
const RPCRequestError = require('./RPCRequestError');
const RPCResponseError = require('./RPCResponseError');
const prom = require('./promiseExtras');
const getRootPrototype = require('./getRootPrototype');
const NOOP = () => {};
const RETURN_TRUE = () => true;
const PRIVATE = Symbol('JSONBird PRIVATE');
const REQUEST_ERROR_ID = Symbol('JSONBird RPCRequestError id');
const DEFAULT_OPTIONS = {
receiveErrorStack: false,
sendErrorStack: false,
writableMode: 'json-stream',
readableMode: 'json-stream',
endOfJSONWhitespace: '',
sessionId: undefined,
firstRequestId: 0,
defaultTimeout: 0,
endOnFinish: true,
finishOnEnd: true,
pingReceive: true,
pingMethod: 'jsonbird.ping',
pingInterval: 2000,
pingTimeout: 1000,
pingNow: Date.now.bind(Date),
setTimeout: (func, delay) => setTimeout(func, delay),
clearTimeout: (id) => clearTimeout(id),
};
const getObjectTag = object => Object.prototype.toString.call(object);
const objectBuiltins = new Set(Object.getOwnPropertyNames(Object.prototype));
/**
* JSONBird is a Duplex stream which makes it easy to create a flexible JSON-RPC 2.0 client or server (or a bidirectional combination)
* over any reliable transport. You can use out of order messaging or an in-order byte stream.
*/
class JSONBird extends Duplex {
/**
* This event is fired if an uncaught error occurred
*
* Most errors end up at the caller of our functions or at the remote peer, instead of this event.
* Note that if you do not listen for this event on node.js, your process might exit.
*
* @event JSONBird#error
* @param {Error} error
*/
/**
* This event is fired if our peer sent us something that we were unable to parse.
*
* These kind of errors do not end up at the 'error' event
*
* @event JSONBird#protocolError
* @param {Error} error
*/
/**
* The most recent ping sent to our peer succeeded
*
* @event JSONBird#pingSuccess
* @param {number} delay How long the ping took to resolve (in milliseconds)
*/
/**
* The most recent ping sent to our peer timed out or resulted in an error
*
* @event JSONBird#pingFail
* @param {number} consecutiveFails The amount of consecutive pings that failed
* @param {Error} error
*/
/**
* Converts any javascript `Error` object to a JSON-RPC error object
*
* @param {Error} error The `message`, `code` and `data` properties of this `error` will be copied over to the resulting object.
* @param {boolean} [includeErrorStack=false] If `true` and `error.data` is `undefined`, the resulting `data` object will contain
* a "javascriptError" objecting containing the `fileName`, `lineNumber`, `columnNumber` and `stack` of the `error`.
* @return {{code: number, message: string, data: *}}
*/
static errorToResponseObject(error, includeErrorStack) {
const responseError = {};
responseError.code = Number(error.code) || 0;
responseError.message = String(error.message);
if (error.data !== undefined) {
responseError.data = error.data;
}
if (includeErrorStack) {
if (responseError.data === undefined) {
responseError.data = {};
}
if (typeof responseError.data === 'object' &&
responseError.data && // null?
responseError.data.javascriptError === undefined
) {
responseError.data = Object.assign({
javascriptError: {
name: error.name,
fileName: String(error.fileName),
lineNumber: Number(error.lineNumber),
columnNumber: Number(error.columnNumber),
stack: String(error.stack),
},
}, responseError.data);
}
}
return responseError;
}
/**
* Is the given value for the JSON-RPC `jsonrpc` property a value that we recognise?
*
* Currently, only "2.0" is supported
*
* @param {*} jsonrpc
* @return {boolean}
*/
static isValidVersion(jsonrpc) {
return jsonrpc === '2.0';
}
/**
* Is the given value for the JSON-RPC `id` property valid?
*
* @param {*} id
* @return {boolean}
*/
static isValidID(id) {
return typeof id === 'number' || typeof id === 'string';
}
/**
* Is the given value for the JSON-RPC `method` property valid?
*
* @param {*} method
* @return {boolean}
*/
static isValidMethodName(method) {
return typeof method === 'string';
}
/**
* Is the given value for the JSON-RPC `params` property valid?
*
* @param {*} params
* @return {boolean}
*/
static isValidParams(params) {
return Boolean(params && (Array.isArray(params) || typeof params === 'object'));
}
/**
* Test if the given property `name` of `object` is one of the builtin Object.prototype functions.
*
* Such as: hasOwnProperty, __defineGetter__, etc
*
* @param {Object} object
* @param {string} name
* @return {boolean}
*/
static isObjectBuiltinFunction(object, name) {
const method = object[name];
if (typeof method === 'function' && objectBuiltins.has(name)) {
const rootProto = getRootPrototype(object);
if (rootProto && method === rootProto[name]) {
// Do not allow methods from Object (like __defineGetter__) to be called...
// Unless they have been overridden on the object itself, or a prototype that is not the top most prototype.
// `Object.prototype` is nt used directly, to support cross realm objects (a different realm has a different
// `Object`)
return true;
}
}
return false;
}
/**
* @param {Object} [optionsArg] The effect of these options are documented at the getter/setter with the same name
* @param {boolean} [optionsArg.receiveErrorStack=false]
* @param {boolean} [optionsArg.sendErrorStack=false]
* @param {string} [optionsArg.writableMode=json-stream]
* @param {string} [optionsArg.readableMode=json-stream]
* @param {number} [optionsArg.firstRequestId=0] The first request id to use
* @param {string} [optionsArg.sessionId=randomString()]
* @param {string} [optionsArg.endOfJSONWhitespace=]
* @param {boolean} [optionsArg.endOnFinish=true]
* @param {boolean} [optionsArg.finishOnEnd=true]
* @param {boolean} [optionsArg.pingReceive=true]
* @param {string} [optionsArg.pingMethod='jsonbird.ping']
* @param {number} [optionsArg.pingInterval=2000]
* @param {number} [optionsArg.pingTimeout=1000]
* @param {number} [optionsArg.pingNow=Date.now] Timer function used to figure out ping delays
* @param {Function} [optionsArg.setTimeout=global.setTimeout]
* @param {Function} [optionsArg.clearTimeout=global.clearTimeout]
*/
constructor(optionsArg = {}) {
const options = Object.assign({}, DEFAULT_OPTIONS, optionsArg);
if (options.writableMode !== 'object' &&
options.writableMode !== 'json-stream' &&
options.writableMode !== 'json-message') {
throw Error('JSONBird: Invalid value for `writableMode` option');
}
if (options.readableMode !== 'object' &&
options.readableMode !== 'json-stream' &&
options.readableMode !== 'json-message') {
throw Error('JSONBird: Invalid value for `readableMode` option');
}
if (typeof options.endOfJSONWhitespace !== 'string') {
throw Error('JSONBird: The `endOfJSONWhitespace` option must be a string');
}
// empty string is also valid, and is the default:
if (/[^\x20\x09\x0a\x0d]/.test(options.endOfJSONWhitespace)) {
throw Error(
'JSONBird: The `endOfJSONWhitespace` option must only contain whitespace characters ' +
'(whitespace as defined in rfc4627)'
);
}
super({
allowHalfOpen: true,
decodeStrings: false, // disable string -> Buffer conversion for _write
readableObjectMode: options.readableMode === 'object' || options.readableMode === 'json-message',
writableObjectMode: options.writableMode === 'object' || options.writableMode === 'json-message',
});
this[PRIVATE] = Object.seal({
clientPendingMap: new Map(),
defaultTimeout: 0,
endOfJSONWhitespace: options.endOfJSONWhitespace,
endOnFinish: false,
ended: false, // Readable has ended
finishOnEnd: false,
finished: false, // Writable has finished
methods: [],
nextId: Math.floor(options.firstRequestId) || 0,
notifications: [],
pendingRequestPromise: Promise.resolve(),
pendingResponsesPromise: Promise.resolve(),
readJSONParser: null,
readJSONParserValues: [],
readableMode: options.readableMode,
receiveErrorStack: false,
sendErrorStack: false,
serverPending: 0,
sessionId: String(options.sessionId === undefined ? shortid.generate() : (options.sessionId || '')),
writableMode: options.writableMode,
pingReceive: Boolean(options.pingReceive),
pingMethod: String(options.pingMethod),
pingInterval: 0,
pingTimeout: 0,
pingNow: options.pingNow,
isSendingPings: false,
pingTimer: 0,
pingConsecutiveFails: 0,
setTimeout: options.setTimeout,
clearTimeout: options.clearTimeout,
});
this.receiveErrorStack = options.receiveErrorStack;
this.sendErrorStack = options.sendErrorStack;
this.defaultTimeout = options.defaultTimeout;
this.endOnFinish = options.endOnFinish;
this.finishOnEnd = options.finishOnEnd;
this.pingInterval = options.pingInterval;
this.pingTimeout = options.pingTimeout;
if (this.writableMode === 'json-stream') {
const readJSONParser = new JSONParser();
this[PRIVATE].readJSONParser = readJSONParser;
readJSONParser.onValue = value => {
if (readJSONParser.stack.length === 0) { // full object
this[PRIVATE].readJSONParserValues.push(value);
}
};
}
this.on('finish', () => {
const {clientPendingMap} = this[PRIVATE];
// our writable stream is finished, we will no longer receive any data (_write)
this[PRIVATE].finished = true;
this.stopPinging();
// eslint-disable-next-line prefer-const
for (let pendingData of clientPendingMap.values()) {
const {reject, timer} = pendingData;
if (timer) {
this.clearTimeout(timer);
}
reject(new RPCRequestError(
Error(
'JSONBird: The Writable side of this Duplex stream has finished while this call was pending, ' +
'we will be unable to receive a response object'
),
-32000
));
}
clientPendingMap.clear();
if (this.endOnFinish && !this[PRIVATE].ended) {
// end our read stream as soon as all of our outgoing objects have been sent
this.waitForPendingResponses().then(() => {
this.push(null); // end
});
}
});
this.on('end', () => {
// our readable stream has ended, we will no longer be able to send any data
this[PRIVATE].ended = true;
this.stopPinging();
if (this.finishOnEnd && !this.finished) {
this.waitForPendingRequests().then(() => {
this.end(); // finish
});
}
});
if (this.pingReceive) {
this.method(this.pingMethod, RETURN_TRUE);
}
}
/**
* Generate a new id to be used for an outgoing request object
*
* @return {string|number}
*/
generateId() {
const priv = this[PRIVATE];
const id = priv.nextId;
if (priv.nextId >= 9007199254740992) {
priv.nextId = -9007199254740992;
}
else {
++priv.nextId;
}
return priv.sessionId ? id + ' ' + priv.sessionId : id;
}
/**
* The HTML setTimeout function
*
* This function may be overridden for unit tests
* @return {Function} https://html.spec.whatwg.org/#dom-settimeout
*/
get setTimeout() {
return this[PRIVATE].setTimeout;
}
/**
* The HTML clearTimeout function
*
* This function may be overridden for unit tests
* @return {Function} https://html.spec.whatwg.org/#dom-cleartimeout
*/
get clearTimeout() {
return this[PRIVATE].clearTimeout;
}
/**
* Has the readable side of this duplex stream been ended?
*
* (has the 'end' event been emitted)
*
* @return {boolean}
*/
get ended() {
return this[PRIVATE].ended;
}
/**
* Has the writable side of this duplex stream been finished?
*
* (has the 'finish' event been emitted)
*
* @return {boolean}
*/
get finished() {
return this[PRIVATE].finished;
}
/**
* This is a string that will be appended to the id of all request objects that we send out.
*
* This is useful in case the same transport is reused, to make sure that we do not parse any stale response objects.
* By default, this is set to a short unique id (using the "shortid" module)
*
* @return {string}
*/
get sessionId() {
return this[PRIVATE].sessionId;
}
/**
* Determines how to JSONBird interprets messages that are written to the writable side of this Duplex stream.
*
* If the value is "object", the writable stream is put in object mode and a plain old javascript object is expected.
*
* For example:
* ```javascript
* rpc.write({jsonrpc: '2.0', method: 'subtract', params: [42, 23], id: 0})
* ```
*
* If the value is "json-message", the writable stream is put in object mode and a json string or a Buffer (utf8) is expected,
*
* For example:
* ```javascript
* rpc.write('{"jsonrpc":"2.0","method":"subtract","params":[42,23],"id":0}')
* rpc.write('{"jsonrpc"') // invalid json string, a `protocolError` event will emitted
* ```
*
* If the value is "json-stream", a streaming sequence of json strings or Buffers (utf8) are expected.
*
* For example:
* ```javascript
* // will wait until more data arrives to complete the json string:
* rpc.write('{"jsonrpc":"2.0","method":"subt')
* rpc.write('ract","params":[42,23],"id":0}{"jsonrpc"')
* rpc.write(':"2.0","method":"subtract","params":[100,1],"id":1}')
* ```
*
* @return {string} "object", "json-stream" or "json-message"
*/
get writableMode() {
return this[PRIVATE].writableMode;
}
/**
* Determines how JSONBird sends messages to the readable side of this Duplex stream.
*
* If the value is "object", the readable stream is put in object mode and a plain old javascript object is sent.
*
* For example:
* ```javascript
* rpc.on('data', object => assert.deepEqual(object, {jsonrpc: '2.0', result: 19, id: 0}));
* ```
*
* If the value is "json-message", the readable stream is put in object mode and a json string is sent.
*
* For example:
* ```javascript
* rpc.on('data', string => console.log('json string:', string));
* // json string: {"jsonrpc":"2.0","result":19,"id":0}
* // json string: {"jsonrpc":"2.0","result":99,"id":1}
* ```
*
* If the value is "json-stream", a streaming sequence of json strings are sent.
*
* For example:
* ```javascript
* rpc.on('data', string => console.log('chunk:', string));
* // chunk: {"jsonrpc":"2.0","res
* // chunk: ult":19,"id":0}{"jsonrpc":"2.0",
* // chunk: "result":99,"id":1}
* ```
*
* @return {string} "object" or "json-stream"
*/
get readableMode() {
return this[PRIVATE].readableMode;
}
/**
* This value is appended to the end of every json string sent to the readable stream.
*
* Only whitespace characters are allowed. This option only has an affect if `readableMode == 'json-stream'`
*
* @return {string}
*/
get endOfJSONWhitespace() {
return this[PRIVATE].endOfJSONWhitespace;
}
/**
* If `true` and the writable side of this Duplex stream has finished, automatically end the readable side (after all pending
* responses have been sent).
*
* @return {boolean}
*/
get endOnFinish() {
return this[PRIVATE].endOnFinish;
}
/**
* If `true` and the writable side of this Duplex stream has finished, automatically end the readable side (after all pending
* responses have been sent).
*
* @param {boolean} value
*/
set endOnFinish(value) {
this[PRIVATE].endOnFinish = Boolean(value);
}
/**
* If `true` and the readable side of this Duplex stream has ended, automatically finish the writable side (after all pending
* requests have received a response).
*
* @return {boolean}
*/
get finishOnEnd() {
return this[PRIVATE].finishOnEnd;
}
/**
* If `true` and the readable side of this Duplex stream has ended, automatically finish the writable side (after all pending
* requests have received a response).
*
* @param {boolean} value
*/
set finishOnEnd(value) {
this[PRIVATE].finishOnEnd = Boolean(value);
}
/**
* The number of incoming RPC requests for which we have not sent a reply yet
*
* @return {number}
*/
get serverPending() {
return this[PRIVATE].serverPending;
}
/**
* The number of outstanding RPC requests for which we have not yet received a response.
*
* @return {number}
*/
get clientPending() {
const {clientPendingMap} = this[PRIVATE];
return clientPendingMap.size;
}
/**
* If true and a remote method throws, attempt to read stack trace information from the JSON-RPC `error.data` property. This stack
* trace information is then used to set the `fileName`, `lineNumber`, `columnNumber` and `stack` properties of our local `Error`
* object (the Error object that the `.call()` function will reject with).
*
* @return {boolean}
*/
get receiveErrorStack() {
return this[PRIVATE].receiveErrorStack;
}
/**
* If true and a remote method throws, attempt to read stack trace information from the JSON-RPC `error.data` property. This stack
* trace information is then used to set the `fileName`, `lineNumber`, `columnNumber` and `stack` properties of our local `Error`
* object (the Error object that the `.call()` function will reject with).
*
* @param {boolean} value
*/
set receiveErrorStack(value) {
this[PRIVATE].receiveErrorStack = Boolean(value);
}
/**
* If true, the `fileName`, `lineNumber`, `columnNumber` and `stack` of an `Error` thrown during a method is sent to the client
* using the JSON-RPC `error.data` property.
*
* @return {boolean}
*/
get sendErrorStack() {
return this[PRIVATE].sendErrorStack;
}
/**
* If true, the `fileName`, `lineNumber`, `columnNumber` and `stack` of an `Error` thrown during a method is sent to the client
* using the JSON-RPC `error.data` property.
*
* @param {boolean} value
*/
set sendErrorStack(value) {
this[PRIVATE].sendErrorStack = Boolean(value);
}
/**
* The timeout to use for an outgoing method call unless a different timeout was explicitly specified to `call()`.
*
* @return {number}
*/
get defaultTimeout() {
return this[PRIVATE].defaultTimeout;
}
/**
* The timeout to use for an outgoing method call unless a different timeout was explicitly specified to `call()`.
*
* @param {number} value
*/
set defaultTimeout(value) {
const number = Number(value);
this[PRIVATE].defaultTimeout = number > 0 ? number : 0;
}
/**
* If `true` a method with the name `this.pingMethod` is added which simply returns true as fast as possible.
* @return {boolean}
*/
get pingReceive() {
return this[PRIVATE].pingReceive;
}
/**
* Are we currently sending pings to our peer?
*
* In other words, has `this.startPinging()` been called?
* @return {boolean}
*/
get isSendingPings() {
return this[PRIVATE].isSendingPings;
}
/**
* The method name used when receiving or sending pings.
* @return {string}
*/
get pingMethod() {
return this[PRIVATE].pingMethod;
}
/**
* The time (in milliseconds) between each ping if `isSendingPings` is true.
* This time is in addition to the time spent waiting for the previous ping to settle.
*
* @return {number} milliseconds
*/
get pingInterval() {
return this[PRIVATE].pingInterval;
}
/**
* The time (in milliseconds) between each ping if `isSendingPings` is true.
* This time is in addition to the time spent waiting for the previous ping to settle.
*
* @param {number} value milliseconds
*/
set pingInterval(value) {
this[PRIVATE].pingInterval = value;
}
/**
* The maximum amount of time (in milliseconds) to wait for a ping method call to resolve.
* @return {number} milliseconds
*/
get pingTimeout() {
return this[PRIVATE].pingTimeout;
}
/**
* The maximum amount of time (in milliseconds) to wait for a ping method call to resolve.
* @param {number} value milliseconds
*/
set pingTimeout(value) {
this[PRIVATE].pingTimeout = value;
}
/**
* Returns a promise which resolves as soon as all pending requests (as a server) have had their appropriate responses sent to the
* underlying readable stream.
*
* Note that if a new requests comes in after using waitForPendingResponses(), they will not further delay this Promise.
*
* @return {Promise}
*/
waitForPendingResponses() {
return this[PRIVATE].pendingResponsesPromise;
}
/**
* Returns a promise which resolves as soon as all pending requests (as a client) have had their appropriate responses received from
* the underlying writable stream.
*
* Note that if a new call() is made after using waitForPendingResponses(), it will not further delay this Promise.
*
* @return {Promise}
*/
waitForPendingRequests() {
return this[PRIVATE].pendingRequestPromise;
}
/**
* Registers a new method with the given name.
*
* If the same method name is registered multiple times, earlier definitions will be overridden
*
* @param {string} name The method name
* @param {Function} func
*/
method(name, func) {
if (typeof name !== 'string') {
throw Error('JSONBird: First argument (name) must be a string');
}
if (typeof func !== 'function') {
throw Error('JSONBird: Second argument (func) must be a function');
}
const {methods} = this[PRIVATE];
const lastDefinition = methods[methods.length - 1];
if (lastDefinition && lastDefinition.isSingleMethodMap) {
// reuse the latest definition
lastDefinition.map.set(name, func);
}
else {
methods.push({
isSingleMethodMap: true,
map: new Map([[name, func]]),
object: null,
});
}
}
/**
* Registers multiple methods using an object or Map.
*
* Each key->value pair is registered as a method.
* Values that are not a function are ignored.
* The `this` object during a method call is set to the `objectOrMap` (unless a Map was used)
*
* If the same method name is registered multiple times, earlier definitions will be overridden
*
* @param {Object|Map} objectOrMap
*/
methods(objectOrMap) {
if (!objectOrMap || typeof objectOrMap !== 'object') {
throw Error('JSONBird: First argument (objectOrMap) must be an object, or a Map');
}
const {methods} = this[PRIVATE];
if (getObjectTag(objectOrMap) === '[object Map]') {
methods.push({
isSingleMethodMap: false,
map: objectOrMap,
object: null,
});
}
else {
methods.push({
isSingleMethodMap: false,
map: null,
object: objectOrMap,
});
}
}
/**
* Registers a notification with the given name.
*
* A notification is a method for which the return value or thrown Error is ignored. A response object is never sent.
*
* If the same method name is registered multiple times, all functions handlers will be called (in the same order as they were
* registered)
*
* @param {string} name The method name
* @param {Function} func
*/
notification(name, func) {
if (typeof name !== 'string') {
throw Error('JSONBird: First argument (name) must be a string');
}
if (typeof func !== 'function') {
throw Error('JSONBird: Second argument (func) must be a function');
}
const {notifications} = this[PRIVATE];
const lastDefinition = notifications[notifications.length - 1];
if (lastDefinition && lastDefinition.isSingleNotificationMap) {
// reuse the latest definition
let list = lastDefinition.map.get(name);
if (!list) {
list = [];
lastDefinition.map.set(name, list);
}
list.push(func);
}
else {
notifications.push({
isSingleNotificationMap: true,
map: new Map([[name, [func]]]),
object: null,
});
}
}
/**
* Registers multiple notifications using an object or Map.
*
* A notification is a method for which the return value or thrown Error is ignored. A response object is never sent.
*
* If the same method name is registered multiple times, all functions handlers will be called (in the same order as they were
* registered)
*
* Each key->value pair is registered as a notification.
* Values that are not a "function" are ignored.
* The `this` object during a method call is set to the `objectOrMap` (unless a Map was used)
*
* If the same method name is registered multiple times, earlier definitions will be overridden
*
* @param {Object|Map} objectOrMap
*/
notifications(objectOrMap) {
if (!objectOrMap || typeof objectOrMap !== 'object') {
throw Error('JSONBird: First argument (objectOrMap) must be an object, or a Map');
}
const {notifications} = this[PRIVATE];
if (getObjectTag(objectOrMap) === '[object Map]') {
notifications.push({
isSingleNotificationMap: false,
map: objectOrMap,
object: null,
});
}
else {
notifications.push({
isSingleNotificationMap: false,
map: null,
object: objectOrMap,
});
}
}
/**
* Directly call a method registered on this instance, without involving any stream.
*
* This is mostly useful for testing.
*
* @private
* @param {string} name The method name
* @param {...*} args
* @return {Promise} A promise resolving with the return value of the method, or rejecting with an error
*/
callLocal(name, ...args) {
return prom(Promise.resolve().then(() => {
++this[PRIVATE].serverPending;
const {methods} = this[PRIVATE];
if (typeof name !== 'string') {
throw Error('JSONBird: First argument (name) must be a string');
}
if (name.startsWith('rpc.')) { // reserved
throw new RPCRequestError(Error('JSONBird: Method not found'), -32601);
}
for (let i = methods.length - 1; i >= 0; --i) {
const {map, object} = methods[i];
if (map) {
const method = map.get(name);
if (typeof method !== 'function') {
continue;
}
return method(...args);
}
const method = object[name];
if (typeof method !== 'function' || JSONBird.isObjectBuiltinFunction(object, name)) {
continue;
}
return method.apply(object, args);
}
throw new RPCRequestError(Error('JSONBird: Method not found'), -32601);
}))
.finally(() => {
--this[PRIVATE].serverPending;
});
}
/**
* Directly call a method registered on this instance (see callLocal), and send a response object to our readableStream
*
* @private
* @param {number|string} id The id to use in the response object
* @param {string} name The method name
* @param {...*} args
* @return {*} The return value of the method
*/
callLocalAndSendResponse(id, name, ...args) {
if (!JSONBird.isValidID(id)) {
return Promise.reject(Error('JSONBird: First argument is not a valid id'));
}
const returnPromise = this.callLocal(name, ...args)
.then(
result => ({
jsonrpc: '2.0',
id,
result: result === undefined ? null : result, // JSON.stringify({result: undefined}) === '{}'
}),
error => ({
jsonrpc: '2.0',
id,
error: JSONBird.errorToResponseObject(error, this.sendErrorStack),
})
)
.then(object => this.sendObject(object));
// pendingResponsesPromise resolves after all out going objects have been buffered into our read stream
// this includes waiting for any pending method calls (if we are acting as a server) to resolve to a value
// pendingResponsesPromise always resolves to undefined, and it never rejects.
// this needed so that if our write stream is closed, we do not close our read stream too early
this[PRIVATE].pendingResponsesPromise = this[PRIVATE].pendingResponsesPromise.then(
() => returnPromise.then(NOOP, NOOP)
);
return returnPromise;
}
/**
* Call a method on the remote instance, by sending a JSON-RPC request object to our write stream.
*
* If no write stream has been set, the method call will be buffered until a write stream is set (setWriteStream).
* Note: if a read stream is never set, any call() will also never resolve.
*
* @param {string|Object} nameOrOptions The method name or an options object
* @param {string} nameOrOptions.name The method name
* @param {number} nameOrOptions.timeout A maximum time (in milliseconds) to wait for a response. The returned promise will reject
* after this time.
* @param {...*} args
*
* @return {Promise} A Promise which will resole with the return value of the remote method
*/
call(nameOrOptions, ...args) {
const {clientPendingMap} = this[PRIVATE];
if ((typeof nameOrOptions !== 'string' && typeof nameOrOptions !== 'object') || nameOrOptions === null) {
return Promise.reject(new Error('JSONBird: First argument must be a string or an object with at least a "name" property'));
}
const name = typeof nameOrOptions === 'object' ? nameOrOptions.name : nameOrOptions;
if (typeof name !== 'string') {
return Promise.reject(new Error('JSONBird: First argument must be a string or an object with at least a "name" property'));
}
const timeout = typeof nameOrOptions === 'object' && 'timeout' in nameOrOptions
? nameOrOptions.timeout :
this.defaultTimeout;
if (this.finished) {
// note: notify() does have this check because it does not care about response objects
return Promise.reject(new Error(
'JSONBird: The Writable side of this Duplex stream has finished, ' +
'we will be unable to receive a response object for this call'
));
}
const id = this.generateId();
/* istanbul ignore if */
if (clientPendingMap.has(id)) {
return Promise.reject(new RPCRequestError(Error('JSONBird: Generated request "id" is not unique!'), -32603));
}
let pendingData;
const responsePromise = new Promise((resolve, reject) => {
pendingData = {resolve, reject, timer: 0};
pendingData.timer = timeout && this.setTimeout(
() => {
pendingData.timer = 0;
reject(new RPCRequestError(Error(`JSONBird: Remote Call "${name}" timed out after ${timeout}ms`), -32000));
},
timeout
);
});
clientPendingMap.set(id, pendingData);
const returnPromise = prom(this.sendObject({
jsonrpc: '2.0',
id,
method: name,
params: args,
})
.then(() => responsePromise))
.finally(() => {
clientPendingMap.delete(id);
if (pendingData.timer) {
this.clearTimeout(pendingData.timer);
}
});
this[PRIVATE].pendingRequestPromise = this[PRIVATE].pendingRequestPromise.then(
() => returnPromise.then(NOOP, NOOP)
);
return returnPromise;
}
/**
* Returns a new function which calls the given method name by binding the function to this RPC instance and the given method name (or
* options object).
*
* For example:
*
* ```javascript
* const subtract = rpc.bindCall('subtract');
* subtract(10, 3).then(result => console.log(result)) // 7
* ```
*
* @param {string|Object} nameOrOptions The method name or an options object
* @param {string} nameOrOptions.name The method name
* @param {number} nameOrOptions.timeout A maximum time (in milliseconds) to wait for a response. The returned promise will reject
* after this time.
* @return {Function}
*/
bindCall(nameOrOptions) {
return this.call.bind(this, nameOrOptions);
}
/**
* Directly call a notification registered on this instance, without involving any stream.
*
* This function resolves as soon as all invocation for the appropriate registered notifications have been scheduled, but before
* actually invoking any of them.
*
* This is mostly useful for testing.
*
* @private
* @param {string} name The method name
* @param {...*} args
* @return {Promise} A promise resolving with `undefined` and only rejects when an internal JSONBird Error occurs.
*/
notifyLocal(name, ...args) {
return Promise.resolve().then(() => {
if (typeof name !== 'string') {
throw Error('JSONBird: First argument (name) must be a string');
}
if (name.startsWith('rpc.')) { // reserved
return;
}
const {notifications} = this[PRIVATE];
const executeMethod = (thisObject, method) => {
Promise.resolve().then(() => method.apply(thisObject, args)).catch(error => this.emit('error', error));
};
for (let i = notifications.length - 1; i >= 0; --i) {
const {map, object} = notifications[i];
if (map) {
const mapValue = map.get(name);
const methods = Array.isArray(mapValue) ? mapValue : [mapValue];
// eslint-disable-next-line prefer-const
for (let method of methods) {
if (typeof method === 'function') {
executeMethod(undefined, method);
}
}
}
else {
const method = object[name];
if (typeof method !== 'function' || JSONBird.isObjectBuiltinFunction(object, name)) {
continue;
}
executeMethod(object, method);
}
}
});
}
/**
* Execute a notification on the remote instance, by sending a JSON-RPC request object to our write stream.
*
* If no write stream has been set, the method call will be buffered until a write stream is set (setWriteStream).
*
* This function resolves as soon as the request object has been buffered, but does not wait for the remote instance to have
* actually received the request object.
*
* @param {string|Object} nameOrOptions The method name or an options object
* @param {string} nameOrOptions.name The method name
* @param {...*} args
*
* @return {Promise}
*/
notify(nameOrOptions, ...args) {
if ((typeof nameOrOptions !== 'string' && typeof nameOrOptions !== 'object') || nameOrOptions === null) {
return Promise.reject(new Error('JSONBird: First argument must be a string or an object with at least a "name" property'));
}
const name = typeof nameOrOptions === 'object' ? nameOrOptions.name : nameOrOptions;
if (typeof name !== 'string') {
return Promise.reject(new Error('JSONBird: First argument must be a string or an object with at least a "name" property'));
}
return this.sendObject({
jsonrpc: '2.0',
method: name,
params: args,
});
}
/**
* Returns a new function which sends a notification with the given method name by binding the function to this RPC instance and the
* given method name (or options object).
*
* For example:
*
* ```javascript
* const userDeleted = rpc.bindNotify('userDeleted');
* userDeleted(123)
* ```
*
* @param {string|Object} nameOrOptions The method name or an options object
* @param {string} nameOrOptions.name The method name
* @param {number} nameOrOptions.timeout A maximum time (in milliseconds) to wait for a response. The returned promise will reject
* after this time.
* @return {Function}
*/
bindNotify(nameOrOptions) {
return this.notify.bind(this, nameOrOptions);
}
_read(size) {
// noop
}
_write(chunk, encoding, callback) {
const handleRpcError = error => {
if (error instanceof RPCRequestError) {
return this.sendObject({
jsonrpc: '2.0',
error: JSONBird.errorToResponseObject(error),
id: error[REQUEST_ERROR_ID] || null,
})
.then(() => {
this.emit('protocolError', error);
});
}
/* istanbul ignore else */
if (error instanceof RPCResponseError) {
this.emit('protocolError', error);
return null;
}
/* istanbul ignore next */
throw error;
};
Promise.resolve().then(() => {
if (this.writableMode === 'object') {
/* istanbul ignore if */
if (typeof chunk !== 'object' || Buffer.isBuffer(chunk)) {
throw Error(`JSONBird: Assertion Error: expected an object during _write()`);
}
return this.handleObject(chunk).catch(handleRpcError);
}
if (this.writableMode === 'json-message') {
/* istanbul ignore if */
if (typeof chunk !== 'string' && !Buffer.isBuffer(chunk)) {
throw Error(`JSONBird: Assertion Error: expected a string or Buffer during _write()`);
}
const jsonString = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : chunk;
let object;
try {
object = JSON.parse(jsonString); // might throw
}
catch (err) {
// We can not know for certain that this was a request object because JSONBird might be used bidirectionally.
// So assume this is a request object, not sending a response at all is worse than sending too much.
return Promise.reject(new RPCRequestError(
Error(`JSONBird: Error parsing your JSON string: ${err.message}`),
-32700
))
.catch(handleRpcError);
}
return this.handleObject(object).catch(handleRpcError);
}
/* istanbul ignore else */
if (this.writableMode === 'json-stream') {
const {readJSONParser, readJSONParserValues} = this[PRIVATE];
/* istanbul ignore if */
if (typeof chunk !== 'string' && !Buffer.isBuffer(chunk)) {
throw Error(`JSONBird: Assertion Error: expected a string or Buffer during _write()`);
}
try {
// accepts a Buffer or a string
// note: writeJSONParser is not a stream, everything is parsed synchronously,
// "onValue" is also called synchronously
readJSONParser.write(chunk);
}
catch (err) {
// We can not know for certain that this was a request object because JSONBird might be used bidirectionally.
// So assume this is a request object, not sending a response at all is worse than sending too much.
return Promise.reject(new RPCRequestError(
Error(`JSONBird: Error parsing your JSON string: ${err.message}`),
-32700
))
.catch(handleRpcError);
}
const promises = readJSONParserValues.map(object =>
this.handleObject(object).catch(handleRpcError)
);
readJSONParserValues.length = 0;
return Promise.all(promises);
}
/* istanbul ignore next */
throw Error(`JSONBird: Assertion Error: Invalid this.readableMode during _write(): ${this.readableMode}`);
})
// callback(error) is emitted as an 'error' on this stream (and also interrupts the stream)
.then(
() => callback(),
error => {
/* istanbul ignore next */
callback(error);
}
);
}
/**
* Immediately handle the given request or response object, without buffering.
*
* This function waits until all the sanity checks have been performed, however it does not wait for the method handler (the
* function registered using `method()` or `notification()`)
*
* @private
* @param {Object} object plain old javascript options
* @return {Promise} Resolves with `undefined` or it rejects with a `RPCRequestError` or `RPCResponseError` if the object is malformed
*/
handleObject(object) {
if ('method' in object) {
return this.handleRequestObject(object);
}
if ('result' in object || 'error' in object) {
return this.handleResponseObject(object);
}
// We can not know for certain that this was a request object because JSONBird is (optionally) bidirectional.
// So assume this is a request object, not sending a response at all is worse than sending too much.
return Promise.reject(new RPCRequestError(
Error(
'JSONBird: Unable to determine if the message was a request or response object (one of the "method", ' +
'"result" or "error" properties must be present)'
),
-32600
));
}
/**
* Immediately handle the given request object, without buffering.
*
* This function waits until all the sanity checks have been performed, however it does not wait for the method handler (the
* function registered using `method()` or `notification()`)
*
* @private
* @param {Object} object plain old javascript options
* @return {Promise} Resolves with `undefined` or it rejects with a `RPCRequestError` if the object is malformed
*/
handleRequestObject(object) {
let id = null;
return Promise.resolve().then(() => {
if (JSONBird.isValidID(object.id)) {
id = object.id;
}
if (!('jsonrpc' in object)) {
throw new RPCRequestError(Error(
'JSONBird: Invalid Request: "jsonrpc" attribute is missing (JSON-RPC version 1 is not supported)'
), -32600);
}
if (!JSONBird.isValidVersion(object.jsonrpc)) {
throw new RPCRequestError(Error('JSONBird: Invalid Request: given "jsonrpc" version is not supported'), -32600);
}
if (!JSONBird.isValidMethodName(object.method)) {
throw new RPCRequestError(Error('JSONBird: Method not found: "method" attribute must be a string'), -32601);
}
let params = [];
if ('params' in object) {
if (JSONBird.isValidParams(object.params)) {
params = Array.isArray(object.params)
? object.params
: [object.params];
}
else {
throw new RPCRequestError(Error('JSONBird: Invalid Request: "params" must be an array or object'), -32600);
}
}
if ('id' in object) {
if (id === null) {
throw new RPCRequestError(Error('JSONBird: Invalid Request: "id" must be a number or a string'), -32600);
}
// We do not wait for this method to complete, otherwise we could only execute a single method at a time because we are
// currently blocking the stream
this.callLocalAndSendResponse(id, object.method, ...params);
}
else {
this.notifyLocal(object.method, ...params);
}
})
.catch(error => {
error[REQUEST_ERROR_ID] = id;
throw error;
});
}
/**
* Immediately handle the given response object, without buffering.
*
* This function waits until all the sanity checks have been performed
*
* @private
* @param {Object} object plain old javascript options
* @return {Promise} Resolves with `undefined` or it rejects with a `RPCResponseError` if the object is malformed
*/
handleResponseObject(object) {
return Promise.resolve().then(() => {
if (!JSONBird.isValidVersion(object.jsonrpc)) {
// emitted as an 'error' event
throw new RPCResponseError(Error('JSONBird: Invalid Response: "jsonrpc" property must be "2.0"'));
}
if (!JSONBird.isValidID(object.id)) {
throw new RPCResponseError(Error('JSONBird: Invalid Response: "id" property must be a number or string'));
}
if (!('result' in object || typeof object.error === 'object')) {
throw new RPCResponseError(Error('JSONBird: Invalid Response: Must have a "error" or an "result" property'));
}
if ('result' in object && 'error' in object) {
throw new RPCResponseError(Error('JSONBird: Invalid Response: The "error" and "result" properties are both present'));
}
const {clientPendingMap} = this[PRIVATE];
const pendingEntry = clientPendingMap.get(object.id);
if (!pendingEntry) {
throw new RPCResponseError(Error('JSONBird: Invalid Response: Unknown id'));
}
if ('result' in object) {
pendingEntry.