概要
現在、ターミナル機能を内蔵したElectronアプリを開発している。xterm.js + node-ptyでターミナルを埋め込む実装を進める中で「PTYとは何か」「IPCとは何か」「xterm.jsは何をしているのか」みたいな基本概念を調べたので、開発メモとしてまとめたもの。
出発点: package.jsonの依存関係
Electronアプリのpackage.jsonに以下のようなパッケージが入っているのを見て、「これらは何をしているのか」というところから始まった。
xterm— ブラウザ上でターミナルを描画するライブラリnode-pty— Node.jsからOSのPTYを操作するバインディング@xterm/addon-fit— xterm.jsのターミナルサイズをコンテナに合わせるアドオン
それぞれの役割を理解するには、まずPTYとIPCという概念を把握する必要がある。
PTY(Pseudo-Terminal)とは
PTYは「擬似端末」と訳される、OSカーネルが提供する仕組み。物理的なターミナルデバイスをソフトウェアでエミュレートするもの。
PTYにはmaster側とslave側がある。
- master側 — ターミナルを制御するプログラム側(node-ptyがここに位置する)
- slave側 — 本物のターミナルに見えるシェル側(zshなど)
シェルは自分がPTYの中にいることを知らない。ANSIエスケープコード、Ctrl+Cによるシグナル送信、パスワード入力の非表示など、物理端末で動作するものはすべてPTY経由でも動作する。
ここでNode.jsのchild_process.spawnとの違いが出てくる。spawnはraw pipeでI/Oをやり取りするだけなので、上記のようなターミナル機能は使えない。PTYを経由することで、シェルが「自分は本物のターミナルに接続されている」と認識する。
IPC(Inter-Process Communication)とは
IPCはプロセス間通信のこと。Electron固有の概念ではなく、OSレベルの一般的な仕組み。pipe、socket、shared memory、signalなどもIPCの一種。
Electronにおいては、main process(Node.js)とrenderer process(Chromium)がセキュリティのため分離されている。この2つのプロセスが通信するためにipcMain / ipcRendererというAPIが用意されている。ターミナルの実装では、このIPCがキー入力とシェル出力の橋渡しをする。
Electronアプリにおける3つのプロセス
Electronでターミナルを実装すると、3つのプロセスが関わる。
Electron App
├── Main Process (Node.js)
│ └── node-pty → spawns zsh (3rd process)
│
├── Renderer Process (Chromium/browser)
│ └── xterm.js (draws terminal on screen)
│
└── IPC bridge connects themデータの流れはこうなる。
キー入力 → xterm.js (renderer) → IPC → main process → node-pty → zsh
zsh出力 → node-pty → main process → IPC → xterm.js (renderer) → 画面描画シェル(zsh)はElectronとは別の第3のプロセスとして動いている。node-ptyがPTYのmaster側を持ち、zshがslave側に接続されるという構成。
Terminal / TTY / PTYの語源
これらの用語は歴史的な経緯で名前が付いている。
- Terminal — 元々は物理的なキーボード+スクリーンのデバイス。1970年代のVT100などが有名
- TTY — "teletypewriter"の略。Terminalよりさらに古い、テレタイプ端末が由来。Unixでは
/dev/ttyが現在のターミナルデバイスを指す - PTY — TTYの一種で、ソフトウェアで実装された疑似端末。物理デバイスを持たず、プログラム同士の接続に使われる
- xterm — 1984年のX Window System向けターミナルエミュレータが元ネタ。npmの
xtermパッケージ(xterm.js)はその名前を借用している
ネイティブターミナルアプリとの違い
iTerm2、Ghostty、Terminal.appのようなネイティブターミナルアプリと、Electron + xterm.jsの違いを整理する。
- iTerm2 — Objective-C/Swift製。Core Text + Metalでレンダリング。ブラウザエンジンは使っていない
- Ghostty — Zigで書かれた独自エンジン。Metal/OpenGLでレンダリング。VTパーサーもレンダラーもフルスクラッチ
- Terminal.app — Apple製。Objective-C/Swift + macOSネイティブフレームワーク
レンダリング層はアプリごとに全く違うが、PTY層は全員同じOSの仕組みを使っている。
[App-specific renderer] ← アプリごとに異なる
↕
[OS PTY API] ← 全アプリ共通のOS機構
↕
[zsh/bash] ← シェルプロセスつまりターミナルアプリの違いは「どうやって画面に描画するか」だけで、シェルとの通信方法は同じということになる。
xterm.jsの描画方式
xterm.jsはHTML + CSSでテキストを描画しているわけではない。Canvas要素にテキストを描画している。WebGLアドオンを使うとさらに高速化できる。
DOM要素で数千文字のターミナル出力をフォーマットすると遅すぎるため、Canvasに直接描画する方式が採用されている。CSSでスタイルできるのはコンテナ(サイズ、ボーダー等)のみで、ターミナル内部の色、フォント、カーソルなどはJS APIで設定する。
const term = new Terminal({
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace",
fontSize: 14,
theme: {
background: "#1e1e2e",
foreground: "#cdd6f4",
cursor: "#f5e0dc",
},
});「CSSで色を変えたい」と思ってもできない。themeオブジェクトで設定する必要がある。
フォント未インストールによる表示崩れ
xterm.jsでよく遭遇する問題として、指定フォントがインストールされていない場合の表示崩れがある。
上記の設定例だとJetBrains Mono、Fira Code、Cascadia Codeの順にフォールバックして、最終的にシステムのmonospaceに到達する。ここで、絵文字やPowerline記号がダブル幅で描画されるのに対し、xterm.jsがシングル幅と想定するためズレが発生する。

「Cl aude Code」のように文字間に隙間ができたり、ステータスバーの記号が位置ズレしたりする。フォントの幅計算とxterm.jsの文字幅想定が一致しないことが原因。指定したフォントが確実にインストールされている環境でないと、この手の問題が起きる。
まとめ
Electronアプリにターミナルを埋め込む構成を整理すると、以下の3層になる。
- renderer process(xterm.js)— 画面描画とキー入力の受け取り
- main process(node-pty)— PTYのmaster側としてシェルを生成・管理
- シェルプロセス(zsh等)— PTYのslave側として動作
PTY、IPC、プロセス分離といった低レイヤーの概念を理解しておくと、問題が起きたときに「これはレンダリングの問題なのか、PTYの問題なのか、IPCの問題なのか」を切り分けやすくなる。