Files
docs/docs/frontend-plugins.md
rvveber ab74aeff5c fix: Ensure federated types are downloaded before type compiling/ type checking
- Adds a function to the webpack.config and documentation to make sure host types are downloaded to the plugin before the first type compile/ type check is happening

Avoids an error where on cold start the types were not yet downloaded but types were already checked.
2025-11-12 17:16:11 +01:00

19 KiB

Frontend Plugin System

Table of Contents

Overview

The plugin system allows developers to extend the application's functionality and appearance without modifying the core.
It's ideal for teams or third parties to add custom features.


Glossary

  • Remote: An application exposing components via module federation.
  • Host: The main entry point application. This is Docs itself ("impress").
  • Plugin: A remote module integrated into the host to provide UI components.
  • Module Federation: The technology that enables runtime module sharing between separate applications.

Features and Limitations

Features:

  • Add new UI components.
  • Reuse host UI components.
  • Dynamically inject components via CSS selectors and a configuration file.
  • Integrate without rebuilding or redeploying the host application.
  • Build and version plugins independently.

Limitations:

  • Focused on DOM/UI customisations; you cannot add Next.js routes or other server-side features.
  • Runs client-side without direct host state access.
    Shared caches (e.g., React Query) only work if the dependency is also shared as a singleton.
  • Host upgrades may require tweaking CSS selectors
    and matching versions for shared libraries.

Getting Started: Building Your First Plugin

A plugin is a standalone React application bundled with Webpack
that exposes one or more components via Module Federation.
This guide walks you through creating your first plugin.


1. Prepare the Host Environment

Developing a plugin requires running the host application (Docs) in parallel.
This live integration is essential for rendering your plugin, enabling hot-reloading, sharing types for Intellisense,
and discovering the exact versions of shared dependencies.


  1. Clone the repository locally:
    If you haven't already, clone the Docs repository to your local machine and follow the initial setup instructions.
  2. Set the development flag:
    In the host application's .env.development file, set NEXT_PUBLIC_DEVELOP_PLUGINS=true.
  3. Stop conflicting services:
    If you are using the project's Docker setup, make sure
    the frontend service is stopped (docker compose stop frontend-development), as we will run the Docs frontend locally.
  4. Run the host:
    Navigate to src/frontend/apps/impress, run yarn install, and then yarn dev.
  5. Check the logs:
    On startup, the Next.js dev server will print the versions of all shared singleton libraries (e.g., React, styled-components).
    You will need these exact versions for your plugin's package.json.

2. Scaffolding a New Plugin Project

Create a new, simple React project.
Your project should have a webpack.config.js and include dependencies for React, Webpack, and TypeScript.


A minimal package.json would look like this:

{
  "name": "my-plugin",
  "version": "1.0.0",
  "scripts": {
    "dev": "webpack serve --mode=development",
    "build": "webpack --mode=production"
  },
  "dependencies": {
    "react": "<same as host>",
    "react-dom": "<same as host>",
    "styled-components": "<same as host>",
    "@openfun/cunningham-react": "<same as host>",
    "@tanstack/react-query": "<same as host>"
  },
  "devDependencies": {
    "webpack": "^5.0.0",
    "webpack-cli": "^5.0.0",
    "webpack-dev-server": "^4.0.0",
    "ts-loader": "^9.0.0",
    "typescript": "^5.0.0",
    "@types/react": "^18.0.0",
    "@module-federation/native-federation-typescript": "^0.2.1"
  }
}

Replace <same as host> with the versions found in the host's dev startup log.


3. Creating a Plugin Component

This is a React component that your webpack.config.js file exposes.
This minimal example shows how to accept props, which can be passed from the plugin configuration file.


// src/MyCustomComponent.tsx
import React from 'react';

// A simple component with inline prop types
const MyCustomComponent = ({ message }: { message?: string }) => {
  return (
    <div>
      This is the plugin component.
      {message && <p>Message from props: {message}</p>}
    </div>
  );
};

export default MyCustomComponent;

4. Federation Configuration

The core of the plugin is its Webpack configuration.
All plugins should use this webpack.config.js as a base.

Disclaimer: We try to not change this file.
But in the future, it may evolve as the plugin system matures.


const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
const {
  NativeFederationTypeScriptHost,
} = require('@module-federation/native-federation-typescript/webpack');
const {
  NativeFederationTypeScriptHost: NativeFederationTypeScriptHostCore,
} = require('@module-federation/native-federation-typescript');

