XState駆動の開発サーバー:`gatsby develop` がリアクティビティを制御する仕組み
前提知識
- ›第1回:アーキテクチャとモノレポ概観
- ›第2回:ビルドパイプラインとブートストラップ
- ›XState の基礎知識(ステート、トランジション、サービス、子マシン)
- ›Node.js の子プロセスと IPC の理解
XState駆動の開発サーバー:gatsby develop がリアクティビティを制御する仕組み
ビルドパイプラインを工場の組み立てラインに例えるなら、開発サーバーは航空管制塔です。ファイルが変更され、Webhook が届き、GraphQL のミューテーションが発火します。そのすべてを、ときには同時に、ときには直前のリビルドがまだ走っている最中に処理しなければなりません。ステートマシンが真価を発揮するのは、まさにこういった場面です。
Gatsby の gatsby develop コマンドは、XState の階層型ステートマシンによって制御されています。オープンソースプロジェクトの本番コードにおける XState 活用例として非常に洗練されたものです。その設計を理解することで、「ファイルが変わったらパイプラインを再実行すればいい」という単純な発想では良い開発者体験を実現できない理由が見えてきます。
なぜステートマシンなのか
開発サーバーが処理しなければならないイベントを整理してみましょう。
- クエリ実行中にソースファイルが変更される
- スキーマのカスタマイズ中に Webhook が届く
- ページ再生成中に
createNodeミューテーションが発火する sourceNodes中にプラグインがエラーを投げるが、サーバーは動き続けなければならない- エディタの自動保存でファイル変更が連続して発生する
単純な実装、つまりイベントのたびにパイプラインを再起動する方式は、パフォーマンス面で壊滅的なうえ、無限ループのリスクも抱えています。ソースプラグインがファイルを生成し、それがリビルドを引き起こし、再びソースプラグインが走るといったケースです。
ステートマシンを採用することで、Gatsby は3つの強みを手に入れています。
- コンテキスト依存のイベント処理:同じイベント(例:
ADD_NODE_MUTATION)であっても、現在のステートに応じて異なる動作をとる - イベントのバッチ処理:クエリ実行中に発生した複数のファイル変更をまとめて処理する
- 無限ループの検出:リビルドの連鎖が暴走しないよう、上限を設けて防止する
親プロセスと子プロセスの分離
ステートマシンが動き出す前に、Gatsby はプロセス分離の境界を設けます。develop.ts のコマンドハンドラは親プロセスで動作し、実際の開発サーバーは子プロセスで動作します。
sequenceDiagram
participant Parent as develop.ts (Parent)
participant Child as develop-process.ts (Child)
Parent->>Parent: Detect port, resolve SSL
Parent->>Child: new ControllableScript(...)
Parent->>Child: start()
loop Every 1 second
Child->>Parent: { type: "HEARTBEAT" }
end
Child->>Parent: IPC messages (forwarded)
Parent->>Child: IPC messages (forwarded)
Note over Child: XState machine runs here
Child--xParent: Process crashes
Parent->>Parent: Detect missing heartbeat
ControllableScript クラス(develop.ts の49〜154行目)は、execa.node をライフサイクル管理でラップしたものです。子プロセスのブートストラップスクリプトを .cache/ 以下の一時ファイルに書き出し、IPC を有効にした状態(stdio: ['inherit', 'inherit', 'inherit', 'ipc'])で起動します。外部からは start、stop、send、onMessage、onExit といったメソッドで操作できます。
develop-process.ts の38〜45行目のハートビート機構は、実用的なシンプルさが光ります。
if (process.send) {
setInterval(() => {
process.send!({ type: `HEARTBEAT` })
}, 1000)
}
直前のコメントがその意図を端的に説明しています。「親プロセスが SIGKILL で終了しても、Node は生成した子プロセスを終了させない。」ハートビートは親が死ぬと ERR_IPC_CHANNEL_CLOSED でクラッシュし、孤立した子プロセスを道連れに終了させます。この動作が、意図しないゾンビプロセスを防ぐ仕組みになっています。
ポイント: プロセス分離はメモリの分離も兼ねています。ソースプラグインがメモリリークを起こしても、影響を受けるのは子プロセスだけです。親プロセスはクリーンな状態で子プロセスを再起動できます。
developMachine:ステートとトランジション
トップレベルのステートマシンは packages/gatsby/src/state-machines/develop/index.ts で定義されています。各ステートを順に追っていきましょう。
stateDiagram-v2
[*] --> initializing
initializing --> initializingData: DONE
initializingData --> runningPostBootstrap: DONE
runningPostBootstrap --> runningQueries
runningQueries --> startingDevServers: first run, no compiler
runningQueries --> recompiling: source files dirty
runningQueries --> recreatingPages: nodes mutated
runningQueries --> waiting: clean
startingDevServers --> waiting
startingDevServers --> initialGraphQLTypegen: typegen enabled
initialGraphQLTypegen --> waiting
recompiling --> waiting
waiting --> runningQueries: EXTRACT_QUERIES_NOW
waiting --> recreatingPages: mutations flushed
recreatingPages --> runningQueries: DONE
reloadingData --> runningQueries: DONE
state "Global Events" as ge
note right of ge
WEBHOOK_RECEIVED → reloadingData
ADD_NODE_MUTATION → batched
SOURCE_FILE_CHANGED → marked dirty
end note
グローバルイベントハンドラ
マシン設定のトップレベル(29〜57行目)では、三つのグローバルイベントハンドラが定義されています。
ADD_NODE_MUTATION:addNodeMutationアクションを通じてミューテーションをキューに積むSOURCE_FILE_CHANGED:markSourceFilesDirtyでソースファイルをダーティとしてマークするWEBHOOK_RECEIVED:即座にreloadingDataステートへ遷移する
これらのグローバルハンドラは、個別のステートによって上書きできます。たとえば initializing ステートでは、三つすべてを undefined に設定することでグローバルハンドラを無効化しています(62〜66行目)。
initializing: {
on: {
ADD_NODE_MUTATION: undefined,
SOURCE_FILE_CHANGED: undefined,
WEBHOOK_RECEIVED: undefined,
},
// ...
}
初期ブートストラップ中はどのみちパイプライン全体が走るので、ミューテーションを個別に処理する意味がありません。この設計は理に適っています。
waiting ステート
waiting ステート(266〜313行目)は、開発サーバーが準備完了の状態で待機するアイドルステートです。ここでは子マシン(waitForMutations)が呼び出され、受け取ったノードミューテーションをバッチ処理します。ミューテーションが一定量に達するか、ソースファイルが変更されると、子マシンが完了し、親マシンは recreatingPages へ遷移します。
267〜273行目の always ガードは、ファストパスとして機能します。waiting への遷移中にクエリのリクエストが積まれていた場合、待機をスキップして直接 runningQueries へ進みます。
waiting: {
always: [
{
target: `runningQueries`,
cond: ({ pendingQueryRuns }) =>
!!pendingQueryRuns && pendingQueryRuns.size > 0,
},
],
// ...
}
子マシン:データ層とクエリ実行
develop マシンは複雑なワークフローを子マシンに委譲し、XState のサービスとして呼び出します。主要な子マシンのファミリーは二つあります。
データ層マシン
データ層モジュール(packages/gatsby/src/state-machines/data-layer/index.ts)では、再利用可能なステートフラグメントを組み合わせて三つのマシンが定義されています。
| マシン | 使用タイミング | ステートの流れ |
|---|---|---|
initializeDataMachine |
初回起動 | customizingSchema → sourcingNodes → buildingSchema → creatingPages → writingOutRedirects → done |
reloadDataMachine |
Webhook 受信時 | customizingSchema → sourcingNodes → buildingSchema → creatingPages → done |
recreatePagesMachine |
sourceNodes 外でのノードミューテーション | buildingSchema → creatingPages → done |
この組み合わせ方は非常にエレガントです。loadDataStates、initialCreatePagesStates、recreatePagesStates、doneState といったフラグメントとして定義されたステートをミックスして組み立てます。
export const initializeDataMachine = createMachine({
initial: `customizingSchema`,
states: {
...loadDataStates,
...initialCreatePagesStates,
...doneState,
},
}, options)
この設計の恩恵として、recreatePagesMachine はコストの高い customizingSchema と sourcingNodes を丸ごとスキップします。sourceNodes の外側でノードミューテーションが発生した場合は、スキーマのリビルドとページ再生成だけを行えば十分であり、これは正しい動作です。
flowchart TD
subgraph "initializeDataMachine"
I1[customizingSchema] --> I2[sourcingNodes]
I2 --> I3[buildingSchema]
I3 --> I4[creatingPages]
I4 --> I5[writingOutRedirects]
I5 --> I6[done]
end
subgraph "recreatePagesMachine"
R1[buildingSchema] --> R2[creatingPages]
R2 --> R3[done]
end
クエリ実行マシン
クエリ実行マシン(packages/gatsby/src/state-machines/query-running/index.ts)は、クエリのライフサイクル全体を次の順で処理します。
extractingQueries→ コンポーネントファイルからクエリを抽出するwaitingPendingQueries→ 50ms の待機(後述)writingRequires→ async-requires ファイルを書き出すcalculatingDirtyQueries→ 前回の実行結果との差分を計算するrunningStaticQueries→useStaticQueryクエリを実行するrunningPageQueries→ ページクエリを実行するrunningSliceQueries→ スライスクエリを実行するwaitingForJobs→ 非同期ジョブ(画像処理など)の完了を待つdone
46〜54行目の waitingPendingQueries ステートは注目に値します。50ms の待機を PAGE_QUERY_ENQUEUING_TIMEOUT という定数で導入しています。抽出されたクエリは Redux middleware の setTimeout(x, 0) 経由でキューに積まれるため、抽出が「完了」した時点ではまだストアに反映されていません。35行目のコメントには「FIXME: this has to be fixed properly.」と正直に記されています。
イベント処理と無限ループ防止
develop マシンで最も洗練されているのが、runningQueries ステートの終了条件(166〜213行目)です。クエリ実行が完了すると、マシンは一連のガードを順番に評価します。
flowchart TD
A[Queries Done] --> B{Nodes mutated during queries?}
B -->|No| C{First run? No compiler?}
B -->|Yes| D{Recompile count >= 6?}
D -->|Yes| E["PANIC: Infinite loop detected"]
D -->|No| F["recreatingPages<br/>(increment count)"]
C -->|Yes| G[startingDevServers]
C -->|No| H{Source files dirty?}
H -->|Yes| I[recompiling]
H -->|No| J[waiting]
15行目で定義されている RECOMPILE_PANIC_LIMIT 定数は 6 に設定されています。
const RECOMPILE_PANIC_LIMIT = 6
クエリ実行中のノードミューテーションが連続して 6 回を超えると、マシンは panicBecauseOfInfiniteLoop アクションとともに waiting へ遷移します。クエリリゾルバがノードを生成し(これがリビルドを引き起こし、同じクエリが再度走る……)といったパターンを防ぐための安全弁です。
カウンタは、クエリ実行中にノードがミューテートされた際に incrementRecompileCount でインクリメントされ(183〜188行目)、waiting ステートへ入ると resetRecompileCount でリセットされます(274行目)。つまり、クエリ → waiting → クエリというサイクルを正常に完了すればカウンタはリセットされます。上限にカウントされるのは、あくまで連続した「クエリ実行中のミューテーション」だけです。
ポイント: プラグイン開発中に
RECOMPILE_PANIC_LIMITのエラーが出た場合、多くはonCreateNodeハンドラが自分自身を引き起こすようなノードの生成・変更をしていることが原因です。解決策は、ノードを生成する前にノードタイプを確認するガードを追加することです。
開発サーバーの起動
ステートマシンが初めて startingDevServers に到達すると、startWebpackServer サービスが呼び出されます。この関数(packages/gatsby/src/utils/start-server.ts)は以下のコンポーネントを組み合わせて開発サーバーを構築します。
- Express:HTTP サーバー
- webpack-dev-middleware:HMR 対応の JS バンドル配信
- webpack-hot-middleware:ブラウザへの更新プッシュ
- WebSocket:GraphQL クエリ結果のリアルタイム更新
- GraphiQL Explorer:
/__graphqlで利用できる GraphQL IDE - CORS middleware:クロスオリジンリクエストへの対応
第2回で解説した develop webpack ステージが生成するバンドルは、webpack-dev-middleware によって配信されます。ソースファイルが変更されると、webpack コンパイラが再コンパイルし、変更されたモジュールをホットリロードします。
startingDevServers を抜けるとき、三つのアクションが実行されます。assignServers(コンパイラとリスナーの参照をコンテキストに保存)、spawnWebpackListener(ファイル監視を設定)、markSourceFilesClean(ダーティフラグをリセット)です。
全体像を捉えなおす
Gatsby の開発サーバーにおける XState アーキテクチャは、リアクティブなシステム設計の教科書と言えます。開発ライフサイクルを明示的なステートとトランジションでモデル化することで、Gatsby は次の特性を実現しています。
- 正確性:イベントは決して「取りこぼされない」。現在のステートで即座に処理されるか、適切なタイミングまでキューで保持される
- 可観測性:すべてのトランジションが追跡可能(verbose モードでは
logTransitionsを通じてログ出力される) - 耐障害性:どのステートでエラーが起きても、クラッシュではなくエラーログとともに
waitingへ遷移する
次回は、これらのステートマシンを流れるデータ層を深掘りします。中央のステートストアとしての Redux、永続ノードデータベースとしての LMDB、そして生データをクエリ可能な API へと変換する GraphQL スキーマ構築パイプラインを取り上げます。