Read OSS

DevEnv コントローラーパターン:`wrangler dev` がローカル開発環境をどう制御するか

上級

前提知識

  • 本シリーズの第 1・2 回
  • Node.js の EventEmitter パターン
  • Observer / Pub-Sub パターン
  • esbuild と Miniflare の基本的な知識

DevEnv コントローラーパターン:wrangler dev がローカル開発環境をどう制御するか

wrangler dev を実行すると、表面上はシンプルに見えます。ローカルサーバーが起動し、ファイルを変更すれば自動でリロードされ、エラーはブラウザに表示されます。しかし内部では、SDK 全体の中でもっともアーキテクチャの洗練されたサブシステムが動いています。5 つの独立したコントローラーを協調させ、開発ライフサイクルの各フェーズをそれぞれが担うイベント駆動型のコントローラーバスです。

このような設計になったのは必然ではありません。ローカルとリモートの両開発モードを同一コードベースで扱い、マルチワーカー構成に対応し、Hot Module Replacement をサポートする必要がありました。さらに esbuild のインクリメンタルビルドを管理し、プロキシ層をランタイム層から切り離す要件も加わります。これらが積み重なった結果、Node.js の EventEmitter を基盤としたミニチュアなアクターシステムが実現されました。

DevEnv:コントローラーバスとしての EventEmitter

packages/wrangler/src/api/startDevWorker/DevEnv.ts#L22-L70 にある DevEnv クラスは EventEmitter を継承し、ControllerBus インターフェースを実装しています。4 つのコントローラースロットを持ちます。

