Skip to content

Dynamic plugin poc - #1110

Open
b3lix wants to merge 3 commits into
freeipa:dynamic-plugin-pocfrom
b3lix:dynamic-plugin-poc
Open

Dynamic plugin poc#1110
b3lix wants to merge 3 commits into
freeipa:dynamic-plugin-pocfrom
b3lix:dynamic-plugin-poc

Conversation

@b3lix

@b3lix b3lix commented May 5, 2026

Copy link
Copy Markdown
Collaborator

Summary by Sourcery

Introduce a dynamic plugin architecture and dashboard entry point for the FreeIPA Modern WebUI, enabling runtime-loaded plugins to extend navigation, routing, and UI content.

New Features:

  • Add a plugin SDK package that provides a registry, loader, extension points, React helpers, and a Vite configuration preset for plugin authors.
  • Enable runtime loading and registration of plugins via a manifest-driven PluginLoader and shared host dependencies exposed on the global window.
  • Extend the main navigation and routing to include plugin-defined routes and a new Dashboard page with pluggable dashboard content.
  • Add an example Hello World plugin and development-time plugin manifest served from a local dev-plugins directory.

Enhancements:

  • Refactor the Redux store to support dynamic injection of plugin reducers at runtime.
  • Update the app bootstrap flow to load plugins and run lifecycle phases before rendering the root React tree.
  • Enhance the Vite configuration with a dev-only middleware that serves plugin assets during local development.

Build:

  • Add the @freeipa/plugin-sdk workspace package with its own TypeScript build configuration and exports for host and plugin builds.

b3lix added 3 commits May 5, 2026 12:45
Add plugin loading support to the Modern WebUI using @freeipa/plugin-sdk.
Plugins are discovered via a server manifest and loaded at runtime using
dynamic import(), without requiring a rebuild of the host application.
Changes:
- Expose shared dependencies (React, PatternFly, Redux) for plugins
- Load plugins asynchronously before initial render
- Support dynamic route and navigation item registration from plugins
- Support dynamic Redux reducer injection via store.replaceReducer()
- Add Dashboard page with ExtensionSlot for plugin-contributed widgets

Signed-off-by: Erik Belko <ebelko@redhat.com>
Signed-off-by: Erik Belko <ebelko@redhat.com>
Include the plugin SDK (freeipa-webui-plugin-sdk) and the example
hello-world plugin (freeipa-webui-plugin-example) in-tree so colleagues
can review all PoC code in a single PR.

In production these will be separate repositories.

Signed-off-by: Erik Belko <ebelko@redhat.com>
@b3lix b3lix self-assigned this May 5, 2026

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 4 issues, and left some high level feedback:

  • Consider wrapping the async bootstrap() plugin loading (loadPlugins + pluginRegistry.runPhase) in a try/catch so that an unexpected error there doesn’t prevent the main app from rendering and can fall back to a no-plugins startup path.
  • In PluginRegistry, injectedEndpoints is recorded but never read; if you don’t plan to consume this history, you can drop the array to reduce state and simplify the registry, or otherwise expose a getter so it has a clear use.
  • The devPluginsServer Vite middleware uses synchronous fs calls on every request; even though this is dev-only, switching to async fs.promises or caching file existence would avoid blocking the dev server’s event loop on each plugin asset request.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- Consider wrapping the async `bootstrap()` plugin loading (`loadPlugins` + `pluginRegistry.runPhase`) in a try/catch so that an unexpected error there doesn’t prevent the main app from rendering and can fall back to a no-plugins startup path.
- In `PluginRegistry`, `injectedEndpoints` is recorded but never read; if you don’t plan to consume this history, you can drop the array to reduce state and simplify the registry, or otherwise expose a getter so it has a clear use.
- The `devPluginsServer` Vite middleware uses synchronous `fs` calls on every request; even though this is dev-only, switching to async `fs.promises` or caching file existence would avoid blocking the dev server’s event loop on each plugin asset request.

## Individual Comments

