Launch OpenGPEX

Plugin System Overview

This document explains the design philosophy, architecture, and key concepts of the OpenGPEX plugin system. Whether you want to build a custom tool or understand how the editor is structured, start here.


Design Philosophy

OpenGPEX strictly follows Inversion of Control (IoC) and the Open-Closed Principle to achieve complete decoupling between the core engine framework (Workspace) and business plugins.

┌─────────────────────────────────────────────────────────┐
│                 Core Engine (Workspace)                   │
│                                                          │
│   Registry ──── auto-discovers ────► Plugin A            │
│                                  ────► Plugin B            │
│                                  ────► Plugin C            │
│                                                          │
│   Workspace ── renders via Slots ── Registry             │
└─────────────────────────────────────────────────────────┘

The core never imports plugins directly. Instead, plugins register themselves into a metadata-driven registry, and the workspace dynamically renders them into physical slots.


Dual-Track Loading Architecture

OpenGPEX uses a dual-track plugin loading system:

Track Method Use Case
Track 1: Static Source compiled into the Next.js bundle Official built-in plugins, local development
Track 2: Dynamic ESM ZIP loaded at runtime via import() Production hot-plug install, user uploads
┌─────────────────────────────┬────────────────────────────────┐
│  Track 1: Static Compile     │  Track 2: Dynamic Runtime       │
│                              │                                 │
│  plugins/base/   → bundle    │  data/plugins/user/ → ESM load  │
│  plugins/community/ → bundle │  /api/plugins/serve (proxy)     │
│  plugins/user/   → bundle    │  /api/plugins/upload (ingest)   │
│                              │                                 │
│  registry.ts (generated)     │  /api/plugins/list (discovery)  │
└─────────────────────────────┴────────────────────────────────┘

Dual-Registry System

The system maintains two separate registries:

  • core/plugin/registry.ts — All official base and community plugins, generated by sync-plugins.mjs
  • plugins/registry-user.ts — All local user plugins, generated by scan-plugins.mjs

The core loader usePluginInit loads both registries in sequence ("core first, user second") and hands them to PluginService for unified lifecycle management.


Trust Model (sourceType)

Every plugin carries a trust level:

sourceType Meaning Install Method Removable Disablable
base Official core features Compiled into bundle
community Verified third-party, pre-installed Compiled into bundle
user User-installed ZIP upload / Hub download

📌 Users can never elevate a plugin to community status at runtime. Only plugins shipped with the release qualify.


UID Namespace System

Every plugin, command, and signal receives a globally unique identifier (UID):

Entity Formula Example
Plugin author + "." + manifest.id opengpex.drawers.adjustment
Command plugin.uid + "." + cmd.id opengpex.drawers.adjustment.cmd.reset
Signal plugin.uid + "." + sig.id opengpex.drawers.adjustment.signal.active_tab

This ensures zero naming collisions even when multiple plugins declare commands with the same local name.

Scoped Auto-Prefix

When a plugin calls actions.executeCommand(id) from within its own context:

  1. If id is found directly in the global command registry → execute as-is (absolute path)
  2. Otherwise → auto-prefix with ${pluginScope.uid}.${id} (relative path)

This means plugins can use short IDs internally (e.g., 'cmd.apply_color') without worrying about namespace collisions.


Plugin Metadata Contract

Every plugin exports a plugin object implementing the EditorPlugin interface:

import * as P from './protocols';

export const plugin: EditorPlugin = {
  // 1. Identity
  manifest: {
    id: P.PLUGIN_ID,              // e.g. 'drawers.my_tool'
    displayName: 'My Tool',
    version: '1.0.0',
    description: 'A custom drawing tool',
    category: 'drawers',
    author: P.PLUGIN_AUTHOR,      // e.g. 'my-username' — REQUIRED for UID
    requirements: {
      coreVersion: '>=1.0.0',     // minimum engine version
      auth: 'none',
    },
  },

  // 2. Layout
  slot: 'SIDE_BAR',
  show: 'frame-required',
  icon: <Palette size={20} />,
  order: 400,

  // 3. Implementation
  component: MyToolComponent,
  initialConfig: { mode: 'normal' },

  // 4. Capabilities
  commands: [/* ... */],
  signals: [/* ... */],
  interactions: [/* ... */],
  contributions: [/* ... */],

  // 5. Lifecycle
  onInit: (ctx) => { /* setup */ },
  onDestroy: (ctx) => { /* cleanup */ },
};

⚠️ Both manifest.id and manifest.author are required. The system computes UID as author + "." + id. Missing either field will cause registration to fail.


Slot System

Plugins render into physical slots — predefined UI regions in the workspace:

Slot Location Plugin Suffix Convention
SIDE_BAR Sidebar drawers (left & right sides) XxxDrawer, XxxPanel
OPTION_BAR Top strip below the header XxxOptions
DOCK Collapsible bottom panel area TabDock
TL / TR / BL / BR Anchored HUD corners on the viewport XxxHUD, XxxWidget
VIEWPORT_OVERLAY Over the canvas (pointer-interactive) XxxOverlay
STAGE_OVERLAY Stage-level layers (non-interactive) XxxOverlay
STAGE_GIZMOS Interactive transform & crop tools SmartGuides
ROOT_OVERLAY App-level absolute layers XxxPopup, XxxOverlay
TOOL_MENU Left vertical toolbar options XxxTool
HIDDEN Headless background loops TimeTraveler, XxxService

Plugin Lifecycle

Install/Enable → onInit(ctx) → Active (renders in slot) → Disable → onDestroy(ctx)
  1. Registration — Plugin metadata is scanned and added to the registry
  2. InitializationonInit fires with full EditorContextValue
  3. Active — Component renders in its assigned slot, commands/signals are live
  4. DestructiononDestroy fires for cleanup when disabled or uninstalled

Lifecycle Rules

  • onInit / onDestroy should be synchronous and lightweight (fire-and-forget any async work)
  • onDestroy must be idempotent — multiple calls must not throw
  • No DOM manipulation or React re-renders in lifecycle hooks (they execute outside the React tree)
  • Extract lifecycle logic into a separate lifecycle.ts file

The 5-File Pattern

Every plugin follows a standard file structure:

plugins/base/[category]/[PluginName]/
├── index.tsx       # Plugin entry — exports EditorPlugin object
├── protocols.ts    # Constants (PLUGIN_ID, PLUGIN_AUTHOR, command IDs, signal IDs, config interfaces)
├── commands.ts     # Command executors (business logic)
├── hooks.ts        # Custom hooks (bridge UI ↔ commands/signals)
└── components.tsx  # React UI components (pure presentation)

Optional additional files:

  • lifecycle.tsonInit / onDestroy implementations (when needed)
  • worker/ — Plugin-private Web Worker directory (for heavy computation)

Next Steps


Last updated: 2026-07-30