Launch OpenGPEX

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:

  1. Backend persists the upload and returns conflictingStaticUid
  2. Frontend writes { [conflictingStaticUid]: false } into the gpex_enabled_user_plugins LocalStorage map and reloads the page
  3. After reload the static version is skipped and the dynamic version takes effect
  4. Rollback: delete the dynamic plugin in the Hub, then re-enable the static one

Namespace Protection

  • An id beginning with base. or com.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 === false first. The API physically removes the plugin directory; the runtime then calls plugins.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


Last updated: 2026-10-12