export class DevEnv extends EventEmitter implements ControllerBus {
    config: ConfigController;
    bundler: BundlerController;
    runtimes: RuntimeController[];
    proxy: ProxyController;

runtimes が配列になっている点に注目してください。これは意図的な設計で、各ワーカーが独自のランタイムコントローラーを持てるマルチワーカーのローカル開発をサポートするためです。デフォルトのファクトリーは LocalRuntimeControllerRemoteRuntimeController の 2 エントリを生成します。イベントはすべてのランタイムコントローラーにブロードキャストされますが、ローカルモードかリモートモードかによって、実際に動作するのは通常どちらか一方だけです。

classDiagram
    class DevEnv {
        +config: ConfigController
        +bundler: BundlerController
        +runtimes: RuntimeController[]
        +proxy: ProxyController
        +dispatch(event): void
        +startWorker(options): Worker
        +teardown(): Promise
    }
    class ControllerBus {
        <<interface>>
        +dispatch(event): void
    }
    DevEnv --|> EventEmitter
    DevEnv ..|> ControllerBus
    DevEnv --> ConfigController
    DevEnv --> BundlerController
    DevEnv --> RuntimeController
    DevEnv --> ProxyController

ファクトリーインジェクション:テスタビリティとコントローラーの差し替え

DevEnv のコンストラクターはコントローラーを直接インスタンス化しません。代わりに、packages/wrangler/src/api/startDevWorker/DevEnv.ts#L45-L58 でファクトリー関数を受け取ります。

constructor({
    configFactory = (devEnv) => new ConfigController(devEnv),
    bundlerFactory = (devEnv) => new BundlerController(devEnv),
    runtimeFactories = [
        (devEnv) => new LocalRuntimeController(devEnv),
        (devEnv) => new RemoteRuntimeController(devEnv),
    ],
    proxyFactory = (devEnv) => new ProxyController(devEnv),
}: { /* ... typed factory signatures ... */ } = {}) {

これはコンストラクターレベルの依存性注入です。ユニットテストでは実際のプロセスを起動しないスタブコントローラーを注入できます。マルチワーカーモード向けの MultiworkerRuntimeController はデフォルトのランタイムファクトリーを置き換えます。プロキシ層が不要な場合は NoOpProxyController を注入することもできます。

各ファクトリーは DevEnv インスタンス自体を引数として受け取るため、コントローラーはバスへのアクセス権を持ちます。この循環参照は意図的なものです——コントローラーがバスに対してイベントを返せる必要があるためです。

ヒント: DevEnv を使う wrangler コマンドのテストを書くなら、DevEnv 全体をモックするのではなく、モックコントローラーのファクトリーを注入しましょう。ファクトリーパターンはまさにそのために設計されています。

dispatch() によるイベントルーティングテーブル

DevEnv の核心は packages/wrangler/src/api/startDevWorker/DevEnv.ts#L86-L135 にある dispatch() メソッドです。シンプルな switch 文で、イベントを適切なコントローラーにルーティングします。

sequenceDiagram
    participant CC as ConfigController
    participant BC as BundlerController
    participant RC as RuntimeController(s)
    participant PC as ProxyController
    participant DE as DevEnv (dispatch)

    CC->>DE: configUpdate
    DE->>BC: onConfigUpdate
    DE->>PC: onConfigUpdate

    BC->>DE: bundleStart
    DE->>PC: onBundleStart
    DE->>RC: onBundleStart

    BC->>DE: bundleComplete
    DE->>RC: onBundleComplete

    RC->>DE: reloadStart
    DE->>PC: onReloadStart

    RC->>DE: reloadComplete
    DE->>PC: onReloadComplete

    RC->>DE: devRegistryUpdate
    DE->>CC: onDevRegistryUpdate

    PC->>DE: previewTokenExpired
    DE->>RC: onPreviewTokenExpired

ルーティングは明示的で、動的なイベントサブスクリプションや Observer パターンの複雑さはありません。dispatch() を読むだけで、どのイベントがどのコントローラーに影響するかがすぐにわかります。default ケースでは TypeScript の網羅性チェック(const _exhaustive: never = event)を利用しており、未処理のイベント型があればコンパイラが検出してくれます。

イベント型自体は packages/wrangler/src/api/startDevWorker/events.ts でディスクリミネーテッドユニオンとして定義されています。各イベントは現在の config を持ち、該当する場合は現在の bundle も保持しています。これにより、コントローラーが古いデータのキャッシュを自前で管理しなくても、常に最新の状態を参照できます。

BaseController と RuntimeController 抽象クラス

すべてのコントローラーは packages/wrangler/src/api/startDevWorker/BaseController.ts#L27-L49Controller 基底クラスを継承しています。

export abstract class Controller {
    protected bus: ControllerBus;
    #tearingDown = false;

    protected emitErrorEvent(event: ErrorEvent) {
        if (this.#tearingDown) {
            logger.debug("Suppressing error event during teardown");
            return;
        }
        this.bus.dispatch(event);
    }
}

#tearingDown フラグはシャットダウン中のエラーイベントを抑制します。これがなければ、dispose() 実行中の競合状態によって、意図的に終了させたプロセスのエラーがユーザーに通知されてしまいます。

packages/wrangler/src/api/startDevWorker/BaseController.ts#L51-L75 にある RuntimeController 抽象サブクラスは、ランタイム実装が守るべき契約を定義しています。

export abstract class RuntimeController extends Controller {
    abstract onBundleStart(_: BundleStartEvent): void;
    abstract onBundleComplete(_: BundleCompleteEvent): void;
    abstract onPreviewTokenExpired(_: PreviewTokenExpiredEvent): void;

    protected emitReloadStartEvent(data: ReloadStartEvent): void { /*...*/ }
    protected emitReloadCompleteEvent(data: ReloadCompleteEvent): void { /*...*/ }
    protected emitDevRegistryUpdateEvent(data: DevRegistryUpdateEvent): void { /*...*/ }
}

抽象イベントハンドラー(ランタイムが反応すべきもの)と protected イベントエミッター(ランタイムがトリガーできるもの)を分離することで、双方向の契約が明確になっています。

5 つのコントローラーの詳細

各コントローラーは開発パイプラインの 1 ステージを担当します。

ConfigControllerConfigController.ts の 495 行目)は Wrangler の設定を読み込み、エントリーポイントを解決し、chokidar で設定ファイルの変更を監視し、使用可能なポートを確定して configUpdate イベントを発行します。また、ランタイムから届く devRegistryUpdate イベントも処理します。マルチワーカー開発時に他のワーカーが発見されると、ランタイムが設定にその情報を伝えるフィードバックループを形成しています。

BundlerControllerBundlerController.ts の 29 行目)は esbuild を管理します。configUpdate を受け取ると、esbuild のインクリメンタルビルドを設定または再設定します。実行中のビルドに対するアボートシグナルも処理します——esbuild がバンドル中に次の設定変更が届いた場合、古いビルドはキャンセルされます。プロキシがローディング状態を表示できるよう bundleStart を、ランタイムがリロードできるよう bundleComplete を発行します。

LocalRuntimeControllerLocalRuntimeController.ts の 152 行目)は Miniflare インスタンスを管理します。bundleComplete を受け取ると、設定とバンドルを Miniflare のオプションに変換し、setOptions() を呼び出してワーカーをリロードします。更新をシリアライズするために Mutex を使用しています。buildMiniflareOptions() は非同期処理のため、Mutex なしでは頻繁な更新が順序を乱して適用される可能性があります。

