Read OSS

WebDriver BiDi: 新しいプロトコルとCDPブリッジ

上級

前提知識

  • 第1〜3回の記事
  • WebDriver BiDi仕様の基本的な理解

WebDriver BiDi: 新しいプロトコルとCDPブリッジ

WebDriver BiDiは、ブラウザ自動化プロトコルの未来です。オリジナルのWebDriver仕様を継承する双方向・イベント駆動型の後継として、CDPに近い機能をクロスブラウザの標準にもたらすことを目指して設計されています。PuppeteerのBiDi実装は、その機能だけでなく構造的な面でも注目に値します。仕様の関心事、ライブラリの関心事、互換性の関心事をそれぞれ明確に分離した、意図的な3層アーキテクチャを採用しているのです。

第3回では、PuppeteerがCDP経由でChromeを自動化する仕組みを解説しました。この記事では代替となるパスと、両者をつなぐブリッジについて説明します。

BiDiコアレイヤー:仕様に忠実なオブジェクトモデル

packages/puppeteer-core/src/bidi/core/ の中には、WebDriver BiDi仕様に厳密に従ったレイヤーが存在します。このレイヤーはPuppeteerのAPIニーズではなく、仕様そのものの形に沿って設計されています。

コアオブジェクトはBiDi仕様を反映した階層を構成しています:

classDiagram
    class Session {
        +connection: Connection
        +browser: Browser
        +send(method, params)
        +subscribe(events)
    }
    class Browser {
        +session: Session
        +userContexts: UserContext[]
        +addPreloadScript()
        +close()
    }
    class UserContext {
        +browser: Browser
        +browsingContexts: BrowsingContext[]
        +createBrowsingContext()
    }
    class BrowsingContext {
        +userContext: UserContext
        +navigate()
        +reload()
        +captureScreenshot()
    }
    class Realm {
        +browsingContext: BrowsingContext
        +evaluate()
        +callFunction()
    }
    class Navigation {
        +browsingContext: BrowsingContext
        +request: Request
    }
    Session --> Browser
    Browser --> UserContext
    UserContext --> BrowsingContext
    BrowsingContext --> Realm
    BrowsingContext --> Navigation

Session クラスはトップレベルのエントリーポイントです:

packages/puppeteer-core/src/bidi/core/Session.ts#L23-L52

44行目の connection アクセサに付いている @bubble() デコレータに注目してください。これにより、connectionが発行するすべてのイベントがsessionを通じてバブルアップします。このデコレータパターンについては第6回で詳しく解説します。

Session.from() ファクトリメソッドは、session.new BiDiコマンドをケイパビリティ要件とともに送信し、Browser コアオブジェクトを初期化します。この設計により、コアレイヤーは理論上Puppeteerから独立して使用できます。

BrowsingContextとNavigationモデル

BrowsingContext はCDPの「target」に相当するBiDiの概念で、タブやフレームを表します。ただしBiDiのモデルはより豊かです。navigationが第一級オブジェクトとして扱われます。

packages/puppeteer-core/src/bidi/core/BrowsingContext.ts#L7-L25

Navigation クラスは、BiDiで規定されたナビゲーションの完全なライフサイクルをモデル化しています:

packages/puppeteer-core/src/bidi/core/Navigation.ts#L25-L50

Navigationは browsingContext.navigationStartedbrowsingContext.domContentLoadedbrowsingContext.load などのBiDiイベントを監視します。さらに browsingContext.navigationFailedbrowsingContext.fragmentNavigated も対象です。

#matches() メソッドは巧妙な遅延マッチング戦略を実装しています。最初に一致したナビゲーションIDがそのNavigationのIDとして採用されます。この仕組みにより、作成時点ではIDが不明であるというレースコンディションを解決しています。

packages/puppeteer-core/src/bidi/core/Navigation.ts#L99-L149

ヒント: core/ をその他の bidi/ から分離することには、デバッグ上の実践的なメリットがあります。BiDiの機能が動作しない場合、問題が仕様レイヤー(イベントの誤り、ステートマシンの誤り)にあるのか、アダプターレイヤー(PuppeteerのAPIへのマッピングの誤り)にあるのかをすばやく切り分けられます。この分離は自身のプロジェクトにも取り入れる価値があります。

Puppeteerアダプターレイヤー

コアレイヤーの上に位置するのがアダプター、すなわち BidiBrowserBidiPageBidiFrame です。ここでBiDiの概念がPuppeteerの抽象APIにマッピングされます。BrowsingContextPage に、Realmevaluate() の実行コンテキストに、BiDiイベントがPuppeteerイベントに変換される場所です。

BidiBrowser はコアの SessionBrowser をラップします:

packages/puppeteer-core/src/bidi/Browser.ts#L58-L59

BidiPage はコアの BrowsingContext をラップします:

packages/puppeteer-core/src/bidi/Page.ts#L28-L32

概念のマッピングは次のとおりです:

BiDi概念 Puppeteer概念
Session (内部管理。BidiBrowserが管理)
UserContext BrowserContext
BrowsingContext PageまたはFrame
Realm (WindowRealm) IsolatedWorld / 実行コンテキスト
Navigation Frame.goto()の戻り値の一部
Request/Response HTTPRequest/HTTPResponse
classDiagram
    class BidiBrowser {
        -session: Session (core)
        -browser: Browser (core)
        +protocol = 'webDriverBiDi'
        +newPage()
        +close()
    }
    class BidiPage {
        -browsingContext: BrowsingContext (core)
        +goto()
        +evaluate()
        +screenshot()
    }
    class BidiFrame {
        -browsingContext: BrowsingContext (core)
        +evaluate()
        +waitForSelector()
    }
    BidiBrowser --|> Browser : extends abstract
    BidiPage --|> Page : extends abstract
    BidiFrame --|> Frame : extends abstract
    BidiBrowser --> BidiPage : creates
    BidiPage --> BidiFrame : contains

シリアライゼーション:JavaScriptとBiDiの橋渡し

CDPとBiDiの最も具体的な違いの一つは、JavaScriptの値がプロトコル境界を越える方法です。CDPは複雑な値に対してオブジェクトIDを持つ「リモートオブジェクト」を使用します。一方BiDiは、配列、マップ、セット、日付、正規表現などをインラインで表現できるリッチなシリアライゼーション形式を採用しています。

BidiSerializer はJavaScript → BiDi変換を担います:

packages/puppeteer-core/src/bidi/Serializer.ts#L19-L40

BidiDeserializer はBiDi → JavaScript変換を担います:

packages/puppeteer-core/src/bidi/Deserializer.ts#L14-L40

デシリアライザーはBiDiの型付き値として arraysetobjectmapregexpdatenodewindow、そしてプリミティブを処理します。CDPのアプローチよりも概念的にすっきりしている一方で、すべての値がシリアライゼーション境界を通過することになります。効率的に受け渡せる「リモートオブジェクト参照」は存在しません(ただしBiDiにはDOMノード用の handle 参照があります)。

flowchart LR
    subgraph "Node.js"
        A["JavaScript value"] --> B["BidiSerializer.serialize()"]
    end
    B -->|"BiDi LocalValue"| C["WebDriver BiDi Protocol"]
    C -->|"BiDi RemoteValue"| D["BidiDeserializer.deserialize()"]
    subgraph "Node.js (return)"
        D --> E["JavaScript value"]
    end

BiDi-over-CDPブリッジ

アーキテクチャ上最も興味深い部分が、BiDi-over-CDPブリッジです。CDPの対応は成熟している一方でネイティブのBiDiサポートがまだ発展途上にあるChromeに対しても、これによってPuppeteerはBiDi APIを利用できます。

packages/puppeteer-core/src/bidi/BidiOverCdp.ts#L25-L64

データフローは非常に巧妙です:

sequenceDiagram
    participant Puppeteer as Puppeteer (Node.js)
    participant BidiConn as BidiConnection
    participant NoOp as NoOpTransport
    participant BidiMapper as BidiMapper (in-process)
    participant CdpAdapter as CdpConnectionAdapter
    participant CdpConn as CDP Connection
    participant Chrome

    Puppeteer->>BidiConn: page.goto(url)
    BidiConn->>NoOp: send(BiDi JSON)
    NoOp->>BidiMapper: emitMessage(parsed)
    BidiMapper->>CdpAdapter: sendCommand('Page.navigate', ...)
    CdpAdapter->>CdpConn: send('Page.navigate', ...)
    CdpConn->>Chrome: CDP message
    Chrome-->>CdpConn: CDP response
    CdpConn-->>CdpAdapter: response
    CdpAdapter-->>BidiMapper: response
    BidiMapper-->>NoOp: sendMessage(BiDi response)
    NoOp-->>BidiConn: onmessage(JSON)
    BidiConn-->>Puppeteer: result

