Read OSS

プロダクションビルド、Builder API、そして SSR のための Module Runner

上級

前提知識

  • 第 1〜4 記事:アーキテクチャ・設定・dev サーバー・HMR・モジュールグラフの完全な理解
  • Rollup/Rolldown のバンドル概念(input オプション・output オプション・チャンク・コード分割)
  • SSR の基本概念(サーバーサイドレンダリング・モジュール評価)

プロダクションビルド、Builder API、そして SSR のための Module Runner

Vite の dev サーバーはバンドルしていない ESM をそのまま配信しますが、プロダクション環境ではバンドル・ミニファイ・コード分割されたアウトプットが必要です。Vite 8 ではこの処理を Rolldown に委譲しており、さらに Environment API によって、vite build の1回の実行でクライアント・SSR・エッジワーカーといった複数ターゲット向けの出力を、協調したパイプラインで生成できるようになりました。本記事では、ビルドパイプラインの仕組み、SSR 向け Module Runner、そして dev と production の境界を曖昧にする実験的なフルバンドル dev モードについて解説します。

Builder API とマルチ環境オーケストレーション

vite build を実行すると、lines 343-382 の CLI ハンドラーが builder を生成して buildApp() を呼び出します。

const { createBuilder } = await import('./build')
const builder = await createBuilder(inlineConfig, null)
await builder.buildApp()

createBuilder() は設定を解決したあと、config.environments の各エントリに対して BuildEnvironment を用意します。

flowchart TD
    A["createBuilder(inlineConfig)"] --> B["resolveConfigToBuild()"]
    B --> C{"builder config provided?"}
    C -->|"yes (--app)"| D["Create BuildEnvironment per environment"]
    C -->|"no (legacy)"| E["Create single BuildEnvironment<br/>(client or ssr)"]
    D --> F["ViteBuilder { environments, build, buildApp }"]
    E --> F
    F --> G["builder.buildApp()"]
    G --> H["Run 'buildApp' plugin hooks (pre + normal)"]
    H --> I["config.builder.buildApp(builder)"]
    I --> J["Run 'buildApp' plugin hooks (post)"]
    J --> K{"Any environment built?"}
    K -->|yes| L["Done"]
    K -->|no| M["Fallback: build all environments sequentially"]

buildApp() メソッドのオーケストレーション構造は興味深い設計になっています。プラグインの buildApp フックが順に実行され、その中間にユーザーの config.builder.buildApp コールバックが挟まる形です。どのフックもビルドを行わなかった場合は、フォールバックとしてすべての環境が順次ビルドされます。

マルチ環境ビルドにおいて重要な設計上の考慮点が、プラグインインスタンスの管理です。デフォルトでは、各環境は独自に再解決された設定とプラグインインスタンスを持ちます(lines 1860-1914)。

if (!configBuilder.sharedConfigBuild) {
  environmentConfig = await resolveConfigToBuild(
    inlineConfig, patchConfig, patchPlugins,
  )
}

sharedPlugins オプションとプラグインごとの sharedDuringBuild フラグを使うと、プラグインインスタンスを複数環境で共有できます。patchPlugins 関数は、新たに生成されたプラグインインスタンスを最初の設定解決時の共有インスタンスに置き換えることで、この共有を実現しています。

BuildEnvironment と Rolldown の統合

BuildEnvironmentDevEnvironment と比べて意図的に軽量な設計です。モジュールグラフも、プラグインコンテナも、依存関係オプティマイザーも持ちません。

export class BuildEnvironment extends BaseEnvironment {
  mode = 'build' as const
  isBuilt = false

  constructor(name, config, setup?) {
    // merge environment options, call super
  }

  async init(): Promise<void> {
    if (this._initiated) return
    this._initiated = true
  }
}

実際のバンドル処理は buildEnvironment() が担い、内部で resolveRolldownOptions() を呼び出して Vite の設定を Rolldown の input/output 形式にマッピングします。

sequenceDiagram
    participant B as builder.build(env)
    participant BE as buildEnvironment()
    participant RO as resolveRolldownOptions()
    participant RD as Rolldown
    participant P as Plugins

    B->>BE: buildEnvironment(environment)
    BE->>RO: resolveRolldownOptions(environment)
    Note over RO: Map config.build → Rolldown input<br/>Resolve entry points<br/>Configure output format<br/>Inject environment into plugins
    RO-->>BE: RolldownOptions
    BE->>RD: rolldown(options)
    RD-->>BE: bundle
    BE->>RD: bundle.write() or bundle.generate()
    RD->>P: generateBundle / writeBundle hooks
    RD-->>BE: RolldownOutput

resolveRolldownOptions() はエントリーポイントを設定に基づいて決定します。クライアントビルドでは index.html、ライブラリモードでは明示的なエントリー、rollupOptions.input はオーバーライドとして機能します。また、すべてのプラグインフックで this.environment を参照できるよう、各プラグインを injectEnvironmentToHooks() でラップします。

