Read OSS

Hyper's Plugin Architecture: Decoration, Extension Points, and Module Loading

Advanced

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:

app/plugins/extensions.ts

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:

app/plugins.ts#L64-L92

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') and require('hyper/component') hooks are marked as DEPRECATED in 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:

app/plugins.ts#L366-L396

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:

app/plugins.ts#L432-L438

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:

app/plugins.ts#L49-L59

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 autoUpdatePlugins config option. When set to true (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 the ms library.

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.