Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

WebDriver BiDi

WebDriver BiDi is the W3C-standard bidirectional protocol that succeeds parts of Chrome DevTools Protocol with a cross-browser equivalent. It runs over a WebSocket negotiated by the standard webSocketUrl: true capability on New Session, and both Chromium-based browsers (chromedriver ≥ 115) and Firefox (geckodriver ≥ 0.31) implement it today.

If you’ve used CDP, BiDi will feel familiar: typed commands grouped by module (browsingContext.*, script.*, network.*, …), event subscriptions, request interception. The trade-off is coverage vs. portability:

CDP (Chromium-only)BiDi (cross-browser)
Surface areaLarger, Chromium-specificFull W3C BiDi spec covered
Portable across browsersNoYes
StandardisedNoYes (W3C)
ConnectionDirect WebSocketVia WebDriver session

Enabling BiDi

BiDi requires both a runtime opt-in (the webSocketUrl: true capability on the session) and the bidi feature flag at compile time:

[dependencies]
thirtyfour = { version = "0.37.0", features = ["bidi"] }

enable_bidi() on a capabilities builder sets the W3C webSocketUrl: true flag — that’s what tells the driver to spin up a BiDi WebSocket and return its URL on the session capabilities.

Quick Start

use thirtyfour::bidi::events::Load;
use thirtyfour::prelude::*;
use futures_util::StreamExt;

#[tokio::main(flavor = "multi_thread")]
async fn main() -> WebDriverResult<()> {
    let mut caps = DesiredCapabilities::chrome();
    caps.enable_bidi()?;             // <-- the W3C `webSocketUrl: true` opt-in.
    let driver = WebDriver::managed(caps).await?;

    // Lazy-connect the BiDi WebSocket on first use; cached afterwards.
    let bidi = driver.bidi().await?;

    let status = bidi.session().status().await?;
    println!("driver ready: {}", status.ready);

    // Pick the active browsing context.
    let context = bidi.browsing_context().top_level().await?;

    // Subscribe to load events and navigate. The typed `subscribe::<E>()`
    // call sends `session.subscribe` for us automatically; when the stream
    // drops, `session.unsubscribe` is sent in the background.
    let mut loads = bidi.subscribe::<Load>().await?;
    bidi.browsing_context().navigate(context.clone(), "https://example.com", None).await?;

    if let Some(load) = loads.next().await {
        println!("loaded: {}", load.url);
    }

    driver.quit().await
}

driver.bidi() is async because it lazily dials the WebSocket on first use; it’s cached afterwards, so subsequent calls just clone the handle. Cloning a BiDi is cheap (the transport is Arc-shared).

Modules

The curated typed bindings live under bidi::modules:

ModuleUse it for
sessionstatus, subscribe/unsubscribe, end
browserBrowser-wide control, user contexts, client windows, download behavior
browsing_contextTabs, frames, navigation, screenshots, printing, locating nodes, CSP
scriptevaluate, callFunction, preload scripts, realms
networkInterception, modify request/response, auth, data collectors, headers
storageCookies and partition lookup
loglog.entryAdded events
inputperformActions, releaseActions, setFiles, fileDialogOpened events
permissionssetPermission
emulationGeolocation, locale, timezone, screen, user-agent, scripting & touch
web_extensionInstall / uninstall web extensions

Each command in those modules is a Rust struct that implements BidiCommand, pairing the request type with its response type and the wire method name. Module facades on BiDi (session(), browsing_context(), network(), …) wrap the most common ones in convenient async methods. For everything else, build the struct directly and call bidi.send(MyCommand { ... }).await.

A few helpers worth knowing about:

  • bidi.browsing_context().top_level() — the id of the first top-level context (i.e. “the active tab”). Saves you doing tree.contexts[0].context.clone() by hand.
  • bidi.browsing_context().top_levels() — every top-level context.
  • bidi.network().add_intercept(...) — returns an InterceptGuard you can .remove().await explicitly, or just let drop (best-effort cleanup runs in the background).

Emulation Overrides

The emulation module overrides browser-emulated APIs (geolocation, locale, timezone, screen, user agent, scripting, touch, …). Each command applies globally by default; build the command struct directly to scope it to specific browsing contexts or user contexts. Pass None to clear an override.

#![allow(unused)]
fn main() {
use thirtyfour::bidi::modules::emulation::GeolocationCoordinates;
use thirtyfour::prelude::*;
async fn run(bidi: thirtyfour::bidi::BiDi) -> WebDriverResult<()> {
bidi.emulation()
    .set_geolocation_override(Some(GeolocationCoordinates {
        latitude: -33.8688,
        longitude: 151.2093,
        accuracy: Some(50.0),
        altitude: None,
        altitude_accuracy: None,
        heading: None,
        speed: None,
    }))
    .await?;

bidi.emulation().set_timezone_override(Some("Australia/Sydney".into())).await?;
bidi.emulation().set_locale_override(Some("en-AU".into())).await?;
Ok(()) }
}

Driver support for emulation.* is uneven at the time of writing — chromedriver implements most, geckodriver lags. The wire shape is stable; commands return error("unsupported operation") where the driver doesn’t yet handle them.

The Untyped Escape Hatch

Method names and parameter shapes for commands not in the curated set come from the BiDi spec’s Commands section — every module (session, browsingContext, script, network, …) lists its commands and their wire signatures, e.g. browsingContext.activate.

#![allow(unused)]
fn main() {
use thirtyfour::prelude::*;
async fn run(bidi: thirtyfour::bidi::BiDi) -> WebDriverResult<()> {
// No-arg command:
let res = bidi.send_raw("session.status", ()).await?;
println!("{res}");

// With params:
bidi.send_raw(
    "browsingContext.activate",
    serde_json::json!({ "context": "abc" }),
).await?;
Ok(()) }
}

send_raw accepts anything Serialize for the params. If you reach for it repeatedly on the same command, implement BidiCommand for your own request struct — the same trait the curated modules use. There’s no second-class API.

#![allow(unused)]
fn main() {
use serde::{Deserialize, Serialize};
use thirtyfour::bidi::BidiCommand;

#[derive(Serialize)]
struct ActivateContext { context: String }

#[derive(Deserialize)]
struct ActivateResult {}

impl BidiCommand for ActivateContext {
    const METHOD: &'static str = "browsingContext.activate";
    type Returns = ActivateResult;
}
}

What About CDP?

You can use both in the same session: CDP is on by default, BiDi opts in via the capability + feature flag. CDP gives you Chromium-only surface area that BiDi doesn’t yet cover; BiDi gives you a single event-driven control plane that also works on Firefox. See the CDP chapter for the comparison.

Where Next