backstage

Форк
0

README.md

title: Dynamic Frontend Plugins status: provisional authors:

  • '@Hyperkid123' owners: project-areas:
  • core creation-date: 2024-01-17

BEP:

Discussion Issue

Summary

The dynamic frontend plugins feature is a way of loading additional frontend plugins at runtime, without the requirement of rebuilding and restarting a running Backstage instance. It also provides a method for packaging and distributing plugins as standalone artifacts, which can be installed directly into Backstage.

This system should significantly improve frontend plugin management for Backstage instances, and makes it possible to deploy changes to the app without rebuilding the app itself.

The dynamic plugins leverage the declarative nature of the new frontend system to define what a plugin is and how it is integrated into the rest of the app.

Motivation

Being able to dynamically install plugins unlocks new ways of deploying and managing Backstage, and has the potential to hugely improve adoption by lowering the barrier of entry. A Backstage installation currently requires quite a lot of care to maintain, meaning it may not be worth the investment for smaller organizations. By making it possible to set up and maintain a Backstage instance without the need to manage a codebase, we can make Backstage more accessible to a wider audience.

The ability to dynamically install plugins also allows for more isolated development and deployment of plugins. This can benefit organizations with large Backstage projects, where splitting the codebase into multiple smaller projects can improve development experience and autonomy.

Goals

The overarching goal of this proposal is to outline the full path of how frontend plugin code in a repository makes it way into an existing Backstage app. As part of this, we define the following:

  • Bundling: How plugin code is packaged into a portable artifact.
  • Distribution: How these artifacts are deployed or published and made available for apps to load.
  • Loading: How an app is able to load these artifacts from remote or local sources at runtime.

Each of these may have multiple possible solutions, in particular the loading and distribution steps. This proposal should aim to provide the minimum solutions that avoids the need for each adopter to have to re-bundle open source plugins, while still making it simple to use dynamic installation for their own internal plugins.

There are a couple of sub-goals that are important for this to work:

  • Discover and choose the underlying tooling to enable dynamic frontend plugins.
  • An easy way to package existing frontend plugins for dynamic installation.
  • Reconfiguring the installed plugins at runtime without rebuilding the app, either declaratively or through code.

Non-Goals

The integration of installed plugins and features is not in scope for this proposal, that is the responsibility of the new frontend system.

This proposal does not contain any form of visual interface for managing dynamically installed plugins. The scope of this proposal only includes configuration of dynamic plugins through static configuration and TypeScript interfaces.

This proposal does not aim to make it possible to add or remove plugins into an already running frontend app instance as created by createApp from the Backstage core APIs. The page must be reloaded for any updates to take effect.

Proposal

Definition of UI dynamic plugin

A dynamic UI plugin (from now just plugin) is a plugin that is not part of the output of a backstage instance build. The plugin and its assets are injected into backstage at runtime. In this case, its injected into the browser at some point during user session.

From the user POV, there is no difference between classic and dynamic plugins.

The difference is known only to maintainers and should be limited to

  • build requirements
  • integration into backstage

Dynamic loading tool

Traditional plugins are not dynamic out of the box. Additional changes are required during build time to make a plugin dynamic.

The main application (shell) also requires some changes to be able to load, inject, and propagate mandatory context to dynamic plugins.

Over the last few year, Module Federation has become the standard when it comes to dynamically load JS modules browser (and nodejs) environment from remote locations and sharing context between the shell and the remote module.

Although there are other options, like externalizing dependencies, the module federation has proven itself as a robust solution to this particular problem.

Module federation implementation

There are multiple available implementations of module federation.

Historically, module federation was implemented as a Webpack feature. Since then, additional implementations were created. Mainly for rspack and Vite.

Recent changes claim, that all of these module federation implementations should be compatible with each other and there should be no need for locking backstage into a single implementation.

Compatibility between Webpack and rspack should available via the @module-federation/enhanced package. The compatibility is further described here.

The Vite plugin claims webpack compatibility as well. The Vite plugin is not part of the @module-federation organization. The extend of compatibility is at this time unknown.