module.exports = (env, argv) => {
  const dev = argv.mode !== 'production';

  const moduleFederationConfig = {
    name: 'my_plugin', // A unique name for your plugin
    filename: 'remoteEntry.js',
    exposes: {
      // Maps a public name to a component file
      './MyCustomComponent': './src/MyCustomComponent.tsx',
    },
    remotes: {
      // Allows importing from the host application. The URL is switched automatically.
      impress: dev
        ? 'impress@http://localhost:3000/_next/static/chunks/remoteEntry.js' // Development
        : 'impress@/_next/static/chunks/remoteEntry.js', // Production
    },
    shared: {
      // Defines shared libraries to avoid duplication
      react: { singleton: true },
      'react-dom': { singleton: true },
      'styled-components': { singleton: true },
      '@openfun/cunningham-react': { singleton: true },
      '@tanstack/react-query': { singleton: true },
    },
  };

  let mfTypesReady;
  const ensureFederatedTypesPlugin = {
    apply(compiler) {
      compiler.hooks.beforeCompile.tapPromise(
        'EnsureFederatedTypes',
        async () => {
          if (!mfTypesReady) {
            const downloader = NativeFederationTypeScriptHostCore.raw({
              moduleFederationConfig,
            });
            mfTypesReady = downloader.writeBundle();
          }
          await mfTypesReady;
        },
      );
    },
  };

  return {
    devServer: {
      // The port should match the one in your plugin's configuration file
      port: 8080,
    },
    entry: './src/index.tsx', // Your plugin's entry point; can be an empty file as modules are exposed directly.
    plugins: [
      new ModuleFederationPlugin(moduleFederationConfig),
      // This plugin enables type-sharing for intellisense
      ...(dev
        ? [
            ensureFederatedTypesPlugin, // ensures the zip is ready before the first compile
            NativeFederationTypeScriptHost({ moduleFederationConfig }),
          ]
        : []),
    ],
    // ... other webpack config (output, module rules, etc.)
  };
};

Don't change remotes.impress if you want your released plugin to be deployable by others.


5. Enabling Type-Sharing for Intellisense

To get autocompletion for components and hooks exposed by the host,
configure your plugin's tsconfig.json to find the host's types.


In your plugin's tsconfig.json:

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "*": ["./@mf-types/*"]
    }
  }
}

When you run the host application with NEXT_PUBLIC_DEVELOP_PLUGINS=true, it generates a @mf-types.zip file.
The NativeFederationTypeScriptHost plugin in your webpack config will automatically download and unpack it ahead of the first compile,
making the host's types available to your plugin and IDE. In development with this flag enabled, route changes may be slower because type generation and automatic exposure run during rebuilds; this does not affect production where navigations are instant.


6. Running and Configuring Your Plugin

With the host application already running (from step 1),
you can now start your plugin's development server and configure the host to load it.


  1. Start the plugin:
    In your plugin's project directory, run yarn dev.
  2. Configure the host:
    Tell the host to load your plugin by editing its configuration file.
    When running Docs locally, this file is located at src/backend/impress/configuration/plugins/default.json.
    Update it to point to your local plugin's remoteEntry.js.

{
  "id": "my-custom-component",
  "remote": {
    "url": "http://localhost:8080/remoteEntry.js",
    "name": "my_plugin",
    "module": "./MyCustomComponent"
  },
  "injection": {
    "target": "#some-element-in-the-host"
  },
  "props": {
    "message": "Hello from the configuration!"
  }
}

After changing the target to a valid CSS selector in the host's DOM, save the file.
The host application will automatically detect the change and inject your component, passing the props object along.

Your component should appear in the running host application after a reload.


Host-Plugin Interaction

Host Exports

The host automatically exposes many of its components and hooks.
You can import them in the plugin as if they were local modules, thanks to the remotes configuration in the webpack.config.js.


// In the plugin's code
import { Icon } from 'impress/components';
import { useAuthQuery } from 'impress/features/auth/api';

Choosing Shared Dependencies

Sharing dependencies is critical for performance and stability.


  • Minimal Shared Libraries:
    Always share react, react-dom, styled-components, and @openfun/cunningham-react to use the same instances as the host.
  • Sharing State:
    Libraries that rely on a global context (like @tanstack/react-query) must be shared to access the host's state and cache.
  • Discovering More Shared Libraries: With NEXT_PUBLIC_DEVELOP_PLUGINS=true,
    the host prints its shared dependency map to the Next.js dev server logs on startup.
    You can use this to align versions and add more shared libraries to your plugin.

Important

: Both the host and the plugin must declare a dependency in moduleFederationConfig.shared for it to become a true singleton.
If a shared dependency is omitted from the plugin's config, Webpack will bundle a separate copy, breaking the singleton pattern.


Development Workflow

Test and Debug

  • Use the [PluginSystem] logs in the browser console to see if the plugin is loading correctly.
  • Errors in the plugin are caught by an ErrorBoundary and will not crash the host.

Common Errors:

Issue Cause/Fix
Unreachable remoteEntry.js Check the url in the plugin configuration.
Library version conflicts Ensure shared library versions in package.json match the host's versions.
Invalid CSS selectors Validate the target selector against the host's DOM.

