npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@ptkl/forge-plugin-system

v1.8.1

Published

A flexible and powerful plugin system for React applications with multi-slot architecture and hooks support

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

npm install @ptkl/forge-plugin-system

Install 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-system in your plugin services? Plugin bundles are loaded via the browser's native import(). The browser cannot resolve bare specifiers like @ptkl/forge-plugin-system — it only understands /, ./, ../, or full URLs. Marking it external in 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.js

Add 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 it

When --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 file

fps 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 output

API 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:

  1. Always externalize react, react-dom, react/jsx-runtime — they must come from the host app.
  2. Do NOT externalize or import @ptkl/forge-plugin-system inside your plugin. The browser cannot resolve bare specifiers. If you need getPluginConfig, inline the sessionStorage read directly in your service.
  3. Do NOT call hooks at module level. usePluginConfig, useContext, etc. must only be called inside React component function bodies.
  4. Use watch: { exclude: 'dist/**' } in your rollup config when using postcss with inject: true to 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],
}),