Read OSS

`gatsby build` をCLIからHTMLまで追う: ブートストラップパイプライン

中級

前提知識

  • 第1回: アーキテクチャとモノレポの概要
  • webpackの基本概念(ローダー、プラグイン)
  • ビルドパイプラインの基礎知識

gatsby build をCLIからHTMLまで追う: ブートストラップパイプライン

モノレポ全体の構造を把握したところで、次は gatsby build の実行フローを最初から最後まで追ってみましょう。コマンドハンドラーが起動する瞬間から、最終的なHTMLファイルが public/ に書き出されるまでの全工程です。数十のファイルにまたがる長い旅路ですが、Gatsbyのすべての機能はこのパイプラインのいずれかのステージで表現されるため、全体像を把握しておくことが不可欠です。

ビルドコマンドのパイプライン

packages/gatsby/src/commands/build.ts のビルドコマンドハンドラーは、単一の非同期関数としてエクスポートされています。構造は線形で、各ステージが完了してから次のステージが始まります。

flowchart TD
    A["bootstrap()"] --> B["onPreBuild hook"]
    B --> C["writeOutRequires()"]
    C --> D["buildProductionBundle()"]
    D --> E["buildRenderer()"]
    E --> F["preparePageTemplateConfigs()"]
    F --> G["Build Rendering Engines"]
    G --> H["calculateDirtyQueries()"]
    H --> I["Run Queries"]
    I --> J["buildHTMLPagesAndDeleteStaleArtifacts()"]
    J --> K["onPostBuild hook"]
    K --> L["adapterManager.adapt()"]

パイプラインは大きく3つのフェーズに分けられます。

  1. Bootstrap(127〜131行目): プラグインの初期化、データのソース取得、スキーマ構築、ページ生成、クエリ抽出
  2. Bundle(153〜295行目): JSバンドルとHTMLレンダラーを生成する4つのwebpackコンパイル
  3. Render(302〜511行目): クエリの実行、HTML生成、アダプターによるデプロイ

それぞれのフェーズを詳しく見ていきましょう。

Initializeサービス: 設定、テーマ、プラグイン

127行目の bootstrap() 呼び出しは、第1回で紹介したブートストラップオーケストレーターへと処理を委譲し、そこからさらに initialize() が呼ばれます。これはコードベース全体の中で最も複雑な関数です。実行順序を整理すると次のようになります。

sequenceDiagram
    participant Build as build.ts
    participant Bootstrap as bootstrap/index.ts
    participant Init as services/initialize.ts
    participant Parcel as compileGatsbyFiles
    participant Config as load-config
    participant Themes as load-themes
    participant Plugins as load-plugins

    Build->>Bootstrap: bootstrap({ program })
    Bootstrap->>Init: initialize(context)
    Init->>Parcel: compileGatsbyFiles(siteDirectory)
    Note over Parcel: Compile gatsby-config.ts, gatsby-node.ts
    Init->>Config: loadConfig({ siteDirectory })
    Config->>Themes: loadThemes(config)
    Note over Themes: Recursive theme resolution
    Themes-->>Config: Merged config
    Config-->>Init: Validated config
    Init->>Plugins: loadPlugins(config, siteDirectory)
    Note over Plugins: Normalize → Validate → Flatten
    Init->>Init: startPluginRunner()
    Init-->>Bootstrap: { store, workerPool }

Parcelによるコンパイル

Gatsbyが設定ファイルを読み込む前に、まずTypeScriptとESMのファイルをコンパイルする必要があります。initialize.tsの170〜173行目では、compileGatsbyFiles がParcelのバンドラーを使って gatsby-config.tsgatsby-node.ts をNodeが require できるCommonJS形式に変換します。GatsbyのコンフィグをTypeScriptで書けるのは、他の処理が始まる前にParcelがトランスパイルを担ってくれるためです。

設定の読み込み

設定ローダー(packages/gatsby/src/bootstrap/load-config/index.ts)は次の3つの処理を行います。

  1. コンパイル済みの gatsby-config ファイルを読み込む
  2. handleFlags を通じてフィーチャーフラグを処理する
  3. loadThemes を通じてテーマ解決に設定を渡す

ルートの設定はオブジェクト形式でなければならず、関数形式にはできません。関数形式が許されているのはテーマの設定のみです(テーマオプションを引数として受け取ります)。この区別は28〜36行目でバリデーションされています。

テーマの解決

packages/gatsby/src/bootstrap/load-themes/index.ts のテーマ解決は再帰的に行われます。テーマとは、独自の gatsby-config を持つGatsbyプラグインに過ぎません。テーマは他のテーマに依存でき、各テーマの設定は親の設定とマージされます。解決アルゴリズムは依存ツリーを深さ優先で走査し、すべてのテーマ設定を収集した上で下から上へとマージしていきます。

