nope-js-browser
Version:
NoPE Runtime for the Browser. For nodejs please use nope-js-node
652 lines (651 loc) • 26.2 kB
JavaScript
/**
* @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;
}
}