Plugin architecture with Meteor

jankapunkt

Jan Küster

Posted on January 20, 2021

Plugin architecture with Meteor

Writing packages for Meteor is easy and straight forward. However, if you want to allow your users to extend their application on their own, you usually have to implement some kind of plugin architecture.

By doing so, you can distinctively control what functionality users can add within the limits you define.

In this tutorial we want to focus on a potential approach to load plugins from packages without importing them directly but using a dynamic mechanism:

  • no manual configuration of settings should be required
  • no manual imports of the plugin should be required
  • plugin package added -> plugin available
  • plugin package removed -> plugin not available

Furthermore there should be a very important contstraint:

  • no plugin should be added to the initial client bundle, unless loaded by the plugin-loader (imagine 100 plugins loaded all at application startup -> super slow)

A minimal example project

For this tutorial we will create a minimal example project. I am using the defaults here, including Blaze (Meteor's default frontend). This, however, shouldn't prevent you from picking your favourite frontend as the proposed plugin architecture will (and should!) work independently from it.

Preparations - Overview of the architecture

Our example will consists of three main entities:

  • Meteor project "plugin-example"
  • Package "plugin-loader"
  • Package "hello-plugin"

Their relation is fairly simple: The plugins will use the plugin-loader to "register" themselves, while the Meteor project uses the plugin-loader to load the plugins via dynamic import. Thus, the plugin-loader package has to be a package, shared by the other two.

We want to keep things simple. Therefore, a plugin will consist of the following minimal interface:

{
  name: String,
  run: () => String
}
Enter fullscreen mode Exit fullscreen mode

Now if you haven't installed Meteor yet, you may install it now, which takes only a minute or two.

Step 1 - Create project and packages

Creating the project and the packages is done in no time:

$ meteor create plugin-example
$ cd plugin-example
$ meteor npm install
$ mkdir -p packages
$ cd packages
$ meteor create --package plugin-loader
$ meteor create --package hello-plugin
Enter fullscreen mode Exit fullscreen mode

Once you have created them you need to add both packages to the project:

$ cd ..
$ meteor add plugin-loader hello-plugin
Enter fullscreen mode Exit fullscreen mode

Now everything is setup and we can start implementing the plugin-loader, first.

Step 2 - Implement the plugin-loader

The plugin loader itself is not very complicated, either. It's only functionality defines as the following:

  • register a plugin by a given name and load-function, where the name distincs the plugin from others and the load function will actually load the plugin into the host application
  • load all plugins by executing all registered load functions and return an array of all loaded plugins

For implementation we use a simple Map to store the data and provide only two functions for access:

packages/plugin-loader/plugin-loader.js

export const PluginLoader = {}

/** internal store of load functions **/
const plugins = new Map()

/**
 * Add a plugin to the loader.
 * @param key {String} the plugin name, prevent duplicates
 * @param load {aync Function} imports the actual plugin
 */
PluginLoader.add = (key, load) => {
  plugins.set(key, load)
}

/**
 * Load all registered plugins. Could be extended by a filter.
 * @return {Promise} a promise that resolves to an array of all loaded plugins
 */
PluginLoader.load = () => {
  const values = Array.from(plugins.values())
  plugins.clear()
  return Promise.all(values.map(fct => fct()))
}
Enter fullscreen mode Exit fullscreen mode

That's it for the plugin loader. You can keep the other files in the package as they are and move to the next step.

Step 3 - Implement the plugin

This is the most critical part, since the correct utilization of the plugin loader is presumed in order to not load the plugins into the initial client bundle. Keep focused as I will explain things after the steps in detail.

Let's start off with our plugin itself, which should simply just return some hello-message when called:

packages/hello-plugin/hello-plugin.js

const HelloPlugin = {}

HelloPlugin.name = 'helloPlugin'

HelloPlugin.run = function () {
  return 'Hello from a plugin'
}

;(function () {
  // if you see this line at startup then something went wrong
  console.info('plugin loaded')
})()

module.exports = HelloPlugin
Enter fullscreen mode Exit fullscreen mode

Nothing fancy but now we need to create a new file, which will register the plugin to the loader:

packages/hello-plugin/register.js

import { PluginLoader } from 'meteor/plugin-loader'

PluginLoader.add('helloPlugin', async function () {
  // await import(...) import other dependencies
  // from this package, if necessary
  return import('./hello-plugin')
})
Enter fullscreen mode Exit fullscreen mode

This actually registers not the plugin but an async function that itself is used to call the dynamic import of the plugin (and other files from this package, if necessary).

Caution: If you directly use import('./hello-plugin') it will immediately import the plugin, which is not what we want here.

Finally in order to "automagically" register the plugin, we need to make a small change in the package.js file so it looks like the following:

packages/hello-plugin/package.js

Package.onUse(function (api) {
  api.versionsFrom('1.12.1')
  api.use('ecmascript')
  api.use('plugin-loader')
  api.addFiles('register.js')
})
Enter fullscreen mode Exit fullscreen mode

This works, because api.addFiles not only adds the file to the initial client bundle, it also makes sure the code in it is executed when the client starts. However, since we removed the api.mainModule call and have no other reference to the hello-plugin.js besides the dynamic import, this file will not be added until the loader loads it.

Now we can integrate both packages into our application in the next step.

Step 4 - Load the plugin on demand

To keep things minimal we will only focus on the client here. Therefore, we will only do changes in the client/ folder.

Based on the initial main.js file we import the plugin loader and create some reactive variable to indicate, whether we have loaded plugins, or not.

client/main.js

import { Template } from 'meteor/templating';
import { ReactiveVar } from 'meteor/reactive-var';
import { PluginLoader } from 'meteor/plugin-loader'
import './main.html';

const loadedPlugins = new Map()

Template.hello.onCreated(function helloOnCreated() {
  const instance = this
  instance.loaded = new ReactiveVar(false)
})

Template.hello.helpers({
  plugins () {
    return Array.from(loadedPlugins.values())
  },
  loaded () {
    return Template.instance().loaded.get()
  }
})

...
Enter fullscreen mode Exit fullscreen mode

Then we add a button, on whose action we actually load the plugins using the loader:

client/main.js

...

Template.hello.events({
  'click .load-button': async function (event, instance) {
    const allPlugins = await PluginLoader.load()

    allPlugins.forEach(plugin => {
      loadedPlugins.set(plugin.name, plugin)
    })

    instance.loaded.set(true)
  }
})
Enter fullscreen mode Exit fullscreen mode

Since PluginLoader.load returns a Promise<Array> (via Promise.all) we can use async/await to keep the code readable.

When all plugins have been loaded we can simply store them in a data structure (like a Map, used in the example) and then set the reactive variable loaded to true so it will cause the Template to render our plugins.

Note, that you can't directly store the plugins in a reactive variable, since they may loose their functions to work.

Finally, the Template is nothing fancy and should look like the following:

client/main.html

<head>
  <title>plugin-example</title>
</head>

<body>
  <h1>Plugins example</h1>

  {{> hello}}
</body>

<template name="hello">
    {{#if loaded}}
        {{#each plugin in plugins}}
            {{plugin.name}}: {{plugin.run}}
        {{/each}}
    {{else}}
        <button class="load-button">Load plugins</button>
    {{/if}}
</template>
Enter fullscreen mode Exit fullscreen mode

All done and ready to start. 🚀

Step 5 - running the code

In your project you can enter the meteor command to run the code:

$ cd /path/to/plugin-example
$ meteor
Enter fullscreen mode Exit fullscreen mode

Then open http://localhost:3000/ and you should see something like this:

load plugin button screenshot

At this point your browser console (F12) should not!!! have printed "plugin loaded"

Now click the button and load the plugin. You should see now the plugin output:

loaded plugin screenshot

Additionally in your browser console there should now the "plugin loaded" have been printed.

🎉 Congratulations, you created an initial foundation for a simple plugin architecture in Meteor.

Summary and outlook

With this tutorial we have set the foundation of writing pluggable software by using a simple plugin-loader mechanism.

In future tutorials we could focus on the plugin interface, how it interacts with the host application and how we can make use of some of Meteor's core features (Mongo, Authentication, Methods, Pub/Sub) to ease up plugin-development.



I regularly publish articles here on dev.to about Meteor and JavaScript. If you like what you are reading and want to support me, you can send me a tip via PayPal.

You can also find (and contact) me on GitHub, Twitter and LinkedIn.

Keep up with the latest development on Meteor by visiting their blog and if you are the same into Meteor like I am and want to show it to the world, you should check out the Meteor merch store.

💖 💪 🙅 🚩
jankapunkt
Jan Küster

Posted on January 20, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

Plugin architecture with Meteor
javascript Plugin architecture with Meteor

January 20, 2021