Download src/hk_cdp_client.erl from Snapkitty/hyperkitty-chromium: direct link, hf CLI and curl.
- Browser
- Download file 6.25 kB
-
https://huggingface.co/Snapkitty/hyperkitty-chromium/resolve/main/src/hk_cdp_client.erl
- Command line
-
hf download hf://Snapkitty/hyperkitty-chromium/src/hk_cdp_client.erl
-
curl -L -o hk_cdp_client.erl https://huggingface.co/Snapkitty/hyperkitty-chromium/resolve/main/src/hk_cdp_client.erl
6.25 kB
| % SPDX-License-Identifier: LicenseRef-Sovereign-Leviathan-SL-AGPL3-001 | |
| % Licensing: see LICENSE in the repository root. | |
| %%%------------------------------------------------------------------- | |
| %%% @doc HK-BROWSER :: low-level Chrome DevTools Protocol transport. | |
| %%% | |
| %%% One `hk_cdp_client' process owns one CDP WebSocket connection to | |
| %%% a single browser target (tab). It is a thin JSON-RPC layer: | |
| %%% | |
| %%% - `command/3' sends `{"id": N, "method": M, "params": P}' and | |
| %%% blocks the caller until the matching `{"id": N, ...}' | |
| %%% response frame arrives (or `TimeoutMs' elapses). | |
| %%% - Frames that carry a `"method"' instead of an `"id"' are CDP | |
| %%% *events* (e.g. `"Page.loadEventFired"'); these are forwarded | |
| %%% as `{cdp_event, self(), Method, Params}' to the owning | |
| %%% `hk_browser_session' (the client's own pid is included since | |
| %%% one session owns several clients, one per tab, and the | |
| %%% session needs to know which tab an event belongs to), which | |
| %%% is what lets navigation completion be observed rather than | |
| %%% guessed at with a sleep. | |
| %%% | |
| %%% This module deliberately knows nothing about HyperKitty's typed | |
| %%% browser operations (navigate/click/type/...) -- that mapping | |
| %%% lives in `hk_browser_session'. Keeping the CDP transport and the | |
| %%% typed capability surface in separate modules is what the spec's | |
| %%% "treat Chromium as an execution substrate controlled through a | |
| %%% well-defined browser protocol rather than mixing browser state | |
| %%% directly into application state" requirement means concretely. | |
| %%% @end | |
| %%%------------------------------------------------------------------- | |
| -module(hk_cdp_client). | |
| -behaviour(gen_server). | |
| -export([start_link/2, command/3, command/4, close/1]). | |
| -export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2]). | |
| -record(state, { | |
| owner :: pid(), | |
| conn :: pid(), | |
| stream_ref :: reference(), | |
| next_id = 1 :: pos_integer(), | |
| pending = #{} :: #{pos_integer() => {pid(), reference()}} | |
| }). | |
| %% @doc Connect to a target's `webSocketDebuggerUrl' (as returned by | |
| %% Chrome's `/json/new' HTTP endpoint). `Owner' receives | |
| %% `{cdp_event, Method, Params}' for every unsolicited CDP event. | |
| -spec start_link(binary(), pid()) -> {ok, pid()} | {error, term()}. | |
| start_link(WsUrl, Owner) -> | |
| gen_server:start_link(?MODULE, {WsUrl, Owner}, []). | |
| -spec command(pid(), binary(), map()) -> {ok, map()} | {error, term()}. | |
| command(Pid, Method, Params) -> | |
| command(Pid, Method, Params, 15000). | |
| -spec command(pid(), binary(), map(), timeout()) -> {ok, map()} | {error, term()}. | |
| command(Pid, Method, Params, TimeoutMs) -> | |
| try | |
| gen_server:call(Pid, {command, Method, Params}, TimeoutMs) | |
| catch | |
| exit:{timeout, _} -> {error, cdp_timeout} | |
| end. | |
| -spec close(pid()) -> ok. | |
| close(Pid) -> | |
| gen_server:cast(Pid, close). | |
| %%% gen_server callbacks | |
| init({WsUrl, Owner}) -> | |
| case parse_ws_url(WsUrl) of | |
| {ok, Host, Port, Path} -> | |
| case gun:open(Host, Port, #{protocols => [http]}) of | |
| {ok, ConnPid} -> | |
| {ok, _} = gun:await_up(ConnPid, 10000), | |
| StreamRef = gun:ws_upgrade(ConnPid, Path), | |
| receive | |
| {gun_upgrade, ConnPid, StreamRef, [<<"websocket">>], _} -> | |
| {ok, #state{owner = Owner, conn = ConnPid, stream_ref = StreamRef}}; | |
| {gun_response, ConnPid, _, _, Status, _} -> | |
| {stop, {ws_upgrade_failed, Status}}; | |
| {gun_error, ConnPid, StreamRef, Reason} -> | |
| {stop, {ws_upgrade_error, Reason}} | |
| after 10000 -> | |
| {stop, ws_upgrade_timeout} | |
| end; | |
| {error, Reason} -> | |
| {stop, {connect_failed, Reason}} | |
| end; | |
| {error, Reason} -> | |
| {stop, {bad_ws_url, Reason}} | |
| end. | |
| handle_call({command, Method, Params}, From, State = #state{next_id = Id, pending = Pending}) -> | |
| Frame = jsx:encode(#{id => Id, method => Method, params => Params}), | |
| gun:ws_send(State#state.conn, State#state.stream_ref, {text, Frame}), | |
| {noreply, State#state{next_id = Id + 1, pending = Pending#{Id => From}}}. | |
| handle_cast(close, State) -> | |
| gun:close(State#state.conn), | |
| {stop, normal, State}. | |
| handle_info({gun_ws, ConnPid, StreamRef, {text, Frame}}, | |
| State = #state{conn = ConnPid, stream_ref = StreamRef}) -> | |
| handle_frame(jsx:decode(Frame, [return_maps]), State); | |
| handle_info({gun_down, ConnPid, _, _Reason, _}, State = #state{conn = ConnPid}) -> | |
| fail_all_pending(State#state.pending, cdp_connection_down), | |
| State#state.owner ! {cdp_event, self(), <<"__connection_down">>, #{}}, | |
| {noreply, State#state{pending = #{}}}; | |
| handle_info(_Info, State) -> | |
| {noreply, State}. | |
| terminate(_Reason, State) -> | |
| fail_all_pending(State#state.pending, cdp_client_terminated), | |
| ok. | |
| %%% internal | |
| handle_frame(#{<<"id">> := Id} = Msg, State = #state{pending = Pending}) -> | |
| case maps:take(Id, Pending) of | |
| {From, Pending2} -> | |
| Reply = case Msg of | |
| #{<<"error">> := Err} -> {error, Err}; | |
| #{<<"result">> := Result} -> {ok, Result}; | |
| _ -> {ok, #{}} | |
| end, | |
| gen_server:reply(From, Reply), | |
| {noreply, State#state{pending = Pending2}}; | |
| error -> | |
| {noreply, State} | |
| end; | |
| handle_frame(#{<<"method">> := Method} = Msg, State) -> | |
| Params = maps:get(<<"params">>, Msg, #{}), | |
| State#state.owner ! {cdp_event, self(), Method, Params}, | |
| {noreply, State}; | |
| handle_frame(_Other, State) -> | |
| {noreply, State}. | |
| fail_all_pending(Pending, Reason) -> | |
| maps:foreach(fun(_Id, From) -> gen_server:reply(From, {error, Reason}) end, Pending). | |
| parse_ws_url(Url) -> | |
| case uri_string:parse(Url) of | |
| #{host := Host, port := Port, path := Path} -> | |
| {ok, binary_to_list(Host), Port, binary_to_list(Path)}; | |
| #{host := Host, path := Path} -> | |
| {ok, binary_to_list(Host), 80, binary_to_list(Path)}; | |
| {error, Reason, _} -> | |
| {error, Reason} | |
| end. | |