ヒント: ViteBuilder インターフェースは個々の環境ビルド向けに build(environment) を公開しています。フレームワーク側は buildApp フックでビルド順を制御できます。たとえばクライアントを先にビルドしてアセットマニフェストを収集し、それを SSR ビルドに渡すといった使い方が可能です。

ビルド専用プラグイン

resolveBuildPlugins() はプロダクションビルド時のみ動作するプラグインを組み立てます。

flowchart LR
    subgraph "pre plugins"
        A1[prepareOutDir]
        A2[rollup-options-plugins]
        A3[webWorkerPost]
    end
    subgraph "post plugins"
        B1[importAnalysisBuild]
        B2[esbuild minifier]
        B3[terser minifier]
        B4[license extraction]
        B5[manifest generation]
        B6[ssrManifest]
        B7[buildReporter]
        B8[loadFallback - native]
    end

buildImportAnalysisPlugin は、第 3 記事で紹介した dev 専用の importAnalysisPlugin のビルド版です。dev プラグインがバンドルなしの ESM 配信のためにインポートを書き換えるのに対し、こちらはモジュールのプリロードを担います。具体的には <link rel="modulepreload"> ヒントと __vitePreload() ラッパーを注入し、動的インポートのウォーターフォールを防ぎます。

post プラグインの末尾にある nativeLoadFallbackPlugin() はネイティブ Rolldown プラグインで、どのプラグインも処理しなかったモジュールのファイルシステムからの読み込みを担うセーフティネットです。

ビルドモードにおける CSS 処理

CSS 処理は 3 つのプラグインによるパイプラインで行われ、dev とビルドとで挙動が異なります。

flowchart TD
    subgraph "cssPlugin (transform)"
        A1["Preprocessor: Sass/Less/Stylus"] --> A2["PostCSS transforms"]
        A2 --> A3["CSS Modules scoping"]
        A3 --> A4["URL rewriting"]
    end
    subgraph "cssPostPlugin (renderChunk)"
        B1["Extract CSS from JS chunks"]
        B1 --> B2["Generate .css asset files"]
        B2 --> B3["Minify with LightningCSS"]
    end
    subgraph "cssAnalysisPlugin (dev only)"
        C1["Track CSS dependencies"]
        C1 --> C2["HMR support for CSS"]
    end
    A4 --> B1

cssPlugin はトランスフォームフェーズを担当し、プリプロセッサ・PostCSS・CSS Modules のスコープ処理・URL の書き換えを行います。ビルド時には cssPostPlugin がチャンクの抽出を処理します。build.cssCodeSplittrue(デフォルト)の場合、各 JS チャンクに対応する CSS ファイルが生成されます。false の場合はすべての CSS が一つのファイルに結合されます。

Vite 8 での CSS ミニファイは、サーバー環境ではデフォルトで LightningCSS が使われ、クライアント環境では build.minify の設定に従います。build.cssMinify オプションで明示的に制御することも可能です。

Module Runner:SSR モジュールの実行

ModuleRunner は、開発中にサーバーサイドのモジュールを実行するための Vite の仕組みです。DevEnvironment からトランスフォーム済みのコードを取得し、評価してモジュールキャッシュを管理します。HMR にも対応しています。

sequenceDiagram
    participant App as SSR Application
    participant MR as ModuleRunner
    participant T as Transport
    participant DE as DevEnvironment
    participant PC as pluginContainer

    App->>MR: runner.import('/src/server.ts')
    MR->>T: fetchModule('/src/server.ts')
    T->>DE: environment.fetchModule(id)
    DE->>PC: transformRequest(url)
    PC-->>DE: TransformResult { code, map }
    DE-->>T: FetchResult { code }
    T-->>MR: FetchResult
    MR->>MR: ESModulesEvaluator.runExternalModule(code)
    Note over MR: new AsyncFunction(ssrImport, ssrExport, ...)(code)
    MR-->>App: module.exports

lines 53-80 のコンストラクタは、トランスポートを設定し、必要に応じて HMRClient を生成します。

constructor(
  public options: ModuleRunnerOptions,
  public evaluator: ModuleEvaluator = new ESModulesEvaluator(),
) {
  this.transport = normalizeModuleRunnerTransport(options.transport)
  if (options.hmr !== false) {
    this.hmrClient = new HMRClient(
      resolvedHmrLogger,
      this.transport,
      ({ acceptedPath }) => this.import(acceptedPath),
    )
    this.transport.connect(createHMRHandlerForRunner(this))
  }
}

ESModulesEvaluator はトランスフォーム済みのコードを、Vite の SSR モジュール形式で動作する関数に変換します。相互運用の仕組みとして __vite_ssr_import____vite_ssr_export____vite_ssr_import_meta__ が使われます。

サーバー側の fetchModule() は、runner からのリクエストを環境のトランスフォームパイプラインに橋渡しします。まず対象モジュールが Node.js の組み込みモジュールや外部 URL かどうかを確認し、該当する場合は externalize の結果を返します。それ以外の場合は environment.transformRequest(url) を呼び出してコードを返します。

