# Frontend Plugin System ## Table of Contents - [Overview](#overview) - [Getting Started: Building Your First Plugin](#getting-started-building-your-first-plugin) - [1. Prepare the Host Environment](#1-prepare-the-host-environment) - [2. Scaffolding a New Plugin Project](#2-scaffolding-a-new-plugin-project) - [3. Federation Configuration](#3-federation-configuration) - [4. Enabling Type-Sharing for Intellisense](#4-enabling-type-sharing-for-intellisense) - [Host-Plugin Interaction](#host-plugin-interaction) - [Host Exports](#host-exports) - [Choosing Shared Dependencies](#choosing-shared-dependencies) - [Plugin Configuration File](#plugin-configuration-file) - [Configuration Structure](#configuration-structure) - [Example Configuration](#example-configuration) - [Injection Position Examples](#injection-position-examples) - [Development Workflow](#development-workflow) - [1. Run Host and Plugin in Parallel](#1-run-host-and-plugin-in-parallel) - [2. Test and Debug](#2-test-and-debug) - [3. Best Practices](#3-best-practices) ## 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**: Technology for runtime module sharing between apps. ### Features and Limitations **Features:** - Add new UI components. - Reuse host UI components. - Dynamically inject via CSS selectors and config. - Integrate without rebuilding or redeploying the host. - Build and version plugins independently. **Limitations:** - Focused on DOM/UI customisations; you cannot add Next.js routes or other server features. - Runs client-side without direct host state access; shared caches (e.g. React Query) only work when the dependency is also shared as a singleton. - Host upgrades may require tweaking selectors and matching versions for libraries the host already provides. ## 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. ### 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. The following steps prepare the host environment. 1. **Clone the repository locally**: If you haven't already, clone the Docs repository to your local machine, and read how to get started with development. 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`) - 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 singletons (e.g., React, styled-components). You will need these for your plugin's `package.json`. ### 2. Scaffolding a New Plugin Project You will need to 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: ```json { "name": "my-plugin", "version": "1.0.0", "scripts": { "start": "webpack serve --mode=development", "build": "webpack --mode=production" }, "dependencies": { "react": "", "react-dom": "", "styled-components": "", "@openfun/cunningham-react": "", "@tanstack/react-query": "" }, "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 `` with versions from the hosts dev startup log. ### 3. Federation Configuration The core of the plugin is its Webpack configuration. Here is a sample `webpack.config.js` to get you started. ```javascript const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin'); const { NativeFederationTypeScriptHost } = require('@module-federation/native-federation-typescript/webpack'); 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 impress: 'impress@http://localhost:3000/_next/static/chunks/remoteEntry.js', }, 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 }, }, }; module.exports = (env, argv) => { const dev = argv.mode !== 'production'; 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 ? [NativeFederationTypeScriptHost({ moduleFederationConfig })] : []), ], // ... other webpack config (output, module rules, etc.) }; }; ``` ### 4. Enabling Type-Sharing for Intellisense To get autocompletion for components and hooks exposed by the host, you need to configure your plugin's `tsconfig.json` to find the host's types. In your plugin's `tsconfig.json`: ```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, making the host's types available to your plugin (and IDE). ## Host-Plugin Interaction ### Host Exports The host automatically exposes many of its components and hooks. You can import them in your plugin as if they were local modules, thanks to the `remotes` configuration in your `webpack.config.js`. ```typescript // In your 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 their `shared` configuration for it to become a true singleton. If you omit a shared dependency from your plugin's config, Webpack will bundle a separate copy into your plugin, breaking the singleton pattern. ## Plugin Configuration File Once your plugin is running, you need to tell the host application how to load and inject it. This is done via a JSON configuration file loaded by the host at runtime from the backend. The default path for this file in the backend container is `/app/impress/configuration/plugins/default.json`. > When running Docs locally the backend is bind-mapped to the container, so you can simply live edit `src/backend/impress/configuration/plugins/default.json` > When running in production you can replace this file through infrastructure methods. e.g. k8s configmap. ### 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`). | | - `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 (`!`). | ### Example Configuration This JSON tells the host to load `MyCustomComponent` from your plugin's `remoteEntry.js` and inject it into the DOM. ```json { "id": "my-custom-component", "remote": { "url": "http://localhost:8080/remoteEntry.js", "name": "my_plugin", "module": "./MyCustomComponent" }, "injection": { "target": "#some-element-in-the-host" } } ``` > For production, you can use a relative `url` if the plugin's `remoteEntry.js` is served from the host's public folder. ### Injection Position Examples The `injection.position` property controls how your plugin is inserted relative to the `target` element.
View injection examples **before** ```json { "id": "my-custom-component-0", "injection": { "target": "#item2", "position": "before" } } ``` ```html
``` **after** ```json { "id": "my-custom-component-0", "injection": { "target": "#item1", "position": "after" } } ``` ```html
``` **prepend** ```json { "id": "my-custom-component-0", "injection": { "target": "#some-element-in-the-host", "position": "prepend" } } ``` ```html
``` **append** (default) ```json { "id": "my-custom-component-0", "injection": { "target": "#some-element-in-the-host", "position": "append" } } ``` ```html
``` **replace** ```json { "id": "my-custom-component-0", "injection": { "target": "#item1", "position": "replace" } } ``` ```html
```
## Development Workflow ### 1. Run Host and Plugin in Parallel Enable `NEXT_PUBLIC_DEVELOP_PLUGINS=true` in the host's `.env` file. Start both the host and your plugin's dev servers. This enables hot-reloading and live type-sharing. ### 2. Test and Debug - Use the `[PluginSystem]` logs in the browser console to see if your plugin is loading correctly. - Errors in your plugin are caught by an `ErrorBoundary` and will not crash the host. Common Errors: | Issue | Cause/Fix | |------------------------|-----------| | Unreachable `remoteEntry.js` | Check the `url` in your config JSON. | | Library version conflicts | Ensure `shared` library versions in your `package.json` match the host's. | | Invalid CSS selectors | Validate the `target` selector against the host's DOM. | ### 3. Best Practices - Build modular components with well-typed props. - Prefer using the host's exposed types and components over implementing your own. - 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.