The level of compatibility has to be tested. Before then, no decision in regards to which implementation(s) will be used should be made. The chosen tool, or their mix, will shape the design and implementation.

That said, packages from the @module-federation/* organization should have higher priority as they are based directly on the module federation concepts and are well supported.

Plugin integration into shell applications

Plugins should be integrated via the new UI system. The system already provides an asynchronous way of loading plugins. The dynamic plugins can be loaded in a similar fashion.

Plugins should be defined declaratively through configuration. Similar to what was described in this RFC and in this issue.

Plugin registry

Because plugins are not available at build time, some sort of registry needs to exist to store the information.

This registry needs to be mutable at runtime (add/remove new plugin metadata) and changes have to be reflected on session refresh.

This is currently an issue as the app config is embedded into JS assets during build time.

Each plugin is required to provide manifest file (metadata) in predefined format. This manifest will be used to inject the plugin assets into the browser.

Plugin discovery

Plugin discovery is a pre-requisite for Plugin registry. This should be responsible for scanning for available plugins and generating/modifying the plugin registry to always keep it up to date.

Design Details

NOTE The details are based on the Janus implementation of dynamic frontend plugins. The implementation leverages Scalprum which is a Webpack based dynamic plugin manager for React applications.

Module federation implementation experiments

Test should consist of trying to run permutations of webpack/Rspack/vite based shell apps/plugins and discover if we can freely choose any tool, or if we should restrict the tooling to just a subset of the available options.

The outcome of initial testing is positive and it is possible to mix and match different build tools and consume different remote modules in a single shell application.

The experimental code can be found in this repository.

So far a lot of custom code needs to be written to bridge Webpack, Rspack, @module-federation/enhanced with Vite. The first three are compatible out of the box, but Vite requires extra bridge to be able to consume/provide modules with/to other builds.

React context and singleton sharing

We can share React context and its values. Meaning a shell application (or a plugin parent) can have a context provider and a plugin will consume the context value.

An example is this package in the experiment repo.

The shell apps supply the provider and remote modules consume it. There are no issues with any combination of tooling.

Optimized module sharing

Module sharing optimizations are also working nicely. (Optimization description)

Mixing shared scope is working between various modules using various build tools.

Sample configuration: https://github.com/scalprum/mf-mixing-experiments/blob/master/mixed-remote-modules-collection/webpack.config.js#L27-L41

const plugin = new ModuleFederationPlugin({
...
shared: {
'@mui/material/Button': {
requiredVersion: '>=5.0.0',
version: '5.15.6',
},
'@mui/material/TextField': {
requiredVersion: '>=5.0.0',
version: '5.15.6',
},
'@mui/material/Typography': {
requiredVersion: '>=5.0.0',
version: '5.15.6',
},
'@mui/material/Divider': {
requiredVersion: '>=5.0.0',
version: '5.15.6',
},
...
},
});

This config ensures that only those modules (from @mui/material) that are used in the code will be shared. If a relative imports and the entire dependency name is used, the entire dependency will be shared, regardless of which modules are consumed. Tree shaking does not work when an entire dependency is shared! Explanation of why is described here.

This can be checked by debugging network traffic and shared scopes:

notifications system architecture diagram

Plugin manifest

Each plugin should have a manifest file with important metadata. This metadata is used to load the remote assets to browser. The plugin manifest should be part of a build output.

A manifest should have:

  • name of plugin
  • how can be the init container accessed
  • what is the base URL (assets pathname)
  • name of the entry script(s)

Scalprum compatible manifest

type PluginManifest = {
name: string;
version: string;
dependencies?: Record<string, string>;
customProperties?: AnyObject;
baseURL: string;
extensions: Extension[];
loadScripts: string[];
registrationMethod: 'callback' | 'custom';
buildHash?: string;
}
{
"name": "backstage.plugin-github-actions",
"version": "0.6.6",
"extensions": [],
"registrationMethod": "callback", // where container init is available in browser
"baseURL": "auto",
"loadScripts": [
"backstage.plugin-github-actions.804b91040fcbca6585ce.js"
],
"buildHash": "804b91040fcbca6585ce1bcd4b1f8aa2"
}
registrationMethod

Refers to webpack output.libraryTarget.

Callback refers to jsonp and a the custom is used if other available target configuration has been picked.

It is recommended to use either global or jsonp as these are environment agnostic (browser VS node). jsonp requires additional configurations. The global is preferable due to its simplicity.

baseURL

The baseURL is derived from webpack public path

The public path can also be set to auto to remove the need to specify origin or pathname and resolve the pathname at runtime.

In Scalprum, some manifest post processing is required to load the initial scripts if the auto baseURL is chosen.

Plugin registry

Plugin registry can be fairly simplistic. It can be as simple as JSON file containing list/map of available plugins and their manifests

type RegistryEntry = {
name: string // plugin name
manifestLocation: string // path to the manifest resource
}
// object for easy access
type PluginRegistry = {
[pluginName: string]: RegistryEntry
}
// or as an array
type PluginRegistry = RegistryEntry[]

Example of such registry

// as object
{
"backstage.plugin-github-actions": {
"name": "backstage.plugin-github-actions",
"manifestLocation": "https://foo-bar.com/api/plugin-storage/plugin-manifest.json"
},
// ..rest of plugins
}
// as array
[
{
"name": "backstage.plugin-github-actions",
"manifestLocation": "https://foo-bar.com/api/plugin-storage/plugin-manifest.json"
},
// ...rest of plugins
]

Scalprum by default lazy loads plugins and manifests. That is because Scalprum initializes plugins only once they are supposed to be rendered in browser.

Because backstage does not require that functionality in initial dynamic plugin implementation and loads all plugins at bootstrap, there is an alternative to embed manifest data into the registry itself.

type RegistryEntry = {
name: string // plugin name
pluginManifest: PluginManifest
}
// object for easy access
type PluginRegistry = {
[pluginName: string]: RegistryEntry
}
// or as an array
type PluginRegistry = RegistryEntry[]

Example of registry with embedded manifests

// as object
{
"backstage.plugin-github-actions": {
"name": "backstage.plugin-github-actions",
"pluginManifest": {
"name": "backstage.plugin-github-actions",
"version": "0.6.6",
"extensions": [],
"registrationMethod": "callback",
"baseURL": "auto",
"loadScripts": [
"backstage.plugin-github-actions.804b91040fcbca6585ce.js"
],
"buildHash": "804b91040fcbca6585ce1bcd4b1f8aa2"
}
},
// ..rest of plugins
}
// as array
[
{
"name": "backstage.plugin-github-actions",
"pluginManifest": {
"name": "backstage.plugin-github-actions",
"version": "0.6.6",
"extensions": [],
"registrationMethod": "callback",
"baseURL": "auto",
"loadScripts": [
"backstage.plugin-github-actions.804b91040fcbca6585ce.js"
],
"buildHash": "804b91040fcbca6585ce1bcd4b1f8aa2"
}
},
// ...rest of plugins
]

Webpack build configuration

NOTE This is a sample current configuration in the Janus project. It uses Scalprum webpack based build plugin to generate the output. It does not take the all options in consideration. This section will likely change considerably.

Part of a Scalprum tooling is also dynamic-plugins-sdk. Right now the package is a part of a different project, but that is about to change. More details in the Scalprum roadmap.

Sample plugin configuration using Scalprum SDK

import { DynamicRemotePlugin } from '@openshift/dynamic-plugin-sdk-webpack';
const sharedModules = {
/**
* Mandatory singleton packages for sharing
*/
react: {
singleton: true,
requiredVersion: '*',
},
'react-dom': {
singleton: true,
requiredVersion: '*',
},
'react-router-dom': {
singleton: true,
requiredVersion: '*',
},
'react-router': {
singleton: true,
requiredVersion: '*',
},
...
/**
* Full list of shared modules in Janus
* https://github.com/janus-idp/backstage-plugins/blob/87a6b045c7b0f301ebed8b8f99dc1741fa2b044b/packages/cli/src/lib/bundler/scalprumConfig.ts#L16
*/
}
const dynamicPluginPlugin = new DynamicRemotePlugin({
extensions: [],
sharedModules,
entryScriptFilename: `${options.pluginMetadata.name}.[contenthash].js`,
pluginMetadata: {
// version cna be used from the package.json version field
version: '1.0.0',
/**
* Name can be easily derived from the plugin name
* https://github.com/janus-idp/backstage-plugins/blob/87a6b045c7b0f301ebed8b8f99dc1741fa2b044b/packages/cli/src/commands/export-dynamic-plugin/frontend.ts#L40
*/
name: 'backstage.plugin-github-actions',
/**
* Path to the plugin entry point.
* It can default to the same entry point as in regular build.
* Plugins can expose multiple modules. We have found that one is sufficient from backstage plugins.
*/
exposedModules: {
PluginRoot: './src/index.ts'
}
},
});

