Hi all,
I’d like to share a proposal for how frontend plugins could work in the future.
Call this a preliminary talk-through of an ADR or OEP. More below! And you know me: it’s a bit of a novel, but hopefully a worthy read.
Context
I’m currently working with Axim on building out the Open edX frontend’s module federation capabilities, as described in OEP-65.
This work is happening in frontend-base. As part of this, I’m building a ‘shell’ to own the header and footer of an Open edX site, and that shell is responsible for loading modules at runtime on demand.
The pages in our MFEs will evolve into self-contained, modular components which will be configured into the site at a particular path. They may be independently built, deployed, and loaded via module federation, or bundled with the shell and deployed as a single bundle.
Simultaneous to this long-term work, we’re adding plugin slots via frontend-plugin-framework (FPF) throughout the code base (in particular the headers and footers). FPF provides a syntax for adding plugins into these slots via an env.config.jsx
file.
One important question we asked early on was:
“Are these modules actually just plugins?”
I’m starting to have an answer to this. And I think the answer is… yes and no. Yes, they are mostly the same, but no, neither one is actually a plugin in and of itself. The real answer is that frontend-plugin-framework plugins and frontend-base modules are actually two parts of a larger, more robust plugin system that we don’t have yet.
Principles
I think this future plugin system has a few important differences to what we’ve built so far.
Plugin n’ Play
It’s core guiding principle is that a plugin should be self-defining and opinionated about what it does and where (in the UI) it does it. There should be very little work involved in “plugging it in” to the site.
Both FPF’s configuration in env.config.jsx
and the configuration I’ve been working on in frontend-base do not follow this principle. They require manual work on the part of the operator to “hook up” all the pieces. This was, I think, an attempt at keeping the system flexible by putting all the connective code in the hands of the operator so they can tweak it.
A Component is not a plugin, it’s part of a plugin
The things we’ve been calling “plugins” are really just UI components. An actual plugin is, in many cases, bigger than that. A system that only allows us to drop UI components into slots is ignoring even simple things like metadata associated with that component. In the simplest illustrative case, even a component representing a new tab on the course home page needs a title associated with it for the tab.
In a much more complex example, take the idea of adding an e-commerce frontend to your site. Adding e-commerce is much more than a single component - there’s a catalog, shopping cart, cart icon in the header, link to the catalog, help links in the footer, checkout process, confirmation page, order history, link to order history in the user menu, and probably a myriad of links added throughout the site to integrate e-commerce into other pages.
That is a frontend plugin. Components and all the related configuration, tweaks, and links that enable the core functionality, which may be split across multiple slots throughout the site.
Proposed Schema (Putting the principles together)
Combining the above two points, our hypothetical e-commerce plugin should be as easy to add to your site as:
+ import ecommerce from '@davidjoy/ecommerce-plugin';
const config: SiteConfig = {
siteName: "My Open edX Site",
baseUrl: "http://localhost",
// ... other config
plugins: [
+ ecommerce
]
}
Of course, there’s a tiny bit more too it than that.
An ecommerce frontend plugin will need a backend to work with. And this config file is for a particular environment (production, staging, development), so we’ll need to at least tell it where to find its backend:
// This is TypeScript by the way, so yay auto-complete and type-checking.
- import ecommerce from '@davidjoy/ecommerce-plugin';
+ import configureEcommerce from '@davidjoy/ecommerce-plugin';
const config: SiteConfig = {
siteName: "My Open edX Site",
baseUrl: "http://localhost",
// ... other config
plugins: [
- ecommerce
+ configureEcommerce({
+ ecommerceApiBaseUrl: 'http://localhost/api/ecommerce',
+ // ... Maybe some other options too.
+ })
]
}
What’s in the configureEcommerce function?
An important thing just happened! Talking about configureEcommerce
, we left the operator’s code. Once we get into this function, we’re now firmly in code of plugin developers. The operator’s work is done; the plugin author’s work is just beginning.
I imagine configureEcommerce
looks something like this representative sample:
export default function configureEcommerce(options): PluginConfig {
return {
slots: {
'frontend.shell.main': [
{
op: 'append',
id: 'ecommerce.catalogPage',
path: 'catalog',
config: catalogPageConfig,
}
]
'shell.header.rightContent': [
{
op: 'before',
id: 'ecommerce.cartButton',
relatedId: 'shell.header.rightContent.userMenu',
config: cartButtonConfig
}
],
// ... a bunch of other slot/operation definitions using the `options` as necessary.
}
}
}
Let’s break this down a bit.
Slot and component identifiers
The keys of the slots
object uniquely identify the slot. Note, however, that they are about the semantics of the slot, not the implementation. They aren’t prefixed with an organization who wrote the code because that may change depending on what plugins are loaded.
As an example, if I replace the default header with my own implementation, we don’t want downstream plugins (like ecommerce above) to need to know that the name of ‘openedx.shell.header.rightContent’ changed to ‘davidjoy.shell.header.rightContent’. We want ecommerce to just do the right thing and insert our cart button next to the userMenu; we don’t care that I wrote my own user menu component.
Therefore, slot identifiers are unique, semantically meaningful identifiers, independent from their actual implementations.
To be clear, this is difficult to monitor and enforce, and will require oversight to ensure that the semantics of our slots are consistent and intuitive.
Operations
Each slot identifier takes an array of operations. This should be familiar to those who know FPF well; I’ve tried to keep the syntax as simple and brief as possible for this pseudo-code.
These operations have a few fields:
- An operation identifier (append, prepend, insert after, insert before, replace, hide, etc.)
- A component identifier; this is like the slot identifiers, but uniquely identifies the plugin component. This is important so that other plugins can reference our components; you can see that happening with the CartButton referencing the user menu.
- Some slots require additional metadata like a
path
ortitle
. Themain
slot above, for instance, requires a path. A new deep-linkable tab in course home, as we discussed earlier, would require atitle
and apath
. - A
config
if the operation involves adding a UI element. This is an object that includes a few things: 1) A Reactcomponent
if there are no routes, 2) A react-routerroutes
config for all the sub-routes (if any) being loaded into the slot, and 1) amessages
object of i18n localizations that need to be included.
In this way, a single plugin can define all of the operations necessary to add itself and all its component parts to the site. You’ll also notice that this is still just a big data structure; that means there’s an easy escape hatch for operators who need to deal with conflicting plugins; they can manually define the portion of the configs that conflict.
Consequences
There are some implications here.
Plugins (almost) all the way down
I’ve thought this through enough to believe that the “module” system I’m building for frontend-base can actually be modeled as plugins in this paradigm. This likely means a ‘default’ installation of the Open edX frontend includes a set of plugins providing the functionality of our core MFEs: learning, learner dashboard, profile, account, authentication, authoring, instructor dashboard, etc.
All of these plugins are loaded by the ‘shell’ in frontend-base, which owns the initialization/configuration/module/plugin loading system and a thin layout wrapper around our UI components. Over time, heck, even that layout and the header/footer could be modeled as plugins, but for now we’ll just leave them in the shell for simplicity’s sake.
The semantically meaningful identifiers are important here - a plugin that wants to link to the course home page should be able to do so:
- Without knowing it’s path (i.e., ‘/home’), or even if it’s part of the single page application or at a different domain.
- Without knowing what code implements the course home page.
Module Federation
For sanity’s sake, an entire plugin must opt into being loaded via module federation. In our e-commerce example, this ultimately means we tell the site where to load the e-commerce configuration asynchronously at runtime, rather than including it in our config file.
const config: SiteConfig = {
siteName: "My Open edX Site",
baseUrl: "http://localhost",
// ... other config
plugins: [
- configureEcommerce({
- ecommerceApiBaseUrl: 'http://localhost/api/ecommerce',
- // ... Maybe some other options too.
- })
+ {
+. remoteUrl: 'http://localhost:8080/remoteEntry.js',
+ libraryId: 'ecommerce',
+ moduleId: 'config',
+ }
]
}
The shell takes that module federation config and will load the ‘config’ module immediately so that it understands all the operations being applied by the plugin. As with a ‘direct’-style plugin above, the plugin code is responsible for efficiently lazy-loading the bulk of its code on demand.
Robust plugin operations help minimize dependencies between plugins
In general, we want plugins to be able to modify other functionality without having a hard dependency on it. A robust set of operations (append, prepend, insert before, insert after, replace, wrap, hide, etc.) helps support this.
That said, as an escape hatch, a plugin author will be able to add other plugins as peerDependencies
in their code to support more nuanced interactions. Harkening back to my prior posts about ‘frontend projects’, deployment happens in the context of a project. A project can have a set of dependencies that includes all the plugins needed to build it, and any code not used will be tree-shaken out as normal. In this way, a plugin can also act as a bit of a component library to support complex interactions in downstream plugins.
Expanded list of slot types
Today, FPF deals primarily in UI components. But we can imagine that there’s a much expanded list of plugin types:
- Widgets (what FPF does today)
- Routes (a widget with react-router metadata)
- Menu Items (used primarily in the header/footer)
- Scripts (loaded into a slot in the )
- Services (replacements for logging, auth, or analytics services)
- Stylesheets
- Filters / Function Middleware
- Hooks / Event Listeners
All of these could be defined using roughly the same set of operations and a few new ‘slot’ implementations for non-React-component use cases.
Conclusion
I’m bringing this up now because I need to define how “modules” (i.e., MFEs) are loaded in frontend-base, and have (through this post!) convinced myself that the system I’ve started on is not quite robust enough to handle what we will ultimately need. Instead, I believe it should converge with frontend-plugin-framework along these lines.
As with all things, I’m sure there are some hidden complexities here that I haven’t thought of, and I’d welcome some constructive feedback. If this feels directionally aligned to what we’d like to see in our frontend plugin system in the future, I’d love to know that too! Thanks all.