From 14d45a27edb99f63b4f6e010221e3a0880ae246d Mon Sep 17 00:00:00 2001 From: KernelDeimos Date: Thu, 31 Oct 2024 17:50:05 -0400 Subject: [PATCH] feat: add extension API for modules Allows modules to register a listener to the 'install' event without creating a Module class. This changes how external modules are installed. External modules are now referred to as "extensions"; this commit does not update the term but does use 'extension' as the name of the global. --- mods/mods_available/kdmod/module.js | 18 +++--- src/backend/src/Extension.js | 19 ++++++ src/backend/src/ExtensionModule.js | 13 ++++ src/backend/src/Kernel.js | 38 ++++++++---- src/putility/src/features/EmitterFeature.js | 68 +++++++++++++++++++++ src/useapi/main.js | 1 + 6 files changed, 136 insertions(+), 21 deletions(-) create mode 100644 src/backend/src/Extension.js create mode 100644 src/backend/src/ExtensionModule.js create mode 100644 src/putility/src/features/EmitterFeature.js diff --git a/mods/mods_available/kdmod/module.js b/mods/mods_available/kdmod/module.js index 7df28108..c91b6d51 100644 --- a/mods/mods_available/kdmod/module.js +++ b/mods/mods_available/kdmod/module.js @@ -16,14 +16,12 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -module.exports = class BillingModule extends use.Module { - install (context) { - const services = context.get('services'); +extension.on('install', ({ services }) => { + const services = context.get('services'); - const { CustomPuterService } = require('./CustomPuterService.js'); - services.registerService('__custom-puter', CustomPuterService); - - const { ShareTestService } = require('./ShareTestService.js'); - services.registerService('__share-test', ShareTestService); - } -} + const { CustomPuterService } = require('./CustomPuterService.js'); + services.registerService('__custom-puter', CustomPuterService); + + const { ShareTestService } = require('./ShareTestService.js'); + services.registerService('__share-test', ShareTestService); +}); diff --git a/src/backend/src/Extension.js b/src/backend/src/Extension.js new file mode 100644 index 00000000..4f43ea15 --- /dev/null +++ b/src/backend/src/Extension.js @@ -0,0 +1,19 @@ +const { AdvancedBase } = require("@heyputer/putility"); +const EmitterFeature = require("@heyputer/putility/src/features/EmitterFeature"); +const { Context } = require("./util/context"); + +class Extension extends AdvancedBase { + static FEATURES = [ + EmitterFeature({ + decorators: [ + fn => Context.get(undefined, { + allow_fallback: true, + }).abind(fn) + ] + }), + ]; +} + +module.exports = { + Extension, +} diff --git a/src/backend/src/ExtensionModule.js b/src/backend/src/ExtensionModule.js new file mode 100644 index 00000000..a4b3924b --- /dev/null +++ b/src/backend/src/ExtensionModule.js @@ -0,0 +1,13 @@ +const { AdvancedBase } = require("@heyputer/putility"); + +class ExtensionModule extends AdvancedBase { + async install (context) { + const services = context.get('services'); + + this.extension.emit('install', { context, services }) + } +} + +module.exports = { + ExtensionModule, +}; diff --git a/src/backend/src/Kernel.js b/src/backend/src/Kernel.js index 881b922e..09ecf309 100644 --- a/src/backend/src/Kernel.js +++ b/src/backend/src/Kernel.js @@ -21,7 +21,9 @@ const { Context } = require('./util/context'); const BaseService = require("./services/BaseService"); const useapi = require('useapi'); const yargs = require('yargs/yargs') -const { hideBin } = require('yargs/helpers') +const { hideBin } = require('yargs/helpers'); +const { Extension } = require("./Extension"); +const { ExtensionModule } = require("./ExtensionModule"); class Kernel extends AdvancedBase { @@ -226,25 +228,39 @@ class Kernel extends AdvancedBase { if ( ! stat.isDirectory() ) { continue; } + + const mod = new ExtensionModule(); + mod.extension = new Extension(); - const mod_class = this.useapi.withuse(() => require(mod_path)); - const mod = new mod_class(); - if ( ! mod ) { - continue; - } + // This is where the module gets the 'use' and 'def' globals + await this.useapi.awithuse(async () => { + // This is where the module gets the 'extension' global + await useapi.aglobalwith({ + extension: mod.extension, + }, async () => { + const maybe_promise = require(mod_path); + if ( maybe_promise && maybe_promise instanceof Promise ) { + await maybe_promise; + } + }); + }); const mod_context = this._create_mod_context(mod_install_root_context, { - name: mod_class.name ?? mod_dirname, + name: mod_dirname, ['module']: mod, external: true, mod_path, }); - - if ( mod.install ) { - this.useapi.awithuse(async () => { + + // TODO: DRY `awithuse` and `aglobalwith` with above + await this.useapi.awithuse(async () => { + await useapi.aglobalwith({ + extension: mod.extension, + }, async () => { + // This is where the 'install' event gets triggered await mod.install(mod_context); }); - } + }); } } } diff --git a/src/putility/src/features/EmitterFeature.js b/src/putility/src/features/EmitterFeature.js new file mode 100644 index 00000000..9ed2e3ff --- /dev/null +++ b/src/putility/src/features/EmitterFeature.js @@ -0,0 +1,68 @@ +/** + * A simpler alternative to TopicsFeature. This is an opt-in and not included + * in AdvancedBase. + * + * Adds methods `.on` and `emit`. Unlike TopicsFeature, this does not implement + * a trait. Usage is similar to node's built-in EventEmitter, but because it's + * installed as a mixin it can be used with other class features. + * + * When listeners return a promise, they will block the promise returned by the + * corresponding `emit()` call. Listeners are invoked concurrently, so + * listeners of the same event do not block each other. + */ +module.exports = ({ decorators }) => ({ + install_in_instance (instance, { parameters }) { + // install the internal state + const state = instance._.emitterFeature = {}; + state.listeners_ = {}; + state.callbackDecorators = decorators || []; + + instance.emit = async (key, data, meta) => { + meta = meta ?? {}; + const parts = key.split('.'); + + const promises = []; + for ( let i = 0; i < parts.length; i++ ) { + const part = i === parts.length - 1 + ? parts.join('.') + : parts.slice(0, i + 1).join('.') + '.*'; + + // actual emit + const listeners = state.listeners_[part]; + if ( ! listeners ) continue; + for ( let i = 0; i < listeners.length; i++ ) { + let callback = listeners[i]; + for ( const decorator of state.callbackDecorators ) { + callback = decorator(callback); + } + + promises.push(callback(data, { + ...meta, + key, + })); + } + } + + return await Promise.all(promises); + } + + instance.on = (selector, callback) => { + const listeners = state.listeners_[selector] || + (state.listeners_[selector] = []); + + listeners.push(callback); + + const det = { + detach: () => { + const idx = listeners.indexOf(callback); + if ( idx !== -1 ) { + listeners.splice(idx, 1); + } + } + }; + + return det; + } + } +}); + diff --git a/src/useapi/main.js b/src/useapi/main.js index c45fb9e1..1187a89c 100644 --- a/src/useapi/main.js +++ b/src/useapi/main.js @@ -114,5 +114,6 @@ const useapi = function useapi () { // We export some things on the function itself useapi.globalwith = globalwith; +useapi.aglobalwith = aglobalwith; module.exports = useapi;