The DynamicRemotePlugin webpack plugin takes care of the rest, including the manifest generation.

Sample plugin raw webpack configuration

The Scalprum config translates to a following base webpack plugin configuration:

import { container } from 'webpack';
const dynamicPlugin = new container.ModuleFederationPlugin({
name: 'backstage.plugin-github-actions',
library: {
type: 'global',
/**
* Some library.type has name limitation
* for example, if "type": "var" is used, the library.name can contain the "-" character
*/
name: 'backstage.plugin-github-actions'
},
filename: 'backstage.plugin-github-actions.[contenthash].js',
exposes: {
PluginRoot: './src/index.ts'
},
// list of shared modules like "react", "react-dom", etc
shared: sharedModules,
});

Additional plugin would have to be written from scratch to generate the plugin manifest file.

Sample shell application webpack configuration

Shell application (backstage) has very simple module federation configuration. It does not require anything special to be able to inject plugins. Main requirement is to provide core shared packages like react and react-dom.

import { container } from 'webpack';
const scalprumPlugin = new container.ModuleFederationPlugin({
name: 'backstageHost',
filename: 'backstageHost.[contenthash].js',
// same share modules list as with plugin config
shared: [sharedModules],
});

Nothing else is required.

Plugin storage

Where will the dynamic plugin assets be hosted? Module federation does not strictly require the remote assets to be all hosted on the same origin.

