Read OSS

Hyperのアーキテクチャ:Electron製ターミナルエミュレーターのコードベースを読み解く

中級

前提知識

  • Electronの基本知識(main processとrenderer processの違い)
  • webpackの基本的な概念への理解
  • TypeScriptの基礎知識

Hyperのアーキテクチャ:Electron製ターミナルエミュレーターのコードベースを読み解く

Hyperは「ターミナルエミュレーターである前に、Webアプリである」という思想で作られています。Electron上に構築され、React・Redux・xterm.jsを使ってフルスペックのターミナル体験を描画しながら、ほぼすべての機能をサードパーティプラグインに開放しています。パフォーマンスが求められるターミナルという舞台でElectronモデルを限界まで活用するとどうなるか。その答えを知りたいなら、Hyperのコードベースは格好の教材です。

この最初の記事では、プロジェクト全体の構造を俯瞰します。3つの独立したプロセス、驚くほどフラットなディレクトリ構造、そして「null-loaderトリック」のような独創的な手法を含む、一風変わったビルドシステムを見ていきましょう。

3プロセス構成の概要

Hyperは3つの独立した実行単位から成り立っており、それぞれに専用のエントリーポイントとビルドターゲットがあります。

  1. Main Processapp/)— ウィンドウ管理・PTYセッション・メニュー・設定ファイルの監視・プラグインのインストール・自動アップデートを担うElectronのmain process
  2. Renderer Processlib/)— React/Reduxを動かし、xterm.jsターミナルのレンダリング・キーボードショートカット・分割ペインUIを管理するElectronのrenderer process
  3. CLIツールcli/)— プラグイン管理とコマンドラインからのアプリ起動を担う、スタンドアロンのNode.jsバイナリ(hyper
flowchart TD
    subgraph "Main Process (app/)"
        A[BrowserWindow Manager] --> B[PTY Sessions via node-pty]
        A --> C[Config Watcher]
        A --> D[Plugin Installer]
        A --> E[Menu System]
    end

    subgraph "Renderer Process (lib/)"
        F[React + Redux] --> G[xterm.js Terminal]
        F --> H[Split Pane UI]
        F --> I[Keyboard Shortcuts]
    end

    subgraph "CLI (cli/)"
        J[Plugin Management]
        K[App Launcher]
    end

    A <-->|"RPC over IPC"| F
    J -->|"Edits hyper.json"| C

Hyperのアーキテクチャが際立っているのは、main processがサービスロケーターとして機能している点です。appオブジェクトは実行時にconfigpluginscreateWindowgetLastFocusedWindowといったプロパティで拡張されます。このパターンはメインエントリーポイントの冒頭から確認できます。

app/index.ts#L42-L56

こうして拡張されたappオブジェクトは、プラグインと内部サブシステムの両方に渡される共有コンテキストとなり、設定・プラグイン管理・ウィンドウ追跡への参照を一手に引き受けます。

ディレクトリ構造の解説

Hyperのリポジトリは意図的にフラットな構成を取っています。monorepoツールは使わず、3つのプロセスに対応する3つのトップレベルディレクトリと、共有の型定義ディレクトリだけがあります。

ディレクトリ プロセス 役割
app/ Main Electronのmain process:ウィンドウ・セッション・設定・プラグイン・メニュー
app/config/ Main 設定の読み込み・マイグレーション・パス・JSONスキーマ
app/ui/ Main ウィンドウ作成・コンテキストメニュー
app/plugins/ Main 拡張ポイントの定義・yarnベースのインストール処理
app/menus/ Main プラットフォーム固有のアプリケーションメニュー
lib/ Renderer React/Reduxアプリ:コンポーネント・コンテナ・アクション・リデューサー
lib/components/ Renderer Reactコンポーネント:Term・TermGroup・Header・Tabs
lib/containers/ Renderer Reduxと接続されたコンテナコンポーネント
lib/store/ Renderer Reduxストアの設定・middleware
lib/actions/ Renderer 「effects」パターンを採用したReduxのaction creator
lib/reducers/ Renderer Reduxリデューサー:ui・sessions・termGroups
lib/utils/ Renderer RPCクライアント・プラグインの読み込み・設定アクセス
cli/ CLI スタンドアロンのプラグイン管理ツール
typings/ 共有 IPC・設定・状態に関するTypeScriptの型定義

ヒント: Hyperのデータモデルを理解するなら、typings/ディレクトリから読み始めるのが最短ルートです。typings/common.d.tsにはすべてのIPCイベントが、typings/config.d.tsには設定の全体像が定義されています。これらが3つのプロセスをつなぐ「契約書」です。

main processのTypeScriptはtscによって個別にコンパイルされ(webpackは使いません)、renderer processはwebpackによってtarget/renderer/bundle.jsにバンドルされます。この分離は意図的な設計です。main processはNode.js上で動作するためバンドルは不要ですが、renderer processはV8スナップショットのためにバンドルが必要です。

ビルドシステム:3つのwebpack設定

webpack.config.tsは3つの設定オブジェクトの配列をエクスポートしており、それぞれがまったく異なる目的を果たしています。

設定1:hyper-app — null-loaderトリック

webpack.config.ts#L10-L69

これはおそらく見たことがないほど変わったwebpack設定です。何もコンパイルしません。すべての.ts.jsファイルはnull-loaderに通され、ソースコードがすべて破棄されます。出力ファイルの名前は文字通りignore_this.jsです。

この設定の存在意義はただひとつ——CopyWebpackPluginを動かすことです。HTMLファイル・JSON設定・キーマップ・静的アセット・パッチをtarget/ディレクトリにコピーするためだけにwebpackが使われています。

シェルスクリプトではダメなのか、と思うかもしれません。理由は、この設定が開発中に使われるwebpack -wのウォッチモードと統合されているからです。HTMLテンプレートやキーマップのJSONを編集すると、自動的にターゲットディレクトリに反映されます。

設定2:hyper — Rendererのバンドル

webpack.config.ts#L72-L154

renderer用の設定は標準的なelectron-rendererターゲットですが、大きな特徴が1つあります。約30の依存関係を列挙した巨大なexternalsブロックです。それぞれがrequire("./node_modules/...")パスにマッピングされています。

externals: {
  react: 'require("./node_modules/react/index.js")',
  redux: 'require("./node_modules/redux/lib/redux.js")',
  xterm: 'require("./node_modules/xterm/lib/xterm.js")',
  // ... 25+ more
}

つまりこれらのモジュールはbundle.jsにはバンドルされません。代わりに、アプリ内のnode_modulesディレクトリから実行時にロードされます。これがHyperのV8スナップショット最適化の根幹です。

設定3:hyper-cli — CLIツール

webpack.config.ts#L155-L193

CLIツールをbin/cli.jsにバンドルする、シンプルなNode.jsターゲットの設定です。TypeScriptの処理にbabel-loaderを使い、rcパッケージの実行可能スクリプトを扱うためにshebang-loaderも組み込んでいます。

flowchart LR
    subgraph "webpack.config.ts"
        A["hyper-app\n(null-loader + CopyPlugin)"] -->|"→ target/"| D[HTML + JSON + Static]
        B["hyper\n(babel-loader + externals)"] -->|"→ target/renderer/"| E[bundle.js]
        C["hyper-cli\n(babel-loader)"] -->|"→ bin/"| F[cli.js]
    end
    G["tsc --build"] -->|"→ target/"| H[Main process JS]

起動速度を高めるV8スナップショット

ターミナルエミュレーターにとって、起動の速さは体験の質を左右します。Hyperがこれに対処しているのがV8スナップショットです。重い依存関係を事前にコンパイルしたヒープスナップショットとして用意することで、Electronが初回のパースとコンパイルをスキップできるようになります。

package.jsonpostinstallスクリプトがこの仕組みを統括しています。

yarn run v8-snapshot && webpack --config-name hyper-app && electron-builder install-app-deps

V8スナップショットのパイプラインは3つの段階で進みます。

sequenceDiagram
    participant P as postinstall
    participant M as mksnapshot
    participant W as webpack
    participant E as Electron

    P->>M: Run electron-mksnapshot
    M->>M: Pre-compile node_modules into snapshot blob
    P->>W: Build renderer bundle with externals
    Note over W: Dependencies excluded from bundle<br/>They'll come from the snapshot
    P->>E: Copy snapshot blobs to app directory
    E->>E: On startup, load snapshot instead of parsing JS

renderer側のwebpack設定におけるexternalsが、この仕組みの核心です。React・Redux・xterm.jsなどの多くのライブラリをwebpackのバンドル対象から外すことで、それらは起動時にJavaScriptのソースとしてパースされるのではなく、V8スナップショットからロードされます。externalsに指定されたrequire("./node_modules/...")という独特のパスは、スナップショットのコンテキスト内でモジュールが正しく解決されるために欠かせない記述です。

Main Processの起動シーケンス

app/index.tsのmain processエントリーポイントは、インポートが実行されるよりも前に、最優先で処理すべき事項を冒頭の数行で片付けます。

app/index.ts#L1-L22

起動順序は以下の通りです。

  1. CLIフラグの処理(5〜12行目):--helpまたは--versionが渡された場合、情報を出力して即座に終了します。Electronを起動する必要はありません。
  2. @electron/remoteの初期化(16〜17行目):BrowserWindowが生成される前に必ず実行しなければなりません。これによりrendererからmain processのAPIを同期的に呼び出せるようになり、プラグインシステムで多用されます。
  3. 設定のセットアップ(21〜22行目):hyper.jsonを読み込み、chokidarによるファイル監視を開始し、非推奨となったCSSの有無を確認します。
sequenceDiagram
    participant OS as OS
    participant M as Main Process
    participant W as BrowserWindow
    participant R as Renderer

    OS->>M: Launch Electron
    M->>M: Check CLI flags (--help, --version)
    M->>M: Initialize @electron/remote
    M->>M: config.setup() — load + watch hyper.json
    M->>M: Extend app object (config, plugins, getWindows)
    M->>M: Wait for app.ready event
    M->>M: Install dev extensions (if dev mode)
    M->>W: createWindow() — BrowserWindow + RPC + sessions
    M->>W: loadURL(index.html)
    W->>R: Renderer starts
    M->>M: Set up menu, plugins.onApp, auto-updater
    M->>M: Register SSH protocol handler

app.readyイベントが発火すると、createWindow関数がインラインで定義され、即座に呼び出されて最初のウィンドウが生成されます。この関数はapp.createWindowにも紐付けられるため、プラグインから後から呼び出すことも可能です。ウィンドウの配置には連鎖的なオフセット処理が入っており、新しいウィンドウは最後にフォーカスされていたウィンドウから34pxずつずらして表示されます。画面外に出ないよう、境界チェックも行われます。

Renderer Processの起動シーケンス

lib/index.tsxのrendererエントリーポイントは、V8スナップショットのユーティリティを呼び出すところから始まり、すぐにReduxストアを作成してコアオブジェクトをグローバルに公開します。

lib/index.tsx#L1-L35

Object.definePropertyを使ってstorerpcconfigpluginsの4つのオブジェクトがwindowに登録されます。これはmain processのサービスロケーターパターンと対をなすrenderer側の仕組みです。renderer上で動作するプラグインはこれらのグローバル変数を通じてシステムと対話できます。

続いて、rendererは約30件のRPCイベント登録を行います。

lib/index.tsx#L72-L234

rpc.on(...)ハンドラーはReduxのアクションをdispatchします。'ready'イベント(72行目)はRPCチャネルが確立された時点で発火し、Reduxの初期ステートセットアップを開始します。'session data'イベント(81行目)は最もパフォーマンスに直結しており、データ文字列の先頭36文字からUUIDを取り出し、xterm.jsのレンダリングへとdispatchします。

最後にReactアプリがマウントされます。

root.render(
  <Provider store={store_}>
    <HyperContainer />
  </Provider>
);

ヒント: Hyperのデバッグには、グローバルのwindow.storeが最大の味方になります。起動中のHyperインスタンスでDevToolsを開き、store.getState()を実行すれば、セッション・termグループ・UI設定など、Reduxのステートツリー全体を手軽に確認できます。

次回予告

アーキテクチャ全体の地図が描けました。3つのプロセスの役割と相互の関係が整理されたところで、次はHyperの設計で最も面白い部分、プロセス間をつなぐ接着剤に目を向けましょう。型付きRPCシステムがElectronのIPC境界をまたいで、ターミナルデータ・セッションのライフサイクルイベント・コマンドをどのように運ぶのか。次の記事では、PTYの生成からデータのバッチ処理、xterm.jsへのレンダリングまで、ターミナルセッションの完全なライフサイクルを追跡します。