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.
This commit is contained in:
KernelDeimos 2024-10-31 17:50:05 -04:00
parent f36718005f
commit 14d45a27ed
6 changed files with 136 additions and 21 deletions

View File

@ -16,8 +16,7 @@
* 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/>.
*/
module.exports = class BillingModule extends use.Module {
install (context) {
extension.on('install', ({ services }) => {
const services = context.get('services');
const { CustomPuterService } = require('./CustomPuterService.js');
@ -25,5 +24,4 @@ module.exports = class BillingModule extends use.Module {
const { ShareTestService } = require('./ShareTestService.js');
services.registerService('__share-test', ShareTestService);
}
}
});

View File

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

View File

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

View File

@ -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 {
@ -227,24 +229,38 @@ class Kernel extends AdvancedBase {
continue;
}
const mod_class = this.useapi.withuse(() => require(mod_path));
const mod = new mod_class();
if ( ! mod ) {
continue;
const mod = new ExtensionModule();
mod.extension = new Extension();
// 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);
});
}
});
}
}
}

View File

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

View File

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