From da613af9ea610497101ce9cb03fbd79d7f483272 Mon Sep 17 00:00:00 2001 From: KernelDeimos Date: Thu, 17 Oct 2024 22:45:39 -0400 Subject: [PATCH] dev: add generic pub/sub system for use anywhere Dispatching and listening to events is non-trivial. The apparent triviality is in implementing a list of listeners and calling them. The non-triviality is in the nature of what happens to a system when it has multiple different interfaces to register listeners and publish events. This commit adds TopicsFeature, which allows any class extending AdvancedBase to declare topics. A topic is a simple pub/sub channel. TopicsFeature will manage the state of listeners so the class doesn't need to. A GC-friendly mechanism for detaching listeners is also provided. --- src/backend/src/util/listenerutil.js | 3 + src/putility/index.js | 5 + src/putility/src/AdvancedBase.js | 1 + src/putility/src/features/TopicsFeature.js | 40 ++++++++ src/putility/src/features/TraitsFeature.js | 1 + src/putility/src/libs/listener.js | 103 +++++++++++++++++++++ src/putility/src/traits/traits.js | 4 + src/putility/test/listener.test.js | 24 +++++ src/putility/test/topics.test.js | 48 ++++++++++ 9 files changed, 229 insertions(+) create mode 100644 src/putility/src/features/TopicsFeature.js create mode 100644 src/putility/src/libs/listener.js create mode 100644 src/putility/src/traits/traits.js create mode 100644 src/putility/test/listener.test.js create mode 100644 src/putility/test/topics.test.js diff --git a/src/backend/src/util/listenerutil.js b/src/backend/src/util/listenerutil.js index 711758a7..2a9416c8 100644 --- a/src/backend/src/util/listenerutil.js +++ b/src/backend/src/util/listenerutil.js @@ -16,6 +16,9 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ + +// DEPRECATED: use putility.libs.listener instead + class MultiDetachable { constructor() { this.delegates = []; diff --git a/src/putility/index.js b/src/putility/index.js index 1acc8221..17f98549 100644 --- a/src/putility/index.js +++ b/src/putility/index.js @@ -19,6 +19,7 @@ const { AdvancedBase } = require('./src/AdvancedBase'); const { Service } = require('./src/concepts/Service'); const { ServiceManager } = require('./src/system/ServiceManager'); +const { TTopics } = require('./src/traits/traits'); module.exports = { AdvancedBase, @@ -28,8 +29,12 @@ module.exports = { libs: { promise: require('./src/libs/promise'), context: require('./src/libs/context'), + listener: require('./src/libs/listener'), }, concepts: { Service, }, + traits: { + TTopics, + }, }; diff --git a/src/putility/src/AdvancedBase.js b/src/putility/src/AdvancedBase.js index 51b59fb2..1a48ada7 100644 --- a/src/putility/src/AdvancedBase.js +++ b/src/putility/src/AdvancedBase.js @@ -27,6 +27,7 @@ class AdvancedBase extends FeatureBase { require('./features/PropertiesFeature'), require('./features/TraitsFeature'), require('./features/NariMethodsFeature'), + require('./features/TopicsFeature'), ] } diff --git a/src/putility/src/features/TopicsFeature.js b/src/putility/src/features/TopicsFeature.js new file mode 100644 index 00000000..365679d6 --- /dev/null +++ b/src/putility/src/features/TopicsFeature.js @@ -0,0 +1,40 @@ +const { RemoveFromArrayDetachable } = require("../libs/listener"); +const { TTopics } = require("../traits/traits"); +const { install_in_instance } = require("./NodeModuleDIFeature"); + +module.exports = { + install_in_instance: (instance, { parameters }) => { + const topics = instance._get_merged_static_array('TOPICS'); + + instance._.topics = {}; + + for ( const name of topics ) { + instance._.topics[name] = { + listeners_: [], + }; + } + + instance.mixin(TTopics, { + pub: (k, v) => { + const topic = instance._.topics[k]; + if ( ! topic ) { + console.warn('missing topic: ' + topic); + return; + } + for ( const lis of topic.listeners_ ) { + lis(); + } + }, + sub: (k, fn) => { + const topic = instance._.topics[k]; + if ( ! topic ) { + console.warn('missing topic: ' + topic); + return; + } + topic.listeners_.push(fn); + return new RemoveFromArrayDetachable(topic.listeners_, fn); + } + }) + + } +}; diff --git a/src/putility/src/features/TraitsFeature.js b/src/putility/src/features/TraitsFeature.js index ba597251..157aa7fd 100644 --- a/src/putility/src/features/TraitsFeature.js +++ b/src/putility/src/features/TraitsFeature.js @@ -26,6 +26,7 @@ module.exports = { instance.as = trait_name => instance._.impls[trait_name]; instance.list_traits = () => Object.keys(instance._.impls); + instance.mixin = (name, impl) => instance._.impls[name] = impl; for ( const cls of chain ) { const cls_traits = cls.IMPLEMENTS; diff --git a/src/putility/src/libs/listener.js b/src/putility/src/libs/listener.js new file mode 100644 index 00000000..1d54b93d --- /dev/null +++ b/src/putility/src/libs/listener.js @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2024 Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const { FeatureBase } = require("../bases/FeatureBase"); +const { TDetachable } = require("../traits/traits"); + +// NOTE: copied from src/backend/src/util/listenerutil.js, +// which is now deprecated. + +class MultiDetachable extends FeatureBase { + static FEATURES = [ + require('../features/TraitsFeature'), + ]; + + constructor() { + this.delegates = []; + this.detached_ = false; + } + + add (delegate) { + if ( this.detached_ ) { + delegate.detach(); + return; + } + + this.delegates.push(delegate); + } + + static IMPLEMENTS = { + [TDetachable]: { + detach () { + this.detached_ = true; + for ( const delegate of this.delegates ) { + delegate.detach(); + } + } + } + } +} + +class AlsoDetachable extends FeatureBase { + static FEATURES = [ + require('../features/TraitsFeature'), + ]; + + constructor () { + super(); + this.also = () => {}; + } + + also (also) { + this.also = also; + return this; + } + + static IMPLEMENTS = { + [TDetachable]: { + detach () { + this.detach_(); + this.also(); + } + } + } +} + +// TODO: this doesn't work, but I don't know why yet. +class RemoveFromArrayDetachable extends AlsoDetachable { + constructor (array, element) { + super(); + this.array = new WeakRef(array); + this.element = element; + } + + detach_ () { + const array = this.array.deref(); + if ( ! array ) return; + const index = array.indexOf(this.element); + if ( index !== -1 ) { + array.splice(index, 1); + } + } +} + +module.exports = { + MultiDetachable, + RemoveFromArrayDetachable, +}; diff --git a/src/putility/src/traits/traits.js b/src/putility/src/traits/traits.js new file mode 100644 index 00000000..2416e6d3 --- /dev/null +++ b/src/putility/src/traits/traits.js @@ -0,0 +1,4 @@ +module.exports = { + TTopics: Symbol('TTopics'), + TDetachable: Symbol('TDetachable'), +}; diff --git a/src/putility/test/listener.test.js b/src/putility/test/listener.test.js new file mode 100644 index 00000000..399a8f66 --- /dev/null +++ b/src/putility/test/listener.test.js @@ -0,0 +1,24 @@ +const { RemoveFromArrayDetachable } = require("../src/libs/listener"); +const { expect } = require('chai'); +const { TDetachable } = require("../src/traits/traits"); + +describe('RemoveFromArrayDetachable', () => { + it ('does the thing', () => { + const someArray = []; + + const add_listener = (key, lis) => { + someArray.push(lis); + return new RemoveFromArrayDetachable(someArray, lis); + } + + const det = add_listener('test', () => { + console.log('i am test func'); + }); + + expect(someArray.length).to.equal(1); + + det.as(TDetachable).detach(); + + expect(someArray.length).to.equal(0); + }) +}) diff --git a/src/putility/test/topics.test.js b/src/putility/test/topics.test.js new file mode 100644 index 00000000..18b8702e --- /dev/null +++ b/src/putility/test/topics.test.js @@ -0,0 +1,48 @@ +const { AdvancedBase } = require("../src/AdvancedBase"); +const { TTopics, TDetachable } = require("../src/traits/traits"); + +describe('topics', () => { + it ('works', () => { + // A trait for something that's "punchable" + const TPunchable = Symbol('punchable'); + + class SomeClassWithTopics extends AdvancedBase { + // We can "listen on punched" + static TOPICS = ['punched'] + + // Punchable trait implementation + static IMPLEMENTS = { + [TPunchable]: { + punch () { + this.as(TTopics).pub('punched!', { + information: 'about the punch', + in_whatever: 'format you desire', + }); + } + } + } + } + + const thingy = new SomeClassWithTopics(); + + // Register the first listener, which we expect to be called both times + let first_listener_called = false; + thingy.as(TTopics).sub('punched', () => { + first_listener_called = true; + }); + + // Register the second listener, which we expect to be called once, + // and then we're gonna detach it and make sure detach works + let second_listener_call_count = 0; + const det = thingy.as(TTopics).sub('punched', () => { + second_listener_call_count++; + }); + + thingy.as(TPunchable).punch(); + det.as(TDetachable).detach(); + thingy.as(TPunchable).punch(); + + expect(first_listener_called).to.equal(true); + expect(second_listener_call_count).to.equal(1); + }) +});