浏览代码

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.
KernelDeimos 7 月之前
父节点
当前提交
14d45a27ed

+ 8 - 10
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 <https://www.gnu.org/licenses/>.
  */
-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);
+});

+ 19 - 0
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,
+}

+ 13 - 0
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,
+};

+ 28 - 12
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_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);
                     });
-                }
+                });
             }
         }
     }

+ 68 - 0
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;
+        }
+    }
+});
+

+ 1 - 0
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;