Best Practices

  • Build modular components with well-typed props.
  • Prefer using the host's exposed types and components over implementing new ones.
  • Keep shared dependency versions aligned with the host
    and re-test after host upgrades.
  • Treat plugin bundles as untrusted: vet dependencies and avoid unsafe scripts.

Plugin Configuration File Reference

This section provides a detailed reference for all fields in the plugin configuration JSON.
For deployment details, see Deploying Docs with Plugins.


Configuration Structure

Field Type Required Description
id String Yes Unique component identifier (e.g., "my-widget").
remote Object Yes Remote module details.
- url String Yes Path to remoteEntry.js (absolute/relative).
- name String Yes Federation remote name (e.g., "myPlugin").
- module String Yes Exposed module (e.g., "./Widget").
injection Object Yes Integration control.
- target String Yes CSS selector for insertion point.
- position String No (default: "append") Insertion position (before, after, replace, prepend, append). See examples.
- observerRoots String/Boolean No DOM observation: CSS selector, true (observe whole document), or false (default; disable observers).
props Object No Props passed to the plugin component.
visibility Object No Visibility controls.
- routes Array No Path globs (e.g., ["/docs/*", "!/docs/secret*"]); supports * and ? wildcards plus negation (!).

Injection Position Examples

The injection.position property controls how the plugin is inserted relative to the target element.

View injection examples

before

{
  "id": "my-custom-component-0",
  "injection": {
    "target": "#item2",
    "position": "before"
  }
}
<ul id="some-element-in-the-host">
  <li id="item1"></li>
  <div id="plugin-container-my-custom-component-0"></div>
  <li id="item2"></li>
</ul>

after

{
  "id": "my-custom-component-0",
  "injection": {
    "target": "#item1",
    "position": "after"
  }
}
<ul id="some-element-in-the-host">
  <li id="item1"></li>
  <div id="plugin-container-my-custom-component-0"></div>
  <li id="item2"></li>
</ul>

prepend

{
  "id": "my-custom-component-0",
  "injection": {
    "target": "#some-element-in-the-host",
    "position": "prepend"
  }
}
<ul id="some-element-in-the-host">
  <div id="plugin-container-my-custom-component-0"></div>
  <li id="item1"></li>
  <li id="item2"></li>
</ul>

append (default)

{
  "id": "my-custom-component-0",
  "injection": {
    "target": "#some-element-in-the-host",
    "position": "append"
  }
}
<ul id="some-element-in-the-host">
  <li id="item1"></li>
  <li id="item2"></li>
  <div id="plugin-container-my-custom-component-0"></div>
</ul>

replace

{
  "id": "my-custom-component-0",
  "injection": {
    "target": "#item1",
    "position": "replace"
  }
}
<ul id="some-element-in-the-host">
  <div id="plugin-container-my-custom-component-0"></div>
  <li id="item1" data-pluginsystem-hidden="true"></li>
  <li id="item2"></li>
</ul>

Releasing a Plugin

When you are ready to release your plugin, you need to create a production build.


Run the build command in your plugin's directory:

yarn build

This command bundles your code for production.
Webpack will generate a dist folder (or similar) containing the remoteEntry.js file and other JavaScript chunks.
The remoteEntry.js is the manifest that tells other applications what modules your plugin exposes.
These are the files you will need for deployment.


The webpack.config.js provided is already configured to switch the remotes URL to the correct production path automatically,
so no code changes are needed before building.


Deploying Docs with Plugins

To use plugins in a production environment, you need to deploy both the plugin assets and the configuration file.
The recommended approach is to serve the plugin's static files from the same webserver that serves the host (docs frontend).


  1. Deploy Plugin Assets:
    Copy the contents of your plugin's build output directory (e.g., dist/)
    into the frontend container's /usr/share/nginx/html/assets directory at a chosen path.
    E.g.: Placing assets in /usr/share/nginx/html/assets/plugins/my-plugin/
    would make the plugin's remoteEntry.js available at https://production.domain/assets/plugins/my-plugin/remoteEntry.js.

  1. Deploy Plugin Configuration: The host's plugin configuration file must be updated to point to the deployed assets.
    This file is typically managed via infrastructure methods
    (e.g., a Kubernetes configmap replacing /app/impress/configuration/plugins/default.json in the backend container).

Update the remote.url to the public-facing path that matches where you deployed the assets:

{
  "id": "my-custom-component",
  "remote": {
    "url": "/assets/plugins/my-plugin/remoteEntry.js",
    "name": "my_plugin",
    "module": "./MyCustomComponent"
  },
  "injection": {
    "target": "#some-element-in-the-host"
  },
  "props": {
    "message": "Hello from production!"
  }
}