Read OSS

開発サーバーの仕組み:HTTPリクエストからモジュール変換まで

上級

前提知識

  • 第1回:アーキテクチャとコードベースのナビゲーション
  • 第2回:設定と環境システム
  • Node.js の connect ミドルウェアパターンの理解

開発サーバーの仕組み:HTTPリクエストからモジュール変換まで

Vite の開発サーバーは、「瞬時に起動する」という開発体験を支える中核です。アプリケーション全体を事前にバンドルするのではなく、ブラウザからのリクエストに応じてモジュールをオンデマンドで変換しながら配信します。しかし「オンデマンド」というのは決して「シンプル」を意味しません。その裏側には、厳密に順序付けられたミドルウェアスタック、鮮度チェック付きのリクエスト重複排除システム、そして Rolldown のフック実行モデルをエミュレートするプラグインコンテナが存在します。

この記事では、createServer() の初期化からミドルウェアチェーンを経て、TypeScript ソースをブラウザ実行可能な JavaScript へと変換するパイプラインまで、リクエストのライフサイクル全体を追っていきます。

サーバーの生成と初期化

すべての起点は server/index.ts_createServer です。この関数は設定を解決したうえで、サーバーの各コンポーネントを組み立てます。

sequenceDiagram
    participant User
    participant _createServer
    participant Config
    participant HTTP
    participant Environments
    participant Middlewares

    User->>_createServer: createServer(inlineConfig)
    _createServer->>Config: resolveConfig(inlineConfig, 'serve')
    _createServer->>HTTP: resolveHttpServer(middlewares, https)
    _createServer->>_createServer: createWebSocketServer(httpServer, config)
    _createServer->>_createServer: chokidar.watch(root, configDeps, envFiles)
    _createServer->>Environments: Create environments via dev.createEnvironment()
    _createServer->>Environments: Initialize each (pluginContainer, moduleGraph)
    _createServer->>Middlewares: Register 18 middleware layers
    _createServer-->>User: ViteDevServer

560〜579行目では、環境の生成と初期化が並列で実行されます。

await Promise.all(
  Object.entries(config.environments).map(
    async ([name, environmentOptions]) => {
      const environment = await environmentOptions.dev.createEnvironment(
        name, config, { ws }
      )
      environments[name] = environment
      await environment.init({ watcher, previousInstance })
    },
  ),
)

第2回で見たように、各環境はそれぞれ独自のモジュールグラフとプラグインコンテナを持つ DevEnvironment インスタンスとして生成されます。previousInstance パラメータはサーバーの再起動をシームレスにする仕組みで、古いインスタンスから状態を引き継ぐことができます。

環境の生成後、583行目では後方互換性のための ModuleGraph ラッパーが設定されます。これは旧 API を使用するプラグインのために、クライアントと SSR のモジュールグラフを統合したものです。

18層のミドルウェアスタック

920〜1031行目のミドルウェア登録は、このコードベースの中でも特に慎重に順序が設計されたセクションです。スタック全体を示します。

flowchart TD
    A["1. timeMiddleware (DEBUG only)"] --> B["2. rejectInvalidRequestMiddleware"]
    B --> C["3. rejectNoCorsRequestMiddleware"]
    C --> D["4. CORS middleware"]
    D --> E["5. Host validation middleware"]
    E --> F["6. Plugin configureServer hooks (pre)"]
    F --> G["7. cachedTransformMiddleware"]
    G --> H["8. Proxy middleware"]
    H --> I["9. Base path middleware"]
    I --> J["10. Open-in-editor (__open-in-editor)"]
    J --> K["11. HMR ping handler"]
    K --> L["12. Public file serving"]
    L --> M["13. transformMiddleware ★"]
    M --> N["14. Raw FS serving (/@fs/)"]
    N --> O["15. Static file serving"]
    O --> P["16. HTML fallback (SPA/MPA)"]
    P --> Q["17. Plugin configureServer hooks (post)"]
    Q --> R["18. Index HTML transform"]
    R --> S["19. 404 handler"]
    S --> T["20. Error handler"]

この順序には明確な意図があります。

  • セキュリティを最優先(第2〜5層):無効なリクエスト、CORS 違反、DNS リバインディング攻撃はいかなる処理よりも先に弾きます。
  • プラグインフックの分割(第6層と第17層)configureServer フックは関数を返すことで「post フック」として動作させることができます。そのため、内部ミドルウェアより前に実行される pre フックと、静的ファイル配信の後・HTML変換の前に実行される post フックに分割されています。
  • 変換前にキャッシュ確認(第7層)cachedTransformMiddleware はリクエストの etag を確認し、一致すれば即座に 304 を返してトランスフォームパイプライン全体をスキップします。
  • トランスフォームが主役(第13層):ここでモジュールの解決・ロード・変換がオンデマンドで行われます。
  • HTML変換は最後(第18層):script タグの注入や import map の解決が確実に行われるよう、HTML ファイルの処理は他のすべての後になっています。

ヒント: ミドルウェアの順序に関する問題をデバッグするときは、DEBUG=connect:dispatcher を設定すると、各リクエストをどのミドルウェアが処理しているかを確認できます。Vite はすべてのミドルウェアに名前を付けており(例:viteCachedTransformMiddlewareviteHMRPingMiddleware)、まさにこの目的のためです。

トランスフォームパイプライン:リクエストの重複排除とキャッシュ

リクエストがトランスフォームミドルウェアに到達し、キャッシュに存在しない場合、transformRequest が呼び出されます。この関数は鮮度チェック付きのリクエスト重複排除を実装しています。