RemoteRuntimeController は Cloudflare エッジ上のプレビューセッションを管理します。ワーカーのコードをリモートプレビューエンドポイントにアップロードし、リモートプレビュー URL をプロキシデータとして含む reloadComplete を発行します。

ProxyControllerProxyController.ts の 46 行目)はユーザー向けの HTTP プロキシとして、独立した Miniflare インスタンスを起動します。これはもっとも意外なアーキテクチャ上の決断であり、専用のセクションで詳しく説明します。

flowchart LR
    CONFIG["ConfigController<br/>watches config files"] -->|configUpdate| BUS["DevEnv Bus"]
    BUS -->|configUpdate| BUNDLER["BundlerController<br/>manages esbuild"]
    BUNDLER -->|bundleComplete| BUS
    BUS -->|bundleComplete| RUNTIME["RuntimeController<br/>manages Miniflare"]
    RUNTIME -->|reloadComplete| BUS
    BUS -->|reloadComplete| PROXY["ProxyController<br/>HTTP proxy"]

2 層プロキシアーキテクチャ

コードをざっと読んだだけでは気づきにくい重要なポイントがあります。ProxyController は packages/wrangler/src/api/startDevWorker/ProxyController.ts#L60-L85独自の Miniflare インスタンスを起動しています。これは LocalRuntimeController のものとは完全に別物です。

なぜでしょうか。プロキシ自体が Cloudflare Worker だからです。あなたのワーカーへのリクエストを中継する Worker が存在するわけです。これにより、プロキシは Workers の全 API にアクセスでき、次のことが可能になります。

  • ライブリロードの注入 — プロキシが HTML レスポンスを横断し、ライブリロードスクリプトを注入
  • リクエストの検査 — ワーカーに届く前にリクエストを検査・変更
  • インスペクタープロキシ — Chrome DevTools とワーカーの V8 インスペクターを橋渡しする WebSocket エンドポイントをプロキシがホスト
  • エラーのレンダリング — わかりやすいエラーページをあなたのワーカーではなくプロキシワーカーがレンダリング
flowchart LR
    BROWSER["Browser"] -->|"HTTP request"| PROXY_MF["ProxyController's Miniflare<br/>(Proxy Worker)"]
    PROXY_MF -->|"forwarded request"| RUNTIME_MF["LocalRuntimeController's Miniflare<br/>(Your Worker)"]
    RUNTIME_MF -->|"response"| PROXY_MF
    PROXY_MF -->|"modified response<br/>(+ live reload)"| BROWSER
    DEVTOOLS["Chrome DevTools"] -->|"WebSocket"| PROXY_MF
    PROXY_MF -->|"WebSocket"| RUNTIME_MF

この 2 層アーキテクチャでは、ユーザーが直接アクセスするポート(デフォルト 8787)はプロキシの Miniflare が受け持ちます。ユーザーのワーカーはプロキシ経由でしかアクセスできない内部ポートで動作します。ワーカーがリロードされると、ProxyController は新しい内部 URL を含む reloadComplete イベントを受け取り、プロキシのルーティングを更新します。

startDev() とマルチワーカーのサポート

wrangler dev のエントリーポイントは packages/wrangler/src/dev/start-dev.ts#L31 にある startDev() です。シングルワーカー構成では DevEnv インスタンスを 1 つ作成します。複数ワーカーが設定されている場合(配列形式の設定)は、複数の DevEnv インスタンスが生成されます。それらは packages/wrangler/src/api/startDevWorker/MultiworkerRuntimeController.tsMultiworkerRuntimeController を通じて協調します。

マルチワーカーモードでは、プライマリワーカーがすべてのコントローラーを持つフル構成の DevEnv を受け取り、セカンダリワーカーはプライマリのプロキシがすべての受信トラフィックを処理するため NoOpProxyController を使用する場合があります。MultiworkerRuntimeController は dev レジストリの更新を結びつけ、ローカル開発中にワーカー同士がお互いのサービスバインディングを発見できるようにします。

ヒント: startDev() 内の devEnv 変数の型は DevEnv | DevEnv[] | undefined というユニオン型です。これはシングルワーカー・マルチワーカー・未初期化の 3 つの状態を表しています。マルチワーカーの問題をデバッグするときは、プライマリとセカンダリのどちらの DevEnv を見ているかを確認しましょう。

次回について

本記事の LocalRuntimeController は重い処理を Miniflare に委譲しています。しかし Miniflare 自体も 28 のプラグイン・workerd の子プロセスマネージャー・独自の設定シリアライゼーションパイプラインを持つ相当な規模のシステムです。第 4 回では Miniflare のアーキテクチャの内部に踏み込みます。