プラグインの読み込み

プラグインローダー(packages/gatsby/src/bootstrap/load-plugins/index.ts)はマージ済みの設定を受け取り、次の処理を順番に実行します。

  1. 正規化: 文字列形式のプラグイン参照を { resolve, options } オブジェクトに変換する
  2. バリデーション: プラグインオプションをスキーマに対して検証する
  3. 内部プラグインの読み込み: Gatsbyに同梱されているプラグインを追加する
  4. フラット化: プラグインツリーを単一の配列に展開する
  5. 照合: どのプラグインがどのAPIを実装しているかを整理する
  6. エクスポートの検証: 既知のAPIリスト(api-node-docsapi-browser-docsapi-ssr-docs)に照らしてエクスポートを確認する

最終的な出力はフラットな配列で、SET_SITE_FLATTENED_PLUGINS(79〜82行目)としてReduxにディスパッチされます。この配列が、すべてのプラグインとそれぞれが実装するAPIの正規レジストリとなります。

APIランナー: コアとプラグインをつなぐ橋

sourceNodescreatePagesonCreateWebpackConfig などのプラグインフックを呼び出す必要があるとき、Gatsbyは必ず packages/gatsby/src/utils/api-runner-node.js のAPIランナーを経由します。

APIランナーはGatsbyのプラグインアーキテクチャの中枢神経とも言える存在です。各API呼び出しに対して、次の処理を行います。

  1. フラット化されたプラグインリストから、要求されたAPIを実装しているプラグインを検索する
  2. 各プラグインに渡す豊富なコンテキストオブジェクトを構築する。内容は以下のとおり:
    • バインドされたアクションクリエーター(createNodecreatePagecreateRedirect など)
    • データアクセス関数(getNodegetNodesgetNodesByType
    • ユーティリティ関数(createNodeIdcreateContentDigest
    • スキーマの型ビルダー(buildObjectTypebuildUnionType など)
    • プラグインにスコープされた cache インスタンス
    • 構造化ログのための reporter
  3. 各プラグインの実装を並列ではなく順番に呼び出す
sequenceDiagram
    participant Core as Gatsby Core
    participant Runner as api-runner-node
    participant PluginA as gatsby-source-filesystem
    participant PluginB as gatsby-transformer-remark
    participant Redux as Redux Store

    Core->>Runner: apiRunnerNode("sourceNodes", { parentSpan })
    Runner->>Runner: Look up plugins implementing "sourceNodes"
    Runner->>Runner: Construct context (actions, getNode, cache...)
    Runner->>PluginA: sourceNodes(context)
    PluginA->>Redux: createNode(fileNode)
    PluginA-->>Runner: done
    Runner->>PluginB: sourceNodes(context)
    PluginB-->>Runner: done
    Runner-->>Core: results

プラグインに渡されるアクションクリエーターは「二重バインド」されています。まずReduxに対して bindActionCreators でバインドされ、次に traceIdplugin.namedeferNodeMutation フラグなどのメタデータを注入するラッパーで特定のプラグインとAPI呼び出しにバインドされます(87〜100行目)。これにより、プラグインがディスパッチするすべてのアクションに発信元の情報が付与されます。

ヒント: プラグインを順番に実行するのは意図的な設計です。決定論的な動作を保証するためで、プラグインAの sourceNodes は常にプラグインBより先に実行されます。あるプラグインが他のプラグインの作成したノードに依存している場合、この順序が重要になります。

4つのwebpackステージ

Gatsbyはそれぞれ異なる目的を持つ4つのwebpack設定を使用しています。packages/gatsby/src/utils/webpack.config.js のファクトリー関数は stage パラメーターを受け取り、それに対応した設定を生成します。

flowchart TD
    subgraph "Development"
        A["develop"] -->|"Hot reload, CSS injection"| A1["Browser bundle"]
        B["develop-html"] -->|"No HMR, SSR target"| B1["HTML renderer"]
    end

    subgraph "Production"
        C["build-javascript"] -->|"Minified, chunked"| C1["Browser JS/CSS"]
        D["build-html"] -->|"Node target, SSR"| D1["HTML renderer"]
    end

    style A fill:#e8f5e9
    style B fill:#e8f5e9
    style C fill:#e1f5fe
    style D fill:#e1f5fe
ステージ ターゲット 用途
develop ブラウザ React Fast RefreshとCSSホットリロードを備えた開発サーバー
develop-html Node 開発モード用SSRレンダラー(HMRプラグインなし)
build-javascript ブラウザ コード分割を含む本番用JSおよびCSSバンドル
build-html Node 静的生成用の本番HTMLレンダラー

40〜44行目 のコメントは、この構成を簡潔に説明しています。

// Four stages or modes:
//   1) develop: for `gatsby develop` command, hot reload and CSS injection into page
//   2) develop-html: same as develop without react-hmre in the babel config for html renderer
//   3) build-javascript: Build JS and CSS chunks for production
//   4) build-html: build all HTML files

プラグインは onCreateWebpackConfig フックを使ってこれらの設定を変更できます。このフックには stage パラメーターが渡されるため、ステージごとに異なる修正を適用することが可能です。

ビルドコマンドでは本番用ステージのみが使用されます。160行目buildProductionBundlebuild-javascript ステージを実行し、続いて184行目buildRendererbuild-html ステージを実行します。

クエリの実行とHTML生成

webpackのバンドルが完成すると、Gatsbyはクエリ実行フェーズに入ります。302行目calculateDirtyQueries が、どのクエリを実行する必要があるかを判断します(インクリメンタルビルドでは変更されたクエリのみが実行されます)。

306〜308行目では重要な最適化が行われています。

queryIds.pageQueryIds = queryIds.pageQueryIds.filter(
  query => getPageMode(query) === `SSG`
)

ビルド時にクエリが実行されるのはSSGページのみです。DSGページはクエリの実行を最初のリクエスト時まで先送りし、SSRページはリクエストのたびにクエリを実行します。これがレンダリングモードシステムの仕組みです。詳細は第5回で解説します。

マルチコアマシンでは、クエリはワーカープールで並列実行されます(330行目)。クエリが完了した後、507行目buildHTMLPagesAndDeleteStaleArtifacts が最終的なHTMLファイルを生成します。

キャッシュ管理とビルドの永続化

ビルド全体を通じて、状態はLMDBを使って .cache/ ディレクトリに永続化されます。packages/gatsby/src/redux/index.ts のReduxストアでは、永続化する状態スライスが明示的に定義されています。

const persistedReduxKeys = [
  `nodes`, `typeOwners`, `statefulSourcePlugins`, `status`,
  `components`, `jobsV2`, `staticQueryComponents`,
  `webpackCompilationHash`, `pageDataStats`, `pages`,
  `staticQueriesByTemplate`, `pendingPageDataWrites`,
  `queries`, `html`, `slices`, `slicesByTemplate`,
]

次のビルド時には、ストア作成時に readState() を通じてこれらのスライスがLMDBから読み戻されます。どのクエリが「ダーティ」でどのHTMLファイルが「古い」かをGatsbyが判断できるのは、現在の状態と永続化された状態を比較しているからです。

flowchart LR
    subgraph ".cache/ Directory"
        A["data/datastore (LMDB)"] -->|Nodes| B["Redux State"]
        C["caches-lmdb/"] -->|Plugin caches| D["Per-plugin data"]
        E["redux.state (deprecated)"] -.->|Legacy| B
    end

    subgraph "Build"
        F["Previous Build State"] --> G["Calculate Dirty Queries"]
        G --> H["Only rebuild changed pages"]
    end

    B --> F

133〜146行目saveState() 関数は、永続化対象のキーを使って writeToCache を呼び出します。GATSBY_DISABLE_CACHE_PERSISTENCE というエスケープハッチが用意されている点も注目です。これは、サイト規模が非常に大きい場合にNode.jsの v8.serialize のバッファサイズ制限を回避するために追加されました。

アダプターとの統合

ビルドパイプラインの最終ステージはアダプターによるデプロイです。645〜648行目では次の処理が行われます。

if (adapterManager) {
  await adapterManager.storeCache()
  await adapterManager.adapt()
}

アダプターマネージャー(initialize.tsの189〜192行目で初期化時に生成)は、ビルド出力から RoutesManifestFunctionsManifest を収集し、プラットフォームアダプターに渡します。アダプターはこれをデプロイ先プラットフォームのネイティブフォーマットに変換します。アダプターの詳細は第5回で取り上げます。

ヒント: ビルドが途中で失敗した場合、.cache/ ディレクトリに中途半端な状態が残ることがあります。gatsby clean を実行すればディレクトリが完全に削除され、フルビルドが強制されます。キャッシュ関連の問題をデバッグする際の最終手段です。

次回予告

ここまで、ビルドパイプラインを一本のコンベアベルトとして追ってきました。しかし gatsby develop はさらに難しい課題に直面しています。リアルタイムで変更に反応しながら一貫性を保ち続けなければならないのです。次回は、開発サーバーを制御するXStateステートマシンに踏み込みます。これはコードベース全体の中でも最も際立ったアーキテクチャ上の特徴です。