sequenceDiagram
    participant Browser
    participant transformRequest
    participant _pendingRequests
    participant doTransform
    participant loadAndTransform

    Browser->>transformRequest: GET /src/App.tsx
    transformRequest->>transformRequest: Record timestamp
    transformRequest->>_pendingRequests: Check for pending request
    alt Pending request exists and is fresh
        _pendingRequests-->>transformRequest: Return existing promise
    else Pending request is stale
        transformRequest->>_pendingRequests: Abort stale, start new
    end
    transformRequest->>doTransform: Process URL
    doTransform->>doTransform: moduleGraph.getModuleByUrl(url)
    alt Cache hit (fresh transformResult)
        doTransform-->>transformRequest: Return cached result
    else Cache miss
        doTransform->>doTransform: pluginContainer.resolveId(url)
        doTransform->>loadAndTransform: Load and transform
        loadAndTransform->>loadAndTransform: pluginContainer.load(id)
        loadAndTransform->>loadAndTransform: pluginContainer.transform(code, id)
        loadAndTransform->>loadAndTransform: Store in moduleGraph
        loadAndTransform-->>transformRequest: TransformResult
    end

110〜127行目の重複排除は、微妙な競合状態に対処するための実装です。モジュールの処理中に HMR やオプティマイザーの更新によって無効化が発生すると、処理中のリクエストが古くなります。この関数はリクエストのタイムスタンプを module.lastInvalidationTimestamp と比較し、処理中の結果を再利用するか、破棄して再開始するかを判断します。

doTransform はキャッシュを2段階でチェックします。まず URL で確認し、次に解決済みの ID で確認します(異なる URL が同じファイルに解決されることがあるためです)。両方でキャッシュミスが発生した場合にのみ、loadAndTransform へと進みます。

loadAndTransform は Rollup スタイルのフックシーケンス(loadtransform)を実行します。ロードされたコードはファイルシステムアクセスポリシーに照らして検証された後、各プラグインの transform フックを順に通過します。

EnvironmentPluginContainer

プラグインコンテナは、Vite の中でも特に歴史的な経緯を持つコンポーネントです。pluginContainer.ts のファイル冒頭にはその出自が明記されています。

This file is refactored into TypeScript based on
https://github.com/preactjs/wmr/blob/main/packages/wmr/src/lib/rollup-plugin-container.js

WMR を起源とするこのプラグインコンテナは、開発モード中に Rolldown のプラグインフック実行をエミュレートします。開発モードでは実際にバンドルは行われないため、コンテナはプラグインが期待する resolveIdloadtransform というフックチェーンをシミュレートします。

flowchart TD
    REQ["transformRequest(url)"] --> RID["resolveId hooks<br/>(first non-null wins)"]
    RID --> LOAD["load hooks<br/>(first non-null wins)"]
    LOAD -->|"No plugin loaded"| FS["Read from file system"]
    LOAD -->|"Plugin returned code"| TRANSFORM
    FS --> TRANSFORM["transform hooks<br/>(sequential, each sees previous output)"]
    TRANSFORM --> RESULT["TransformResult<br/>{code, map, etag}"]

コンテナはプラグインごとに PluginContext を生成し、プラグインが必要とする this.resolve()this.emitFile()this.getModuleInfo() といったメソッドを提供します。モジュール情報は Proxy を介して遅延評価され、プラグインが実際に this.getModuleInfo() を呼び出したときにのみ計算されます。

モジュールグラフ:環境ごとの管理と後方互換性

EnvironmentModuleGraph は高速なルックアップのために4つのインデックスを管理しています。

urlToModuleMap: Map<string, EnvironmentModuleNode>    // URL → module
idToModuleMap: Map<string, EnvironmentModuleNode>     // resolved ID → module
etagToModuleMap: Map<string, EnvironmentModuleNode>   // etag → module
fileToModulesMap: Map<string, Set<EnvironmentModuleNode>>  // file → modules

EnvironmentModuleNode は、インポート関係(importersimportedModules)、HMR の状態(acceptedHmrDepsisSelfAccepting)、変換結果のキャッシュ、そして無効化のタイムスタンプを保持します。invalidationState フィールドは、タイムスタンプの更新のみで済むソフト無効化と、完全な再変換が必要なハード無効化を区別するために使われます。

後方互換性のために、mixedModuleGraph.tsModuleNode ラッパーが新旧の API を橋渡しします。このラッパーは _clientModule_ssrModule の両方への参照を保持し、_get ヘルパーによってクライアントモジュールの値を優先して返し、存在しない場合は SSR にフォールバックします。

_get<T extends keyof EnvironmentModuleNode>(
  prop: T,
): EnvironmentModuleNode[T] {
  return (this._clientModule?.[prop] ?? this._ssrModule?.[prop])!
}

この二重参照の設計により、旧来の server.moduleGraph(現在は非推奨)を使用するプラグインも、Vite のエコシステムが環境ごとのモジュールグラフへ移行する間、引き続き動作できるようになっています。

次回予告

ブラウザからのリクエストが18層のミドルウェアを通過し、トランスフォームパイプラインへと入り、プラグインフックを経てモジュールグラフに格納されるまでの流れを追ってきました。しかしまだ、プラグインの内部で何が起きているかには踏み込んでいません。次回は Vite のプラグインシステムを掘り下げます。Rolldown を拡張した Plugin インターフェース、2段階のソートシステム、フィルター最適化などを解説します。さらに、3500行以上に及ぶ CSS プラグインや bare import を書き換える import analysis プラグインなど、コアプラグインの詳細も見ていきましょう。