Read OSS

Hyper のプラグインアーキテクチャ:デコレーション、拡張ポイント、モジュールローディング

上級

前提知識

  • 記事 1〜4
  • Node.js モジュールシステムの基礎
  • Higher-order component (HOC) パターン

Hyper のプラグインアーキテクチャ:デコレーション、拡張ポイント、モジュールローディング

これまでに見てきた Hyper の各システム — RPC ブリッジ、Redux ミドルウェアチェーン、React コンポーネントツリー、設定パイプライン — はすべて、ひとつの原則のもとに設計されています。それは「プラグインが介入・拡張・置き換えを行えること」です。これは後付けの機能ではなく、Hyper が存在する_理由_そのものです。Hyper は純粋なパフォーマンスよりも極限までの拡張性を選んでおり、プラグインアーキテクチャはその選択を実現するための仕組みです。

この記事では、38 の拡張ポイントをすべてカタログ化し、React や xterm のシングルトンをプラグインと共有するための Module._load パッチを解説します。また、コンポーネントから設定に至るデコレーションチェーンを追い、プラグインのインストールからホットリロードまでの流れを順を追って確認します。

38 の拡張ポイント

拡張ポイントの一覧は、単一の Set にまとめられています。

app/plugins/extensions.ts

38 のフックは、5 つのカテゴリに分類されます。

カテゴリ 拡張ポイント 用途
ライフサイクル onApp, onWindow, onWindowClass, onRendererWindow, onUnload アプリ・ウィンドウのイベント時にコードを実行する
コンポーネントデコレーター decorateHyper, decorateHeader, decorateTerms, decorateTermGroup, decorateSplitPane, decorateTerm, decorateTab, decorateTabs, decorateNotification, decorateNotifications, decorateHyperTerm HOC で React コンポーネントをラップする
Config / Env デコレーター decorateConfig, decorateKeymaps, decorateEnv, decorateBrowserOptions, decorateMenu, decorateSessionClass, decorateSessionOptions, decorateWindowClass 設定・オプションオブジェクトを変換する
State / Dispatch マッパー mapHyperTermState, mapTermsState, mapHeaderState, mapNotificationsState, mapHyperTermDispatch, mapTermsDispatch, mapHeaderDispatch, mapNotificationsDispatch Redux に接続されたコンポーネントに props を注入する
Redux 拡張 middleware, reduceUI, reduceSessions, reduceTermGroups Redux ミドルウェアチェーンとリデューサーを拡張する
Props ゲッター getTermProps, getTabProps, getTabsProps, getTermGroupProps コンポーネントに渡す props を変更する

プラグインモジュールが読み込まれると、Hyper はそのモジュールがこのセット内のいずれかのキーをエクスポートしているかを検証します。一致するキーがなければ、エラー通知とともにそのプラグインは拒否されます。これにより、無関係な npm パッケージを誤って Hyper プラグインとしてインストールしてしまう事故を防いでいます。

Module._load パッチ:依存関係の共有

Hyper のプラグインは、ホストアプリケーションと同じ React および xterm.js インスタンスを使用する必要があります。プラグインが独自の React をバンドルしてしまうと、useState が壊れ、context が伝播されず、コンポーネントツリーが分断されます。Hyper はこの問題を、Node の 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"]

このパッチは両方のプロセスで適用されます。メインプロセス側の app/plugins.ts#L64-L92 では、後方互換性のために ReactReactDOMReact.PureComponent を返します。一方、レンダラー側の lib/utils/plugins.ts#L168-L201 では、さらに hyper/notifyhyper/Notificationhyper/decorate といったレンダラー固有のモジュールも返します。

レンダラー側では macOS に限り、child_process も IPC 経由にリダイレクトされます。これにより、Electron のサンドボックスによってブロックされうるプロセスの生成をプラグインが行えないよう制御しています。

Tip: require('react')require('hyper/component') のフックは、ソースコード内で DEPRECATED とマークされています。最新の Hyper プラグインは、React を依存関係として自前でバンドルするべきです。ただし、多くのプラグインがいまだにこの仕組みに依存しているため、パッチ自体は引き続き残されています。

メインプロセスのデコレーションパターン

decorateEntity 関数は、メインプロセスにおけるプラグインデコレーションの要となる関数です。

app/plugins.ts#L366-L396

この関数は読み込まれたプラグインモジュールを順番に走査し、各プラグインのデコレーター関数を呼び出しながら、その結果を次のプラグインへと引き渡していきます。プラグインがエラーをスローした場合や不正な型を返した場合は、通知を出してスキップされます。デコレーションチェーン全体が止まることはありません。

