Read OSS

Gatsby のアーキテクチャ:105 パッケージのモノレポを読み解く

中級

前提知識

  • React の基礎知識
  • npm/yarn によるパッケージ管理の基本的な理解
  • モノレポの概念についての理解

Gatsby のアーキテクチャ:105 パッケージのモノレポを読み解く

Gatsby は、これまでに作られた最も野心的なオープンソース JavaScript プロジェクトの1つです。おなじみの gatsby buildgatsby develop コマンドの裏側には、約 105 のパッケージを含むモノレポが存在します。source plugin、transformer plugin、GraphQL データレイヤー、XState で駆動される開発サーバー、そして deployment adapter の抽象化まで、多岐にわたる要素で構成されています。これらのピースがどのように組み合わさっているかを理解することが、このフレームワークにコントリビュートしたり、深く活用したりするための鍵となります。

この記事は全 5 回のシリーズの第 1 回です。リポジトリ構造を俯瞰したうえで、CLI コマンドがグローバルインストールからプロジェクトローカルの実行へとどのように辿り着くかを追います。そして、その後のすべてを形作る build パイプラインと develop サーバーの根本的なアーキテクチャの違いを紹介します。

モノレポ構造:Lerna + Yarn Workspaces

Gatsby は Lerna と Yarn Workspaces を組み合わせてパッケージを管理しています。ルートの設定はシンプルですが、レイアウトの全体像を把握するには十分です。

lerna.json

{
  "packages": ["packages/*"],
  "npmClient": "yarn",
  "useWorkspaces": true,
  "version": "independent"
}

"version": "independent" という設定は重要な意味を持ちます。各パッケージが独立してバージョン管理・公開されるため、コアフレームワークの変更がエコシステム内のすべてのプラグインにバージョンアップを強いることがありません。Yarn Workspaces(ルートの package.json で設定)はシンボリックリンクを管理し、パッケージが npm に公開する前でも互いに require し合えるようにしています。

graph TD
    subgraph "Core"
        gatsby["gatsby"]
        cli["gatsby-cli"]
        coreutils["gatsby-core-utils"]
        pluginutils["gatsby-plugin-utils"]
    end

    subgraph "Source Plugins"
        fs["gatsby-source-filesystem"]
        contentful["gatsby-source-contentful"]
        drupal["gatsby-source-drupal"]
        wordpress["gatsby-source-wordpress"]
    end

    subgraph "Transformer Plugins"
        remark["gatsby-transformer-remark"]
        sharp["gatsby-transformer-sharp"]
        yaml["gatsby-transformer-yaml"]
    end

    subgraph "Feature Plugins"
        image["gatsby-plugin-image"]
        mdx["gatsby-plugin-mdx"]
        feed["gatsby-plugin-feed"]
        offline["gatsby-plugin-offline"]
    end

    subgraph "Adapters"
        netlify["gatsby-adapter-netlify"]
    end

    cli --> gatsby
    gatsby --> coreutils
    gatsby --> pluginutils
    fs --> coreutils
    remark --> fs

パッケージは自然と 3 つの階層に分類されます。

Tier 役割
Core gatsby, gatsby-cli, gatsby-core-utils, gatsby-plugin-utils, gatsby-page-utils フレームワークランタイム、CLI、共有ユーティリティ
Ecosystem Plugins gatsby-source-filesystem, gatsby-transformer-remark, gatsby-plugin-image データソーシング、変換、および機能追加
Adapters & Utilities gatsby-adapter-netlify, gatsby-graphiql-explorer, create-gatsby デプロイアダプター、ツール、スキャフォールディング

Tip: 「機能 X はどこにあるのか?」を調べるときは、命名規則を手がかりにしましょう。gatsby-source-* はデータのソーシング、gatsby-transformer-* はノードの変換、gatsby-plugin-* はビルド・ランタイム機能の追加、gatsby-adapter-* はデプロイプラットフォーム固有の処理を担います。

CLI の委譲パターン

