Spaces:
Build error
Frontend Plugin System Architecture
This document provides a comprehensive technical overview of the frontend plugin system architecture in the PDF-TEI Editor. For practical plugin development guidance, see the Plugin Development Guide in the code-assistant documentation.
Overview
The PDF-TEI Editor uses a plugin-based architecture that supports both class-based plugins and object-based plugins. The system provides:
- Dependency resolution - Automatic topological sorting ensures plugins load in correct order
- Dual architecture - Supports both Plugin classes and object-based plugin descriptors
- Endpoint system - Flexible extension points for plugin communication
- Centralized management -
PluginManagerhandles registration and invocation - State orchestration -
Applicationclass coordinates plugins with immutable state management
Core Components
PluginManager
The PluginManager (app/src/modules/plugin-manager.js) is responsible for:
- Plugin registration - Maintains registry of all plugins and their dependencies
- Dependency resolution - Uses topological sorting to determine load order
- Endpoint invocation - Orchestrates calls to plugin extension points
- Cache management - Optimizes endpoint lookups via caching
Key Features
Dependency Resolution:
const manager = new PluginManager();
manager.register({
name: 'logger',
install() { /* ... */ }
});
manager.register({
name: 'database',
deps: ['logger'], // Will load after logger
install() { /* ... */ }
});
// Plugins invoked in dependency order: logger β database
await manager.invoke('install');
Endpoint Invocation Modes:
The manager supports flexible invocation patterns through flags and options:
No-Call Flag (
!prefix): Retrieve values without calling functionsconst configs = await manager.invoke('!config.timeout'); // [undefined, 5000]Throw Flag (
!suffix): Fail fast on errorsawait manager.invoke('install!'); // Throws on first errorExecution Modes:
parallel(default): All plugins execute concurrentlysequential: Plugins execute in dependency order, one at a time
Result Formats:
first: Return first fulfilled valuevalues: Array of all fulfilled valuesfull: Complete{status, value/reason}objects
Plugin Class Conversion:
The manager automatically converts Plugin class instances into plugin configuration objects using the getEndpoints() method, allowing both patterns to coexist seamlessly.
Plugin Base Class
The Plugin base class (app/src/modules/plugin-base.js) is implemented on top of the object-based plugin pattern. It converts a class instance into a plugin descriptor object by auto-mounting class methods as extension points via getExtensionPoints().
Auto-mounted extension points β methods recognized by name convention, no declaration needed:
- Lifecycle methods:
install,ready,start,shutdown,updateInternalState,onStateUpdate - Per-key state handlers: any method matching
on<Key>Change(e.g.onXmlChangeβonStateUpdate.xml)
Manually mounted extension points β require explicit registration:
static extensionPoints = [ep.path]with[ep.path](...args) { return this.method(...args) }β computed method delegating to a named method; key is the full EP path string, conflict-free
See plugin-communication.md for when and how to use each mechanism.
Additional features:
- Singleton pattern β
createInstance()andgetInstance()ensure one instance per class - State management β
this.state(read-only),this.dispatchStateChange(),this.hasStateChanged() - Context access β
PluginContextprovides controlled access to application services - Dependency injection β
getDependency(name)returns another plugin's public API
Use private getter properties to access dependencies lazily rather than assigning them in the constructor. This avoids circular dependency issues and keeps deps declarations minimal:
class MyPlugin extends Plugin {
// Resolved at call time β no constructor assignment, no deps entry needed
// unless the dependency must be installed before this plugin's install() runs
get #logger() { return this.getDependency('logger') }
get #xmlEditor() { return this.getDependency('xmleditor') }
async someAction() {
this.#logger.debug('action triggered')
const tree = this.#xmlEditor.getXmlTree()
}
}
Only add a plugin to deps when it must be fully installed before this plugin's own install() runs.
Lifecycle Methods
class MyPlugin extends Plugin {
async install(initialState) {
// Called during plugin registration
// Setup UI, register templates
}
async initialize() {
// Called after registration
// Optional initialization logic
}
async start() {
// Called when app starts
// Begin plugin operations
}
async shutdown() {
// Called on window.beforeunload
// Cleanup resources
}
}
State Management in Plugin Classes
Plugin classes get automatic state management through the base class:
class MyPlugin extends Plugin {
async onStateUpdate(changedKeys, state) {
// Catch-all: called on every state change
if (changedKeys.includes('user')) {
this.updateUI();
}
}
async handleAction() {
await this.dispatchStateChange({ customProperty: 'value' });
}
get currentUser() {
return this.state.user; // read-only
}
}
Per-Key State Handlers (on<Key>Change)
Instead of a catch-all onStateUpdate, declare methods named on<Key>Change where Key is the state property name with the first letter capitalized. The base class auto-discovers these and registers them as onStateUpdate.<key> extension points, so they are called only when that specific key changes.
class MyPlugin extends Plugin {
// Called only when state.xml changes
async onXmlChange(newXml, prevXml) { ... }
// Called only when state.user changes
async onUserChange(newUser, prevUser) { ... }
// Called only when state.sessionId changes
async onSessionIdChange(newId, prevId) { ... }
}
Naming: on + state key with first letter uppercased + Change (e.g. state.editorReadOnly β onEditorReadOnlyChange).
Per-key handlers receive (newValue, prevValue). Use this.state to access other state properties. Both on<Key>Change methods and onStateUpdate can coexist in the same class.
Custom Extension Points
Declare static extensionPoints and implement a computed method that delegates to a named method:
import ep from '../extension-points.js'
class MyPlugin extends Plugin {
static extensionPoints = [ep.toolbar.contentItems];
/**
* Extension point handler for `ep.toolbar.contentItems`.
* Called by ToolbarPlugin during start() to collect this plugin's toolbar contributions.
* Delegates to {@link MyPlugin#getToolbarContentItems}.
* @returns {Array<{element: HTMLElement, priority: number, position: string}>}
*/
[ep.toolbar.contentItems](...args) { return this.getToolbarContentItems(...args) }
getToolbarContentItems() {
return [{ element: this.#ui, priority: 5, position: 'center' }]
}
}
See plugin-communication.md for the full pattern, including how the host plugin invokes contributions.
PluginContext
The PluginContext (app/src/modules/plugin-context.js) provides Plugin classes with controlled access to application services:
updateState(changes)- Dispatch state changeshasStateChanged(state, ...keys)- Check if keys changedgetChangedStateKeys(state)- Get all changed keys
This abstraction prevents direct access to the Application instance and enforces proper encapsulation.
Application Orchestrator
The Application class (app/src/app.js) coordinates between plugins and state management:
- Plugin registration - Registers plugins with the PluginManager
- State updates - Orchestrates state changes through StateManager
- Plugin notifications - Notifies plugins of state changes via endpoints
- Singleton API - Exports singleton instance and plugin APIs
// app.js simplified structure
export class Application {
constructor() {
this.pluginManager = new PluginManager();
this.stateManager = new StateManager();
}
async updateState(changes) {
// Update state immutably
const newState = await this.stateManager.updateState(changes);
// Notify plugins
await this.pluginManager.invoke('updateInternalState', newState);
const changedKeys = this.stateManager.getChangedKeys();
await this.pluginManager.invoke('onStateUpdate', [changedKeys, newState]);
return newState;
}
}
// Export singleton API
export const app = Application.getInstance();
Plugin Types
Plugin Classes
Plugin classes extend the Plugin base class and receive automatic state management:
import Plugin from '../modules/plugin-base.js';
class MyPlugin extends Plugin {
constructor(context) {
super(context, {
name: 'my-plugin',
deps: ['dependency1']
});
}
/**
* @param {ApplicationState} state
*/
async install(state) {
await super.install(state);
// Setup UI
}
/**
* @param {(keyof ApplicationState)[]} changedKeys
* @param {ApplicationState} state
*/
async onStateUpdate(changedKeys, state) {
if (changedKeys.includes('user')) {
this.updateUI();
}
}
async handleClick() {
await this.dispatchStateChange({
customProperty: 'new value'
});
}
getEndpoints() {
return {
...super.getEndpoints(),
'custom.action': this.handleCustomAction.bind(this)
};
}
}
export default MyPlugin;
Features:
- Automatic state management via
this.state - Built-in lifecycle methods
- Singleton pattern:
MyPlugin.getInstance() - Auto-discovered change handlers:
onXmlChange(newVal, prevVal)for any state key getDependency(name)for runtime access to other plugins' APIs
Plugin Objects
Plugin objects are plain JavaScript descriptors that the system uses directly. The class-based Plugin class is implemented on top of this primitive pattern. If you need to understand the lower-level mechanics or work with object-based plugins directly, see Object-Based Plugin Pattern.
import { updateState } from '../app.js';
let currentState;
/**
* @param {String[]} changedKeys
* @param {ApplicationState} state
*/
async function onStateUpdate(changedKeys, state) {
currentState = state;
if (changedKeys.includes('user')) {
// React to changes
}
}
async function handleAction() {
await updateState({ customProperty: 'new value' });
}
export const api = { handleAction };
export const plugin = {
name: 'my-plugin',
deps: ['dependency1'],
api,
install: async (state) => { /* setup */ },
onStateUpdate
};
export default plugin;
Characteristics:
- Manual state management β track state in a closure variable
- The
apifield is whatgetDependency('my-plugin')returns in other plugins - Extension point paths map to nested object properties (
state.updateβplugin.state.update)
Endpoint System
Endpoints are extension points where plugins can provide functionality. Defined in app/src/endpoints.js.
Standard Lifecycle Endpoints
install- Plugin initialization, receives initial statestart- Application startup after all plugins installedready- Deferred initialization after page loadshutdown- Cleanup on application exit
State Management Endpoints
updateInternalState- Silent state sync for Plugin classesonStateUpdate- Reactive notifications with changed keys
Custom Endpoints
Plugins can define custom endpoints for specialized functionality:
// endpoints.js
const endpoints = {
validation: {
validate: "validation.validate",
configure: "validation.configure",
result: "validation.result"
},
log: {
debug: "log.debug",
info: "log.info",
warn: "log.warn"
}
}
Plugins expose custom endpoints via getEndpoints():
class ValidationPlugin extends Plugin {
getEndpoints() {
return {
...super.getEndpoints(),
'validation.validate': this.validate.bind(this),
'validation.configure': this.configure.bind(this)
};
}
}
Other plugins can invoke these endpoints:
// Invoke validation from another plugin
await app.pluginManager.invoke('validation.validate', {
type: 'xml',
text: xmlContent
});
Plugin Registration and Loading
Registration Flow
Plugins are collected in app/src/plugins.js, which is the central registry:
// app/src/plugins.js
// Class-based plugins β imported from plugin-registry.js (auto-generated)
import { MyPlugin } from './plugin-registry.js';
// Object-based plugins β imported directly
import myObjectPlugin from './plugins/my-object-plugin.js';
const plugins = [
MyPlugin, // Plugin class β instantiated automatically
myObjectPlugin, // Plugin object β used as-is
// ...
];
export default plugins;
// Export singleton APIs for cross-plugin access
export const myPlugin = MyPlugin.getInstance();
To add a new class-based plugin:
- Create
app/src/plugins/my-plugin.jswith the class - Run
node bin/build.js --steps=pluginsto add it toplugin-registry.js - Import from
./plugin-registry.jsand add to thepluginsarray inplugins.js
Loading Process
- Registration - Plugins registered with PluginManager
- Dependency resolution - Topological sort determines load order
- Instantiation - Plugin classes instantiated via
createInstance() - Installation -
installendpoint invoked sequentially in dependency order - Startup -
startendpoint invoked after all installations complete - Ready -
readyendpoint invoked after initial page load
Dependency Order Example
const plugins = [
configPlugin, // No dependencies - loads first
urlHashStatePlugin, // deps: ['config']
clientPlugin, // deps: ['config']
dialogPlugin, // deps: ['config']
validationPlugin, // deps: ['dialog']
// ...
];
// Resolved order:
// config β urlHashState, client, dialog β validation β ...
State Management Integration
The plugin system is tightly integrated with immutable state management. See state-management.md for comprehensive state details, and plugin-communication.md for when to use state propagation vs. other inter-plugin mechanisms.
Key Principles
onStateUpdateandon<Key>Changehandlers are observers β they react to state but never calldispatchStateChangethemselves (creates infinite loops)- State changes only from event handlers or async operations (API responses, timers)
- Use
dispatchStateChange()in Plugin classes,updateState()in object-based plugins - Store plugin-specific data in
state.ext[this.name]to avoid key collisions
State Update Flow
Event Handler β dispatchStateChange()
β
Application.updateState()
β
New immutable state created
β
Plugins notified via onStateUpdate / on<Key>Change
β
Plugins update UI
Template Registration System
Plugins use a template registration system supporting both development and production modes. See architecture.md for details.
Usage in Plugins
import { registerTemplate, createSingleFromTemplate } from '../ui.js';
class MyPlugin extends Plugin {
async install(state) {
await super.install(state);
// Register template (async, done once)
await registerTemplate('my-template', 'my-template.html');
// Create elements (synchronous)
const element = createSingleFromTemplate('my-template');
document.body.appendChild(element);
}
}
Key Points:
registerTemplate()is async, called during installcreateSingleFromTemplate()is synchronous, fast- Templates support parameter substitution via
${param}syntax - Development mode loads from files, production from bundled JSON
Memory Management
The plugin system implements several memory management strategies:
- State history limit - StateManager keeps only last 10 states
- Endpoint cache - PluginManager caches endpoint lookups, cleared on registration changes
- Singleton instances - Plugin class instances stored in WeakMap-style registry
- Proper cleanup -
shutdownendpoint allows plugins to clean up resources
Best Practices
Plugin Design
- Single responsibility - Each plugin handles one feature or concern
- Minimal dependencies - Only depend on truly required plugins
- Explicit endpoints - Use
getEndpoints()to document plugin capabilities - State extensions - Use
state.ext[pluginName]for plugin-specific state
State Management
See plugin-communication.md for the full state propagation pattern and decision guide.
- Never mutate β use
dispatchStateChange()orupdateState() - Never call state updates inside
onStateUpdateβ useon<Key>Changehandlers for reactive UI updates - Use
changedKeys.includes()in catch-allonStateUpdateto avoid unnecessary work
Performance
- Template registration - Register templates during
install, create during runtime - Conditional updates - Only update UI for relevant state changes
- Endpoint caching - Trust the PluginManager's cache
- Parallel invocation - Default parallel mode is fastest for independent operations
Migrating Object-Based Plugins to Class-Based
To convert an object-based plugin to a Plugin class:
Create class extending Plugin
class MyPlugin extends Plugin { constructor(context) { super(context, { name: 'my-plugin', deps: [] }); } }Move endpoint functions to methods
async install(state) { await super.install(state); // Original install code }Replace manual state tracking
// Before: let currentState; // After: this.state (automatic)Update state changes
// Before: await updateState({ ... }); // After: await this.dispatchStateChange({ ... });Implement getEndpoints() for custom endpoints
getEndpoints() { return { ...super.getEndpoints(), 'custom.action': this.handleAction.bind(this) }; }Export class and update registration
// plugins/my-plugin.js export default MyPlugin; // Run build step, then update plugins.js: import { MyPlugin } from './plugin-registry.js'; const plugins = [MyPlugin, ...]; export const myPlugin = MyPlugin.getInstance();
Related Documentation
- Plugin System Overview - Overview of frontend and backend plugin systems
- Object-Based Plugin Pattern - Underlying primitive pattern
- Backend Plugin System - Backend plugin architecture
- Architecture Overview - Complete system architecture
- Plugin Development Guide - Practical plugin development
- Inter-Plugin Communication - State, extension points, getDependency β when to use each
- State Management - Immutable state architecture
- Coding Standards - Code quality requirements