CDPEx
OTP-native Chrome DevTools Protocol
browser automation for Elixir. Launch headless Chrome and drive it directly over a
Mint.WebSocket connection — no ChromeDriver, no Node.js.
CDPEx.with_page([], fn page ->
{:ok, _} = CDPEx.Page.navigate(page, "https://example.com")
CDPEx.Page.html(page)
end)
#=> {:ok, "<html>…</html>"}Why CDPEx?
It drives Chrome over CDP the way Puppeteer and Playwright do — but it's pure
Elixir: the browser and each page's CDP connection are supervised OTP
processes (a page is a lightweight handle over its connection). A Chrome crash
or a dropped socket surfaces to the caller as {:error, reason} instead of a
hung session, and terminate/2 guarantees the OS process is reaped (no zombie
Chromes).
| CDPEx | chrome_remote_interface | ChromicPDF | Wallaby | |
|---|---|---|---|---|
| Transport | CDP (WebSocket) | CDP (WebSocket) | CDP (WebSocket) | WebDriver / ChromeDriver |
| Runtime deps | mint_web_socket, jason | hackney + others | a few | ChromeDriver process |
| Supervised lifecycle | ✅ | — | ✅ (PDF pool) | partial |
| Scope | general automation | low-level client | PDF / screenshots | testing |
| Node.js required | no | no | no | no |
If you want a small, dependency-light CDP client with proper OTP supervision — and you don't want a ChromeDriver process or a Node sidecar — that's the gap CDPEx fills.
Status
v0.1 is single-browser, one-WebSocket-per-page, headless Chrome only. Connection pooling,
sessionIdmultiplexing, network interception, and stealth are intentionally out of scope for this release.
Installation
Add cdp_ex to your deps in mix.exs:
def deps do
[
{:cdp_ex, "~> 0.1"}
]
end
You also need Chrome or Chromium installed. CDPEx finds it via, in order: the
:chrome_binary option, CDP_EX_CHROME_BINARY, CHROME_BINARY, then an OS default.
For reproducible setups, point it at a
Chrome for Testing binary.
Usage
Resource-safe (recommended)
with_page/3 opens a page, runs your function, and always tears everything down —
even if the function raises:
# Throwaway browser + page for one job:
{:ok, title} =
CDPEx.with_page([], fn page ->
{:ok, _} = CDPEx.Page.navigate(page, "https://example.com")
CDPEx.Page.evaluate(page, "document.title")
end)Explicit lifecycle
{:ok, browser} = CDPEx.launch(headless: true)
{:ok, page} = CDPEx.new_page(browser)
{:ok, _page} = CDPEx.Page.navigate(page, "https://example.com")
:ok = CDPEx.Page.wait_for_selector(page, "h1")
{:ok, html} = CDPEx.Page.html(page)
{:ok, "Example Domain"} = CDPEx.Page.evaluate(page, "document.querySelector('h1').textContent")
{:ok, _png} = CDPEx.Page.screenshot(page, path: "example.png")
:ok = CDPEx.close_page(browser, page)
:ok = CDPEx.stop(browser)Under your supervision tree
Because terminate/2 reaps Chrome, supervise the browser with a :shutdown
timeout (not :brutal_kill):
children = [
{CDPEx.Browser, name: MyBrowser, headless: true}
]
Supervisor.start_link(children, strategy: :one_for_one)Page operations
| Function | Description |
|---|---|
navigate/3 |
Go to a URL, waiting for networkAlmostIdle (configurable) |
wait_for_selector/3 | Poll until a CSS selector matches |
evaluate/3 |
Run JS and return the value (returnByValue) |
click/3 |
Synthetic .click() on the first match |
html/2 |
Full serialized DOM (document.documentElement.outerHTML) |
screenshot/2 |
PNG bytes, or write to :path |
Full API: hexdocs.pm/cdp_ex.
Development
mix deps.get
mix test # unit tests (no Chrome needed)
mix test --include integration # real-Chrome tests (set CDP_EX_CHROME_BINARY)
mix ci # format, credo, dialyzer, unit tests
Integration tests are tagged :integration and excluded by default; they launch a
real Chrome and drive it against a local fixture HTTP server.
Acknowledgements
Built on mint_web_socket. Inspired by
the production CDP work in ChromicPDF and
by Puppeteer's protocol layer.
License
MIT — see LICENSE.