Gatsby のアーキテクチャで特に洗練されたパターンの一つが、2 段階の CLI 解決です。実際には 2 つ の CLI が存在します。グローバルにインストールされた gatsby-cli パッケージと、プロジェクトローカルの gatsby パッケージです。gatsby build を実行しても、グローバルの CLI がビルドロジックを直接実行するわけではなく、プロジェクトローカルのインストールに処理を委譲します。

フェーズ 1:グローバルエントリーポイント

グローバルエントリーポイントは packages/gatsby-cli/src/index.ts にあります。その責務は限定的ですが、非常に重要です。

  1. Node.js バージョンの検証(22〜38 行目):ランタイムが最小バージョン要件を満たしているか確認する
  2. グローバルエラーハンドラーの設定unhandledRejectionuncaughtException のハンドラーを登録する
  3. CLI の初期化:76 行目で createCli(process.argv) を呼び出す
sequenceDiagram
    participant User
    participant GlobalCLI as gatsby-cli (global)
    participant Yargs
    participant LocalGatsby as gatsby (local)

    User->>GlobalCLI: gatsby build
    GlobalCLI->>GlobalCLI: Check Node.js version
    GlobalCLI->>GlobalCLI: Set up error handlers
    GlobalCLI->>Yargs: createCli(process.argv)
    Yargs->>GlobalCLI: resolveLocalCommand("build")
    GlobalCLI->>LocalGatsby: resolveCwd.silent("gatsby/dist/commands/build")
    LocalGatsby-->>GlobalCLI: build command handler
    GlobalCLI->>LocalGatsby: Execute build(args)

フェーズ 2:ローカルコマンドの解決

処理の核心は packages/gatsby-cli/src/create-cli.ts にあります。resolveLocalCommand 関数は resolveCwd.silent() を使って、プロジェクトローカルの gatsby インストールを探し出します。

const cmdPath =
  resolveCwd.silent(`gatsby/dist/commands/${command}`) ||
  // Old location of commands
  resolveCwd.silent(`gatsby/dist/utils/${command}`)

このパターンによって、グローバルにインストールされた gatsby-cli@5.x が、node_modulesgatsby@4.x を使用するプロジェクトでも正しく動作できます。グローバル CLI は薄いルーターに過ぎず、実際のコマンドロジックはプロジェクトが依存する gatsby のバージョンから提供されます。

プロジェクトローカルの gatsby パッケージにも独自の bin エントリー(packages/gatsby/cli.js)があります。これは ./dist/bin/gatsby.js を require するだけの 3 行のファイルで、npx gatsby build を直接実行した際のフォールバックパスとして機能します。

Tip: CLI 関連の問題をデバッグするときは、resolveLocalCommand がどの gatsby バージョンを解決しているかを必ず確認しましょう。グローバル CLI の期待とローカルパッケージのエクスポートの不一致は、原因不明のエラーを引き起こすよくある要因です。

Build と Develop:2 つのアーキテクチャ

Gatsby の設計が真に興味深いのはここからです。gatsby buildgatsby develop は出力結果が異なるだけでなく、根本的に異なるアーキテクチャパターンを採用しています。

gatsby build:逐次的な命令型パイプライン

packages/gatsby/src/commands/build.ts のビルドコマンドは、一連のステップを順番に実行するシンプルな async 関数です。

flowchart LR
    A[bootstrap] --> B[writeOutRequires]
    B --> C[Build JS Bundle]
    C --> D[Build HTML Renderer]
    D --> E[Run Queries]
    E --> F[Generate HTML]
    F --> G[onPostBuild]
    G --> H[Adapter Deploy]

各ステップは次が始まる前に完了します。並行処理も、イベントハンドリングも、state machine もありません。設定の読み込み → データのソーシング → スキーマのビルド → ページの生成 → JS のバンドル → HTML のレンダリング → デプロイという、古典的なビルドパイプラインです。

gatsby develop:XState によるリアクティブな State Machine

