UNPKG

@zerodensity/realityhub-api

Version:
711 lines (616 loc) 20.6 kB
// Copyright (c) 2019-2021 Zero Density Inc. // // This file is part of realityhub-api. // // realityhub-api is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License version 2, as published by // the Free Software Foundation. // // realityhub-api 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 realityhub-api. If not, see <https://www.gnu.org/licenses/>. import { v4 as uuid } from 'uuid'; import { BrokerBase } from './BrokerBase.js'; import { BrokerError } from './BrokerError.js'; import { RawRequest } from './RawRequest.js'; import { consoleLogger } from './consoleLogger.js'; import { onceMultiple } from './onceMultiple.js'; export class BrokerClient extends BrokerBase { /** * BrokerClient constructor * @param {object} params Parameters * @param {string} params.webSocketURL WebSocket URL * @param {string} [params.moduleName] Module name * @param {number} [params.maxPacketSize] Maximum websocket packet size * @param {Logger} [params.logger] Logger instance * @param {boolean} [params.isDuplicate] [Private property, used internally] * @param {boolean} [params.parent] [Private property, used internally] */ constructor(params = {}) { super(params); this.setMaxListeners(20); for (const func of [ this.onOpen, this.onClose, this.onError, this.connect, this.onSocketMessage, ]) { const name = func.name; this[name] = func.bind(this); } this.logger = params.logger || consoleLogger(this.moduleName, { silent: true }); this.isDuplicate = params.isDuplicate; this.parent = params.parent; this.ssl = params.ssl; this.duplicates = new Set(); // moduleName of the server (will be set when we receive a ping message) this.serverModuleName = null; /** * A set of module names * Each time registerHandlersToRemote is called, the `remote` is added to this set. * BrokerClient uses the list of the remote endpoints for re-registering after a * reconnect. * @type {Set<string>} */ this.registrars = new Set(); this.webSocketURL = params.webSocketURL; this.connected = false; if (this.isDuplicate) { this.connected = this.isConnected(); } } /** * Returns a `Promise` that will resolve once a `connect` event is emitted. * If BrokerClient is already connected then it will resolve in the next * event loop. * @returns {Promise} */ getConnectPromise() { return new Promise((resolve) => { const looper = setInterval(() => { if (this.connected) { clearInterval(looper); resolve(); } }, 0); }); } forceReconnect() { this.removeSocketListeners(); this.socket = null; this.connect(this.connectOptions); } /** * Connect to server. * @param {object} options Options * @param {string} options.host Hostname or IP of the target server. * @param {number} options.port WebSocket port of the target server. */ connect(options) { if (this.isDuplicate) return; this.connectOptions = options; let url; if (!options) { url = new URL(location.href); } else { url = { hostname: options.host, port: options.port }; } const scheme = (url.protocol === 'https:' || this.ssl) ? 'wss' : 'ws'; const webSocketURL = url.port ? `${scheme}://${url.hostname}:${url.port}${this.webSocketURL}` : `${scheme}://${url.hostname}${this.webSocketURL}`; this.logger.info(`BrokerClient is connecting to ${webSocketURL}`); if (typeof process !== "undefined" && process.versions != null && process.versions.node != null) { const NODE_TLS_REJECT_UNAUTHORIZED = process.env.NODE_TLS_REJECT_UNAUTHORIZED; process.env.NODE_TLS_REJECT_UNAUTHORIZED = 0; this.socket = new WebSocket(webSocketURL); process.env.NODE_TLS_REJECT_UNAUTHORIZED = NODE_TLS_REJECT_UNAUTHORIZED; } else { this.socket = new WebSocket(webSocketURL); } this.addSocketListeners(); } /** * Duplicates a BrokerClient in order to share the same WebSocket. * @param {object} params Parameters * @param {string} params.moduleName Module name of the duplicate BrokerClient (a duplicate * can have a different name than its parent) * @returns {BrokerClient} */ duplicate(params) { const { moduleName } = params; let duplicates; let duplicate; if (this.isDuplicate) { duplicates = this.parent.duplicates; duplicate = new BrokerClient({ parent: this.parent, isDuplicate: true, webSocketURL: this.webSocketURL, logger: this.logger, moduleName, }); } else { duplicates = this.duplicates; duplicate = new BrokerClient({ parent: this, isDuplicate: true, webSocketURL: this.webSocketURL, logger: this.logger, moduleName, }); } duplicate.on('destroy', () => duplicates.delete(duplicate)); duplicates.add(duplicate); duplicate .ping() .catch((ex) => { console.error(`Failed to send ping`); console.trace(ex.message); }); return duplicate; } /** * Returns `true` if WebSocket connection is established. * @returns {boolean} */ isConnected() { if (this.isDuplicate) { return this.parent.isConnected(); } return this.connected; } /** * Sends a ping message to server. * @private */ ping() { return this.sendMessage({ type: 'ping' }); } /** * Returns the WebSocket instance. * @private */ getSocket() { return this.socket; } async onSocketMessage(event) { try { await this.handleMessage(event.data, this.socket); } catch (ex) { console.trace(ex); } } /** * Handles incoming messages. * @param {string} rawMessage Raw message * @async * @private */ async handleMessage(rawMessage) { let message; try { message = JSON.parse(rawMessage); const socket = this.isDuplicate ? this.parent.getSocket() : this.socket; switch (message.type) { case 'response': { this.emit(`response::${message.requestId}`, message); // Send the response to other duplicates (if we are parent) if (!this.isDuplicate) { for (const duplicate of this.duplicates) { duplicate.handleMessage(rawMessage); } } break; } case 'event': { // Run previously registered event handlers for (const [subscribedEvent, entries] of this.events.entries()) { if (subscribedEvent === message.eventName) { for (const entry of entries) { try { entry.eventHandler(...message.data); } catch (ex) { this.logger.warn(ex); } finally { if (entry.once) { this.unsubscribeFromAPIEvent(subscribedEvent, entry.eventHandler); } } } } } // Send the event to other duplicates (if we are parent) if (!this.isDuplicate) { for (const duplicate of this.duplicates) { duplicate.handleMessage(rawMessage); } } break; } case 'subscribe': { const [vendor, moduleName, ...rest] = message.eventName.split('.'); const eventName = rest.join('.'); const targetModuleName = [vendor, moduleName].join('.'); if (this.moduleName === targetModuleName) { this.emit('subscribe', { eventName }); await this.sendResponse(socket, message, true); } else { // Check if the target is one of the duplicates (if we are parent) if (!this.isDuplicate) { for (const duplicate of this.duplicates) { if (duplicate.moduleName === targetModuleName) { duplicate.handleMessage(rawMessage); return; } } } await this.sendResponse( socket, message, false, [{ error: `${message.eventName} sent to ${this.moduleName}. This is probably a mistake.` }], ); } break; } case 'unsubscribe': { const [vendor, moduleName, ...rest] = message.eventName.split("."); const eventName = rest.join("."); const targetModuleName = [vendor, moduleName].join("."); if (this.moduleName === targetModuleName) { this.emit('unsubscribe', { eventName }); await this.sendResponse(socket, message, true); } else { // Check if the target is one of the duplicates (if we are parent) if (!this.isDuplicate) { for (const duplicate of this.duplicates) { if (duplicate.moduleName === targetModuleName) { duplicate.handleMessage(rawMessage); return; } } } await this.sendResponse( socket, message, false, [{ error: `${message.eventName} sent to ${this.moduleName}. This is probably a mistake.` }], ); } break; } case 'ping': { this.serverModuleName = message.moduleName; if (!this.isDuplicate && message.targetModuleName !== this.moduleName) { for (const duplicate of this.duplicates) { if (duplicate.moduleName === message.targetModuleName) { duplicate.handleMessage(rawMessage); return; } } } await Promise.all([ this.sendResponse(socket, message, true), this.resubscribeModuleEvents(), this.subscribeToAPIEvent(`${message.moduleName}.moduleconnect`, ({ moduleName }) => { this.emit('moduleconnect', { moduleName }); this.resubscribeModuleEvents(); }), this.subscribeToAPIEvent(`${message.moduleName}.moduledisconnect`, ({ moduleName }) => { this.emit('moduledisconnect', { moduleName }); }), ]); break; } default: { if (!this.apiHandlers.has(message.type)) { await this.sendResponse( socket, message, false, [{ error: `There is no handler registered for this type of message: ${message.type}` }], ); return; } const { messageHandler, relay } = this.apiHandlers.get(message.type); try { let responseMessage = await messageHandler(...message.data); if (responseMessage instanceof RawRequest) { const rawRequest = responseMessage; rawRequest.setAncillaryData({ ...message.ancillaryData, caller: { moduleName: message.moduleName, }, }); responseMessage = await rawRequest.call(...message.data); } await this.sendResponse(socket, message, true, responseMessage, relay); } catch (ex) { if (ex instanceof BrokerError) { this.logger.error(ex.message); await this.sendResponse(socket, message, false, [{ error: ex.message }], relay); return; } this.logger.trace(ex); await this.sendResponse(socket, message, false, [{ error: 'ERROR' }], relay); } break; } } } catch (ex) { if (ex.code === 'TIMEOUT') { console.warn('Message timed out.'); console.log(message); return; } console.trace(ex); } } /** * Sends a message. * @async * @param {object} message * @returns {Promise.<Array, Error>} */ async sendMessage(message) { const id = uuid(); const socket = this.isDuplicate ? this.parent.getSocket() : this.socket; const webSocketMessage = Object.assign( {}, message, { id, time: new Date().valueOf(), moduleName: this.moduleName, }, ); if (socket.readyState !== WebSocket.OPEN) { try { await onceMultiple(this, ['connect'], webSocketMessage.timeout || this.messageTimeout); } catch (ex) { console.error(`Timeout: Socket is not ready`); throw ex; } } let ret; try { ret = super.sendMessage(webSocketMessage, socket); } catch (ex) { console.error(`BrokerBase::sendMessage throwed an exception`); throw ex; } return ret; } /** * @private */ addSocketListeners() { if (this.isDuplicate) return; this.socket.addEventListener('open', this.onOpen); this.socket.addEventListener('message', this.onSocketMessage); this.socket.addEventListener('error', this.onError); this.socket.addEventListener('close', this.onClose); } removeSocketListeners() { if (!this.socket) return; this.socket.removeEventListener('open', this.onOpen); this.socket.removeEventListener('message', this.onSocketMessage); this.socket.removeEventListener('error', this.onError); this.socket.removeEventListener('close', this.onClose); } /** * @private */ resubscribeModuleEvents() { for (const eventName of this.events.keys()) { const moduleName = eventName .split('.') .slice(0, 2) .join('.'); this .sendMessage({ type: 'subscribe', eventName, targetModuleName: moduleName, }) .catch(new Function()); } } /** * @async * @private */ async onOpen() { try { this.connected = true; this.emit('connect'); if (this.isDuplicate) { await this.ping(); } for (const registrar of this.registrars) { await this.registerHandlersToRemote(registrar); } if (!this.isDuplicate) { for (const duplicate of this.duplicates) { duplicate.onOpen(); } } } catch (ex) { console.trace(ex.message); } } /** * @private * @param {ErrorEvent} err */ onError(err) { if (err.error instanceof Error) { err = err.error.message; } if (!this.isDuplicate) { this.logger.trace(err); } if (!this.isDuplicate) { this.logger.warn(`${this.moduleName} couldn't connect to WebSocket server.`); for (const duplicate of this.duplicates) { duplicate.onError(err); } } } /** * @private */ onClose(e) { if (this.connected) { this.connected = false; this.emit('disconnect', e); this.events.delete(`${this.serverModuleName}.moduleconnect`); if (!this.isDuplicate) { for (const duplicate of this.duplicates) { duplicate.onClose(); } } } setTimeout(() => this.connect(this.connectOptions), 1000); } /** * Send the local API handler list to the `targetModuleName` so it will relay * messages targeting those handlers. * @param {string} targetModuleName Module name of the API Server * @returns {Promise.<Array, Error>} */ registerHandlersToRemote(targetModuleName) { this.registrars.add(targetModuleName); return this.sendMessage({ type: `${targetModuleName}.registerAPIHandlers`, data: Array.from(this.apiHandlers.keys()), targetModuleName, }); } /** * Send a message to all remote endpoints telling them not to relay any messages * to this module anymore. * @async * @returns {Promise.<Array, Error>[]} */ async deregisterHandlersFromRemotes() { try { const promises = []; for (const registrar of this.registrars) { promises.push( this.sendMessage({ type: `${targetModuleName}.deregisterAPIHandlers`, data: Array.from(this.apiHandlers.keys()), targetModuleName: registrar, }) ); } return Promise.all(promises); } catch (ex) { console.trace(ex.message); } } /** * Unsubscribes from all subscriptions. * @returns {Promise.<Array, Error>[]} */ unsubscribeFromAllEvents() { const promises = []; for (const eventName of this.events.keys()) { promises.push(this.unsubscribeFromAPIEvent(eventName)); } return Promise.all(promises); } /** * Performs cleanup. * @async */ async destroy() { try { if (this.isDuplicate) { await Promise.all([ this.deregisterHandlersFromRemotes(), this.unsubscribeFromAllEvents(), this.sendMessage({ type: 'event', eventName: `${this.moduleName}.disconnect`, targetModuleName: this.serverModuleName, }), ]); this.emit('destroy'); } else { this.socket.close(); this.removeSocketListeners(); super.destroy(); } } catch (ex) { console.trace(ex.message); } } /** * Register a module's API handlers to RealityHub * @async * @param {Object.<string, function>} handlers Key will be registered to the API tree. * @param {*} [context=null] Handlers' `this` will be set to this context. * The value (function) will handle the API calls. * @param {string} [remote='hub.core'] Remote * @example * // server.js * brokerClient.registerAPIHandlers(this, { * addNumbers: function (number1, number2) { * return number1 + number2; * }, * }).catch((ex) => console.trace(ex)); * * // client.js * brokerClient.api.moduleVendor.moduleName.addNumber(3, 5) * .then((result) => { * // Will log 15 * console.log(result); * }) * .catch((ex) => console.trace(ex)); * @returns {Promise} */ async registerAPIHandlers(handlers, context = null, remote = 'hub.core') { for (const [handlerName, handler] of Object.entries(handlers)) { this.registerAPIHandler(handlerName, handler.bind(context)); } return this.registerHandlersToRemote(remote); } /** * Third-party modules can use this method to initialize a BrokerClient * and register themselves to RealityHub. * @async * @static * @param {{ clientModuleName?: string, menuTitle?: string, moduleName: string, serverURL: string, webSocketURL?: string, hub: {host: string, port: number }}} params Parameters * @param {string} [params.clientModuleName] Client Module Name (`<vendor>.<client module name>`) * @param {string} [params.menuTitle] Menu Title * @param {string} params.moduleName Backend Module Name (`<vendor>.<backend module name>`) * @param {string} params.serverURL Your module has to serve your client files over HTTP or HTTPS. * RealityHub will look for an `index.js` file in this path. This script file will be imported * by RealityHub's `index.html` via a `<script type="module">` tag. Relative paths in your scripts * will be proxied by RealityHub. * @param {string} [params.webSocketURL="/core"] WebSocket URL to connect. RealityHub's API Server * is serving at `/core` by default. *(Default: /core)* * @param {{ host: string, port: number }} params.hub RealityHub connection parameters * @param {string} params.hub.host RealityHub hostname or IP address * @param {string} params.hub.port RealityHub port * @returns {Promise<BrokerClient, Error>} A BrokerClient instance. */ static async initModule(params) { const { moduleName, serverURL, hub, webSocketURL = '/core', clientModuleName, menuTitle } = params; const hubClient = new BrokerClient({ moduleName, webSocketURL }); hubClient.connect(hub); await hubClient.getConnectPromise(); if (!serverURL) return hubClient; await hubClient.api.hub.core.registerProxyURL({ moduleName, serverURL, clientModuleName, menuTitle }); return hubClient; } }