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.
This commit is contained in:
KernelDeimos 2024-10-17 22:45:39 -04:00
parent c5ad3a8362
commit da613af9ea
9 changed files with 229 additions and 0 deletions

View File

@ -16,6 +16,9 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// DEPRECATED: use putility.libs.listener instead
class MultiDetachable {
constructor() {
this.delegates = [];

View File

@ -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,
},
};

View File

@ -27,6 +27,7 @@ class AdvancedBase extends FeatureBase {
require('./features/PropertiesFeature'),
require('./features/TraitsFeature'),
require('./features/NariMethodsFeature'),
require('./features/TopicsFeature'),
]
}

View File

@ -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);
}
})
}
};

View File

@ -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;

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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,
};

View File

@ -0,0 +1,4 @@
module.exports = {
TTopics: Symbol('TTopics'),
TDetachable: Symbol('TDetachable'),
};

View File

@ -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);
})
})

View File

@ -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);
})
});