develop コマンドはこれとは対照的です。本質的にリアクティブな問題を扱う必要があるからです。ファイルが変更され、webhook が届き、GraphQL のミューテーションが発火する。開発サーバーはこれらすべてのイベントに応答しなければなりません。同時に発生することもあれば、別のリビルドが進行中に発生することもあります。

packages/gatsby/src/commands/develop.tsControllableScript の子プロセスを起動します。その子プロセス(packages/gatsby/src/commands/develop-process.ts)の中で XState の state machine が生成・解釈されます。

const machine = developMachine.withContext({
  program,
  parentSpan,
  app,
  reporter,
  pendingQueryRuns: new Set([`/`]),
  shouldRunInitialTypegen: true,
})

const service = interpret(machine)
service.start()

これは見た目上の選択ではなく、アーキテクチャ上の必然です。state machine の詳細は第 3 回で掘り下げます。

flowchart TD
    subgraph "Parent Process (develop.ts)"
        A[ControllableScript] -->|IPC| B[Child Process]
        A -->|heartbeat| C[Crash Detection]
    end

    subgraph "Child Process (develop-process.ts)"
        B --> D[XState developMachine]
        D --> E[initializing]
        E --> F[initializingData]
        F --> G[runningQueries]
        G --> H[startingDevServers]
        H --> I[waiting]
        I -->|file change| J[recompiling]
        I -->|node mutation| K[recreatingPages]
        J --> I
        K --> G
    end

Gatsby の中核:packages/gatsby/src/ のレイアウト

packages/gatsby/src/ ディレクトリは、フレームワークのコアロジックが集まる場所です。規模は大きいですが、内部構造は明確なパターンに従っています。

ディレクトリ 役割
bootstrap/ 初期化パイプライン:設定の読み込み、プラグインの読み込み、テーマの解決
commands/ CLI コマンドハンドラー:build.tsdevelop.tsserve.tsclean.ts
services/ スタンドアロン関数としての個別ビルドステージ:initializesourceNodesbuildSchema
state-machines/ develop 用の XState machine:develop/data-layer/query-running/waiting/
redux/ Redux store、reducer、action creator、型定義、永続化
schema/ GraphQL スキーマの構築、推論、拡張、resolver
query/ クエリの抽出、コンパイル、バリデーション、実行
datastore/ LMDB をバックエンドとする永続的なノードストレージ
internal-plugins/ Gatsby に同梱され、独自の plugin API を使用するプラグイン
utils/ 豊富なユーティリティ群:webpack 設定、API ランナー、ページデータ、アダプターなど

Bootstrap のオーケストレーター

build と develop の両方で共有される初期化パイプライン、bootstrap シーケンスは packages/gatsby/src/bootstrap/index.ts に定義されています。

const context = {
  ...bootstrapContext,
  ...(await initialize(bootstrapContext)),
}
await customizeSchema(context)
await sourceNodes(context)
await buildSchema(context)
// ... createPages, extractQueries, etc.

../services からインポートされる各関数は、個別のビルドステージを表しています。このサービス関数パターンこそが、各ステージを再利用可能にしている要因です。同じ sourceNodes サービスが、build パイプラインと develop の state machine の両方で使われています。

flowchart TD
    A[initialize] --> B[customizeSchema]
    B --> C[sourceNodes]
    C --> D[buildSchema]
    D --> E[createPages]
    E --> F[extractQueries]
    F --> G[writeOutRedirects]
    G --> H[postBootstrap]

    style A fill:#e1f5fe
    style C fill:#e8f5e9
    style D fill:#e8f5e9
    style E fill:#fff3e0
    style F fill:#fff3e0

次回予告

次回は gatsby build コマンドを最初から最後まで追いかけます。initialize サービス(Parcel ベースのコンパイル、設定の読み込み、テーマの解決)を通じてデータがどのように流れ、コアとプラグインを繋ぐ API ランナーブリッジを経由し、4 つの異なる webpack ステージを渡り、最終的な HTML ファイルへと至るまでを追います。また、.cache/ ディレクトリがインクリメンタルビルドをどのように実現しているかにも踏み込みます。