最も頻繁に使われるチェーンが、設定のデコレーションパイプラインです。

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 はすべてのカラー値が存在することを保証し(存在しない場合はデフォルト値にフォールバック)、htermConfigTranslate は Hyper の前身である hterm 時代のレガシーな CSS セレクターを xterm.js 向けに書き換えます。

レンダラーのコンポーネントデコレーションとエラーバウンダリ

レンダラー側の decorate() 関数は、メインプロセス版よりも洗練された実装になっています。デコレートされた各コンポーネントをエラーバウンダリでラップする点が特徴です。

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);
  }
};

プラグインのコンポーネントデコレーターがレンダリング中にクラッシュしても、エラーバウンダリがそれを捕捉し、デコレートされていない元のコンポーネントへフォールバックします。ターミナルは動き続け、失われるのはそのプラグインが加えるビジュアル上の変更だけです。サードパーティのコードが同じ React ツリーで動作するプラグインエコシステムにおいて、この安定性の担保は非常に重要です。

lib/utils/plugins.ts#L96-L139 にある内側の getDecorated 関数は、デコレーターをチェーン状につなぎ、その結果をキャッシュします。各プラグインのデコレーターは、それまでに積み上げられたコンポーネントクラスを受け取り、新しいクラスを返します。exposeDecorated ラッパーは onDecorated ref コールバックを提供することで、プラグインが内部のコンポーネントインスタンスにアクセスできるようにしています。

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 統合:カスタム connect() とリデューサーデコレーション

Hyper のカスタム connect() 関数は、Redux の connect にプラグインフックポイントを追加したものです。接続される各コンポーネント(Hyper、Terms、Header、Notifications)に対して、プラグインは mapStatemapDispatch のデコレーターを提供できます。

lib/utils/plugins.ts#L459-L529

mapStateToProps 関数はまずアプリ自身の state マッパーを実行し、その後、各プラグインの state マッパーを順番に呼び出して、Redux の全 state と積み上げられた props の両方を渡します。各プラグインは props を追加・変更・削除できます。mapDispatchToProps でも同様のチェーンが行われます。

リデューサーのデコレーションも同じパターンに従います。decorateReducer 関数はベースとなるリデューサーをラップし、ベースリデューサーがアクションを処理した後、各プラグインのリデューサー拡張が順番に 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_;
  };
};

lib/reducers/ にある各リデューサーは decorateReducer の呼び出しで締められています。具体的には decorateTermGroupsReducer(reducer)decorateUIReducer(reducer)decorateSessionsReducer(reducer) などです。

プラグインのインストールとホットリロードの流れ

プラグインのライフサイクル全体は、設定の検出、npm インストール、モジュールのロード、サブスクライバーへの通知という一連の流れで構成されています。

app/plugins.ts#L49-L59

プラグインの変更検出には JSON シリアライズによる比較を使用しています。設定ウォッチャーが発火すると、現在のプラグインリストをシリアライズし、前回の値と比較します。

config.subscribe(() => {
  const plugins_ = config.getPlugins();
  if (plugins !== plugins_) {
    const id_ = getId(plugins_);   // JSON.stringify
    if (id !== id_) {
      id = id_;
      plugins = plugins_;
      updatePlugins();
    }
  }
});

updatePlugins の流れは次のとおりです。

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.tsELECTRON_RUN_AS_NODE=true を設定した状態(Electron を通常の Node.js として動作させるため)で Yarn を子プロセスとして実行し、タイムアウトは 5 分に設定されています。--no-lockfile フラグにより、毎回クリーンなインストールが保証されます。

clearCache 関数はまず既存のプラグインモジュールに対して onUnload フックを実行し、その後、プラグインディレクトリ配下のパスに一致する require.cache のエントリをすべて削除します。レンダラー側の対応する処理(lib/utils/plugins.ts#L203-L222)では、window.require.cache に対して同様の処理を行い、onRendererUnload フックを呼び出します。

Tip: プラグインの自動更新は autoUpdatePlugins 設定オプションで制御できます。true(デフォルト)に設定すると、Hyper は 5 時間ごとに更新を確認します。"1h""30m" のようなカスタムインターバル文字列も指定可能で、内部では ms ライブラリによってパースされます。

次回予告

これでプラグインシステムの全体像が見えてきました。パズルの最後のピースは設定システムです。hyper.json がどのように読み込まれてマージされ、ウォッチやマイグレーションが行われるのかを見ていきます。コマンドラインからプラグインを管理するスタンドアロンCLIツールについても取り上げます。最終回では、設定パイプラインの全体像を追いながらHyperの設計思想を解き明かします。