### Comment 1
<location path="freeipa-webui-plugin-sdk/src/PluginLoader.ts" line_range="45-54" />
<code_context>
+    `[PluginLoader] Loading ${enabledPlugins.length} plugin(s)...`
+  );
+
+  const results = await Promise.allSettled(
+    enabledPlugins.map(async (entry) => {
+      try {
+        const mod = await import(/* @vite-ignore */ entry.entrypoint);
+        const pluginModule: PluginModule = mod.default ?? mod;
+
+        if (!pluginModule.id || !pluginModule.register) {
+          console.error(
+            `[PluginLoader] Plugin at "${entry.entrypoint}" does not export a valid PluginModule.`
+          );
+          return;
+        }
+
+        await pluginRegistry.registerPlugin(pluginModule);
+      } catch (err) {
+        console.error(
+          `[PluginLoader] Failed to load plugin "${entry.id}" from "${entry.entrypoint}":`,
+          err
+        );
+      }
+    })
+  );
+
+  const failed = results.filter((r) => r.status === "rejected").length;
+  if (failed > 0) {
+    console.warn(`[PluginLoader] ${failed} plugin(s) failed to load.`);
</code_context>
<issue_to_address>
**issue (bug_risk):** The failure counting logic in loadPlugins never reports failures because errors are caught inside the mapped promises.

Because the `enabledPlugins.map` callback catches and swallows all errors, each async function always resolves, so `Promise.allSettled` never sees any rejections. Consequently, `results.filter((r) => r.status === "rejected")` is always empty and `failed` is always 0.

To fix this, either let the error escape the callback so the promise rejects, or keep the `try/catch` but update a separate failure counter when a plugin fails instead of relying on `status === 'rejected'`.
</issue_to_address>

### Comment 2
<location path="freeipa-webui-plugin-sdk/src/PluginRegistry.ts" line_range="184-34" />
<code_context>
+
+  // -- Lifecycle --------------------------------------------------------
+
+  async runPhase(phase: string): Promise<void> {
+    const handlers = this.phaseHandlers.get(phase) || [];
+    for (const handler of handlers) {
+      try {
+        handler();
+      } catch (err) {
+        console.error(
+          `[PluginRegistry] Error in "${phase}" phase handler:`,
+          err
+        );
+      }
+    }
+  }
</code_context>
<issue_to_address>
**suggestion (bug_risk):** runPhase is async but cannot await async phase handlers, which may lead to misleading usage.

`runPhase` returns `Promise<void>` and callers `await` it, but `PhaseHandler` is `() => void`, so any `async` handler becomes fire-and-forget and isn’t awaited. This can cause phases to appear complete while async work is still running. Either support async handlers (e.g. `type PhaseHandler = () => void | Promise<void>` and `await handler()`), or make `runPhase` synchronous so its completion semantics are clear.

Suggested implementation:

```typescript
  // -- Lifecycle --------------------------------------------------------
+
+  async runPhase(phase: string): Promise<void> {
+    const handlers = this.phaseHandlers.get(phase) || [];
+    for (const handler of handlers) {
+      try {
+        await handler();
+      } catch (err) {
+        console.error(
+          `[PluginRegistry] Error in "${phase}" phase handler:`,
+          err
+        );
+      }
+    }
+  }

```

1. Locate the `PhaseHandler` type (or equivalent) in `PluginRegistry.ts`. Change it from something like:
   `type PhaseHandler = () => void;`
   to:
   `type PhaseHandler = () => void | Promise<void>;`
2. Ensure any `phaseHandlers` collection is typed with the updated `PhaseHandler` (e.g. `Map<string, PhaseHandler[]>`).
3. No call sites of `runPhase` need changes; they already `await` the returned `Promise<void>`, which will now correctly represent completion of all async handler work.
</issue_to_address>

### Comment 3
<location path="vite.config.ts" line_range="24-33" />
<code_context>
+  return {
+    name: "dev-plugins-server",
+    configureServer(server: any) {
+      server.middlewares.use((req: any, res: any, next: any) => {
+        const urlPath = (req.url || "").split("?")[0];
+        if (!urlPath.startsWith(PLUGINS_PREFIX)) {
+          return next();
+        }
+
+        const relative = urlPath.slice(PLUGINS_PREFIX.length);
+        const filePath = path.join(PLUGINS_DIR, relative);
+
+        if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
+          return next();
+        }
+
+        const ext = path.extname(filePath);
+        const mimeTypes: Record<string, string> = {
+          ".js": "application/javascript",
+          ".json": "application/json",
+          ".map": "application/json",
+        };
+
+        res.setHeader("Content-Type", mimeTypes[ext] || "application/octet-stream");
+        res.setHeader("Access-Control-Allow-Origin", "*");
+        fs.createReadStream(filePath).pipe(res);
+      });
+    },
</code_context>
<issue_to_address>
**🚨 suggestion (security):** The devPluginsServer middleware should guard against path traversal from plugin URLs and handle stream errors more defensively.

`relative` comes straight from the URL and is joined with `PLUGINS_DIR`, so `..` segments could escape the intended directory. Normalize and resolve the path, then verify the resolved `filePath` is still under `PLUGINS_DIR` (e.g. via `path.resolve` and a prefix check) before serving. Also, `fs.createReadStream(filePath)` may emit errors after the `statSync` check; add an `on('error', ...)` handler that calls `next(err)` or returns a 404 to avoid uncaught errors.

Suggested implementation:

```typescript
    configureServer(server: any) {
      server.middlewares.use((req: any, res: any, next: any) => {
        const urlPath = (req.url || "").split("?")[0];
        if (!urlPath.startsWith(PLUGINS_PREFIX)) {
          return next();
        }

        // Normalize relative path to avoid path traversal (e.g. ".." segments)
        const rawRelative = urlPath.slice(PLUGINS_PREFIX.length);
        const normalizedRelative = path
          .normalize(rawRelative)
          .replace(/^([/\\])+/, ""); // strip any leading slashes/backslashes

        const pluginsRoot = path.resolve(PLUGINS_DIR);
        const filePath = path.resolve(PLUGINS_DIR, normalizedRelative);

        // Ensure resolved path is still within the plugins root directory
        if (filePath !== pluginsRoot && !filePath.startsWith(pluginsRoot + path.sep)) {
          return next();
        }

        let stats: fs.Stats;
        try {
          stats = fs.statSync(filePath);
        } catch {
          return next();
        }

        if (!stats.isFile()) {
          return next();
        }

        const ext = path.extname(filePath);
        const mimeTypes: Record<string, string> = {
          ".js": "application/javascript",
          ".json": "application/json",
          ".map": "application/json",
        };

        res.setHeader("Content-Type", mimeTypes[ext] || "application/octet-stream");
        res.setHeader("Access-Control-Allow-Origin", "*");

        const stream = fs.createReadStream(filePath);

        stream.on("error", (err: NodeJS.ErrnoException) => {
          if (err.code === "ENOENT") {
            if (!res.headersSent) {
              res.statusCode = 404;
              res.end("Not found");
            }
            return;
          }

          if (!res.headersSent) {
            res.statusCode = 500;
            res.end("Error reading file");
          }

          // Let Vite/Connect error handling deal with unexpected errors
          next(err);
        });

        stream.pipe(res);
      });
    },

```

This edit assumes `fs` and `path` are already imported in `vite.config.ts` as they are used elsewhere in the file. If not, you should add:
- `import fs from "fs";`
- `import path from "path";`
near the top of the file, matching the existing import style.
</issue_to_address>

### Comment 4
<location path="src/pages/Dashboard/Dashboard.tsx" line_range="38-40" />
<code_context>
+            </CardBody>
+          </Card>
+        </GalleryItem>
+        <ExtensionSlot
+          extensionPointId={EXTENSION_POINTS.DASHBOARD_CONTENT}
+        />
+      </Gallery>
+    </PageSection>
</code_context>
<issue_to_address>
**question (bug_risk):** ExtensionSlot is rendered directly inside Gallery, which may break layout if plugin components don’t return GalleryItem nodes.

If plugin components render arbitrary blocks (e.g. a bare `Card`), the PatternFly `Gallery` layout may break because it expects children wrapped in `GalleryItem`. Either document that extensions must render `GalleryItem` themselves, or have the slot map each extension into a `GalleryItem` so the layout contract is enforced by the host.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +45 to +54
const results = await Promise.allSettled(
enabledPlugins.map(async (entry) => {
try {
const mod = await import(/* @vite-ignore */ entry.entrypoint);
const pluginModule: PluginModule = mod.default ?? mod;

if (!pluginModule.id || !pluginModule.register) {
console.error(
`[PluginLoader] Plugin at "${entry.entrypoint}" does not export a valid PluginModule.`
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): The failure counting logic in loadPlugins never reports failures because errors are caught inside the mapped promises.

Because the enabledPlugins.map callback catches and swallows all errors, each async function always resolves, so Promise.allSettled never sees any rejections. Consequently, results.filter((r) => r.status === "rejected") is always empty and failed is always 0.

To fix this, either let the error escape the callback so the promise rejects, or keep the try/catch but update a separate failure counter when a plugin fails instead of relying on status === 'rejected'.

/** Host calls this so plugins can read IPA config. */
setConfigGetter(getter: () => Record<string, unknown>): void {
this.configGetter = getter;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): runPhase is async but cannot await async phase handlers, which may lead to misleading usage.

runPhase returns Promise<void> and callers await it, but PhaseHandler is () => void, so any async handler becomes fire-and-forget and isn’t awaited. This can cause phases to appear complete while async work is still running. Either support async handlers (e.g. type PhaseHandler = () => void | Promise<void> and await handler()), or make runPhase synchronous so its completion semantics are clear.

Suggested implementation:

  // -- Lifecycle --------------------------------------------------------
+
+  async runPhase(phase: string): Promise<void> {
+    const handlers = this.phaseHandlers.get(phase) || [];
+    for (const handler of handlers) {
+      try {
+        await handler();
+      } catch (err) {
+        console.error(
+          `[PluginRegistry] Error in "${phase}" phase handler:`,
+          err
+        );
+      }
+    }
+  }
  1. Locate the PhaseHandler type (or equivalent) in PluginRegistry.ts. Change it from something like:
    type PhaseHandler = () => void;
    to:
    type PhaseHandler = () => void | Promise<void>;
  2. Ensure any phaseHandlers collection is typed with the updated PhaseHandler (e.g. Map<string, PhaseHandler[]>).
  3. No call sites of runPhase need changes; they already await the returned Promise<void>, which will now correctly represent completion of all async handler work.

Comment thread vite.config.ts
Comment on lines +24 to +33
server.middlewares.use((req: any, res: any, next: any) => {
const urlPath = (req.url || "").split("?")[0];
if (!urlPath.startsWith(PLUGINS_PREFIX)) {
return next();
}

const relative = urlPath.slice(PLUGINS_PREFIX.length);
const filePath = path.join(PLUGINS_DIR, relative);

if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 suggestion (security): The devPluginsServer middleware should guard against path traversal from plugin URLs and handle stream errors more defensively.

relative comes straight from the URL and is joined with PLUGINS_DIR, so .. segments could escape the intended directory. Normalize and resolve the path, then verify the resolved filePath is still under PLUGINS_DIR (e.g. via path.resolve and a prefix check) before serving. Also, fs.createReadStream(filePath) may emit errors after the statSync check; add an on('error', ...) handler that calls next(err) or returns a 404 to avoid uncaught errors.

Suggested implementation:

    configureServer(server: any) {
      server.middlewares.use((req: any, res: any, next: any) => {
        const urlPath = (req.url || "").split("?")[0];
        if (!urlPath.startsWith(PLUGINS_PREFIX)) {
          return next();
        }

        // Normalize relative path to avoid path traversal (e.g. ".." segments)
        const rawRelative = urlPath.slice(PLUGINS_PREFIX.length);
        const normalizedRelative = path
          .normalize(rawRelative)
          .replace(/^([/\\])+/, ""); // strip any leading slashes/backslashes

        const pluginsRoot = path.resolve(PLUGINS_DIR);
        const filePath = path.resolve(PLUGINS_DIR, normalizedRelative);

        // Ensure resolved path is still within the plugins root directory
        if (filePath !== pluginsRoot && !filePath.startsWith(pluginsRoot + path.sep)) {
          return next();
        }

        let stats: fs.Stats;
        try {
          stats = fs.statSync(filePath);
        } catch {
          return next();
        }

        if (!stats.isFile()) {
          return next();
        }

        const ext = path.extname(filePath);
        const mimeTypes: Record<string, string> = {
          ".js": "application/javascript",
          ".json": "application/json",
          ".map": "application/json",
        };

        res.setHeader("Content-Type", mimeTypes[ext] || "application/octet-stream");
        res.setHeader("Access-Control-Allow-Origin", "*");

        const stream = fs.createReadStream(filePath);

        stream.on("error", (err: NodeJS.ErrnoException) => {
          if (err.code === "ENOENT") {
            if (!res.headersSent) {
              res.statusCode = 404;
              res.end("Not found");
            }
            return;
          }

          if (!res.headersSent) {
            res.statusCode = 500;
            res.end("Error reading file");
          }

          // Let Vite/Connect error handling deal with unexpected errors
          next(err);
        });

        stream.pipe(res);
      });
    },

This edit assumes fs and path are already imported in vite.config.ts as they are used elsewhere in the file. If not, you should add:

  • import fs from "fs";
  • import path from "path";
    near the top of the file, matching the existing import style.

Comment on lines +38 to +40
<ExtensionSlot
extensionPointId={EXTENSION_POINTS.DASHBOARD_CONTENT}
/>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question (bug_risk): ExtensionSlot is rendered directly inside Gallery, which may break layout if plugin components don’t return GalleryItem nodes.

If plugin components render arbitrary blocks (e.g. a bare Card), the PatternFly Gallery layout may break because it expects children wrapped in GalleryItem. Either document that extensions must render GalleryItem themselves, or have the slot map each extension into a GalleryItem so the layout contract is enforced by the host.

@duzda duzda left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would make sense, to create packages folder and move each of the project there, then we can treat freeipa-webui as a monorepo of multiple related projects. This makes sense for plugins and we should decouple our code anyways and break it into multiple smaller packages.

There's A LOT happening in the sdk, this is not important now, please ensure the production environment works, as that is where the complexity lies.

Please don't touch code you don't have to, don't remove comments for the sake of removing.

A README on how all of this is put together would be helpful. Consider adding a page into /docs.

I've tried to run and grasp all of this together, however I've stumbled across the same issues as Carla did. I could resolve freeipa/sdk by moving it one level up and running npm run build, but this leads nowhere, as I've stumbled across following error:

Image

After following the steps in the doc, Hello world from a dynamically loaded plugin indeed works, but that is purposeless, as we're simply reading a file from a dist folder, it's the same as loading an image from the dist folder, just with a bit more fluff

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is duplicated from manifest.json, you can import json into .js file, but this defeats the purpose, the example needs to include vite (or any other build tool), which is where the "difficult" part comes into play.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can remove this example, as it's not realistic, it's too simple to really tell us anything.

"private": true,
"type": "module",
"description": "Example plugin for FreeIPA Modern WebUI - demonstrates dynamic plugin loading.",
"license": "GPL-3.0-or-later",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we decide to use examples, it shouldn't really be GPL licensed, I would steer very far away from any plugins as a developer. It's an issue for the future, and will have to be discussed team-wide, but instead of just using GPL rather omit the license for now.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, I'm not sure if I would add the license parameter here. I would make sense to infer that the license for a custom plugin would be the same as FreeIPA's, IMHO.

fileName: () => "plugin.js",
},
sourcemap: true,
minify: false,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minify should be true, that's the purpose.

targets: string[];
title: string;
description: string;
component: ComponentType<any>;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why this and any other component are of this type instead of ReactNode?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or JSX.Element.

"src"
],
"scripts": {
"build": "tsc -b",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add a build system instead of using only tsc, you're including vite, I see no vite config and no invoking the build tool.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is a lot of types for MVP

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding Dashboard is out of scope, the Dashboard here is not related to plugins at all.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be used.

Comment thread vite.config.ts
* Vite plugin that serves plugin files from dev-plugins/ during development.
* In production, plugins are served by the IPA server (Apache/httpd).
*/
function devPluginsServer() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dev server is nice, but we need to make sure this works in production.

Comment thread src/main.tsx
import { api } from "./services/rpc";
import { injectPluginReducer } from "./store/store";

exposeSharedDependencies({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm really unsure whether this works.

@github-actions

Copy link
Copy Markdown

This PR has not received any attention in 60 days.

@github-actions github-actions Bot added the stale This PR/issue is stale and will be closed label Jul 31, 2026
@duzda duzda removed the stale This PR/issue is stale and will be closed label Jul 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants