Skip to main content

Status#

Accepted, in progress. Proposed (2026-10-12), revised the same day: the framework lives in its own repository, seleniumbase-rs-plugins, and this crate keeps only the hook points that call it. Phase 1 (the plugins repository), phase 2 (the hook points of the pure CDP engine) and phase 3 (the hook points of the WebDriver engine), behind the plugins feature, are done, except the test_plugin hook; phases 4 and 5 remain. The phases at the end give the order, what each one holds and where the result differs from the plan.

Context#

Puppeteer and Playwright users extend their browsers with plugins: puppeteer-extra and its maintained fork Zorilla wrap launch() and call every registered plugin at fixed points (beforeLaunch, afterLaunch, onPageCreated, onClose, …). Stealth, ad blocking, captcha solving, resource blocking and user preferences are all separate packages that anyone can publish, and puppeteer.use(StealthPlugin()) is all a user writes. They are an example of the ecosystem we want, not a design to copy: the design below is shaped by Rust (no runtime loading, traits, Cargo) and by this crate’s two engines.

seleniumbase-rs has three extension points today, and none of them lets a third-party crate change how a browser starts or what every page gets:

TodayWhat it coversWhat it cannot do
plugins::observer::TestPluginTest started, failed (with the page), finishedChange launch options; see pages as they open
stealth::providers::EvasionProviderJavaScript added to every document by the stealth layerAnything outside the stealth bootstrap script
LaunchOptions flags (ad_block, shield_webrtc, identity)Fixed built-in behavioursNew behaviours without changing this crate

The Chrome extension in seleniumbase-rs-browser-extension is a different thing: it runs inside the browser. This ADR is about Rust code that runs in the program driving the browser.

Decision#

A small plugin API lives in its own repository and crate; seleniumbase-rs depends on it and calls it; community plugins depend only on it.

Where the code lives#

Repository / crateHoldsDepends on
seleniumbase-rs-plugins / seleniumbase-rs-pluginthe Plugin trait, PluginSet (order, dependencies, requirements), LaunchSettings, the BrowserHandle and PageHandle traits, Packageasync-trait, serde_json, thiserror only
seleniumbase-rs-plugins / seleniumbase-rs-plugin-testingrecording fakes of BrowserHandle and PageHandle, so a plugin is tested without a browser or this cratethe API crate
seleniumbase-rs-plugins / plugins/*official plugins (block resources, JS packages, user preferences, …) and template/ for new onesthe API crate
seleniumbase-rsthe hook points: it turns LaunchSettings into LaunchOptions or BrowserConfig, implements the handle traits for both engines, and calls the hooksthe API crate

Plugins never name seleniumbase-rs types, so they do not break when this crate changes, and the API crate changes rarely. Both engines take a PluginSet. Community plugins are ordinary crates.

The trait#

Every hook is optional and does nothing by default, so a plugin implements only what it needs.

BrowserHandle and PageHandle are small types that send a CDP command (send(method, params)), add a script to every new document, evaluate JavaScript, and say which engine and browser they belong to. Plugins receive them instead of sb_cdp::Browser or BaseCase, so the same plugin works with both engines and the hook signatures stay stable when those types grow.

The existing TestPlugin stays as it is. A Plugin that also wants the test hooks returns one from fn test_plugin(&self) -> Option<Box<dyn TestPlugin>>.

Using it#

On WebDriver the same set goes to BrowserConfig::with_plugins(plugins).

Where the hooks run#

HookPure CDP engineWebDriver engine
before_launchBrowser::launch, before launch(); the settings become LaunchOptionsBaseCase::new, before the driver starts; the settings become BrowserConfig
on_browserafter launch and after Browser::connectafter the session starts
on_page_createdin prepare_session, for every tab the library attaches toChromium only, through the DevTools bridge; other browsers skip it
packagesadded in prepare_sessionadded at session start on Chromium; on other browsers after each open
on_closeBrowser::close and dropBaseCase::quit

Rules the set enforces#

  • Order. Plugins run in the order they were added, except those with RUN_LAST, which run after the rest (Zorilla does the same).
  • Dependencies. A missing dependency stops the launch with SeleniumBaseError::Plugin. Rust cannot install a crate at run time, so the error names the plugin and the crate to add. A plugin may also bring its own dependencies with fn bundled(&self) -> Vec<Arc<dyn Plugin>>; they are added when missing.
  • Requirements. HEADFUL refuses a headless launch, LAUNCH_ONLY refuses connect, CHROMIUM_ONLY refuses Firefox and Safari. Each refusal is an error, not a warning.
  • Errors fail closed. An error from before_launch, on_browser or on_page_created stops that launch or that page and is returned to the caller: a stealth or proxy plugin that did not run must not leave an unprotected browser. Errors from on_close and from the test hooks are logged, as TestPlugin errors are today.
  • Names are unique. Adding a second plugin with the same name is an error.

Packages (JavaScript and CSS)#

This is the JS package manager of Python SeleniumBase (add_js_link, add_css_link), made declarative so a plugin can say what every page needs:

A package needs a pinned version in its URL and either only_on or .everywhere(), so a plugin never loads a CDN script into every site by accident. Code from a URL can be fetched by the program and injected inline (.inline()), as add_js_code_from_link does, for pages whose content security policy blocks the CDN.

The same work fills a gap: sb_cdp::Page gets add_script_tag, add_style_tag and add_init_script, which BaseCase already has as add_js_link, add_css_link and add_js_code.

Distribution#

  • Crates are named seleniumbase-rs-plugin-<name> and tagged seleniumbase-rs-plugin on GitHub, so a search finds them. The docs keep a list of community plugins.
  • template/ in the plugins repository has the crate layout, a test against the recording fakes, and CI. Copy it, or use it with cargo generate.
  • Plugin authors test with seleniumbase-rs-plugin-testing: the fakes record each CDP command and script a hook sends, with no browser and no dependency on this crate. An end-to-end check can still use Browser::new_mocked.
  • Until the crates are on crates.io, a plugin and the program must resolve to the same seleniumbase-rs-plugin; two git tags are two different crates to Cargo. The API crate changes rarely, and when it does the program fixes a mismatch with one [patch] entry, which the docs show. Publishing to crates.io later ends the problem through semver.

What is not part of this#

  • Loading plugins at run time (dlopen, a plugin folder). Rust has no stable ABI, so a plugin built with another compiler or crate version would crash. Plugins are compiled in.
  • WebAssembly plugins. Possible later, if people want plugins in other languages; not needed for Rust crates.
  • A plugin hub or registry service. A list in the docs and a GitHub topic are enough until there are many plugins.

Security#

A plugin is Rust code that runs with the program’s rights and sees every page, cookie and password the browser handles. Adding one is the same trust decision as adding any dependency: review it, pin it, and run cargo deny. The docs say this on the page that explains how to install a plugin.

Packages run inside the page, with the site’s origin. Pinned versions, Subresource Integrity and only_on keep a compromised CDN or a typo from reaching every site the browser visits.

Consequences#

  • Built-in behaviours can move into plugins over time (ad_block, shield_webrtc, the stealth bootstrap as a StealthPlugin over the EvasionRegistry). The existing LaunchOptions flags keep working; they become shortcuts that add the built-in plugin.
  • Every launch and every new tab now walks the plugin set. With no plugins that is an empty loop.
  • The hook list becomes public API. Adding a hook with a default body is not a breaking change; changing a signature is.

Phases#

  1. The plugins repository. Create seleniumbase-rs-plugins (public) with the API crate (Plugin, PluginSet, Requirements, Package, LaunchSettings, the handle traits), the testing crate, tests for order, dependencies, requirements and fail-closed errors, and template/.

  2. Hook points, pure CDP engine. seleniumbase-rs depends on the API crate: LaunchOptions::plugins, the hooks in Browser::launch, connect, prepare_session and close, PageHandle for sb_cdp::Page, Page::add_script_tag, add_style_tag, add_init_script, SeleniumBaseError::Plugin; one official plugin (block resources).

    Done (2026-10-15), behind the optional plugins feature, with the API crate pinned to a git revision and re-exported as seleniumbase_rs::plugin_api. LaunchOptionsBuilder::plugins; Browser::launch checks the set, runs before_launch (the settings become LaunchOptions: arguments, preferences written into the profile, extensions loaded with Extensions.loadUnpacked, proxy, headless), checks again, and calls on_browser; Browser::connect_with_plugins checks as attaching and calls on_browser; prepare_session adds the packages and calls on_page_created through a PageHandle on the tab’s session; close and the last drop call on_close once. Errors stop the launch or the tab as SeleniumBaseError::Plugin, and a tab opened by new_page is closed again. Block resources lives in the plugins repository. Left for phase 3, and done there: the Page::add_script_tag, add_style_tag and add_init_script methods, and PageHandle for sb_cdp::Page itself (the hooks use a handle on the tab’s session). The tests run against the mock transport, not a real Chrome. Guide: Browser Plugins.

  3. Hook points, WebDriver engine. BrowserConfig::with_plugins, the Chromium page hooks through the DevTools bridge, test_plugin.

    Done (2026-10-15), except test_plugin, which the API crate does not have yet. BrowserConfig::with_plugins; BrowserSession::connect, and so BaseCase::new, checks the set against a WebDriver LaunchContext (a server other than the local default counts as attaching), runs before_launch on settings taken from the configuration and puts them back (Chromium: arguments, capability preferences, extension folders, proxy, headless; Firefox: its arguments and preferences, headless; Safari: nothing), checks again, then calls on_browser and gives the first window the packages and on_page_created. Windows opened with switch_to_new_window and BaseCase::open_new_window get them too, and apply_plugins_to_window gives them to a window reached another way; reconnect runs the hooks again for the new browser; quit and drop call on_close. A refusal or a failing hook stops the session with SeleniumBaseError::Plugin and quits the browser. Unlike the table above, on_page_created runs on every browser, Firefox and Safari refusing send; their init scripts and packages run as WebDriver scripts at once and again after each open, after the page’s own scripts. Plugins together with reuse_session are refused. sb_cdp::Page got add_script_tag, add_style_tag, add_init_script and remove_init_script, and implements PageHandle. Tested against a mock WebDriver server and the mocked browser, and in Chrome 155 headless on macOS (tests/webdriver_plugins_chrome.rs, tests/sb_cdp_page_inject_chrome.rs); Firefox and Safari only against the mock server.

  4. Built-ins and community. ad_block and shield_webrtc as plugins, a StealthPlugin, the real Bootstrap Tour and Hopscotch libraries for the two tour themes that fall back to the built-in tour today, the template and a “Writing a plugin” tutorial.

  5. Later, if asked for. Plugins that add MCP tools (feature mcp-server), and a data() hook that lets plugins share values, as puppeteer-extra’s getDataFromPlugins does.