Packaging & Distribution
How to build, package, and distribute OpenGPEX v2 plugins for installation by other users.
Build Tool
pnpm pack-plugin <PluginFolderName> # Standard build
pnpm pack-plugin <PluginFolderName> --minify # Minified production build
Example:
pnpm pack-plugin ColorPickerDrawer
# Output: dist/user-plugin-colorpickerdrawer-1.0.0.zip
ZIP Structure
user-plugin-colorpickerdrawer-1.0.0.zip
├── plugin.manifest # Metadata (auto-generated from the entry file's manifest block)
├── dist/
│ ├── index.js # ESM bundle (external imports rewritten to window.__GPEX__)
│ └── index.css # Copied from the plugin root (if index.css exists)
└── visuals/ # Every non-code subdirectory is copied as resources
└── logo.svg
The packager writes the metadata file as plugin.manifest; the upload endpoint also accepts a legacy plugin.json as a fallback. Worker chunks referenced via new URL(..., import.meta.url) are emitted alongside the bundle and picked up automatically.
plugin.manifest Requirements
{
"id": "drawers.color_picker",
"displayName": "Color Picker",
"version": "1.0.0",
"description": "A simple color picker tool",
"author": "my-username",
"category": "drawers"
}
Both id and author are required — the packager aborts the build if either is missing (process.exit(1)). Validation runs at three gates: packaging, upload, and registration.
External Dependencies (Mandatory)
These packages are always externalized — the host provides them at runtime:
| Package | Reason |
|---|---|
react, react-dom |
Shared single React instance |
lucide-react |
Shared icon library |
@opengpex/editor/* |
Editor framework APIs |
The pack-plugin script handles externalization automatically. Bundling React causes "Invalid hook call" crashes at runtime.
Shared Host Modules (window.__GPEX__)
Dynamically loaded plugins resolve externals through window.__GPEX__, which exposes:
react,react/jsx-runtime,react-dom,lucide-react@opengpex/editor/core/context— editor hooks@opengpex/editor/core/types— type definitions (types only, erased at build)@opengpex/editor/widgets/ActionButton,FancyButton,ColorPickerPro,Switch— shared editor widgets
Note: only these four widgets are exposed. Widgets such as ActionDropdown or ActionGroup are not on window.__GPEX__ yet — either import them from your own dependency copy or avoid them in dynamically loaded plugins.
Upload Pipeline
Browser ── POST /api/plugins/upload (ZIP) ──► upload route
1. Cloud Mode check → 403 if GPEX_CLOUD_MODE=true
2. ZIP pre-extraction in memory for auditing
3. Manifest validation → 400 if plugin.manifest (or plugin.json) is missing or id/author missing
4. Namespace protection → 400 if id starts with "base." or "com.gpex.plugins.base." (case-insensitive)
5. Zip Slip protection → 400 if any entry path escapes the target directory
6. Conflict detection → conflictingStaticUid reported, upload allowed
7. Persist to data/plugins/user/{localId}/ (flat ZIPs get a sandbox folder derived from the plugin id)
Static/Dynamic Conflict Handling
When an uploaded ZIP collides with an existing static user plugin:
- Backend persists the upload and returns
conflictingStaticUid - Frontend writes
{ [conflictingStaticUid]: false }into thegpex_enabled_user_pluginsLocalStorage map and reloads the page - After reload the static version is skipped and the dynamic version takes effect
- Rollback: delete the dynamic plugin in the Hub, then re-enable the static one
Namespace Protection
- An
idbeginning withbase.orcom.gpex.plugins.base.→ the packager refuses to build and the upload route rejects with 400 - The dynamic loader forces
sourceType: 'user'on every uploaded plugin - UIDs are computed from the sanitized author plus the manifest id (
${author}.${id}, author lowercased with invalid chars mapped to_), so two plugins can never claim the same namespace
Enable/Disable and Deletion
- Toggle — writes a boolean to LocalStorage under the standard UID key (
gpex_enabled_user_plugins), then reloads the page. - Delete — requires
enabled === falsefirst. The API physically removes the plugin directory; the runtime then callsplugins.unregisterPlugin(uid).
Distribution Channels
| Channel | Method |
|---|---|
| Hub upload | Upload ZIP via the GPEX Hub panel (self-host installs) |
| Direct share | Send the ZIP; recipient uploads via Hub → Installed |
| Git repository | Clone into plugins/user/ for static development |
Style Isolation
- Custom styles go in
index.css(extracted automatically by the packager) - Must use scoped class names — global tag selectors are forbidden
- Inherit the editor theme through CSS custom properties; never hardcode theme colors
Next Steps
- Your First Plugin — build something to package
- Asset Management — bundling static assets
- Security & Isolation — trust levels for distributed plugins
Last updated: 2026-10-12