@zerodensity/realityhub-api
Version:
RealityHub API Javascript Implementation
504 lines (416 loc) • 16.3 kB
JavaScript
// 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 { EventEmitter } from 'events';
import { BrokerError } from './BrokerError.js';
import { onceMultiple } from './onceMultiple.js';
const DEFAULT_MAX_WS_PACKET_SIZE = 50 /*MB*/ * 1024 * 1024;
/**
* BrokerBase constructor
* @param {object} params Parameters
* @param {string} [params.moduleName] Module name
* @param {number} [params.maxPacketSize] Maximum websocket packet size
* @param {Logger} [params.logger] Logger instance
*/
export class BrokerBase extends EventEmitter {
constructor(params) {
super();
this.moduleName = params.moduleName;
this.maxPacketSize = params.maxPacketSize || DEFAULT_MAX_WS_PACKET_SIZE;
this.logger = params.logger;
this.events = new Map();
this.apiHandlers = new Map();
this.messageTimeout = 2000;
this.overridenTimeout = NaN; // NaN = use the implementation
try {
if (typeof window != 'undefined' && typeof localStorage != 'undefined') {
this.overridenTimeout = Number(localStorage.getItem('BROKER_TIMEOUT')) || NaN;
} else if (process && process.env) {
this.overridenTimeout = Number(process.env.BROKER_TIMEOUT) || NaN;
}
} catch (ex) {}
if (this.overridenTimeout) {
const logger = this.logger || console;
logger.warn(`Broker Timeout is overriden to ${this.overridenTimeout} milliseconds!`);
}
let maxPacketSizeRead = DEFAULT_MAX_WS_PACKET_SIZE;
try {
if (typeof window != 'undefined' && typeof localStorage != 'undefined') {
maxPacketSizeRead ||= Number(localStorage.getItem('MAX_WS_PACKET_SIZE'));
} else if (process && process.env) {
maxPacketSizeRead ||= Number(process.env.MAX_WS_PACKET_SIZE);
}
} catch (ex) {
const logger = this.logger || console;
logger.error(`Cannot read maxPacketSize from either localStorage or env, defaulting to ${maxPacketSizeRead}`)
}
this.maxPacketSize = Math.max(this.maxPacketSize, maxPacketSizeRead);
if(this.maxPacketSize !== DEFAULT_MAX_WS_PACKET_SIZE) {
const logger = this.logger || console;
logger.log(`BrokerBase is created with maxPacketSize: ${this.maxPacketSize}`);
}
this.initProxy();
}
getMethodProxy(vendorName, moduleName, options) {
options = {
timeout: 2000,
excludedClients: [],
...options,
};
return new Proxy({}, {
get: (_, methodName) => {
if (methodName === 'emit' && this.moduleName !== `${vendorName}.${moduleName}`) {
throw new Error('A module can only emit its own events.');
}
return (...args) => {
switch (methodName) {
case 'emit': {
this.emitMessage(args, vendorName, moduleName, options);
break;
}
case 'on': {
const eventName = args.shift();
const eventHandler = args.shift();
if (typeof eventName !== 'string') {
throw new Error('eventName must be a string');
}
if (typeof eventHandler !== 'function') {
throw new Error('eventHandler must be a function');
}
const fullyQualifiedName = `${vendorName}.${moduleName}.${eventName}`;
this
.subscribeToAPIEvent(fullyQualifiedName, eventHandler)
.catch((err) => {
console.error(`Couldn't subscribe to ${fullyQualifiedName}`);
if (err.code !== 'TIMEOUT') {
console.trace(err);
}
});
break;
}
case 'once': {
const eventName = args.shift();
const eventHandler = args.shift();
/**
* If the subscribed event is not emitted within the given timeout then the
* event handler will be removed automatically to prevent memory leak.
* If a timeout is not provided by caller than a default timeout of 5 minutes is set.
*/
const timeout = args.shift() || 60 * 1000 * 5;
if (typeof eventName !== 'string') {
throw new Error('eventName must be a string');
}
if (typeof eventHandler !== 'function') {
throw new Error('eventHandler must be a function');
}
if (typeof timeout !== 'number' || isNaN(timeout)) {
throw new Error('timeout must be a number');
}
const fullyQualifiedName = `${vendorName}.${moduleName}.${eventName}`;
this
.subscribeToAPIEvent(fullyQualifiedName, eventHandler, { once: true })
.catch((err) => {
console.error(`Couldn't subscribe to ${fullyQualifiedName}`);
if (err.code !== 'TIMEOUT') {
console.trace(err);
}
});
break;
}
case 'off': {
const eventName = args.shift();
const eventHandler = args.shift();
const fullyQualifiedName = `${vendorName}.${moduleName}.${eventName}`;
if (typeof eventName !== 'string') {
throw new Error('eventName must be a string');
}
if (eventHandler && typeof eventHandler !== 'function') {
throw new Error('eventHandler must be a function');
}
this
.unsubscribeFromAPIEvent(fullyQualifiedName, eventHandler)
.catch((err) => {
console.error(`Couldn't unsubscribe from ${fullyQualifiedName}`);
if (err.code !== 'TIMEOUT') {
console.trace(err);
}
});
break;
}
case 'callTimeout': {
const timeout = args[0];
if (typeof timeout !== 'number') {
throw new Error('callTimeout: timeout is required.');
}
const clonedOptions = JSON.parse(JSON.stringify(options));
clonedOptions.timeout = timeout;
return this.getMethodProxy(vendorName, moduleName, clonedOptions);
}
case 'excludeClients': {
const excludedClients = args[0] || [];
if (!(excludedClients instanceof Array)) {
throw new Error('excludedClients requires 1 parameter: an array of strings');
}
const clonedOptions = JSON.parse(JSON.stringify(options));
clonedOptions.excludedClients = clonedOptions.excludedClients.concat(excludedClients);
return this.getMethodProxy(vendorName, moduleName, clonedOptions);
}
default: {
return this.sendMessage({
data: args,
timeout: options.timeout,
type: `${vendorName}.${moduleName}.${methodName}`,
targetModuleName: `${vendorName}.${moduleName}`,
excludedClients: options.excludedClients,
});
}
}
};
},
set: (_, methodName, handler) => {
if (this.moduleName !== `${vendorName}.${moduleName}`) {
throw new Error('Cannot register methods to other modules.');
}
if (typeof handler !== 'function') {
throw new Error('Handler must be a function.');
}
if (['emit', 'on', 'off'].includes(methodName)) {
throw new Error(`${methodName} is a reserved method name.`);
}
return this.registerAPIHandler(methodName, handler);
},
});
}
/**
* Initializes the Proxy object.
* @private
*/
initProxy() {
// These nested proxies allow us to get vendorName, moduleName and methodName.
// e.g. const pong = await this.api.hub.core.ping();
this.api = new Proxy({}, {
get: (_, vendorName) => {
return new Proxy({}, {
get: (_, moduleName) => {
return this.getMethodProxy(vendorName, moduleName);
},
set: (_, moduleName, api) => {
if (this.moduleName !== `${vendorName}.${moduleName}`) {
throw new Error('Cannot register methods to other modules.');
}
if (typeof api !== 'object') {
throw new Error('API must be set to an object.');
}
for (const [methodName, handler] of Object.entries(api)) {
if (typeof handler !== 'function') {
throw new Error('Handler must be a function.');
}
if (['emit', 'on', 'off'].includes(methodName)) {
throw new Error(`${methodName} is a reserved method name.`);
}
this.registerAPIHandler(methodName, handler);
}
return true;
},
});
},
set: function () {
console.warn('Module name and method name are required.');
return false;
},
});
}
/**
* Send a response message through `socket`.
* @private
* @param {WebSocket} socket Target socket.
* @param {object} message The message to respond.
* @param {boolean} success Whether the request was successfully processed or not.
* @param {array} [data] Additional payload
* @param {boolean} [relayedMessage=false]
* @returns {Promise.<array, Error>}
*/
sendResponse(socket, message, success, data = [], relayedMessage = false) {
if (!socket) return;
const { id: requestId, moduleName: targetModuleName, timeout, instigatorId } = message;
const websocketMessage = {
type: 'response',
targetModuleName,
instigatorId,
requestId,
timeout,
success,
data,
};
if (relayedMessage) {
websocketMessage.moduleName = message.targetModuleName;
}
return this.sendMessage(websocketMessage, socket, relayedMessage);
}
/**
* Registers an API request handler.
* @param {string} messageType Message type.
* @param {function} messageHandler Handler function.
* @returns {boolean} `false` if a handler has already been assigned to the `messageType`.
*/
registerAPIHandler(messageType, messageHandler) {
messageType = `${this.moduleName}.${messageType}`;
if (this.apiHandlers.has(messageType)) return false;
this.apiHandlers.set(messageType, {
relay: false,
messageHandler,
});
return true;
}
/**
* Subscribe to an API event.
* @param {string} eventName Fully qualified event name.
* @param {function} eventHandler A function which will be called when the event is received.
* @param {object} [options] Options
* @param {boolean} [options.sendMessage=true] If set to `true`, it will send a subscription message over WebSocket.
* Otherwise the message will only be registered internally.
* @param {boolean} [options.once=false] If true then the handler will be invoked only once and it won't be invoked for
* the future events that are emitted.
* @returns {Promise.<array, Error>}
*/
subscribeToAPIEvent(eventName, eventHandler, options) {
options = {
sendMessage: true,
once: false,
...options,
};
// Add handler to handlers map
const handlerArray = this.events.get(eventName) || [];
handlerArray.push({ eventHandler, once: options.once });
this.events.set(eventName, handlerArray);
// Send a subscription message over WebSocket
if (options.sendMessage) {
const targetModuleName = eventName.split('.')
.slice(0, 2)
.join('.');
return this.sendMessage({
type: 'subscribe',
eventName,
targetModuleName,
});
}
}
/**
* Unsubscribe from an API event.
* @param {string} eventName Fully qualified event name.
* @param {function} [eventHandler] A previously registered handler function. All handlers of the event will
* be removed unless `eventHandler` is provided.
* @param {boolean} [sendMessage=true] Will send an unsubscription request when set to `true`.
* @returns {Promise.<array, Error>}
*/
unsubscribeFromAPIEvent(eventName, eventHandler, sendMessage = true) {
if (eventHandler) {
const handlerArray = (this.events.get(eventName) || []).filter((entry) => entry.eventHandler !== eventHandler);
this.events.set(eventName, handlerArray);
} else {
this.events.delete(eventName);
}
if (sendMessage) {
const targetModuleName = eventName.split('.')
.slice(0, 2)
.join('.');
return this.sendMessage({
type: 'unsubscribe',
eventName,
targetModuleName,
});
}
}
/**
* Send an API message through a socket.
* @async
* @private
* @param {object} message Message object. `time`, `id`, `moduleName` and `data` keys will be added
* to the message object. Unlike other mentioned fields `data` will not get overridden when provided.
* @param {object} socket Socket instance.
* @param {boolean} [relayedMessage=false]
* @returns {Promise.<array, Error>}
*/
async sendMessage(message, socket, relayedMessage = false) {
message.id = uuid();
if (!relayedMessage) {
message.moduleName = this.moduleName;
}
message.time = new Date().valueOf();
const packet = JSON.stringify(message);
if (packet.length > this.maxPacketSize) {
this.logger.trace(new Error('MAX_WS_PACKET_SIZE'));
}
socket.send(packet);
if (!['event', 'response'].includes(message.type)) {
let responseMessage;
try {
responseMessage = await onceMultiple(this, [`response::${message.id}`], this.overridenTimeout || message.timeout || this.messageTimeout);
} catch (ex) {
const logger = this.logger || console;
logger.debug(`${this.moduleName} failed to send message ${message.type} to ${message.targetModuleName || ''}`);
return;
}
if (!responseMessage) return;
if (responseMessage.success) {
return responseMessage.data;
} else {
let errorMessage = `${message.moduleName}'s "${message.type}" request has failed.`;
if (responseMessage.data instanceof Array && responseMessage.data.length && responseMessage.data[0].error) {
errorMessage = responseMessage.data[0].error;
}
this.logger.error(errorMessage);
throw new BrokerError(errorMessage);
}
}
}
/**
* Send a ping request.
* @param {string} targetModuleName
* @private
*/
ping(targetModuleName) {
return this.sendMessage({ type: `${targetModuleName}.ping` });
}
/**
* @private
* @param {array} args
* @param {string} vendorName
* @param {string} moduleName
*/
emitMessage(args, vendorName, moduleName, options = {}) {
const eventName = args.shift();
if (typeof eventName !== 'string') {
throw new Error('eventName must be a string');
}
const fullyQualifiedName = `${vendorName}.${moduleName}.${eventName}`;
this.sendMessage({
type: 'event',
eventName: fullyQualifiedName,
data: args,
excludedClients: options.excludedClients || [],
})
.catch((err) => {
console.error(`Couldn't emit ${fullyQualifiedName}`);
if (err.code !== 'TIMEOUT') {
console.trace(err);
}
});
}
destroy() {
this.removeAllListeners();
}
}