-
Notifications
You must be signed in to change notification settings - Fork 25
Dynamic plugin poc #1110
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dynamic-plugin-poc
Are you sure you want to change the base?
Dynamic plugin poc #1110
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| { | ||
| "apiVersion": "1", | ||
| "plugins": [ | ||
| { | ||
| "id": "hello-world", | ||
| "name": "Hello World Plugin", | ||
| "version": "1.0.0", | ||
| "entrypoint": "/ipa/modern-ui/plugins/hello-world/plugin.js", | ||
| "enabled": true | ||
| } | ||
| ] | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| node_modules/ | ||
| dist/ |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| { | ||
| "name": "freeipa-webui-plugin-example", | ||
| "version": "1.0.0", | ||
| "private": true, | ||
| "type": "module", | ||
| "description": "Example plugin for FreeIPA Modern WebUI - demonstrates dynamic plugin loading.", | ||
| "license": "GPL-3.0-or-later", | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also, I'm not sure if I would add the |
||
| "scripts": { | ||
| "build": "vite build" | ||
| }, | ||
| "devDependencies": { | ||
| "vite": "^6.3.5", | ||
| "typescript": "^5.8.3" | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| const helloWorldPlugin = { | ||
| id: "hello-world", | ||
| name: "Hello World Plugin", | ||
| version: "1.0.0", | ||
| description: "A minimal example plugin that logs to the console.", | ||
|
|
||
| register() { | ||
| console.log("Hello World from a dynamically loaded plugin!"); | ||
| }, | ||
| }; | ||
|
|
||
| export default helloWorldPlugin; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| { | ||
| "compilerOptions": { | ||
| "target": "ES2020", | ||
| "module": "ES2020", | ||
| "moduleResolution": "bundler", | ||
| "lib": ["ES2020", "DOM"], | ||
| "strict": true, | ||
| "esModuleInterop": true, | ||
| "skipLibCheck": true, | ||
| "outDir": "dist", | ||
| "isolatedModules": true | ||
| }, | ||
| "include": ["src"] | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| import { defineConfig } from "vite"; | ||
|
|
||
| export default defineConfig({ | ||
| build: { | ||
| lib: { | ||
| entry: "src/index.ts", | ||
| formats: ["es"], | ||
| fileName: () => "plugin.js", | ||
| }, | ||
| sourcemap: true, | ||
| minify: false, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. minify should be true, that's the purpose. |
||
| }, | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| node_modules/ | ||
| dist/ |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| { | ||
| "name": "@freeipa/plugin-sdk", | ||
| "version": "0.1.0", | ||
| "description": "Plugin SDK for FreeIPA Modern WebUI – types, registry, loader, and build utilities for plugin authors.", | ||
| "license": "GPL-3.0-or-later", | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Again, be vary of GPL, here it kinda makes sense, the sdk should be the same as main project. |
||
| "type": "module", | ||
| "main": "dist/index.js", | ||
| "types": "dist/index.d.ts", | ||
| "exports": { | ||
| ".": { | ||
| "import": "./dist/index.js", | ||
| "types": "./dist/index.d.ts" | ||
| }, | ||
| "./pluginViteConfig": { | ||
| "import": "./dist/pluginViteConfig.js", | ||
| "types": "./dist/pluginViteConfig.d.ts" | ||
| } | ||
| }, | ||
| "files": [ | ||
| "dist", | ||
| "src" | ||
| ], | ||
| "scripts": { | ||
| "build": "tsc -b", | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please add a build system instead of using only |
||
| "clean": "rm -rf dist" | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We're not in a Makefile, we don't do clean |
||
| }, | ||
| "peerDependencies": { | ||
| "react": "^18.0.0", | ||
| "react-dom": "^18.0.0", | ||
| "react-router": "^7.0.0", | ||
| "@reduxjs/toolkit": "^2.0.0", | ||
| "react-redux": "^9.0.0", | ||
| "@patternfly/react-core": "^6.0.0", | ||
| "@patternfly/react-icons": "^6.0.0", | ||
| "@patternfly/react-table": "^6.0.0" | ||
| }, | ||
| "peerDependenciesMeta": { | ||
| "@patternfly/react-icons": { "optional": true }, | ||
| "@patternfly/react-table": { "optional": true } | ||
| }, | ||
|
Comment on lines
+37
to
+40
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why're these optional? |
||
| "devDependencies": { | ||
| "@patternfly/react-core": "^6.4.0", | ||
| "@patternfly/react-icons": "^6.4.0", | ||
| "@patternfly/react-table": "^6.4.0", | ||
| "@reduxjs/toolkit": "^2.6.1", | ||
| "@types/react": "^18.0.0", | ||
| "@types/react-dom": "^18.1.1", | ||
| "react": "^18.0.0", | ||
| "react-dom": "^18.0.0", | ||
| "react-redux": "^9.2.0", | ||
| "react-router": "^7.12.0", | ||
| "typescript": "^5.8.3", | ||
| "vite": "^6.3.5" | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| import React from "react"; | ||
| import { NavItem } from "@patternfly/react-core"; | ||
| import { NavLink } from "react-router"; | ||
| import { usePluginNavItems } from "./hooks"; | ||
|
|
||
| /** | ||
| * Renders PatternFly NavItem elements for every nav entry registered by | ||
| * plugins. Drop this at the end of the host's sidebar Nav component. | ||
| */ | ||
| export const DynamicNav: React.FC = () => { | ||
| const navItems = usePluginNavItems(); | ||
|
|
||
| if (navItems.length === 0) { | ||
| return null; | ||
| } | ||
|
|
||
| return ( | ||
| <> | ||
| {navItems.map((item) => ( | ||
| <NavItem key={`plugin-nav-${item.pluginId}-${item.path}`}> | ||
| <NavLink to={item.path}>{item.label}</NavLink> | ||
| </NavItem> | ||
| ))} | ||
| </> | ||
| ); | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| import React from "react"; | ||
| import { Route } from "react-router"; | ||
| import { usePluginRoutes } from "./hooks"; | ||
|
|
||
| /** | ||
| * Renders <Route> elements for every route registered by plugins. | ||
| * Drop this inside the host's <Routes> tree. | ||
| */ | ||
| export const DynamicRoutes: React.FC = () => { | ||
| const routes = usePluginRoutes(); | ||
|
|
||
| return ( | ||
| <> | ||
| {routes.map((r) => ( | ||
| <Route | ||
| key={`plugin-${r.pluginId}-${r.path}`} | ||
| path={r.path} | ||
| element={<r.component />} | ||
| /> | ||
| ))} | ||
| </> | ||
| ); | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,95 @@ | ||
| import React from "react"; | ||
| import { usePluginExtensions } from "./hooks"; | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Error boundary that catches crashes in plugin components | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| interface ErrorBoundaryProps { | ||
| pluginId: string; | ||
| children: React.ReactNode; | ||
| } | ||
|
|
||
| interface ErrorBoundaryState { | ||
| error: Error | null; | ||
| } | ||
|
|
||
| class PluginErrorBoundary extends React.Component< | ||
| ErrorBoundaryProps, | ||
| ErrorBoundaryState | ||
| > { | ||
| constructor(props: ErrorBoundaryProps) { | ||
| super(props); | ||
| this.state = { error: null }; | ||
| } | ||
|
|
||
| static getDerivedStateFromError(error: Error): ErrorBoundaryState { | ||
| return { error }; | ||
| } | ||
|
|
||
| componentDidCatch(error: Error, info: React.ErrorInfo): void { | ||
| console.error( | ||
| `[ExtensionSlot] Plugin "${this.props.pluginId}" crashed:`, | ||
| error, | ||
| info.componentStack | ||
| ); | ||
| } | ||
|
|
||
| render(): React.ReactNode { | ||
| if (this.state.error) { | ||
| return ( | ||
| <div | ||
| style={{ | ||
| padding: "8px 12px", | ||
| margin: "4px 0", | ||
| background: "#fef3f2", | ||
| border: "1px solid #f04438", | ||
| borderRadius: 4, | ||
| fontSize: "0.85em", | ||
| color: "#b42318", | ||
| }} | ||
| > | ||
| Plugin "{this.props.pluginId}" encountered an error. | ||
| </div> | ||
| ); | ||
| } | ||
| return this.props.children; | ||
| } | ||
| } | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // ExtensionSlot component | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| export interface ExtensionSlotProps { | ||
| extensionPointId: string; | ||
| /** Arbitrary context passed as props to every plugin component. */ | ||
| context?: Record<string, unknown>; | ||
| } | ||
|
|
||
| export const ExtensionSlot: React.FC<ExtensionSlotProps> = ({ | ||
| extensionPointId, | ||
| context = {}, | ||
| }) => { | ||
| const extensions = usePluginExtensions(extensionPointId); | ||
|
|
||
| if (extensions.length === 0) { | ||
| return null; | ||
| } | ||
|
|
||
| return ( | ||
| <> | ||
| {extensions.map((ext, idx) => { | ||
| const Component = ext.component; | ||
| return ( | ||
| <PluginErrorBoundary | ||
| key={`${ext.pluginId}-${idx}`} | ||
| pluginId={ext.pluginId} | ||
| > | ||
| <Component {...context} /> | ||
| </PluginErrorBoundary> | ||
| ); | ||
| })} | ||
| </> | ||
| ); | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| import type { PluginManifest, PluginModule } from "./types"; | ||
| import { pluginRegistry } from "./PluginRegistry"; | ||
|
|
||
| export interface LoadPluginsOptions { | ||
| manifestUrl?: string; | ||
| } | ||
|
|
||
| const DEFAULT_MANIFEST_URL = "/ipa/modern-ui/plugins/manifest.json"; | ||
|
|
||
| /** | ||
| * Fetch the plugin manifest from the server and dynamically import each | ||
| * enabled plugin. Plugins that fail to load or register are skipped | ||
| * individually without affecting other plugins. | ||
| */ | ||
| export async function loadPlugins( | ||
| options: LoadPluginsOptions = {} | ||
| ): Promise<void> { | ||
| const manifestUrl = options.manifestUrl ?? DEFAULT_MANIFEST_URL; | ||
|
|
||
| let manifest: PluginManifest; | ||
| try { | ||
| const response = await fetch(manifestUrl); | ||
| if (!response.ok) { | ||
| console.warn( | ||
| `[PluginLoader] Manifest fetch returned ${response.status}, no plugins loaded.` | ||
| ); | ||
| return; | ||
| } | ||
| manifest = await response.json(); | ||
| } catch (err) { | ||
| console.warn("[PluginLoader] Could not fetch plugin manifest:", err); | ||
| return; | ||
| } | ||
|
|
||
| const enabledPlugins = manifest.plugins.filter((p) => p.enabled); | ||
| if (enabledPlugins.length === 0) { | ||
| console.info("[PluginLoader] No enabled plugins found."); | ||
| return; | ||
| } | ||
|
|
||
| console.info( | ||
| `[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.` | ||
| ); | ||
|
Comment on lines
+45
to
+54
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 To fix this, either let the error escape the callback so the promise rejects, or keep the |
||
| 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.`); | ||
| } | ||
|
|
||
| await pluginRegistry.runPhase("init"); | ||
| } | ||
There was a problem hiding this comment.
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 importjsoninto.jsfile, 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.There was a problem hiding this comment.
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.