A proposal for the evolution of frontend plugins/modules

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. :pray:

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 or title. The main slot above, for instance, requires a path. A new deep-linkable tab in course home, as we discussed earlier, would require a title and a path.
  • A config if the operation involves adding a UI element. This is an object that includes a few things: 1) A React component if there are no routes, 2) A react-router routes config for all the sub-routes (if any) being loaded into the slot, and 1) a messages 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:

  1. Without knowing it’s path (i.e., ‘/home’), or even if it’s part of the single page application or at a different domain.
  2. 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.

3 Likes

Wouldn’t this be slow, if we have to asynchronously load the configuration for dozens (or more) of plugins at runtime? I would personally think that is fine for development, but in production this should be “baked in” to optimize performance. Sure, the plugins themselves can still be lazy-loaded, but the overall config and shell should be pre-baked.

I know some people really prioritize the ability to dynamically re-deploy one MFE without a rebuild process but I personally think it’s much more important to focus on end user performance (which we’ve never been good at in our MFEs, e.g. learner-dashboard at home.edx.org scores 35 out of 100 on Chrome’s Lighthouse performance score).

the overall config and shell should be pre-baked

Keeping in mind this statement is specifically about plugins deployed independently via module federation - The difficulty here comes down to the fact that we don’t want a dependency on the plugin in this case (otherwise we’ve defeated the purpose of module federation).

Having no dependency means that there’s nowhere for us to import a “configureMyPlugin” function from. In which case if we want the config baked in, the operator needs to write it themself (or copy/paste it from some doc somewhere, presumably). This violates the “plugin n’ play” principle, which I believe we want to preserve.

An alternative would be to publish multiple packages for a plugin, one of which would be the ‘configuration helper package’ which is just enough code to help us bake in the config. Even still, it violates the independent deployment principle.

The config block loaded asynchronously is presumably pretty small… I don’t expect it’s ‘slow’ any more than making any other asynchronous requests before load (such as the MFE config API).

Thinking out loud, the best option may be to ‘cheat’ a little for simple plugins and bake a condition into the config that dictates when to go lazy load it’s config. For a plugin that exists at a route, for instance, this is that react-router is loading it’s path. For a widget plugin, this is that the widget is being displayed. A big complex plugin like ‘ecommerce’ is something we likely need to load up front so we know how it affects our site, but for anything smaller, we can probably find ways of deferring any requests.

much more important to focus on end user performance

Also, if this is the priority to the detriment of build speed, then by all means just have one build process, bake it all in, and don’t use module federation. :slight_smile: The system supports that. It’s cuts down on up-front requests, certainly.

Also worth noting that if you want module federation for your plugins but don’t care that they’re deployed as a group, you can combine multiple plugins into a single deployment (frontend projects supports this)… which means even if you have 20 plugins, you’d only make one request for the config for all of them.

Haha, well this is just my bias. I would prefer to ditch module federation and work on making our build process lightning fast. But I know we decided module federation was a requirement for now.

Oh yeah, I’m a big fan of the “plugin n’ play” principle and as you know, have been suggesting similar things in our Slack discussions. Definitely like your proposals along that line in this post.

That’s not necessary. A plugin can provide a single package which defines conditional exports or subpath exports to separate the config code from the lazy-loaded UI code.

Yeah it’s more about the number of requests and the overhead that adds than any significant amount of data transfer / download delay.

I thought the point of module federation was independent deployment? But yeah I guess if you have 20 plugins that affect a single MFE, even with module federation, you’d want to consolidate that into one config loading.


I guess what I’m asking is: can we have an option to just “bake everything in” during the shell build, for those of us who prefer to deploy that way, and get the speed benefits? At OpenCraft, we don’t even deploy frontends separately from edx-platform. So we’d have no reason to pay the price of dynamic config.

can we have an option to just “bake everything in” during the shell build, for those of us who prefer to deploy that way, and get the speed benefits?

This absolutely works. :+1:

1 Like