UNPKG

@akala/json-rpc-ws

Version:

json-rpc websocket transport

322 lines 11.9 kB
import { default as Errors } from './errors.js'; import debug from 'debug'; import { IsomorphicBuffer, SocketProtocolAdapter } from '@akala/core'; const logger = debug('akala:json-rpc-ws'); export class JsonRpcSocketAdapter extends SocketProtocolAdapter { constructor(socket) { super({ receive(data, self) { logger('message %j', data); let payload; if (typeof (data) !== 'string') { if (data instanceof IsomorphicBuffer) payload = jsonParse(data.toString('utf8')); } else if (typeof (data) == 'string') { payload = jsonParse(data); } if (!payload) { console.error(data); socket.send(self.transform.send(Errors('parseError', -1), self)); } return [payload]; }, send(payload) { return JSON.stringify(payload); } }, socket); } } /** * Quarantined JSON.parse try/catch block in its own function * * @param {String} data - json data to be parsed * @returns {Object} Parsed json data */ // eslint-disable-next-line @typescript-eslint/no-explicit-any const jsonParse = function jsonParse(data) { let payload; try { payload = JSON.parse(data); } catch (error) { logger(error); payload = null; } return payload; }; /** * JSON spec requires a reply for every request, but our lib doesn't require a * callback for every sendMethod. We need a dummy callback to throw into responseHandlers * for when the user doesn't supply callback to sendMethod */ const emptyCallback = function emptyCallback() { logger('emptycallback'); }; /** * json-rpc-ws connection * * @constructor * @param {SocketAdapter} socket - socket adapter for this connection * @param {Object} parent - parent that controls this connection */ export class Connection { socket; parent; sub; /** * */ constructor(socket, parent) { this.socket = socket; this.parent = parent; if (!this.socket.send) throw new Error('socket.send is not defined'); logger('new Connection to %s', parent.type); this.sub = socket.on('message', this.message.bind(this)); // this.on('message', this.message.bind(this)); this.once('close', this.close.bind(this)); this.once('error', this.close.bind(this)); // if (isBrowserSocket(parent, socket)) // { // socket.addEventListener('close', socketClosed.bind(this), { once: true }); // socket.addEventListener('error', socketError.bind(this), { once: true }); // } // else // { // socket.once('close', this.close.bind(this)); // socket.once('error', this.close.bind(this)); // } } on(event, handler) { return this.socket.on(event, handler); } once(event, handler) { return this.socket.once(event, handler); } id = crypto.randomUUID(); responseHandlers = {}; /** * Send json payload to the socket connection * * @param {Object} payload - data to be stringified * @private * @todo validate payload * @todo make sure this.connection exists, is connected * @todo if we're not connected look up the response handler from payload.id */ sendRaw(payload) { payload.jsonrpc = '2.0'; this.socket.send(payload); } /** * Validate payload as valid jsonrpc 2.0 * http://www.jsonrpc.org/specification * Reply or delegate as needed * * @param {Object} payload - payload coming in to be validated * @returns {void} */ processPayload(payload) { const version = payload.jsonrpc; const id = payload.id; const method = payload.method; let params = payload.params; let result = payload.result; if (typeof payload.error == 'object' && payload.error && 'stack' in payload.error && 'message' in payload.error) { const error = new Error(); payload.error = Object.assign(error, { stack: error.stack, message: error.message, name: error.name }, payload.error); } const error = payload.error; if (version !== '2.0') { return this.sendError('invalidRequest', id, { info: 'jsonrpc must be exactly "2.0"' }); } //Will either have a method (request), or result or error (response) if (typeof method === 'string') { const handler = this.parent.getHandler(method); if (!handler) { return this.sendError('methodNotFound', id, { info: 'no handler found for method ' + method }); } if (id !== undefined && id !== null && typeof id !== 'string' && typeof id !== 'number') { return this.sendError('invalidRequest', id, { info: 'id, if provided, must be one of: null, string, number' }); } if (params !== undefined && params !== null && typeof params !== 'object') { return this.sendError('invalidRequest', id, { info: 'params, if provided, must be one of: null, object, array' }); } logger('message method %s', payload.method); if (id === null || typeof id === 'undefined') { return handler.call(this, params, emptyCallback); } const handlerCallback = function handlerCallback(err, reply) { logger('handler got callback %j, %j', err, reply); if (typeof this.socket != 'undefined') this.sendResult(id, err, reply); else console.error('no socket to reply to'); }; if (payload.stream) params = this.buildStream(id, params); return handler.call(this, params, handlerCallback.bind(this)); } // needs a result or error at this point if (result === undefined && error === undefined) { return this.sendError('invalidRequest', id, { info: 'replies must have either a result or error' }); } if (typeof id === 'string' || typeof id === 'number') { logger('message id %s result %j error %j', id, result, error); const responseHandler = this.responseHandlers[id]; if (!responseHandler) { return this.sendError('invalidRequest', id, { info: 'no response handler for id ' + id }); } delete this.responseHandlers[id]; if (payload.stream) { result = this.buildStream(id, result); } return responseHandler.call(this, error, result); } } /** * Send a result message * * @param {String} id - id for the message * @param {Object} error - error for the message * @param {String|Object|Array|Number} result - result for the message * @public * */ sendResult(id, error, result, isStream) { logger('sendResult %s %s %j %j', id, isStream, error, result); // Assert(id, 'Must have an id.'); // Assert(error || result, 'Must have an error or a result.'); if (error && result) throw new Error('Cannot have both an error and a result'); const response = { id: id, stream: typeof isStream === 'undefined' ? undefined : !!isStream || this.isStream(result) }; if (result) { let cleanResult; if (typeof result == 'object' && !Array.isArray(result)) { cleanResult = {}; Object.getOwnPropertyNames(result).forEach(p => { if (p[0] != '_') Object.defineProperty(cleanResult, p, Object.getOwnPropertyDescriptor(result, p)); }); } else cleanResult = result; response.result = cleanResult; if (response.stream) { if (typeof id == 'undefined') throw new Error('streams are not supported without an id'); logger('result is stream'); this.sendStream(id, result); } } else { if (error instanceof Error) // eslint-disable-next-line @typescript-eslint/no-explicit-any error = Object.fromEntries([...Object.entries(error), ['message', error.message], ['stack', error.stack]]); response.error = error; } this.sendRaw(response); } /** * Send a method message * * @param {String} method - method for the message * @param {Array|Object|null} params - params for the message * @param {function} callback - optional callback for a reply from the message * @public */ sendMethod(method, params, callback) { const id = crypto.randomUUID(); if (typeof method !== 'string' || !method.length) throw new Error('method must be a non-empty string'); if (params !== null && params !== undefined && !(params instanceof Object)) throw new Error('params, if provided, must be an array, object or null'); logger('sendMethod %s', method, id); if (callback) { this.responseHandlers[id] = callback; } else { this.responseHandlers[id] = emptyCallback; } const request = { id: id, method: method }; if (params) { if (this.isStream(params)) { request.stream = true; this.sendStream(id, params); } request.params = params; } this.sendRaw(request); } /** * Send an error message * * @param {Object} error - json-rpc error object (See Connection.errors) * @param {String|Number|null} id - Optional id for reply * @param {Any} data - Optional value for data portion of reply * @public */ sendError(error, id, data) { logger('sendError %s', error); //TODO if id matches a responseHandler, we should dump it right? this.sendRaw(Errors(error, id, data)); } /** * Called when socket gets 'close' event * * @param {ConnectionError} error - optional error object of close wasn't expected * @private */ close(error) { logger('close'); if (error && error !== 1000) { // debugger; logger('close error %s', error['stack'] || error); } this.sub?.(); this.parent.disconnected(this); //Tell parent what went on so it can track connections // delete this.socket; } /** * Hang up the current socket */ hangup() { logger('hangup'); if (!this.socket) throw new Error('Not connected'); return new Promise((resolve, reject) => { const socket = this.socket; socket.once('error', reject); socket.once('close', resolve); this.socket.close(); }); } /** * Incoming message handler * * @param {String} data - message from the websocket * @returns {void} * @private */ message(payload) { //Validate as json first, easy reply if it's not //If it's an array iterate and handle //If it's an object handle //name of handle function ?!?!? if (!payload) return; //Object or array if (payload instanceof Array) { for (const innerPayload of payload) this.processPayload(innerPayload); } else { this.processPayload(payload); } } } //# sourceMappingURL=shared-connection.js.map