@ptkl/forge-plugin-system
v1.8.1
Published
A flexible and powerful plugin system for React applications with multi-slot architecture and hooks support
Maintainers
Readme
Forge Plugin System (@ptkl/forge-plugin-system)
A TypeScript-first plugin system for React applications. Plugins are independent bundles (built with rollup via the fps CLI) that register themselves into the host app's registry at runtime — adding UI components into slots, executing hooks, contributing routes, and exposing configurable settings.
Table of Contents
- Installation
- Core Concepts
- Host App Setup
- Writing a Plugin
- Reading Plugin Config (in your plugin)
- Rendering Plugins in the Host App
- Plugin Management UI
- Playground (Live Development)
- CLI Reference (
fps) - API Reference
- Bundling Rules
Installation
npm install @ptkl/forge-plugin-systemInstall the CLI globally:
npm install -g @ptkl/forge-plugin-system
# or use npx fps ...Core Concepts
| Concept | Description |
|---|---|
| Plugin | A self-contained ES module bundle that calls window.registerPlugin(plugin) on load |
| PluginRegistryProvider | React context that manages all registered plugins |
| Slot | A named injection point in the host UI where plugin components render |
| Hook | A named function in a plugin that the host calls at specific lifecycle points |
| Settings schema | Declares what config fields a plugin accepts — rendered as a form in the Plugin Management dialog |
| Playground | WebSocket-based hot-reload dev server for plugin development |
Host App Setup
Wrap your app with PluginRegistryProvider. It handles loading plugins from Thunder (your backend), exposes window.registerPlugin for dynamically loaded bundles, and persists plugin configs.
import { PluginRegistryProvider, PlaygroundProvider } from '@ptkl/forge-plugin-system';
function App() {
return (
<PluginRegistryProvider
repository="plugins" // Thunder collection name where installed plugins are stored
enableDynamicLoading={true} // Load plugins from Thunder on mount (default: true)
>
<PlaygroundProvider> {/* only needed in development */}
<YourRouter />
</PlaygroundProvider>
</PluginRegistryProvider>
);
}PluginRegistryProvider props
| Prop | Type | Default | Description |
|---|---|---|---|
| repository | string | 'plugins' | Thunder collection name for installed plugins |
| initialPlugins | Plugin[] | [] | Statically registered plugins (bypasses Thunder) |
| enableDynamicLoading | boolean | true | Load plugins from Thunder on mount |
| onPluginRegister | (plugin) => void | — | Called whenever a plugin registers |
| onPluginUnregister | (name) => void | — | Called whenever a plugin is removed |
| playgroundConfig | Partial<PlaygroundConfig> | — | Override playground WebSocket settings |
Writing a Plugin
Plugin Structure
A plugin is a TypeScript/JavaScript file that calls window.registerPlugin with a Plugin object. The fps CLI bundles it into a single ES module.
// src/index.tsx
import MyComponent from './MyComponent';
window.registerPlugin({
meta: {
name: 'MyPlugin', // Must be unique. Used as the settings key.
id: 'my-plugin', // Used as permission prefix (e.g. "my-plugin::read")
version: '1.0.0',
description: 'Does something useful',
author: 'Your Name',
},
// UI component for the default slot
Component: MyComponent,
// Multiple named components for different slots
Components: {
Header: HeaderWidget,
Footer: FooterWidget,
},
// Components organized by slot name
slots: {
sidebar: {
components: { Widget: SidebarWidget },
scripts: { onInit: () => console.log('sidebar ready') },
},
},
});Component Slots
Slots are named injection points. A plugin declares which slot its component belongs to via the slot name used in slots or the host's <PluginSlot> configuration.
// Plugin side — register to a slot called "dashboard"
window.registerPlugin({
meta: { name: 'Analytics' },
slots: {
dashboard: {
components: { Chart: AnalyticsChart },
},
},
});// Host side — render everything registered for "dashboard"
import { PluginSlot } from '@ptkl/forge-plugin-system';
<PluginSlot
slotName="dashboard"
layout="grid"
gridColumns={2}
sharedProps={{ userId: currentUser.id }}
/>Script Hooks
Hooks are named functions the host can invoke at lifecycle points.
// Plugin
window.registerPlugin({
meta: { name: 'Tracker' },
hooks: {
onLogin: (args) => { track('user_login', args); },
onPageView: async (args) => { await sendAnalytics(args); },
},
// Or legacy single script
execute: (args) => { console.log('executed', args); },
});// Host — invoke hooks
import { useHookScript } from '@ptkl/forge-plugin-system';
const { executePlugins } = useHookScript('onLogin');
await executePlugins('onLogin', { userId });Routes
Plugins can contribute routes to the host router.
window.registerPlugin({
meta: { name: 'Reports' },
routes: [
{
path: '/reports',
component: ReportsPage,
title: 'Reports',
icon: 'file-text',
order: 50,
action: 'view_reports', // permission action to guard this route
},
],
// Or use the callback form for dynamic registration
routes: (registerRoute) => {
registerRoute({ path: '/reports', component: ReportsPage }, { namespace: 'main' });
},
});// Host — merge plugin routes with your app routes
import { useAppRoutesWithPlugins } from '@ptkl/forge-plugin-system';
const appRoutes = [{ path: '/dashboard', element: <Dashboard /> }];
const { allRoutes } = useAppRoutesWithPlugins(appRoutes);Settings Schema
Declare what configuration fields your plugin accepts. The PluginManagement dialog renders them automatically as a settings form.
window.registerPlugin({
meta: { name: 'Reports' },
settingsSchema: [
{
key: 'reportsCollection',
label: 'Collection',
type: 'string',
defaultValue: 'reports',
description: 'Thunder collection where reports are stored',
required: false,
},
{
key: 'pageSize',
label: 'Page Size',
type: 'number',
defaultValue: 20,
},
{
key: 'enableExport',
label: 'Enable Export',
type: 'boolean',
defaultValue: true,
},
{
key: 'theme',
label: 'Theme',
type: 'select',
defaultValue: 'light',
options: [
{ label: 'Light', value: 'light' },
{ label: 'Dark', value: 'dark' },
],
},
],
});Saved values are persisted to localStorage (key: fps:plugin-configs) and synced to sessionStorage on every app load and save.
Permissions
Declare permissions your plugin requires. They are prefixed with meta.id so they are namespaced.
window.registerPlugin({
meta: { name: 'Reports', id: 'reports' },
permissions: ['view_reports', 'edit_reports', 'delete_reports'],
// Results in: "reports::view_reports", "reports::edit_reports", etc.
});// Host — check permissions
import { usePluginPermissions } from '@ptkl/forge-plugin-system';
const { hasPermission, getAllPermissions } = usePluginPermissions();
const canView = hasPermission('reports::view_reports');Reading Plugin Config (in your plugin)
From a React component — use usePluginConfig. This reads from the registry context:
import { usePluginConfig } from '@ptkl/forge-plugin-system';
// IMPORTANT: must be called inside a React component body, never at module level
function MyComponent() {
const { reportsCollection, pageSize } = usePluginConfig('Reports', {
reportsCollection: 'reports',
pageSize: 20,
});
// use values...
}From a service / utility (outside React) — inline the sessionStorage read directly. Do NOT import @ptkl/forge-plugin-system in services because the browser can't resolve bare module specifiers when dynamically importing plugin bundles:
// ✅ Correct — inline the read, no import needed
function getCollection(): string {
try {
const stored = sessionStorage.getItem('fps:plugin-configs');
const all = stored ? JSON.parse(stored) : {};
return all?.['Reports']?.reportsCollection ?? 'reports';
} catch {
return 'reports';
}
}
export async function listReports() {
const col = getCollection(); // called inside the async function, not at module level
// ...
}Why not import
@ptkl/forge-plugin-systemin your plugin services? Plugin bundles are loaded via the browser's nativeimport(). The browser cannot resolve bare specifiers like@ptkl/forge-plugin-system— it only understands/,./,../, or full URLs. Marking itexternalin rollup leaves the bare specifier in the output and causes a runtime error. Keep it out of the plugin bundle entirely.
Rendering Plugins in the Host App
<PluginSlot>
Renders all plugin components registered for a given slot name.
<PluginSlot
slotName="sidebar"
layout="stack" // 'stack' | 'grid' | 'flex'
gridColumns={2} // when layout="grid"
sharedProps={{ userId }} // passed as props to every plugin component
filter={(p) => p.meta.enabled !== false}
sort={(a, b) => (a.meta.priority ?? 0) - (b.meta.priority ?? 0)}
onError={(err, name) => console.error(name, err)}
errorComponent={MyErrorBoundary}
emptyComponent={() => <p>No plugins installed</p>}
/>usePlugins
Access plugins for a slot directly:
import { usePlugins } from '@ptkl/forge-plugin-system';
const { plugins, isLoading } = usePlugins('sidebar');Plugin Management UI
PluginManagement is a ready-made dialog for managing installed plugins — install, enable/disable, configure settings, and view details.
import { PluginManagement } from '@ptkl/forge-plugin-system';
<PluginManagement
open={isOpen}
onOpenChange={setIsOpen}
repository="plugins"
/>The settings cog button appears automatically for any plugin that declares a settingsSchema. The form fields are rendered based on the field type (string, number, boolean, select). Saved values persist to localStorage and are immediately available via sessionStorage.
Playground (Live Development)
The playground server provides hot-reload during plugin development. It watches your built plugin file and pushes updates to the host app over WebSocket.
1. Start the host app in dev mode
The PlaygroundProvider component must be mounted (done automatically when wrapping with it in development).
2. Start the playground server in your plugin project
# Using your own rollup config (recommended)
fps playground --config rollup.config.js --output dist/plugin.js
# Or let fps handle the build
fps playground --input src/index.tsx --output dist/plugin.jsAdd to package.json:
{
"scripts": {
"dev": "fps playground --config rollup.config.js --output dist/plugin.js"
}
}The playground connects to ws://localhost:11400 by default and replaces the installed plugin version with your local build in real time.
CLI Reference (fps)
fps build
Build a plugin bundle for production.
fps build [options]
Options:
-i, --input <file> Entry file (default: src/index.tsx or index.js)
-o, --output <file> Output file (default: dist/plugin.js)
-n, --name <name> Plugin name (default: from package.json)
-f, --format <format> Output format: es | cjs | umd (default: es)
-w, --watch Watch and rebuild on changes
-m, --minify Minify output
--external <modules> Comma-separated external dependencies
--config <file> Use a custom rollup config file (bypasses fps build entirely)
--include-react Bundle React instead of externalizing itWhen --config is given, fps delegates directly to your project's ./node_modules/.bin/rollup -c <file>. This is the recommended approach for plugins with complex build requirements (PostCSS, Tailwind, etc.).
fps playground
Start the playground WebSocket server for live development.
fps playground [options]
Options:
-p, --port <port> WebSocket port (default: 11400)
-i, --input <file> Entry file
-o, --output <file> Output file to watch (default: dist/plugin.js)
--config <file> Custom rollup config (runs rollup -w -c <file> alongside the server)
--no-build Skip building — only watch the output filefps init
Scaffold a new plugin project.
fps init [options]
Options:
-n, --name <name> Plugin name
-t, --template <t> Template: basic | component | script | hybrid (default: basic)fps verify
Validate a built plugin bundle — checks meta, routes, permissions, and structure.
fps verify [options]
Options:
-f, --file <file> Plugin file to verify (default: auto-detected)
-v, --verbose Show detailed outputAPI Reference
Components
| Export | Description |
|---|---|
| PluginRegistryProvider | Context provider — wrap your app with this |
| PluginSlot | Renders plugin components for a named slot |
| PluginManagement | Full plugin management dialog (install, configure, etc.) |
| PlaygroundProvider | Enables hot-reload during plugin development |
Hooks
| Export | Description |
|---|---|
| usePlugins(slot) | Get plugins registered for a slot |
| usePluginRegistry() | Access the full registry context |
| usePluginRoutes() | Read and manage plugin-contributed routes |
| useAppRoutesWithPlugins(appRoutes) | Merge your routes with plugin routes |
| usePluginPermissions() | Read plugin-declared permissions |
| usePluginConfig(name, defaults) | Read saved plugin settings (React component only) |
| useHookScript(hookName) | Execute plugin hooks by name |
Utilities
| Export | Description |
|---|---|
| getPluginConfig(name, defaults) | Read saved settings from sessionStorage — usable outside React |
| loadDeveloperSettings() | Load dev-override settings for playground mode |
| isPlaygroundMode() | Returns true when running in playground mode |
Types
| Export | Description |
|---|---|
| Plugin | Full plugin definition object |
| PluginMeta | meta block — name, id, version, etc. |
| PluginSettingField | One field in settingsSchema |
| PluginRoute | A route contributed by a plugin |
| RegisteredRoute | A route after registration (includes pluginName, id, etc.) |
| PluginRegistryType | The full registry context shape |
| PlaygroundConfig | Playground WebSocket configuration |
Bundling Rules
Plugin bundles run in the browser via native import(). Keep these rules in mind:
- Always externalize
react,react-dom,react/jsx-runtime— they must come from the host app. - Do NOT externalize or import
@ptkl/forge-plugin-systeminside your plugin. The browser cannot resolve bare specifiers. If you needgetPluginConfig, inline thesessionStorageread directly in your service. - Do NOT call hooks at module level.
usePluginConfig,useContext, etc. must only be called inside React component function bodies. - Use
watch: { exclude: 'dist/**' }in your rollup config when usingpostcsswithinject: trueto prevent a circular rebuild loop.
Minimal rollup.config.js for a plugin
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import typescript from '@rollup/plugin-typescript';
import peerDepsExternal from 'rollup-plugin-peer-deps-external';
export default {
input: 'src/index.tsx',
output: {
file: 'dist/plugin.js',
format: 'es',
sourcemap: true,
},
plugins: [peerDepsExternal(), resolve({ browser: true }), commonjs(), typescript()],
external: ['react', 'react-dom', 'react/jsx-runtime'],
watch: { exclude: 'dist/**' },
};With PostCSS / Tailwind:
import postcss from 'rollup-plugin-postcss';
import tailwindcss from '@tailwindcss/postcss';
import autoprefixer from 'autoprefixer';
// Add to plugins array:
postcss({
inject: { insertAt: 'top' },
minimize: false,
plugins: [tailwindcss, autoprefixer],
}),