UNPKG

nope-js-browser

Version:

NoPE Runtime for the Browser. For nodejs please use nope-js-node

652 lines (651 loc) 26.2 kB
/** * @author Martin Karkowski * @email m.karkowski@zema.de */ import { memoize } from "lodash"; import { NopeEventEmitter } from "../eventEmitter/nopeEventEmitter"; import { generateId } from "../helpers/idMethods"; import { MapBasedMergeData } from "../helpers/mergedData"; import { deepClone, flattenObject, rgetattr, rsetattr, } from "../helpers/objectMethods"; import { comparePatternAndPath as _comparePatternAndPath, containsWildcards, } from "../helpers/pathMatchingMethods"; const DEFAULT_OBJ = { id: "default" }; const LAZY_UPDATE = true; // Way faster; /** * Default implementation of a {@link IPubSubSystem}. * */ export class PubSubSystemBase { // See interface description get options() { return deepClone(this._options); } constructor(options = {}) { this._options = { mqttPatternBasedSubscriptions: true, forwardChildData: true, forwardParentData: true, }; /** * The internal used object to store the data. * * @author M.Karkowski * @type {unknown} * @memberof PubSubSystemBase */ this._data = {}; this._sendCurrentDataOnSubscription = false; this._id = generateId(); /** * List of all Properties. For every property, we store the * PropertyOptions. Then, we know, what elements should be * subscribed and which not. * * @author M.Karkowski * @protected * @memberof PubSubSystemBase */ this._emitters = new Map(); this._emittersToObservers = new Map(); this._matched = new Map(); this._options = Object.assign({ mqttPatternBasedSubscriptions: true, forwardChildData: true, forwardParentData: true, matchTopicsWithoutWildcards: true, }, options); // Flag to stop forwarding data, if disposing is enabled. this._disposing = false; this._generateEmitterType = options.generateEmitterType || (() => { return new NopeEventEmitter(); }); // Create a memoized function for the pattern matching (its way faster) this._comparePatternAndPath = memoize((pattern, path) => { return _comparePatternAndPath(pattern, path, { matchTopicsWithoutWildcards: options.matchTopicsWithoutWildcards, }); }, (pattern, path) => { return `${pattern}-${path}`; }); this.subscriptions = new MapBasedMergeData(this._emitters, "subTopic"); this.publishers = new MapBasedMergeData(this._emitters, "pubTopic"); this.onIncrementalDataChange = new NopeEventEmitter(); } // See interface description register(emitter, options) { if (!this._emitters.has(emitter)) { if (typeof options.topic !== "string" && typeof options.topic !== "object") { throw Error("A Topic must be provided in the options."); } let pubTopic = typeof options.topic === "string" ? options.topic : options.topic.publish || null; let subTopic = typeof options.topic === "string" ? options.topic : options.topic.subscribe || null; if (!(options.mode == "publish" || (Array.isArray(options.mode) && options.mode.includes("publish")))) { pubTopic = false; } if (!(options.mode == "subscribe" || (Array.isArray(options.mode) && options.mode.includes("subscribe")))) { subTopic = false; } // Define a callback, which will be used to forward // the data into the system: let observer = undefined; let callback = undefined; if (pubTopic) { const _this = this; callback = (content, opts) => { // Internal Data-Update of the pub-sub-system // we wont push the data again. Otherwise, we // risk an recursive endloop. if (opts.pubSubUpdate) { return; } // We use this callback to forward the data into the system: _this._pushData(pubTopic, pubTopic, content, opts, false, emitter); }; } // Register the emitter. This will be used during topic matching. this._emitters.set(emitter, { options, pubTopic, subTopic, callback, observer, }); // Update the Matching Rules. if (LAZY_UPDATE) { this._updatePartialMatching("add", emitter, pubTopic, subTopic); } else { this.updateMatching(); } if (callback) { // If necessary. Add the Callback. observer = emitter.subscribe(callback, { skipCurrent: !this._sendCurrentDataOnSubscription, }); // Now lets store our binding. this._emittersToObservers.set(emitter, observer); } // Now, if required, add the Data to the emitter. if (subTopic && this._sendCurrentDataOnSubscription) { if (containsWildcards(subTopic)) { // This is more Complex. this._patternbasedPullData(subTopic, null).map((item) => { // We check if the content is null if (item.data !== null) { emitter.emit(item.data, { sender: this._id, topicOfContent: item.path, topicOfChange: item.path, topicOfSubscription: subTopic, }); } }); } else { const currentContent = this._pullData(subTopic, null); if (currentContent !== null) { emitter.emit(currentContent, { sender: this._id, topicOfContent: subTopic, topicOfChange: subTopic, topicOfSubscription: subTopic, }); } } } } else { throw Error("Already registered Emitter!"); } return emitter; } // See interface description updateOptions(emitter, options) { if (this._emitters.has(emitter)) { const pubTopic = typeof options.topic === "string" ? options.topic : options.topic.publish || null; const subTopic = typeof options.topic === "string" ? options.topic : options.topic.subscribe || null; const data = this._emitters.get(emitter); if (LAZY_UPDATE) { this._updatePartialMatching("remove", emitter, data.pubTopic, data.subTopic); } data.options = options; data.subTopic = subTopic; data.pubTopic = pubTopic; this._emitters.set(emitter, data); if (LAZY_UPDATE) { // Update the Matching Rules. this._updatePartialMatching("add", emitter, pubTopic, subTopic); } else { // Update the Matching Rules. this.updateMatching(); } } else { throw Error("Already registered Emitter!"); } } // See interface description unregister(emitter) { if (this._emitters.has(emitter)) { const { pubTopic, subTopic } = this._emitters.get(emitter); this._emitters.delete(emitter); if (LAZY_UPDATE) { // Update the Matching Rules. this._updatePartialMatching("remove", emitter, pubTopic, subTopic); } else { // Update the Matching Rules. this.updateMatching(); } return true; } return false; } // See interface description registerSubscription(topic, subscription) { // Create the Emitter const emitter = this._generateEmitterType(); // Create the Observer const observer = emitter.subscribe(subscription); // Register the Emitter. Thereby the ELement // will be linked to the Pub-Sub-System. this.register(emitter, { mode: "subscribe", schema: {}, topic: topic, }); // Return the Emitter return observer; } // See interface description get emitters() { return { publishers: Array.from(this._emitters.values()) .filter((item) => { return item.pubTopic; }) .map((item) => { return { schema: item.options.schema, name: item.pubTopic, }; }), subscribers: Array.from(this._emitters.values()) .filter((item) => { return item.subTopic; }) .map((item) => { return { schema: item.options.schema, name: item.subTopic, }; }), }; } /** * Internal Match-Making Algorithm. This allowes to Create a predefined * List between Publishers and Subscribers. Thereby the Process is speed * up, by utilizing this Look-Up-Table * * @author M.Karkowski * @memberof PubSubSystemBase */ updateMatching() { // Clears all defined Matches this._matched.clear(); // Iterate through all Publishers and for (const { pubTopic } of this._emitters.values()) { // Now, lets Update the Matching for the specific Topics. if (pubTopic !== false) this._updateMatchingForTopic(pubTopic); } this.publishers.update(); this.subscriptions.update(); } __deleteMatchingEntry(_pubTopic, _subTopic, _emitter) { if (this._matched.has(_pubTopic)) { const data = this._matched.get(_pubTopic); if (data.dataPull.has(_subTopic)) { data.dataPull.get(_subTopic).delete(_emitter); } if (data.dataQuery.has(_subTopic)) { data.dataQuery.get(_subTopic).delete(_emitter); } } } __addMatchingEntryIfRequired(pubTopic, subTopic, emitter) { // Now lets determine the Path const result = this._comparePatternAndPath(subTopic, pubTopic); if (result.affected) { // We skip content related to the settings. // If no wildcard and no forwardChildData or forwardParentData // is allowed ==> we make shure, that we skip the topic. if (!result.containsWildcards && ((result.affectedByChild && !this._options.forwardChildData) || (result.affectedByParent && !this._options.forwardParentData))) { return; } // We now have match the topic as following described: // 1) subscription contains a pattern // - dircet change (same-level) => content // - parent based change => content // - child based change => content // 2) subscription doesnt contains a pattern: // We more or less want the data on the path. // - direct change (topic = path) => content // - parent based change => a super change if (result.containsWildcards) { if (this._options.mqttPatternBasedSubscriptions) { if (result.patternToExtractData) { this.__addToMatchingStructure("dataQuery", pubTopic, result.patternToExtractData, emitter); } else if (typeof result.pathToExtractData === "string") { this.__addToMatchingStructure("dataPull", pubTopic, result.pathToExtractData, emitter); } else { throw Error("Implementation Error. Either the patternToExtractData or the pathToExtractData must be provided"); } } else { this.__addToMatchingStructure("dataQuery", pubTopic, pubTopic, emitter); } } else { // We skip content related to the settings. // If no wildcard and no forwardChildData or forwardParentData // is allowed ==> we make shure, that we skip the topic. if ((result.affectedByChild && !this._options.forwardChildData) || (result.affectedByParent && !this._options.forwardParentData)) { return; } if (typeof result.pathToExtractData === "string") { this.__addToMatchingStructure("dataPull", pubTopic, result.pathToExtractData, emitter); } else { throw Error("Implementation Error. The 'pathToExtractData' must be provided"); } } } } /** * Helper, to update the Matching. But, we are just considering * @param mode * @param _emitter * @param _pubTopic * @param _subTopic */ _updatePartialMatching(mode, _emitter, _pubTopic, _subTopic) { const consideredPublishedTopics = new Set(); if (_subTopic !== false) { // Iterate through all Publishers and for (const item of this._emitters.values()) { // Extract the Pub-Topic const pubTopicOfOtherEmitter = item.pubTopic; if (pubTopicOfOtherEmitter !== false && !consideredPublishedTopics.has(pubTopicOfOtherEmitter)) { // Now, lets Update the Matching for the specific Topics. if (mode === "remove") { this.__deleteMatchingEntry(pubTopicOfOtherEmitter, _subTopic, _emitter); } else if (mode === "add") { this.__addMatchingEntryIfRequired(pubTopicOfOtherEmitter, _subTopic, _emitter); } // Add this topic to the topics, // that have already been checked. consideredPublishedTopics.add(pubTopicOfOtherEmitter); } } // Additionally, we test for the allready published events: for (const topic of this._matched.keys()) { // we only test already published topics: if (!consideredPublishedTopics.has(topic) && !containsWildcards(topic)) { if (mode === "remove") { this.__deleteMatchingEntry(topic, _subTopic, _emitter); } else if (mode === "add") { this.__addMatchingEntryIfRequired(topic, _subTopic, _emitter); } consideredPublishedTopics.add(topic); } } } if (mode === "add") { if (_pubTopic !== false) { this._updateMatchingForTopic(_pubTopic); } if (_subTopic !== false && !containsWildcards(_subTopic)) { this.__addMatchingEntryIfRequired(_subTopic, _subTopic, _emitter); } } else if (mode === "remove") { if (_subTopic !== false) { this.__deleteMatchingEntry(_subTopic, _subTopic, _emitter); } } this.publishers.update(); this.subscriptions.update(); } emit(eventName, data, options) { return this._pushData(eventName, eventName, data, options); } /** * Unregisters all Emitters and removes all subscriptions of the * "onIncrementalDataChange", "publishers" and "subscriptions" * * @author M.Karkowski * @memberof PubSubSystemBase */ dispose() { this._disposing = true; const emitters = Array.from(this._emitters.keys()); emitters.map((emitter) => { this.unregister(emitter); }); this.onIncrementalDataChange.dispose(); this.publishers.dispose(); this.subscriptions.dispose(); } /** * Internal Helper to lazy update the Matching. * @param entry * @param topicOfChange * @param pathOrPattern * @param emitter */ __addToMatchingStructure(entry, topicOfChange, pathOrPattern, emitter) { // Test if the changing topic is present, // if not, ensure we are omitting it. if (!this._matched.has(topicOfChange)) { this._matched.set(topicOfChange, { dataPull: new Map(), dataQuery: new Map(), }); } // We make otherwise shure, that our [pathOrPattern] entry // is defined if (!this._matched.get(topicOfChange)[entry].has(pathOrPattern)) { this._matched.get(topicOfChange)[entry].set(pathOrPattern, new Set()); } this._matched.get(topicOfChange)[entry].get(pathOrPattern).add(emitter); } /** Function to Interanlly add a new Match * * @export * @param {string} topicOfChange */ _updateMatchingForTopic(topicOfChange) { if (!this._matched.has(topicOfChange)) { this._matched.set(topicOfChange, { dataPull: new Map(), dataQuery: new Map(), }); } // Find all Matches for (const [emitter, item] of this._emitters.entries()) { if (typeof item.subTopic == "string") { // Now lets determine the Path this.__addMatchingEntryIfRequired(topicOfChange, item.subTopic, emitter); } } } /** * Internal Function to notify Asynchronous all Subscriptions * * @author M.Karkowski * @private * @param {string} topicOfContent * @param {string} topicOfChange * @param {*} content * @memberof PubSubSystemBase */ _notify(topicOfContent, topicOfChange, options, emitter = null) { if (this._disposing) { return; } // Check whether a Matching exists for this // Topic, if not add it. if (!this._matched.has(topicOfContent)) { this._updateMatchingForTopic(topicOfContent); } const referenceToMatch = this._matched.get(topicOfContent); // Performe the direct Matches for (const [_pathToPull, _emitters,] of referenceToMatch.dataPull.entries()) { for (const _emitter of _emitters) { // Get a new copy for every emitter. const data = this._pullData(_pathToPull, null); // Only if we want to notify an exclusive emitter we // have to continue, if our emitter isnt matched. if (emitter !== null && emitter === _emitter) { continue; } // Iterate through all Subscribers _emitter.emit(data, { ...options, topicOfChange: topicOfChange, topicOfContent: topicOfContent, topicOfSubscription: this._emitters.get(_emitter) .subTopic, }); } } // Performe the direct Matches for (const [_pattern, _emitters] of referenceToMatch.dataQuery.entries()) { // Fix: Speeding things up. // Get a new copy for every element. const data = this._patternbasedPullData(_pattern, null).filter((item) => { return this._comparePatternAndPath(topicOfChange, item.path).affected; }); if (data.length > 0) { for (const _emitter of _emitters) { // prevent this case! if (_emitter !== null && _emitter !== _emitter) { continue; } // Iterate through all Subscribers _emitter.emit(data, { ...options, mode: "direct", topicOfChange: topicOfChange, topicOfContent: topicOfContent, topicOfSubscription: this._emitters.get(_emitter) .subTopic, }); } } } } _updateOptions(options) { if (!options.timestamp) { options.timestamp = Date.now(); } if (typeof options.forced !== "boolean") { options.forced = false; } if (!Array.isArray(options.args)) { options.args = []; } if (!options.sender) { options.sender = this._id; } return options; } /** * Internal helper to push data to the data property. This * results in informing the subscribers. * * @param path Path, that is used for pushing the data. * @param data The data to push * @param options Options used during pushing */ _pushData(pathOfContent, pathOfChange, data, options = {}, quiet = false, emitter = null) { const _options = this._updateOptions(options); // Force the Update to be true. _options.pubSubUpdate = true; if (containsWildcards(pathOfContent)) { throw 'The Path contains wildcards. Please use the method "patternbasedPullData" instead'; } else if (pathOfContent === "") { this._data = deepClone(data); this._notify(pathOfContent, pathOfChange, _options, emitter); } else { rsetattr(this._data, pathOfContent, deepClone(data)); this._notify(pathOfContent, pathOfChange, _options, emitter); } if (!quiet) { // Emit the data change. this.onIncrementalDataChange.emit({ path: pathOfContent, data, ..._options, }); } } // Function to pull the Last Data of the Topic _pullData(topic, _default = null) { if (containsWildcards(topic)) { throw 'The Path contains wildcards. Please use the method "patternbasedPullData" instead'; } return deepClone(rgetattr(this._data, topic, _default)); } /** * Helper, which enable to perform a pattern based pull. * The code receives a pattern, and matches the existing * content (by using there path attributes) and return the * corresponding data. * @param pattern The Patterin * @param _default The Default Value. * @returns */ _patternbasedPullData(pattern, _default = undefined) { // To extract the data based on a Pattern, // we firstly, we check if the given pattern // is a pattern. if (!containsWildcards(pattern)) { // Its not a pattern so we will speed up // things. const data = this._pullData(pattern, DEFAULT_OBJ); if (data !== DEFAULT_OBJ) { return [ { path: pattern, data, }, ]; } else if (_default !== undefined) { return [ { path: pattern, data: _default, }, ]; } return []; } // Now we know, we have to work with the query, // for that purpose, we will adapt the data object // to the following form: // {path: value} const flattenData = flattenObject(this._data); const ret = []; // We will use our alternative representation of the // object to compare the pattern with the path item. // only if there is a direct match => we will extract it. // That corresponds to a direct level extraction and // prevents to grap multiple items. for (const [path, data] of flattenData.entries()) { const result = this._comparePatternAndPath(pattern, path); if (result.affectedOnSameLevel || result.affectedByChild) { ret.push({ path, data, }); } } // Now we just return our created element. return ret; } /** * Describes the Data. * @returns */ toDescription() { const emitters = this.emitters; return emitters; } }