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つの独立した実行単位から成り立っており、それぞれに専用のエントリーポイントとビルドターゲットがあります。
- Main Process(
app/)— ウィンドウ管理・PTYセッション・メニュー・設定ファイルの監視・プラグインのインストール・自動アップデートを担うElectronのmain process - Renderer Process(
lib/)— React/Reduxを動かし、xterm.jsターミナルのレンダリング・キーボードショートカット・分割ペインUIを管理するElectronのrenderer process - 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オブジェクトは実行時にconfig・plugins・createWindow・getLastFocusedWindowといったプロパティで拡張されます。このパターンはメインエントリーポイントの冒頭から確認できます。
こうして拡張された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設定です。何もコンパイルしません。すべての.ts・.jsファイルはnull-loaderに通され、ソースコードがすべて破棄されます。出力ファイルの名前は文字通りignore_this.jsです。
この設定の存在意義はただひとつ——CopyWebpackPluginを動かすことです。HTMLファイル・JSON設定・キーマップ・静的アセット・パッチをtarget/ディレクトリにコピーするためだけにwebpackが使われています。
シェルスクリプトではダメなのか、と思うかもしれません。理由は、この設定が開発中に使われるwebpack -wのウォッチモードと統合されているからです。HTMLテンプレートやキーマップのJSONを編集すると、自動的にターゲットディレクトリに反映されます。
設定2:hyper — Rendererのバンドル
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ツール
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.jsonのpostinstallスクリプトがこの仕組みを統括しています。
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エントリーポイントは、インポートが実行されるよりも前に、最優先で処理すべき事項を冒頭の数行で片付けます。
起動順序は以下の通りです。
- CLIフラグの処理(5〜12行目):
--helpまたは--versionが渡された場合、情報を出力して即座に終了します。Electronを起動する必要はありません。 @electron/remoteの初期化(16〜17行目):BrowserWindowが生成される前に必ず実行しなければなりません。これによりrendererからmain processのAPIを同期的に呼び出せるようになり、プラグインシステムで多用されます。- 設定のセットアップ(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ストアを作成してコアオブジェクトをグローバルに公開します。
Object.definePropertyを使ってstore・rpc・config・pluginsの4つのオブジェクトがwindowに登録されます。これはmain processのサービスロケーターパターンと対をなすrenderer側の仕組みです。renderer上で動作するプラグインはこれらのグローバル変数を通じてシステムと対話できます。
続いて、rendererは約30件のRPCイベント登録を行います。
各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へのレンダリングまで、ターミナルセッションの完全なライフサイクルを追跡します。