Theoretically plugins can be hosted on some "public CDN" which is detached from individual backstage instances.

Assets can be also be hosted in the same way as they have always been.

Plugin discovery

How to notify/send data to browser

Plugin initialization

Currently all plugins have to be initialized at UI bootstrap (page refresh). The new UI async API can be used to initialize the remote assets.

The CreateAppFeatureLoader can be leveraged to initialize the remote container.

const allPluginManifests = {...} // get this from the config
const asyncFeatureLoaders: CreateAppFeatureLoader[] = Object.values(allPluginManifests).map(({ manifest }) => {
return {
getLoaderName: () => manifest.name,
load: (options) => {
// initialize the remote container, depends on tooling
const plugin = initDynamicPlugin(manifest)
}
}
})
const app = createApp({
features: [
...asyncFeatureLoaders,
// rest of classic features
]
})

Plugin declarative configuration

Janus dynamic plugins ref: https://github.com/janus-idp/backstage-showcase/blob/main/showcase-docs%2Fdynamic-plugins.md#frontend-layout-configuration

TBD, depends heavily on the new UI system

Release Plan

Dependencies

Alternatives

Использование cookies

Мы используем файлы cookie в соответствии с Политикой конфиденциальности и Политикой использования cookies.

Нажимая кнопку «Принимаю», Вы даете АО «СберТех» согласие на обработку Ваших персональных данных в целях совершенствования нашего веб-сайта и Сервиса GitVerse, а также повышения удобства их использования.

Запретить использование cookies Вы можете самостоятельно в настройках Вашего браузера.