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
}
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
Once you have created them you need to add both packages to the project:
$ cd ..
$ meteor add plugin-loader hello-plugin
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()))
}
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
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')
})
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')
})
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()
}
})
...
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)
}
})
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>
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
Then open http://localhost:3000/
and you should see something like this:
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:
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.
Top comments (2)
Thank you for sharing! looking forward to the future tutorials.
Nice! Adding to my bookmarks.