Current section
Files
Jump to
Current section
Files
lib/cdp_ex/protocol.ex
defmodule CDPEx.Protocol do
@moduledoc """
Pure CDP wire helpers: JSON-RPC encoding, frame decoding, and message
classification. No process state, no I/O — every function here is referentially
transparent and unit-testable without a running Chrome.
> Module note: this module aliases `Mint.WebSocket` as `WebSocket`.
The Chrome DevTools Protocol is JSON-RPC 2.0-ish over a WebSocket:
* a **command** is `%{"id" => n, "method" => "Domain.method", "params" => %{}}`
* a **reply** echoes the `"id"` and carries either `"result"` or `"error"`
* an **event** has a `"method"` and `"params"` but no `"id"`
`CDPEx.Connection` owns the socket and drives these helpers.
"""
alias Mint.WebSocket
@prevent_alerts_js "window.alert=function(){};window.confirm=function(){return true};window.prompt=function(){return null};"
@typedoc "A decoded `Mint.WebSocket` frame."
@type frame :: WebSocket.frame() | {:close, integer() | nil, binary()}
@typedoc "The result of classifying a single decoded frame."
@type classification ::
{:reply, id :: pos_integer(), session_id :: String.t() | nil,
{:ok, map()} | {:error, map()}}
| {:event, method :: String.t(), session_id :: String.t() | nil, params :: map()}
| {:ping, binary()}
| {:close, code :: integer() | nil, reason :: binary()}
| :ignore
@doc """
Encodes a CDP command to JSON iodata.
Pass a `session_id` to target a flattened session; omit it (the default) for
commands sent on a page or browser socket directly.
## Examples
iex> CDPEx.Protocol.encode("Page.navigate", %{"url" => "https://x.test"}, 1)
...> |> IO.iodata_to_binary()
...> |> Jason.decode!()
%{"id" => 1, "method" => "Page.navigate", "params" => %{"url" => "https://x.test"}}
iex> CDPEx.Protocol.encode("Page.enable", %{}, 7, "SID")
...> |> IO.iodata_to_binary()
...> |> Jason.decode!()
%{"id" => 7, "method" => "Page.enable", "params" => %{}, "sessionId" => "SID"}
"""
@spec encode(String.t(), map(), pos_integer(), String.t() | nil) :: iodata()
def encode(method, params, id, session_id \\ nil)
when is_binary(method) and is_map(params) and is_integer(id) and id > 0 do
%{"id" => id, "method" => method, "params" => params}
|> put_session(session_id)
|> Jason.encode_to_iodata!()
end
defp put_session(payload, nil), do: payload
defp put_session(payload, session_id), do: Map.put(payload, "sessionId", session_id)
@doc """
Decodes `Mint.WebSocket` stream responses for `ref` into a flat list of frames.
Non-matching responses (other refs, status/headers) are ignored. Returns the
advanced websocket along with the frames so the caller can thread state.
"""
@spec decode_frames(WebSocket.t(), [term()], reference()) ::
{:ok, WebSocket.t(), [frame()]} | {:error, term()}
def decode_frames(websocket, responses, ref) do
# Accumulate frame chunks in reverse (prepend, not `acc ++ frames`, which is
# O(n²) as the list grows) and flatten once at the end. This runs on every
# inbound transport message, so the linear path is worth the extra step.
result =
Enum.reduce_while(responses, {:ok, websocket, []}, fn
{:data, ^ref, data}, {:ok, ws, acc} ->
case WebSocket.decode(ws, data) do
{:ok, ws, frames} -> {:cont, {:ok, ws, [frames | acc]}}
{:error, _ws, reason} -> {:halt, {:error, {:ws_decode, reason}}}
end
_other, acc ->
{:cont, acc}
end)
case result do
{:ok, ws, chunks} -> {:ok, ws, chunks |> Enum.reverse() |> Enum.concat()}
{:error, _reason} = error -> error
end
end
@doc """
Classifies one decoded frame into a CDP-level action.
Text frames are parsed as JSON and split into replies (by `"id"`) and events
(by `"method"`), each carrying any `"sessionId"` (from flattened CDP sessions)
so the connection can route per session. Ping frames surface so the connection can pong, and a peer
`close` frame surfaces so the connection can shut down and fail pending
callers. Everything else (`pong`, unrecognised JSON) is `:ignore`.
For an error reply, the raw CDP error object is returned; the connection wraps
it with the originating method as `{:cdp_error, method, error}`.
## Examples
iex> CDPEx.Protocol.classify({:text, ~s({"id":1,"result":{"frameId":"A"}})})
{:reply, 1, nil, {:ok, %{"frameId" => "A"}}}
iex> CDPEx.Protocol.classify({:text, ~s({"id":2,"error":{"code":-32000,"message":"boom"}})})
{:reply, 2, nil, {:error, %{"code" => -32000, "message" => "boom"}}}
iex> CDPEx.Protocol.classify({:text, ~s({"method":"Page.loadEventFired","params":{"t":1}})})
{:event, "Page.loadEventFired", nil, %{"t" => 1}}
iex> CDPEx.Protocol.classify({:text, ~s({"method":"Inspector.detached"})})
{:event, "Inspector.detached", nil, %{}}
iex> CDPEx.Protocol.classify({:text, ~s({"method":"Page.lifecycleEvent","sessionId":"S","params":{"x":1}})})
{:event, "Page.lifecycleEvent", "S", %{"x" => 1}}
iex> CDPEx.Protocol.classify({:ping, "hi"})
{:ping, "hi"}
iex> CDPEx.Protocol.classify({:close, 1000, "bye"})
{:close, 1000, "bye"}
iex> CDPEx.Protocol.classify({:pong, "hi"})
:ignore
"""
@spec classify(frame()) :: classification()
def classify({:text, text}) do
case Jason.decode(text) do
{:ok, %{"id" => id, "result" => result} = msg} ->
{:reply, id, Map.get(msg, "sessionId"), {:ok, result}}
{:ok, %{"id" => id, "error" => error} = msg} ->
{:reply, id, Map.get(msg, "sessionId"), {:error, error}}
{:ok, %{"method" => method} = msg} ->
{:event, method, Map.get(msg, "sessionId"), Map.get(msg, "params", %{})}
_other ->
:ignore
end
end
def classify({:ping, data}), do: {:ping, data}
def classify({:close, code, reason}), do: {:close, code, reason}
def classify(_other), do: :ignore
@doc """
Unwraps a `Runtime.evaluate` result.
A thrown JS exception becomes `{:error, {:evaluate_exception, details}}`; a
returned value (with `returnByValue: true`) becomes `{:ok, value}`; `undefined`
becomes `{:ok, nil}`. A result Chrome can only express as an `unserializableValue`
— `NaN`, `Infinity`, `-0`, or a `BigInt` — has no by-value `value` and becomes
`{:error, {:unserializable_value, uv}}`, carrying the raw `unserializableValue`
string. Anything else (an unrecognized result envelope) falls through to
`{:error, {:unexpected_evaluate, _}}`. (Inputs Chrome can't serialize at all,
like `window` or a circular object, never reach here — the `Runtime.evaluate`
call fails first; see `CDPEx.Page.evaluate/3`.)
## Examples
iex> CDPEx.Protocol.evaluate_result(%{"result" => %{"type" => "string", "value" => "<html>"}})
{:ok, "<html>"}
iex> CDPEx.Protocol.evaluate_result(%{"result" => %{"type" => "number", "value" => 42}})
{:ok, 42}
iex> CDPEx.Protocol.evaluate_result(%{"result" => %{"type" => "undefined"}})
{:ok, nil}
iex> {:error, {:evaluate_exception, _}} =
...> CDPEx.Protocol.evaluate_result(%{"exceptionDetails" => %{"text" => "Uncaught"}})
iex> CDPEx.Protocol.evaluate_result(%{"result" => %{"type" => "bigint", "unserializableValue" => "10n"}})
{:error, {:unserializable_value, "10n"}}
"""
@spec evaluate_result(map()) :: {:ok, term()} | {:error, term()}
def evaluate_result(%{"exceptionDetails" => details}),
do: {:error, {:evaluate_exception, details}}
def evaluate_result(%{"result" => %{"value" => value}}), do: {:ok, value}
def evaluate_result(%{"result" => %{"type" => "undefined"}}), do: {:ok, nil}
def evaluate_result(%{"result" => %{"unserializableValue" => uv}}),
do: {:error, {:unserializable_value, uv}}
def evaluate_result(other), do: {:error, {:unexpected_evaluate, other}}
@doc """
Splits a Chrome DevTools `ws://` or `wss://` URL into `{scheme, host, port, target}`.
Uses `URI.parse/1`, so IPv6 hosts, explicit ports, and paths are handled
correctly; a non-`ws(s)://` URL or one missing a host/port raises `ArgumentError`.
`wss://` denotes a remote/TLS DevTools endpoint (see `CDPEx.connect/2`).
The fourth element is the WebSocket-upgrade **request target** — the path *plus
any `?query`* — so a token-bearing cloud endpoint
(`wss://host/cdp?token=…`) reaches the provider with its query intact.
## Examples
iex> CDPEx.Protocol.parse_ws_url("ws://127.0.0.1:9222/devtools/browser/abc-123")
{"ws", "127.0.0.1", 9222, "/devtools/browser/abc-123"}
iex> CDPEx.Protocol.parse_ws_url("wss://[::1]:9222/devtools/browser/abc")
{"wss", "::1", 9222, "/devtools/browser/abc"}
iex> CDPEx.Protocol.parse_ws_url("wss://chrome.example.com/cdp?token=abc")
{"wss", "chrome.example.com", 443, "/cdp?token=abc"}
"""
@spec parse_ws_url(String.t()) :: {String.t(), String.t(), pos_integer(), String.t()}
def parse_ws_url(ws_url) when is_binary(ws_url) do
case URI.parse(ws_url) do
%URI{scheme: scheme, host: host, port: port, path: path, query: query}
when scheme in ["ws", "wss"] and is_binary(host) and host != "" and is_integer(port) ->
{scheme, host, port, request_target(path, query)}
_ ->
raise ArgumentError,
"expected a ws:// or wss:// URL with a host and port, got: #{inspect(ws_url)}"
end
end
defp request_target(path, nil), do: path || "/"
defp request_target(path, query), do: "#{path || "/"}?#{query}"
@doc """
Brackets an IPv6 host literal so it can be interpolated into a URL
(`::1` -> `[::1]`); other hosts pass through unchanged.
"""
@spec bracket_host(String.t()) :: String.t()
def bracket_host(host) do
if String.contains?(host, ":"), do: "[#{host}]", else: host
end
@doc """
The `:server_name_indication` value for a TLS connection to `host`: the host as a
charlist for a DNS name, or `:disable` for an IP literal (RFC 6066 forbids an IP
address as an SNI server name, and some TLS peers reject it).
"""
@spec sni(String.t()) :: charlist() | :disable
def sni(host) do
case :inet.parse_address(String.to_charlist(host)) do
{:ok, _ip} -> :disable
{:error, _} -> String.to_charlist(host)
end
end
@doc """
JavaScript that neutralises `alert`/`confirm`/`prompt` so modal dialogs can't
block automation. Injected via `Page.addScriptToEvaluateOnNewDocument`.
"""
@spec prevent_alerts_js() :: String.t()
def prevent_alerts_js, do: @prevent_alerts_js
end