UNPKG

unetjs

Version:

JavaScript Helper Library for UnetStack

1,346 lines (1,245 loc) 98.9 kB
(function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) : typeof define === 'function' && define.amd ? define(['exports'], factory) : (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.unet = {})); })(this, (function (exports) { 'use strict'; /* fjage.js v2.2.2 */ /** * An action represented by a message. The performative actions are a subset of the * FIPA ACL recommendations for interagent communication. * @enum {string} */ const Performative = { REQUEST: 'REQUEST', // Request an action to be performed AGREE: 'AGREE', // Agree to performing the requested action REFUSE: 'REFUSE', // Refuse to perform the requested action FAILURE: 'FAILURE', // Notification of failure to perform a requested or agreed action INFORM: 'INFORM', // Notification of an event CONFIRM: 'CONFIRM', // Confirm that the answer to a query is true DISCONFIRM: 'DISCONFIRM', // Confirm that the answer to a query is false QUERY_IF: 'QUERY_IF', // Query if some statement is true or false NOT_UNDERSTOOD: 'NOT_UNDERSTOOD', // Notification that a message was not understood CFP: 'CFP', // Call for proposal PROPOSE: 'PROPOSE', // Response for CFP CANCEL: 'CANCEL' // Cancel pending request }; ////// common utilities // generate random ID with length 4*len characters /** * * @private * @param {number} len */ function _guid(len) { const s4 = () => Math.floor((1 + Math.random()) * 0x10000).toString(16).substring(1); return Array.from({ length: len }, s4).join(''); } /** * A simple and lightweight implementation of UUIDv7. * * UUIDv7 is a time-based UUID version that is lexicographically sortable and * is designed to be used as a database key. * * The structure is as follows: * 0 1 2 3 * 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ * | unix_ts_ms | * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ * | unix_ts_ms | ver | rand_a | * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ * |var| rand_b | * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ * | rand_b | * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ * * - unix_ts_ms (48 bits): Unix timestamp in milliseconds. * - ver (4 bits): Version, set to 7. * - rand_a (12 bits): Random data. * - var (2 bits): Variant, set to '10'. * - rand_b (62 bits): Random data. */ class UUID7 { /** * Private constructor to create a UUID7 from a byte array. * @param {Uint8Array} bytes The 16 bytes of the UUID. */ constructor(bytes) { if (bytes.length !== 16) { throw new Error('UUID7 must be constructed with a 16-byte array.'); } this.bytes = bytes; } /** * Generates a new UUIDv7. * @returns {UUID7} A new UUIDv7 instance. */ static generate() { const bytes = new Uint8Array(16); const randomBytes = crypto.getRandomValues(new Uint8Array(10)); const timestamp = Date.now(); // Set the 48-bit timestamp // JavaScript numbers are 64-bit floats, but bitwise operations treat them // as 32-bit signed integers. We need to handle the 48-bit timestamp carefully. const timestampHi = Math.floor(timestamp / 2 ** 16); const timestampLo = timestamp % 2 ** 16; bytes[0] = (timestampHi >> 24) & 0xff; bytes[1] = (timestampHi >> 16) & 0xff; bytes[2] = (timestampHi >> 8) & 0xff; bytes[3] = timestampHi & 0xff; bytes[4] = (timestampLo >> 8) & 0xff; bytes[5] = timestampLo & 0xff; // Copy the 10 random bytes bytes.set(randomBytes, 6); // Set the 4-bit version (0111) in byte 6 bytes[6] = (bytes[6] & 0x0f) | 0x70; // Set the 2-bit variant (10) in byte 8 bytes[8] = (bytes[8] & 0x3f) | 0x80; return new UUID7(bytes); } /** * Extracts the timestamp from the UUID. * @returns {number} The Unix timestamp in milliseconds. */ getTimestamp() { let timestamp = 0; timestamp = this.bytes[0] * 2 ** 40; timestamp += this.bytes[1] * 2 ** 32; timestamp += this.bytes[2] * 2 ** 24; timestamp += this.bytes[3] * 2 ** 16; timestamp += this.bytes[4] * 2 ** 8; timestamp += this.bytes[5]; return timestamp; } /** * Formats the UUID into the standard string representation. * @returns {string} The UUID string. */ toString() { let result = ''; for (let i = 0; i < 16; i++) { result += this.bytes[i].toString(16).padStart(2, '0'); if (i === 3 || i === 5 || i === 7 || i === 9) { result += '-'; } } return result; } } // src/index.ts var isBrowser = typeof window !== "undefined" && typeof window.document !== "undefined"; var isNode = ( // @ts-expect-error typeof process !== "undefined" && // @ts-expect-error process.versions != null && // @ts-expect-error process.versions.node != null ); var isWebWorker = typeof self === "object" && self.constructor && self.constructor.name === "DedicatedWorkerGlobalScope"; var isJsDom = typeof window !== "undefined" && window.name === "nodejs" || typeof navigator !== "undefined" && "userAgent" in navigator && typeof navigator.userAgent === "string" && (navigator.userAgent.includes("Node.js") || navigator.userAgent.includes("jsdom")); ( // @ts-expect-error typeof Deno !== "undefined" && // @ts-expect-error typeof Deno.version !== "undefined" && // @ts-expect-error typeof Deno.version.deno !== "undefined" ); typeof process !== "undefined" && process.versions != null && process.versions.bun != null; const SOCKET_OPEN = 'open'; const SOCKET_OPENING = 'opening'; const DEFAULT_RECONNECT_TIME$1 = 5000; // ms, delay between retries to connect to the server. var createConnection; /** * @class * @ignore */ class TCPConnector { /** * Create an TCPConnector to connect to a fjage master over TCP * @param {Object} opts * @param {string} [opts.hostname='localhost'] - hostname/ip address of the master container to connect to * @param {number} [opts.port=1100] - port number of the master container to connect to * @param {boolean} [opts.keepAlive=true] - try to reconnect if the connection is lost * @param {boolean} [opts.debug=false] - debug info to be logged to console? * @param {number} [opts.reconnectTime=5000] - time before reconnection is attempted after an error */ constructor(opts = {}) { let host = opts.hostname || 'localhost'; let port = opts.port || 1100; this._keepAlive = opts.keepAlive; this._reconnectTime = opts.reconnectTime || DEFAULT_RECONNECT_TIME$1; this.url = new URL('tcp://localhost'); this.url.hostname = host; this.url.port = port.toString(); this._buf = ''; this._firstConn = true; // if the Gateway has managed to connect to a server before this._firstReConn = true; // if the Gateway has attempted to reconnect to a server before this.pendingOnOpen = []; // list of callbacks make as soon as gateway is open this.connListeners = []; // external listeners wanting to listen connection events this.debug = false; this._sockInit(host, port); } _sendConnEvent(val) { this.connListeners.forEach(l => { l && {}.toString.call(l) === '[object Function]' && l(val); }); } _sockInit(host, port){ if (!createConnection){ try { // @ts-ignore import('net').then(module => { createConnection = module.createConnection; this._sockSetup(host, port); }); }catch(error){ if(this.debug) console.log('Unable to import net module'); } }else { this._sockSetup(host, port); } } _sockSetup(host, port){ if(!createConnection) return; try{ this.sock = createConnection({ 'host': host, 'port': port }); this.sock.setEncoding('utf8'); this.sock.on('connect', this._onSockOpen.bind(this)); this.sock.on('error', this._sockReconnect.bind(this)); this.sock.on('close', () => {this._sendConnEvent(false);}); this.sock.send = data => {this.sock.write(data);}; } catch (error) { if(this.debug) console.log('Connection failed to ', this.sock.host + ':' + this.sock.port); return; } } _sockReconnect(){ if (this._firstConn || !this._keepAlive || this.sock.readyState == SOCKET_OPENING || this.sock.readyState == SOCKET_OPEN) return; if (this._firstReConn) this._sendConnEvent(false); this._firstReConn = false; setTimeout(() => { this.pendingOnOpen = []; this._sockSetup(this.url.hostname, this.url.port); }, this._reconnectTime); } _onSockOpen() { this._sendConnEvent(true); this._firstConn = false; this.sock.on('close', this._sockReconnect.bind(this)); this.sock.on('data', this._processSockData.bind(this)); this.pendingOnOpen.forEach(cb => cb()); this.pendingOnOpen.length = 0; this._buf = ''; } _processSockData(s){ this._buf += s; var lines = this._buf.split('\n'); lines.forEach((l, idx) => { if (idx < lines.length-1){ if (l && this._onSockRx) this._onSockRx.call(this,l); } else { this._buf = l; } }); } toString(){ let s = ''; s += 'TCPConnector [' + this.sock ? this.sock.remoteAddress.toString() + ':' + this.sock.remotePort.toString() : '' + ']'; return s; } /** * Write a string to the connector * @param {string} s - string to be written out of the connector to the master * @return {boolean} - true if connect was able to write or queue the string to the underlying socket */ write(s){ if (!this.sock || this.sock.readyState == SOCKET_OPENING){ this.pendingOnOpen.push(() => { this.sock.send(s+'\n'); }); return true; } else if (this.sock.readyState == SOCKET_OPEN) { this.sock.send(s+'\n'); return true; } return false; } /** * @callback TCPConnectorReadCallback * @ignore * @param {string} s - incoming message string */ /** * Set a callback for receiving incoming strings from the connector * @param {TCPConnectorReadCallback} cb - callback that is called when the connector gets a string */ setReadCallback(cb){ if (cb && {}.toString.call(cb) === '[object Function]') this._onSockRx = cb; } /** * Add listener for connection events * @param {function} listener - a listener callback that is called when the connection is opened/closed */ addConnectionListener(listener){ this.connListeners.push(listener); } /** * Remove listener for connection events * @param {function} listener - remove the listener for connection * @return {boolean} - true if the listner was removed successfully */ removeConnectionListener(listener) { let ndx = this.connListeners.indexOf(listener); if (ndx >= 0) { this.connListeners.splice(ndx, 1); return true; } return false; } /** * Close the connector */ close(){ if (!this.sock) return; if (this.sock.readyState == SOCKET_OPENING) { this.pendingOnOpen.push(() => { this.sock.send('{"alive": false}\n'); this.sock.removeAllListeners('connect'); this.sock.removeAllListeners('error'); this.sock.removeAllListeners('close'); this.sock.destroy(); }); } else if (this.sock.readyState == SOCKET_OPEN) { this.sock.send('{"alive": false}\n'); this.sock.removeAllListeners('connect'); this.sock.removeAllListeners('error'); this.sock.removeAllListeners('close'); this.sock.destroy(); } } } const DEFAULT_RECONNECT_TIME = 5000; // ms, delay between retries to connect to the server. /** * @class * @ignore */ class WSConnector { /** * Create an WSConnector to connect to a fjage master over WebSockets * @param {Object} opts * @param {string} [opts.hostname='localhost'] - hostname/ip address of the master container to connect to * @param {number} [opts.port=80] - port number of the master container to connect to * @param {string} [opts.pathname="/"] - path of the master container to connect to * @param {boolean} [opts.keepAlive=true] - try to reconnect if the connection is lost * @param {boolean} [opts.debug=false] - debug info to be logged to console? * @param {number} [opts.reconnectTime=5000] - time before reconnection is attempted after an error */ constructor(opts = {}) { let host = opts.hostname || 'localhost'; let port = opts.port || 80; this.url = new URL('ws://localhost'); this.url.hostname = host; this.url.port = port.toString(); this.url.pathname = opts.pathname || '/'; this._keepAlive = opts.keepAlive; this._reconnectTime = opts.reconnectTime || DEFAULT_RECONNECT_TIME; this.debug = opts.debug || false; // debug info to be logged to console? this._firstConn = true; // if the Gateway has managed to connect to a server before this._firstReConn = true; // if the Gateway has attempted to reconnect to a server before this.pendingOnOpen = []; // list of callbacks make as soon as gateway is open this.connListeners = []; // external listeners wanting to listen connection events this._websockSetup(this.url); } _sendConnEvent(val) { this.connListeners.forEach(l => { l && {}.toString.call(l) === '[object Function]' && l(val); }); } _websockSetup(url){ try { this.sock = new WebSocket(url); this.sock.onerror = this._websockReconnect.bind(this); this.sock.onopen = this._onWebsockOpen.bind(this); this.sock.onclose = () => {this._sendConnEvent(false);}; } catch (error) { if(this.debug) console.log('Connection failed to ', url); return; } } _websockReconnect(){ if (this._firstConn || !this._keepAlive || this.sock.readyState == this.sock.CONNECTING || this.sock.readyState == this.sock.OPEN) return; if (this._firstReConn) this._sendConnEvent(false); this._firstReConn = false; if(this.debug) console.log('Reconnecting to ', this.sock.url); setTimeout(() => { this.pendingOnOpen = []; this._websockSetup(this.sock.url); }, this._reconnectTime); } _onWebsockOpen() { if(this.debug) console.log('Connected to ', this.sock.url); this._sendConnEvent(true); this.sock.onclose = this._websockReconnect.bind(this); this.sock.onmessage = event => { if (this._onWebsockRx) this._onWebsockRx.call(this,event.data); }; this._firstConn = false; this._firstReConn = true; this.pendingOnOpen.forEach(cb => cb()); this.pendingOnOpen.length = 0; } toString(){ let s = ''; s += 'WSConnector [' + this.sock ? this.sock.url.toString() : '' + ']'; return s; } /** * Write a string to the connector * @param {string} s - string to be written out of the connector to the master */ write(s){ if (!this.sock || this.sock.readyState == this.sock.CONNECTING){ this.pendingOnOpen.push(() => { this.sock.send(s+'\n'); }); return true; } else if (this.sock.readyState == this.sock.OPEN) { this.sock.send(s+'\n'); return true; } return false; } /** * @callback WSConnectorReadCallback * @ignore * @param {string} s - incoming message string */ /** * Set a callback for receiving incoming strings from the connector * @param {WSConnectorReadCallback} cb - callback that is called when the connector gets a string * @ignore */ setReadCallback(cb){ if (cb && {}.toString.call(cb) === '[object Function]') this._onWebsockRx = cb; } /** * Add listener for connection events * @param {function} listener - a listener callback that is called when the connection is opened/closed */ addConnectionListener(listener){ this.connListeners.push(listener); } /** * Remove listener for connection events * @param {function} listener - remove the listener for connection * @return {boolean} - true if the listner was removed successfully */ removeConnectionListener(listener) { let ndx = this.connListeners.indexOf(listener); if (ndx >= 0) { this.connListeners.splice(ndx, 1); return true; } return false; } /** * Close the connector */ close(){ if (!this.sock) return; if (this.sock.readyState == this.sock.CONNECTING) { this.pendingOnOpen.push(() => { this.sock.send('{"alive": false}\n'); this.sock.onclose = null; this.sock.close(); }); } else if (this.sock.readyState == this.sock.OPEN) { this.sock.send('{"alive": false}\n'); this.sock.onclose = null; this.sock.close(); } } } /* global Buffer */ /** * Class representing a fjage's on-the-wire JSON message. A JSONMessage object * contains all the fields that can be a part of a fjage JSON message. The class * provides methods to create JSONMessage objects from raw strings and to * convert JSONMessage objects to JSON strings in the format of the fjage on-the-wire * protocol. See {@link https://fjage.readthedocs.io/en/latest/protocol.html#json-message-request-response-attributes fjage documentation} * for more details on the JSON message format. * * Most users will not need to create JSONMessage objects directly, but rather use the Gateway and Message classes * to send and receive messages. However, this class can be useful for low-level access to the fjage protocol * or for generating/consuming the fjåge protocol messages without having them be transmitted over a network. * * @example * const jsonMsg = new JSONMessage(); * jsonMsg.action = 'send'; * jsonMsg.message = new Message(); * jsonMsg.message.sender = new AgentID('agent1'); * jsonMsg.message.recipient = new AgentID('agent2'); * jsonMsg.message.perf = Performative.INFORM; * jsonMsg.toJSON(); // Converts to JSON string in the fjage on-the-wire protocol format * * @example * const jsonString = '{"id":"1234",...}'; // JSON string representation of a JSONMessage * const jsonMsg = new JSONMessage(jsonString); // Parses the JSON string into a JSONMessage object * jsonMsg.message; // Access the Message object contained in the JSONMessage * * @class * @property {string} [id] - A UUID assigned to each JSONMessage object. * @property {string} [action] - Denotes the main action the object is supposed to perform. * @property {string} [inResponseTo] - This attribute contains the action to which this object is a response to. * @property {AgentID} [agentID] - An AgentID. This attribute is populated in objects which are responses to objects requesting the ID of an agent providing a specific service. * @property {Array<AgentID>} [agentIDs] - This attribute is populated in objects which are responses to objects requesting the IDs of agents providing a specific service, or objects which are responses to objects requesting a list of all agents running in a container. * @property {Array<string>} [agentTypes] - This attribute is optionally populated in objects which are responses to objects requesting a list of all agents running in a container. If populated, it contains a list of agent types running in the container, with a one-to-one mapping to the agent IDs in the "agentIDs" attribute. * @property {string} [service] - Used in conjunction with "action" : "agentForService" and "action" : "agentsForService" to query for agent(s) providing this specific service. * @property {Array<string>} [services] - This attribute is populated in objects which are responses to objects requesting the services available with "action" : "services". * @property {boolean} [answer] - This attribute is populated in objects which are responses to query objects with "action" : "containsAgent". * @property {Message} [message] - This holds the main payload of the message. The structure and format of this object is discussed in the {@link https://fjage.readthedocs.io/en/latest/protocol.html#json-message-request-response-attributes fjage documentation}. * @property {boolean} [relay] - This attribute defines if the target container should relay (forward) the message to other containers it is connected to or not. * @property {Object} [creds] - Credentials to be used for authentication. * @property {Object} [auth] - Authentication information to be used for the message. * * */ class JSONMessage { /** * @param {String} [jsonString] - JSON string to be parsed into a JSONMessage object. * @param {Object} [owner] - The owner of the JSONMessage object, typically the Gateway instance. */ constructor(jsonString, owner) { this.id = UUID7.generate().toString(); // unique JSON message ID this.action = null; this.inResponseTo = null; this.agentID = null; this.agentIDs = null; this.agentTypes = null; this.service = null; this.services = null; this.answer = null; this.message = null; this.relay = null; this.creds = null; this.auth = null; this.name = null; if (jsonString && typeof jsonString === 'string') { try { const parsed = JSON.parse(jsonString, _decodeBase64); if (parsed.message) parsed.message = Message.fromJSON(parsed.message); if (parsed.agentID) parsed.agentID = AgentID.fromJSON(parsed.agentID, owner); if (parsed.agentIDs) parsed.agentIDs = parsed.agentIDs.map(id => AgentID.fromJSON(id, owner)); Object.assign(this, parsed); } catch (e) { throw new Error('Invalid JSON string: ' + e.message); } } } /** * Creates a JSONMessage object to send a message. * * @param {Message} msg * @param {boolean} [relay=false] - whether to relay the message * @returns {JSONMessage} - JSONMessage object with request to send a message */ static createSend(msg, relay=false){ if (!(msg instanceof Message)) { throw new Error('Invalid message type'); } const jsonMsg = new JSONMessage(); jsonMsg.action = Actions.SEND; jsonMsg.relay = relay; jsonMsg.message = msg; return jsonMsg; } /** * Creates a JSONMessage object to update WantsMessagesFor list. * * @param {Array<AgentID>} agentIDs - array of AgentID objects for which the gateway wants messages * @returns {JSONMessage} - JSONMessage object with request to update WantsMessagesFor list */ static createWantsMessagesFor(agentIDs) { if (!Array.isArray(agentIDs) || agentIDs.length === 0) { throw new Error('agentIDNames must be a non-empty array'); } const jsonMsg = new JSONMessage(); jsonMsg.action = Actions.WANTS_MESSAGES_FOR; jsonMsg.agentIDs = agentIDs; return jsonMsg; } /** * Creates a JSONMessage object to request the list of agents. * * @returns {JSONMessage} - JSONMessage object with request for the list of agents */ static createAgents(){ const jsonMsg = new JSONMessage(); jsonMsg.action = Actions.AGENTS; jsonMsg.id = UUID7.generate().toString(); // unique JSON message ID return jsonMsg; } /** * Creates a JSONMessage object to check if an agent is contained * * @param {AgentID} agentID - AgentID of the agent to check * @returns {JSONMessage} - JSONMessage object with request to check if the agent is contained */ static createContainsAgent(agentID) { if (!(agentID instanceof AgentID)) { throw new Error('agentID must be an instance of AgentID'); } const jsonMsg = new JSONMessage(); jsonMsg.action = Actions.CONTAINS_AGENT; jsonMsg.id = UUID7.generate().toString(); // unique JSON message ID jsonMsg.agentID = agentID; return jsonMsg; } /** * Creates a JSONMessage object to get an agent for a service. * * @param {string} service - service which the agent must provide * @returns {JSONMessage} - JSONMessage object with request for an agent providing the service */ static createAgentForService(service) { if (typeof service !== 'string' || service.length === 0) { throw new Error('service must be a non-empty string'); } const jsonMsg = new JSONMessage(); jsonMsg.action = Actions.AGENT_FOR_SERVICE; jsonMsg.id = UUID7.generate().toString(); // unique JSON message ID jsonMsg.service = service; return jsonMsg; } /** * Creates a JSONMessage object to get all agents for a service. * * @param {string} service - service which the agents must provide * @returns {JSONMessage} - JSONMessage object with request for all agent providing the service */ static createAgentsForService(service) { if (typeof service !== 'string' || service.length === 0) { throw new Error('service must be a non-empty string'); } const jsonMsg = new JSONMessage(); jsonMsg.action = Actions.AGENTS_FOR_SERVICE; jsonMsg.id = UUID7.generate().toString(); // unique JSON message ID jsonMsg.service = service; return jsonMsg; } /** * Converts the JSONMessage object to a JSON string in the format of the * fjage on-the-wire protocol. If the JSONMessage contains a Message or * AgentID objects, they will be serialized as per the fjåge protocol. * * @returns {string} - JSON string representation of the message */ toJSON() { if (!this.action && !this.id) { throw new Error('Neither action nor id is set. Cannot serialize JSONMessage.'); } const jsonObj = {}; // Add property if not null or undefined if (this.id) jsonObj.id = this.id; if (this.action) jsonObj.action = this.action; if (this.inResponseTo) jsonObj.inResponseTo = this.inResponseTo; if (this.agentID) jsonObj.agentID = this.agentID.toJSON(); if (this.agentIDs) { jsonObj.agentIDs = this.agentIDs.map(id => id.toJSON()); if (jsonObj.agentIDs.length === 0) delete jsonObj.agentIDs; // remove empty array } if (this.service) jsonObj.service = this.service; if (this.services) { jsonObj.services = this.services; if (jsonObj.services.length === 0) delete jsonObj.services; // remove empty array } if (this.answer != undefined) jsonObj.answer = this.answer; if (this.message) jsonObj.message = this.message; if (this.relay) jsonObj.relay = this.relay; if (this.creds) jsonObj.creds = this.creds; if (this.auth) jsonObj.auth = this.auth; if (this.name) jsonObj.name = this.name; return JSON.stringify(jsonObj); } toString() { return this.toJSON(); } } /** * Actions supported by the fjåge JSON message protocol. See * {@link https://fjage.readthedocs.io/en/latest/protocol.html#json-message-request-response-attributes fjage documentation} for more details. * * @enum {string} Actions */ const Actions = { AGENTS : 'agents', CONTAINS_AGENT : 'containsAgent', AGENT_FOR_SERVICE : 'agentForService', AGENTS_FOR_SERVICE : 'agentsForService', SEND : 'send', WANTS_MESSAGES_FOR : 'wantsMessagesFor'}; ////// private utilities /** * Decode large numeric arrays encoded in base64 back to array format. * * @private * * @param {string} _k - key (unused) * @param {any} d - data * @returns {Array} - decoded data in array format * */ function _decodeBase64(_k, d) { if (d === null) return null; if (typeof d == 'object' && 'clazz' in d && 'data' in d && d.clazz.startsWith('[') && d.clazz.length == 2) { return _b64toArray(d.data, d.clazz) || d; } return d; } /** * Convert a base64 encoded string to an array of numbers of the specified data type. * * @private * * @param {string} base64 - base64 encoded string * @param {string} dtype - data type, e.g. '[B' for byte array, '[S' for short array, etc. * @param {boolean} [littleEndian=true] - whether to use little-endian byte order */ function _b64toArray(base64, dtype, littleEndian=true) { let s = _atob(base64); let len = s.length; let bytes = new Uint8Array(len); for (var i = 0; i < len; i++) bytes[i] = s.charCodeAt(i); let rv = []; let view = new DataView(bytes.buffer); switch (dtype) { case '[B': // byte array for (i = 0; i < len; i++) rv.push(view.getUint8(i)); break; case '[S': // short array for (i = 0; i < len; i+=2) rv.push(view.getInt16(i, littleEndian)); break; case '[I': // integer array for (i = 0; i < len; i+=4) rv.push(view.getInt32(i, littleEndian)); break; case '[J': // long array for (i = 0; i < len; i+=8) rv.push(view.getBigInt64(i, littleEndian)); break; case '[F': // float array for (i = 0; i < len; i+=4) rv.push(view.getFloat32(i, littleEndian)); break; case '[D': // double array for (i = 0; i < len; i+=8) rv.push(view.getFloat64(i, littleEndian)); break; default: return; } return rv; } // node.js safe atob function /** * @private * @param {string} a */ function _atob(a){ if (isBrowser || isWebWorker) return window.atob(a); else if (isJsDom || isNode) return Buffer.from(a, 'base64').toString('binary'); } /* global global */ const DEFAULT_QUEUE_SIZE = 128; // max number of old unreceived messages to store const DEFAULT_TIMEOUT$1 = 10000; // default timeout for requests in milliseconds const GATEWAY_DEFAULTS = { 'timeout': DEFAULT_TIMEOUT$1, 'keepAlive' : true, 'queueSize': DEFAULT_QUEUE_SIZE, 'returnNullOnFailedResponse': true }; let DEFAULT_URL; let gObj = {}; /** * * @private * * Initializes the Gateway module. This function should be called before using the Gateway class. * It sets up the default values for the Gateway and initializes the global object. * It also sets up the default URL for the Gateway based on the environment (browser, Node.js, etc.). * @returns {void} */ function init(){ if (isBrowser || isWebWorker){ gObj = window; Object.assign(GATEWAY_DEFAULTS, { 'hostname': gObj.location.hostname, 'port': gObj.location.port, 'pathname' : '/ws/' }); DEFAULT_URL = new URL('ws://localhost'); // Enable caching of Gateways in browser if (typeof gObj.fjage === 'undefined') gObj.fjage = {}; if (typeof gObj.fjage.gateways == 'undefined') gObj.fjage.gateways = []; } else if (isJsDom || isNode){ gObj = global; Object.assign(GATEWAY_DEFAULTS, { 'hostname': 'localhost', 'port': '1100', 'pathname': '' }); DEFAULT_URL = new URL('tcp://localhost'); } } /** * A gateway for connecting to a fjage master container. This class provides methods to * send and receive messages, subscribe to topics, and manage connections to the master container. * It can be used to connect to a fjage master container over WebSockets or TCP. * * @example <caption>Connects to the localhost:1100</caption> * const gw = new Gateway({ hostname: 'localhost', port: 1100 }); * * @example <caption>Connects to the origin</caption> * const gw = new Gateway(); * * @class * @property {AgentID} aid - agent id of the gateway * @property {boolean} connected - true if the gateway is connected to the master container * @property {boolean} debug - true if debug messages should be logged to the console * * Constructor arguments: * @param {Object} opts * @param {string} [opts.hostname="localhost"] - hostname/ip address of the master container to connect to * @param {number} [opts.port=1100] - port number of the master container to connect to * @param {string} [opts.pathname=""] - path of the master container to connect to (for WebSockets) * @param {boolean} [opts.keepAlive=true] - try to reconnect if the connection is lost * @param {number} [opts.queueSize=128] - size of the _queue of received messages that haven't been consumed yet * @param {number} [opts.timeout=10000] - timeout for fjage level messages in ms * @param {boolean} [opts.returnNullOnFailedResponse=true] - return null instead of throwing an error when a parameter is not found * @param {boolean} [opts.cancelPendingOnDisconnect=false] - cancel pending requests on disconnects */ class Gateway { constructor(opts = {}) { // Similar to Object.assign but also overwrites `undefined` and empty strings with defaults for (var key in GATEWAY_DEFAULTS){ if (opts[key] == undefined || opts[key] === '') opts[key] = GATEWAY_DEFAULTS[key]; } var url = DEFAULT_URL; url.hostname = opts.hostname; url.port = opts.port; url.pathname = opts.pathname; let existing = this._getGWCache(url); if (existing) return existing; this._timeout = opts.timeout; // timeout for fjage level messages (agentForService etc) this._keepAlive = opts.keepAlive; // reconnect if connection gets closed/errored this._queueSize = opts.queueSize; // size of _queue this._returnNullOnFailedResponse = opts.returnNullOnFailedResponse; // null or error this._cancelPendingOnDisconnect = opts.cancelPendingOnDisconnect; // cancel pending requests on disconnect this._pending_actions = {}; // msgid to callback mapping for pending actions this._subscriptions = {}; // map for all topics that are subscribed this._pending_receives = {}; // uuid to callbacks mapping for pending receives this._eventListeners = {}; // external listeners wanting to listen internal events this._queue = []; // incoming message _queue this.connected = false; // connection status this.debug = false; // debug info to be logged to console? this.aid = new AgentID('gateway-'+_guid(4)); // gateway agent name this.connector = this._createConnector(url); this._addGWCache(this); } /** * Sends an event to all registered listeners of the given type. * @private * @param {string} type - type of event * @param {Object|Message|string} val - value to be sent to the listeners */ _sendEvent(type, val) { if (!Array.isArray(this._eventListeners[type])) return; this._eventListeners[type].forEach(l => { if (l && {}.toString.call(l) === '[object Function]'){ try { l(val); } catch (error) { console.warn('Error in event listener : ' + error); } } }); } /** * Sends the message to all registered receivers. * * @private * @param {Message} msg * @returns {boolean} - true if the message was consumed by any listener */ _sendReceivers(msg) { for (var lid in this._pending_receives){ try { if (this._pending_receives[lid] && this._pending_receives[lid](msg)) return true; } catch (error) { console.warn('Error in listener : ' + error); } } return false; } /** * @private * @param {string} data - stringfied JSON data received from the master container to be processed * @returns {void} */ _onMsgRx(data) { var jsonMsg; if (this.debug) console.log('< '+data); this._sendEvent('rx', data); try { jsonMsg = new JSONMessage(data, this); }catch(e){ return; } this._sendEvent('rxp', jsonMsg); if (jsonMsg.id && jsonMsg.id in this._pending_actions) { // response to a pending request to master this._pending_actions[jsonMsg.id](jsonMsg); delete this._pending_actions[jsonMsg.id]; } else if (jsonMsg.action == Actions.SEND) { // incoming message from master const msg = jsonMsg.message; if (!msg) return; this._sendEvent('rxmsg', msg); if ((msg.recipient.toJSON() == this.aid.toJSON())|| this._subscriptions[msg.recipient.toJSON()]) { // send to any "message" listeners this._sendEvent('message', msg); // send message to receivers, if not consumed, add to _queue if(!this._sendReceivers(msg)) { if (this._queue.length >= this._queueSize) this._queue.shift(); this._queue.push(msg); } } } else { // respond to standard requests that every gateway must let rsp = new JSONMessage(); rsp.id = jsonMsg.id; rsp.inResponseTo = jsonMsg.action; switch (jsonMsg.action) { case 'agents': rsp.agentIDs = [this.aid]; break; case 'containsAgent': rsp.answer = (jsonMsg.agentID.toJSON() == this.aid.toJSON()); break; case 'services': rsp.services = []; break; case 'agentForService': rsp.agentID = ''; break; case 'agentsForService': rsp.agentIDs = []; break; default: rsp = undefined; } if (rsp) this._msgTx(rsp); } } /** * Sends a message out to the master container. This method is used for sending * fjage level actions that do not require a response, such as alive, wantMessages, etc. * @private * @param {JSONMessage} msg - JSONMessage to be sent to the master container * @returns {boolean} - true if the message was sent successfully */ _msgTx(msg) { const s = msg.toJSON(); if(this.debug) console.log('> '+s); this._sendEvent('tx', s); return this.connector.write(s); } /** * Send a message to the master container and wait for a response. This method is used for sending * fjage level actions that require a response, such as agentForService, agents, etc. * @private * @param {JSONMessage} rq - JSONMessage to be sent to the master container * @param {number} [timeout=opts.timeout] - timeout in milliseconds for the response * @returns {Promise<JSONMessage|null>} - a promise which returns the response from the master container */ _msgTxRx(rq, timeout = this._timeout) { rq.id = UUID7.generate().toString(); return new Promise(resolve => { let timer; if (timeout >= 0){ timer = setTimeout(() => { delete this._pending_actions[rq.id]; if (this.debug) console.log('Receive Timeout : ' + JSON.stringify(rq)); resolve(null); }, timeout); } this._pending_actions[rq.id] = rsp => { if (timer) clearTimeout(timer); resolve(rsp); }; if (!this._msgTx.call(this,rq)) { if(timer) clearTimeout(timer); delete this._pending_actions[rq.id]; if (this.debug) console.log('Transmit Failure : ' + JSON.stringify(rq)); resolve(null); } }); } /** * @private * @param {URL} url - URL object of the master container to connect to * @returns {TCPConnector|WSConnector} - connector object to connect to the master container */ _createConnector(url){ let conn; if (url.protocol.startsWith('ws')){ conn = new WSConnector({ 'hostname':url.hostname, 'port':parseInt(url.port), 'pathname':url.pathname, 'keepAlive': this._keepAlive, 'debug': this.debug }); }else if (url.protocol.startsWith('tcp')){ conn = new TCPConnector({ 'hostname':url.hostname, 'port':parseInt(url.port), 'keepAlive': this._keepAlive, 'debug': this.debug }); } else return null; conn.setReadCallback(this._onMsgRx.bind(this)); conn.addConnectionListener(state => { this.connected = !!state; if (state == true){ this.flush(); this.connector.write('{"alive": true}'); this._update_watch(); } else { if (this._cancelPendingOnDisconnect) { this._sendReceivers(null); this.flush(); } } this._sendEvent('conn', state); }); return conn; } /** * Checks if the object is a constructor. * * @private * @param {Object} value - an object to be checked if it is a constructor * @returns {boolean} - if the object is a constructor */ _isConstructor(value) { try { new new Proxy(value, {construct() { return {}; }}); return true; } catch (err) { return false; } } /** * Matches a message with a filter. * @private * @param {string|Object|function} filter - filter to be matched * @param {Message} msg - message to be matched to the filter * @returns {boolean} - true if the message matches the filter */ _matchMessage(filter, msg){ if (typeof filter == 'string' || filter instanceof String) { return 'inReplyTo' in msg && msg.inReplyTo == filter; } else if (Object.prototype.hasOwnProperty.call(filter, 'msgID')) { return 'inReplyTo' in msg && msg.inReplyTo == filter.msgID; } else if (filter.__proto__.name == 'Message' || filter.__proto__.__proto__.name == 'Message') { return filter.__clazz__ == msg.__clazz__; } else if (typeof filter == 'function' && !this._isConstructor(filter)) { try { return filter(msg); }catch(e){ console.warn('Error in filter : ' + e); return false; } } else { return msg instanceof filter; } } /** * Gets the next message from the _queue that matches the filter. * @private * @param {string|Object|function} filter - filter to be matched */ _getMessageFromQueue(filter) { if (!this._queue.length) return; if (!filter) return this._queue.shift(); let matchedMsg = this._queue.find( msg => this._matchMessage(filter, msg)); if (matchedMsg) this._queue.splice(this._queue.indexOf(matchedMsg), 1); return matchedMsg; } /** * Gets a cached gateway object for the given URL (if it exists). * @private * @param {URL} url - URL object of the master container to connect to * @returns {Gateway|void} - gateway object for the given URL */ _getGWCache(url){ if (!gObj.fjage || !gObj.fjage.gateways) return null; var f = gObj.fjage.gateways.filter(g => g.connector.url.toString() == url.toString()); if (f.length ) return f[0]; return null; } /** * Adds a gateway object to the cache if it doesn't already exist. * @private * @param {Gateway} gw - gateway object to be added to the cache */ _addGWCache(gw){ if (!gObj.fjage || !gObj.fjage.gateways) return; gObj.fjage.gateways.push(gw); } /** * Removes a gateway object from the cache if it exists. * @private * @param {Gateway} gw - gateway object to be removed from the cache */ _removeGWCache(gw){ if (!gObj.fjage || !gObj.fjage.gateways) return; var index = gObj.fjage.gateways.indexOf(gw); if (index != null) gObj.fjage.gateways.splice(index,1); } /** @private */ _update_watch() { let watch = Object.keys(this._subscriptions); watch.push(this.aid.toJSON()); const jsonMsg = JSONMessage.createWantsMessagesFor(watch.map(id => AgentID.fromJSON(id))); this._msgTx(jsonMsg); } /** * Add an event listener to listen to various events happening on this Gateway * * @param {string} type - type of event to be listened to * @param {function} listener - new callback/function to be called when the event happens * @returns {void} */ addEventListener(type, listener) { if (!Array.isArray(this._eventListeners[type])){ this._eventListeners[type] = []; } this._eventListeners[type].push(listener); } /** * Remove an event listener. * * @param {string} type - type of event the listener was for * @param {function} listener - callback/function which was to be called when the event happens * @returns {void} */ removeEventListener(type, listener) { if (!this._eventListeners[type]) return; let ndx = this._eventListeners[type].indexOf(listener); if (ndx >= 0) this._eventListeners[type].splice(ndx, 1); } /** * Add a new listener to listen to all {Message}s sent to this Gateway * * @param {function} listener - new callback/function to be called when a {Message} is received * @returns {void} */ addMessageListener(listener) { this.addEventListener('message',listener); } /** * Remove a message listener. * * @param {function} listener - removes a previously registered listener/callback * @returns {void} */ removeMessageListener(listener) { this.removeEventListener('message', listener); } /** * Add a new listener to get notified when the connection to master is created and terminated. * * @param {function} listener - new callback/function to be called connection to master is created and terminated * @returns {void} */ addConnListener(listener) { this.addEventListener('conn', listener); } /** * Remove a connection listener. * * @param {function} listener - removes a previously registered listener/callback * @returns {void} */ removeConnListener(listener) { this.removeEventListener('conn', listener); } /** * Gets the agent ID associated with the gateway. * * @returns {AgentID} - agent ID */ getAgentID() { return this.aid; } /** * Get an AgentID for a given agent name. * * @param {string} name - name of agent * @returns {AgentID} - AgentID for the given name */ agent(name) { return new AgentID(name, false, this); } /** * Returns an object representing the named topic. * * @param {string|AgentID} topic - name of the topic or AgentID * @param {string} [topic2] - name of the topic if the topic param is an AgentID * @returns {AgentID} - object representing the topic */ topic(topic, topic2) { if (typeof topic == 'string' || topic instanceof String) return new AgentID(topic, true, this); if (topic instanceof AgentID) { if (topic.isTopic()) return topic; return new AgentID(topic.getName()+(topic2 ? '__' + topic2 : '')+'__ntf', true, this); } } /** * Subscribes the gateway to receive all messages sent to the given topic. * * @param {AgentID} topic - the topic to subscribe to * @returns {boolean} - true if the subscription is successful, false otherwise */ subscribe(topic) { if (!topic.isTopic()) topic = new AgentID(topic.getName() + '__ntf', true, this); this._subscriptions[topic.toJSON()] = true; this._update_watch(); return true; } /** * Unsubscribes the gateway from a given topic. * * @param {AgentID} topic - the topic to unsubscribe * @returns {void} */ unsubscribe(topic) { if (!topic.isTopic()) topic = new AgentID(topic.getName() + '__ntf', true, this); delete this._subscriptions[topic.toJSON()]; this._update_watch(); } /** * Gets a list of all agents in the container. * @param {number} [timeout=opts.timeout] - timeout in milliseconds * @returns {Promise<AgentID[]>} - a promise which returns an array of all agent ids when resolved */ async agents(timeout=this._timeout) { let jsonMsg = JSONMessage.createAgents(); let rsp = await this._msgTxRx(jsonMsg, timeout); if (!rsp || !Array.isArray(rsp.agentIDs)) throw new Error('Unable to get agents'); return rsp.agentIDs; } /** * Check if an agent with a given name exists in the container. * * @param {AgentID|string} agentID - the agent id to check * @param {number} [ti