ブリッジは内部的に3つのパーツで構成されています:

  1. NoOpTransport(179〜208行目):BidiMapperの出力をPuppeteerのBidiConnectionに接続するためだけに存在するtransportです。BidiMapperがレスポンスを生成すると sendMessage() が呼ばれ、bidiResponse イベントが発行されます。そのイベントが捕捉され、BidiConnectionの onmessage に転送されます。

  2. CdpConnectionAdapter(70〜107行目):PuppeteerのCDP Connectionをラップし、chromium-bidi のBidiMapperが期待するインターフェースを満たします。セッションごとのCDPクライアントアダプターを管理します。

  3. CDPClientAdapter(115〜172行目):個々のCDPセッションをラップし、PuppeteerのCDPSessionからBidiMapperのイベントシステムへイベントを転送します。

55行目の BidiMapper.BidiServer.createAndStart() 呼び出しで、chromium-bidi(BiDi-to-CDPトランスレーターのリファレンス実装)がプロセス内で起動します。これはブラウザが将来ネイティブに実装するのと同じコードベースです。

CDPとBiDiの機能比較

Puppeteerの現在の進化段階では、2つのプロトコルバックエンドは異なる機能プロファイルを持っています:

機能 CDP BiDi 備考
ページナビゲーション ✅ 完全 ✅ 完全 BiDiはよりクリーンなナビゲーションモデルを持つ
JavaScript評価 ✅ 完全 ✅ 完全 BiDiのシリアライゼーションはより豊富
ネットワークインターセプト ✅ 完全 ✅ 完全 内部的には異なるAPI
スクリーンショット ✅ 完全 ✅ 完全
PDF生成 ✅ 完全 ✅ ネイティブ BiDiはネイティブのbrowsingContext.printを持つ
デバイスエミュレーション ✅ 完全 ⚠️ CDPフォールバック経由 goog:cdp拡張またはCDP接続を使用
コードカバレッジ ✅ 完全 ⚠️ CDP経由 CDP Profilerドメインを使用
Firefoxサポート ❌ 削除済み ✅ 必須 FirefoxはBiDiのみサポート

BidiPage のソースを見ると、BiDiがCDPにフォールバックしている箇所がわかります。../cdp/ からのインポートを探してみましょう:

packages/puppeteer-core/src/bidi/Page.ts#L33-L39

CoverageEmulationManager はCDP実装から直接インポートされています。CDP接続が利用可能な場合(ChromeをBiDi-over-CDPで実行している場合)、BidiPageはこれらを再利用します。

BiDi接続は goog:cdp 拡張もサポートしており、goog:cdp.sendCommandgoog:cdp.getSession といったカスタムコマンドを提供します:

packages/puppeteer-core/src/bidi/Connection.ts#L34-L47

このChrome固有の拡張はエスケープハッチを提供します。BiDiがネイティブに機能をサポートしていない場合、PuppeteerはBiDi接続を通じてCDPコマンドをトンネリングできます。

なぜデュアルプロトコルなのか?

デュアルプロトコル戦略は、Puppeteerの長期的なビジョンを反映しています。CDPはChrome固有であり、標準化トラックには乗っていません。BiDiはW3Cの仕様であり、Firefoxはすでにネイティブにサポートし、Chromeも実装を進めています。第1回で見たように、同一の抽象APIを通じて両プロトコルをサポートすることで、PuppeteerはCDPからBiDiへと標準の成熟に合わせて段階的に移行できるクロスブラウザ自動化ライブラリとして自身を位置づけています。

BiDi-over-CDPブリッジは過渡的な技術です。ChromeのネイティブなBiDiサポートが追いつくまでの間、ユーザーは今日からBiDiのAPIを利用できます。PUPPETEER_WEBDRIVER_BIDI_ONLY フラグにより、チームやユーザーはBiDiだけで動作するものと、まだCDPフォールバックが必要なものをテストで切り分けられます。

次回予告

これで両プロトコルバックエンドを解説しました。次回はプロトコルからユーザー向けの機能へとテーマを移し、Puppeteerがページ上の要素をどのように検索するかを探ります。P-selectorパーサー、ブラウザページ内で動作するinjected scriptランタイム、そしてRxJS observableをベースに構築されたモダンなLocator APIについて解説します。