BiDi Events
The driver only delivers events the client has explicitly subscribed
to via session.subscribe. The typed bidi.subscribe::<E>() call
takes care of that for you — it sends the wire-level subscribe on
first call (per event method, per connection) and tears it down with
session.unsubscribe when the last stream drops. You just open a
stream and pull events off it.
Typed Subscription
#![allow(unused)]
fn main() {
use futures_util::StreamExt;
use thirtyfour::bidi::events::LogEntryAdded;
use thirtyfour::bidi::modules::log::LogLevel;
use thirtyfour::prelude::*;
async fn run(driver: WebDriver) -> WebDriverResult<()> {
let bidi = driver.bidi().await?;
// Auto-sends `session.subscribe`.
let mut events = bidi.subscribe::<LogEntryAdded>().await?;
let context = bidi.browsing_context().top_level().await?;
bidi.browsing_context()
.navigate(
context,
"data:text/html,<script>console.log('hi');console.warn('warn')</script>",
None,
)
.await?;
while let Some(entry) = events.next().await {
let level = match entry.level {
LogLevel::Debug => "DEBUG",
LogLevel::Info => "INFO",
LogLevel::Warn => "WARN",
LogLevel::Error => "ERROR",
LogLevel::Unknown(ref s) => s.as_str(),
};
println!("[{level}] {}", entry.text.as_deref().unwrap_or(""));
}
Ok(()) }
}
A typed event implements BidiEvent, pairing the wire method name
with a Deserialize struct. Items where the wire shape can’t be
deserialised as the requested type are logged via tracing::warn!
(target thirtyfour::bidi) and skipped — install a tracing subscriber
to see them.
Common Event Types
Every typed event is re-exported from
thirtyfour::bidi::events so a single use covers
the common cases:
#![allow(unused)]
fn main() {
use thirtyfour::bidi::events::{
// Page lifecycle
Load, DomContentLoaded, FragmentNavigated, HistoryUpdated,
NavigationStarted, NavigationCommitted, NavigationAborted, NavigationFailed,
// Tabs / frames
ContextCreated, ContextDestroyed,
// Downloads
DownloadWillBegin, DownloadEnd,
// User prompts and file dialogs
UserPromptOpened, UserPromptClosed, FileDialogOpened,
// Network
BeforeRequestSent, ResponseStarted, ResponseCompleted, FetchError, AuthRequired,
// Script / log
RealmCreated, RealmDestroyed, ScriptMessage,
LogEntryAdded,
};
}
Scoped Subscriptions
For events scoped to one browsing context (or one user context),
use the explicit session.send(Subscribe { ... }) form — the
auto-subscribe path is global. Two patterns:
#![allow(unused)]
fn main() {
use thirtyfour::prelude::*;
async fn run(bidi: thirtyfour::bidi::BiDi) -> WebDriverResult<()> {
use thirtyfour::bidi::modules::session::Subscribe;
let context = bidi.browsing_context().top_level().await?;
// Wire-level subscribe scoped to one tab. Then open the local stream
// to read it (the typed subscribe also bumps the global refcount, so
// `unsubscribe` won't fire mid-test if you mix them — but the cleanest
// pattern is to pick one approach per stream).
bidi.send(Subscribe {
events: vec!["browsingContext.load".into()],
contexts: vec![context],
user_contexts: vec![],
}).await?;
let mut raw = bidi.subscribe_raw();
Ok(()) }
}
You can also subscribe by whole module name (e.g. "browsingContext")
to opt in to every event in that module, again via the explicit form.
The full list of valid event names lives in the BiDi spec’s Events section — each module documents its own events, e.g.
browsingContext.load. Module-level subscribes ("browsingContext","network","log", …) match the module headers in the spec.
Raw Events
When the event isn’t yet in the curated set, or you want a single firehose for debugging, switch to a raw stream:
#![allow(unused)]
fn main() {
use futures_util::StreamExt;
use thirtyfour::prelude::*;
async fn run(bidi: thirtyfour::bidi::BiDi) -> WebDriverResult<()> {
bidi.session().subscribe_many(["network".into(), "browsingContext".into()]).await?;
let mut all = bidi.subscribe_raw();
while let Some(event) = all.next().await {
println!("{}: {}", event.method, event.params);
}
Ok(()) }
}
BiDi::subscribe_raw yields every event delivered on the
connection without filtering or deserialisation. Note it doesn’t
auto-subscribe — that’s why we explicitly call
session().subscribe_many(...) first.
Network Interception
Network interception is a request-pause-and-continue loop driven by events:
- Subscribe to the appropriate event (
BeforeRequestSentfor the request phase,ResponseStartedfor the response phase) before registering the intercept — otherwise you might miss the event you’re waiting on. - Register the intercept with
bidi.network().add_intercept(...), choosing one or moreInterceptPhases and optional URL match patterns. The return value is anInterceptGuardthat wraps the underlying id. - The next matching request pauses on the wire. The event arrives
with
is_blocked = trueand arequest.idto identify it. - Continue the request unmodified
(
bidi.network().continue_request(event.request.id)), modify it, fail it (fail_request), or synthesize a response (provide_response). For response-phase interception, usecontinue_response. - When you’re done, drop the guard or call
intercept.remove().awaitfor explicit error handling.
#![allow(unused)]
fn main() {
use futures_util::StreamExt;
use thirtyfour::bidi::events::BeforeRequestSent;
use thirtyfour::bidi::modules::browsing_context::ReadinessState;
use thirtyfour::bidi::modules::network;
use thirtyfour::prelude::*;
async fn run(driver: WebDriver) -> WebDriverResult<()> {
let bidi = driver.bidi().await?;
// (1) Subscribe BEFORE adding the intercept (auto-sends `session.subscribe`).
let mut events = bidi.subscribe::<BeforeRequestSent>().await?;
// (2) Register the intercept. The returned guard removes the intercept
// when it drops; call .remove() explicitly if you want the error.
let intercept = bidi
.network()
.add_intercept(vec![network::InterceptPhase::BeforeRequestSent], None)
.await?;
let context = bidi.browsing_context().top_level().await?;
// (3) Kick off navigation in the background — it won't return until
// we continue the paused request.
let nav = {
let bidi = bidi.clone();
let context = context.clone();
tokio::spawn(async move {
bidi.browsing_context()
.navigate(context, "https://example.com/", Some(ReadinessState::Complete))
.await
})
};
// (4) Wait for the paused request, then let it through.
while let Some(event) = events.next().await {
if event.is_blocked && event.request.url.starts_with("https://example.com/") {
bidi.network().continue_request(event.request.id).await?;
break;
}
}
nav.await.map_err(|e| WebDriverError::FatalError(format!("nav join: {e}")))??;
// (5) Clean up — explicit form, surfaces any error.
intercept.remove().await?;
Ok(()) }
}
For the response phase, swap the phase enum and call
continue_response instead. The shape of the loop is otherwise
identical.
Adding Your Own Typed Event
Write a Deserialize struct matching the wire shape and impl BidiEvent:
#![allow(unused)]
fn main() {
use serde::Deserialize;
use thirtyfour::bidi::BidiEvent;
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PromptOpened {
context: String,
prompt_type: String,
message: String,
}
impl BidiEvent for PromptOpened {
const METHOD: &'static str = "browsingContext.userPromptOpened";
}
}
Then bidi.subscribe::<PromptOpened>().await? works the same as for
the curated events. The wire shape and method name for this event are
specified in browsingContext.userPromptOpened.
Lifecycle
A BiDi handle is Clone (the underlying transport is Arc-shared).
Each typed subscribe::<E>() bumps a per-method refcount; when the
last stream for a given event drops, session.unsubscribe is sent in
the background on the current tokio runtime. Calling session.end()
ends the BiDi session entirely.