Hyper's Plugin Architecture: Decoration, Extension Points, and Module Loading
Prerequisites
- ›Articles 1-4
- ›Node.js module system basics
- ›Higher-order component (HOC) pattern
Hyper's Plugin Architecture: Decoration, Extension Points, and Module Loading
Every system in Hyper we've examined so far — the RPC bridge, the Redux middleware chain, the React component tree, the config pipeline — has been designed with one principle in mind: plugins can intercept, extend, or replace it. This isn't an afterthought; it's the reason Hyper exists. The project trades some raw performance for radical extensibility, and the plugin architecture is how that trade is implemented.
In this article, we'll catalog all 38 extension points, examine the Module._load patching that shares React/xterm singletons with plugins, trace the decoration chain from components to config, and follow a plugin from installation to hot-reload.
The 38 Extension Points
The complete list of extension points lives in a single Set:
These 38 hooks fall into five categories:
| Category | Extension Points | Purpose |
|---|---|---|
| Lifecycle | onApp, onWindow, onWindowClass, onRendererWindow, onUnload |
Run code when app/window events occur |
| Component Decorators | decorateHyper, decorateHeader, decorateTerms, decorateTermGroup, decorateSplitPane, decorateTerm, decorateTab, decorateTabs, decorateNotification, decorateNotifications, decorateHyperTerm |
Wrap React components with HOCs |
| Config/Env Decorators | decorateConfig, decorateKeymaps, decorateEnv, decorateBrowserOptions, decorateMenu, decorateSessionClass, decorateSessionOptions, decorateWindowClass |
Transform configuration and options objects |
| State/Dispatch Mappers | mapHyperTermState, mapTermsState, mapHeaderState, mapNotificationsState, mapHyperTermDispatch, mapTermsDispatch, mapHeaderDispatch, mapNotificationsDispatch |
Inject props into Redux-connected components |
| Redux Extensions | middleware, reduceUI, reduceSessions, reduceTermGroups |
Extend the Redux middleware chain and reducers |
| Props Getters | getTermProps, getTabProps, getTabsProps, getTermGroupProps |
Modify props passed to components |
When a plugin module is loaded, Hyper checks whether it exports any key in this set. If not, the plugin is rejected with an error notification — this prevents users from accidentally installing unrelated npm packages as Hyper plugins.
Module._load Patching: Shared Dependencies
Hyper plugins need to use the same React and xterm.js instances as the host application. If a plugin bundles its own copy of React, useState breaks, context doesn't propagate, and the component tree fractures. Hyper solves this by monkey-patching Node's Module._load:
flowchart TD
A["Plugin calls require('react')"] --> B["Module._load intercepted"]
B --> C{"Module path?"}
C -->|"'react'"| D["Return Hyper's React instance"]
C -->|"'react-dom'"| E["Return Hyper's ReactDOM instance"]
C -->|"'hyper/component'"| F["Return React.PureComponent"]
C -->|"'hyper/notify'"| G["Return notification utility"]
C -->|"'hyper/decorate'"| H["Return decorate HOC"]
C -->|"'child_process'"| I["Return IPC-wrapped child_process (macOS)"]
C -->|"anything else"| J["Call original Module._load"]
This patching happens in both processes. The main process version at app/plugins.ts#L64-L92 returns React, ReactDOM, and React.PureComponent for backwards compatibility, while the renderer version at lib/utils/plugins.ts#L168-L201 adds renderer-specific modules like hyper/notify, hyper/Notification, and hyper/decorate.
The renderer version also patches child_process on macOS to route through IPC, preventing plugins from spawning processes that Electron's sandboxing might block.
Tip: The
require('react')andrequire('hyper/component')hooks are marked asDEPRECATEDin the source. Modern Hyper plugins should bundle their own React as a dependency. But the patching remains because the plugin ecosystem still has many packages relying on it.
Main Process Decoration Pattern
The decorateEntity function is the workhorse of main-process plugin decoration:
It iterates through all loaded plugin modules, calling each one's decorator function and passing the accumulated result to the next. If a plugin throws an error or returns an invalid type, it's skipped with a notification — the decoration chain doesn't break.
The config decoration pipeline is the most heavily used chain:
export const getDecoratedConfig = (profile: string) => {
const baseConfig = config.getProfileConfig(profile);
const decoratedConfig = decorateObject(baseConfig, 'decorateConfig');
const fixedConfig = config.fixConfigDefaults(decoratedConfig);
const translatedConfig = config.htermConfigTranslate(fixedConfig);
return translatedConfig;
};
flowchart LR
A["Base Config\n(defaults + user + profile)"] --> B["Plugin A\ndecorateConfig"]
B --> C["Plugin B\ndecorateConfig"]
C --> D["Plugin C\ndecorateConfig"]
D --> E["fixConfigDefaults\n(ensure colors exist)"]
E --> F["htermConfigTranslate\n(CSS class migration)"]
F --> G["Final Config"]
fixConfigDefaults ensures that all color values exist (falling back to defaults) and htermConfigTranslate rewrites legacy CSS selectors from Hyper's predecessor (hterm) to xterm.js selectors.
Renderer Component Decoration with Error Boundaries
The renderer-side decorate() function is more sophisticated than its main-process counterpart. It wraps each decorated component in an error boundary:
lib/utils/plugins.ts#L144-L166
return class DecoratedComponent extends React.Component {
constructor(props) {
super(props);
this.state = {hasError: false};
}
componentDidCatch() {
this.setState({hasError: true});
notify('Plugin error', `Plugins decorating ${name} has been disabled...`);
}
render() {
const Sub = this.state.hasError ? Component_ : getDecorated(Component_, name);
return React.createElement(Sub, this.props);
}
};
If any plugin's component decorator crashes during rendering, the error boundary catches it and falls back to the undecorated component. The terminal continues working — you just lose the plugin's visual modifications. This is crucial for stability in a plugin ecosystem where third-party code runs in the same React tree.
The inner getDecorated function at lib/utils/plugins.ts#L96-L139 chains decorators and caches the result. Each plugin's decorator receives the accumulated component class and returns a new one. The exposeDecorated wrapper provides an onDecorated ref callback, allowing plugins to access the underlying component instance.
sequenceDiagram
participant R as React Render
participant EB as Error Boundary
participant GD as getDecorated()
participant P1 as Plugin A.decorateTerm
participant P2 as Plugin B.decorateTerm
R->>EB: Render DecoratedComponent
EB->>GD: Get decorated class for 'Term'
GD->>GD: Check cache
alt Not cached
GD->>P1: decorateTerm(BaseClass, {React, ...})
P1-->>GD: EnhancedClassA
GD->>P2: decorateTerm(EnhancedClassA, {React, ...})
P2-->>GD: EnhancedClassB
GD->>GD: Cache as decorated['Term']
end
GD-->>EB: Return cached decorated class
EB->>R: Render decorated class
Note over EB: If render throws, fallback to undecorated
Redux Integration: Custom connect() and Reducer Decoration
Hyper's custom connect() function extends Redux's connect with plugin hook points. For each connected component (Hyper, Terms, Header, Notifications), plugins can provide mapState and mapDispatch decorators:
lib/utils/plugins.ts#L459-L529
The mapStateToProps function first runs the app's own state mapper, then iterates through each plugin's state mapper, passing both the full Redux state and the accumulated props. Each plugin can add, modify, or remove props. The same chaining happens for mapDispatchToProps.
Reducer decoration follows a similar pattern. The decorateReducer function wraps a base reducer so that after the base reducer processes an action, each plugin's reducer extension gets a chance to further transform the state:
const decorateReducer = (name, fn) => {
const reducers = reducersDecorators[name];
return (state, action) => {
let state_ = fn(state, action); // Base reducer runs first
reducers.forEach((pluginReducer) => {
state_ = pluginReducer(state_, action); // Each plugin extends
});
return state_;
};
};
This is why every reducer in lib/reducers/ ends with a decorateReducer call — decorateTermGroupsReducer(reducer), decorateUIReducer(reducer), decorateSessionsReducer(reducer).
Plugin Installation and Hot-Reload Flow
The complete plugin lifecycle spans config detection, npm installation, module loading, and subscriber notification:
Plugin change detection uses JSON serialization comparison — when the config watcher fires, the current plugin list is serialized and compared to the last known value:
config.subscribe(() => {
const plugins_ = config.getPlugins();
if (plugins !== plugins_) {
const id_ = getId(plugins_); // JSON.stringify
if (id !== id_) {
id = id_;
plugins = plugins_;
updatePlugins();
}
}
});
The updatePlugins flow:
sequenceDiagram
participant C as Config Watcher
participant U as updatePlugins
participant S as syncPackageJSON
participant Y as Yarn Install
participant M as Module Loader
C->>U: Plugin list changed
U->>S: Generate package.json from plugin list
S->>S: Write {name, version, dependencies} to plugins/package.json
U->>Y: execFile(electron, [yarn, 'install', ...])
Note over Y: 5-minute timeout, 1MB buffer
Y-->>U: Installation complete
U->>M: clearCache() — delete require.cache entries
M->>M: Trigger onUnload hooks
U->>M: requirePlugins() — reload all modules
M->>M: Validate each module exports extension points
U->>U: Notify watchers (triggers window reload)
app/plugins/install.ts runs Yarn as a child process with ELECTRON_RUN_AS_NODE=true (so Electron behaves as plain Node.js) and a 5-minute timeout. The --no-lockfile flag ensures clean installs every time.
The clearCache function first triggers onUnload hooks on existing plugin modules, then deletes all require.cache entries whose paths start with the plugins directory. The renderer-side equivalent at lib/utils/plugins.ts#L203-L222 does the same for window.require.cache and triggers onRendererUnload hooks.
Tip: Auto-updating plugins is controlled by the
autoUpdatePluginsconfig option. When set totrue(default), Hyper checks for updates every 5 hours. You can also set it to a custom interval string like"1h"or"30m"— it's parsed by themslibrary.
What's Next
We've now covered the plugin system that ties everything together. The final piece of the puzzle is the configuration system — how hyper.json is loaded, merged, watched, and migrated, plus the standalone command-line tool that manages plugins from the command line. In the last article, we'll trace the full config pipeline and examine how Hyper bridges its Electron 3 past with its Electron 22 present.