Runnable 環境と Fetchable 環境

Vite は 2 種類の SSR 環境ファクトリーをあらかじめ用意しており、それぞれ異なる実行モデルを表しています。

RunnableDevEnvironment は Vite サーバーと同じ Node.js プロセス内でモジュールを実行します。プロセス内 HMR 通信には createServerHotChannel() を使うため、WebSocket は不要です。

export function createRunnableDevEnvironment(
  name, config, context = {},
): RunnableDevEnvironment {
  if (context.transport == null) {
    context.transport = createServerHotChannel()
  }
  return new RunnableDevEnvironment(name, config, context)
}

FetchableDevEnvironment はエッジワーカーや Cloudflare Workers のような、プロセス内でモジュールを実行できないリモートランタイムを対象とします。handleRequest 関数が必須で、Fetch API を介して通信します。

export function createFetchableDevEnvironment(
  name, config, context,
): FetchableDevEnvironment {
  if (!context.handleRequest) {
    throw new TypeError(
      'FetchableDevEnvironment requires a `handleRequest` method'
    )
  }
  return new FetchableDevEnvironment(name, config, context)
}
flowchart TD
    subgraph "In-process SSR"
        A[RunnableDevEnvironment] --> B[createServerHotChannel]
        B --> C[ModuleRunner<br/>same Node.js process]
    end
    subgraph "Remote SSR"
        D[FetchableDevEnvironment] --> E[Custom Transport]
        E --> F[ModuleRunner<br/>edge/worker runtime]
    end
    subgraph "Shared"
        G[DevEnvironment base class]
    end
    G --> A
    G --> D

ヒント: フレームワーク作者は、クラス名を直接チェックするのではなく、isRunnableDevEnvironment()isFetchableDevEnvironment() といった型ガードを使って実行時に環境の種別を判定しましょう。

実験的なフルバンドル dev モード

FullBundleDevEnvironment は、バンドルなし dev サーバーに代わる実験的な選択肢です。--experimentalBundle フラグまたは experimental.bundledDev: true で有効化すると、Rolldown の dev() API を使って開発中にアプリをバンドルし、メモリ上から配信します。

import { dev } from 'rolldown/experimental'

export class FullBundleDevEnvironment extends DevEnvironment {
  private devEngine!: DevEngine
  memoryFiles: MemoryFiles = new MemoryFiles()
  // ...
}

MemoryFiles クラスはバンドル済みの出力を Map<string, MemoryFile> で保持し、memoryFilesMiddleware がそれをブラウザに配信します。DevEngine は Rolldown 独自の変更検知を通じて HMR を提供し、Vite のモジュールグラフを完全に迂回します。

このモードは Vite の将来的な方向性を示しています。dev とプロダクションの両方で同じバンドラーを使えば、両者の差異は大きく縮まります。トレードオフを整理すると次のとおりです。

アンバンドル dev バンドル dev プロダクションビルド
起動速度 高速(バンドルなし) やや遅い(初回バンドルあり) 最も遅い(完全な最適化)
HMR モジュール単位の粒度 バンドル単位(Rolldown 経由) N/A
Dev/Prod の一致度 差異あり 高い一致度
ステータス 安定版 実験的 安定版

バンドル dev モードには現時点でいくつかの制限があります。handleHotUpdate/hotUpdate プラグインフックの完全なサポートがまだなく、モジュールグラフとの統合も途中段階です。しかし Rolldown が dev にも十分な速度を発揮できるなら、分かれていた dev とビルドのコードパスはいずれ一本化されるかもしれません。このモードはそうしたアーキテクチャの未来を示す実証例といえます。

シリーズのまとめ

全 6 記事を通じて、Vite 8 のアーキテクチャを CLI エントリーポイントから設定解決、dev サーバーのミドルウェアスタックとトランスフォームパイプライン、HMR と依存関係プリバンドル、そしてプロダクションビルドと SSR モジュール実行まで追いかけてきました。ここで得られるアーキテクチャ上の重要な知見をまとめます。

  1. Environment API が根幹の抽象化である。モジュールグラフ・プラグインコンテナ・依存関係オプティマイザー・ホットチャネルといったすべてのサブシステムが、環境ごとに独立して存在する
  2. PartialEnvironment における Proxy ベースの設定マージにより、環境固有の設定がプラグインに対して透過的に見える
  3. プラグインコンテナは dev 時に Rollup の実行モデルをエミュレートするため、同じプラグインを dev とビルドの両方でシームレスに動作する
  4. Rolldown の統一によって esbuild と Rollup の分断が解消され、依存関係の最適化とプロダクションバンドルで一貫した動作が保証される
  5. src/shared/ における共有 HMR プロトコルにより、ブラウザと SSR ランナーとで同一の HMR セマンティクスが実現されている

コードベースは複雑でありながらも、よく整理された構造を持っています。この 6 つの層を理解しておけば、どの部分のコードを読むときにも確かな語彙と地図を持って臨めるはずです。