% SPDX-License-Identifier: LicenseRef-Sovereign-Leviathan-SL-AGPL3-001 % Licensing: see LICENSE in the repository root. %%%------------------------------------------------------------------- %%% @doc HK-BROWSER :: Chrome's `/json' HTTP control endpoint. %%% %%% Chromium, started with `--remote-debugging-port', exposes a %%% small plain-HTTP API on that port for target (tab) lifecycle -- %%% separate from the per-target CDP WebSocket that `hk_cdp_client' %%% speaks. This module wraps that HTTP surface with `httpc' (part %%% of `inets', already a runtime dependency) so `hk_browser_session' %%% doesn't need its own HTTP client for this one-off, low-volume %%% traffic. %%% @end %%%------------------------------------------------------------------- -module(hk_chrome_http). -export([wait_for_ready/2, new_target/2, close_target/2, version/1]). -define(POLL_INTERVAL_MS, 100). %% @doc Poll `/json/version' until Chrome's debug HTTP server answers %% or `TimeoutMs' elapses. Used right after launching the Chrome OS %% process, whose startup time is not instantaneous or guaranteed. -spec wait_for_ready(inet:port_number(), timeout()) -> ok | {error, timeout}. wait_for_ready(Port, TimeoutMs) -> Deadline = erlang:monotonic_time(millisecond) + TimeoutMs, wait_loop(Port, Deadline). wait_loop(Port, Deadline) -> case version(Port) of {ok, _} -> ok; {error, _} -> case erlang:monotonic_time(millisecond) >= Deadline of true -> {error, timeout}; false -> timer:sleep(?POLL_INTERVAL_MS), wait_loop(Port, Deadline) end end. -spec version(inet:port_number()) -> {ok, map()} | {error, term()}. version(Port) -> get_json("http://127.0.0.1:" ++ integer_to_list(Port) ++ "/json/version"). %% @doc Create a new target (tab) navigated to `Url'; returns its %% CDP target id and WebSocket debugger URL. -spec new_target(inet:port_number(), binary()) -> {ok, #{target_id => binary(), ws_url => binary()}} | {error, term()}. new_target(Port, Url) -> Encoded = uri_string:quote(binary_to_list(Url)), TargetUrl = "http://127.0.0.1:" ++ integer_to_list(Port) ++ "/json/new?" ++ Encoded, %% Chrome >= 111 rejects GET on /json/new with 405 Method Not %% Allowed (a CSRF-style hardening: creating a target must not be %% triggerable by a plain cross-origin GET/link) and requires PUT %% instead. `httpc:request/4' (not the shorthand `/1') is needed to %% pass a method other than the default. case httpc:request(put, {TargetUrl, [], "text/plain", <<>>}, [{timeout, 5000}], []) of {ok, {{_, 200, _}, _, Body}} -> case (catch jsx:decode(list_to_binary(Body), [return_maps])) of #{<<"id">> := TargetId, <<"webSocketDebuggerUrl">> := WsUrl} -> {ok, #{target_id => TargetId, ws_url => WsUrl}}; Other -> {error, {unexpected_response, Other}} end; {ok, {{_, Status, _}, _, _}} -> {error, {http_status, Status}}; {error, Reason} -> {error, Reason} end. -spec close_target(inet:port_number(), binary()) -> ok | {error, term()}. close_target(Port, TargetId) -> Url = "http://127.0.0.1:" ++ integer_to_list(Port) ++ "/json/close/" ++ binary_to_list(TargetId), case httpc:request(get, {Url, []}, [], []) of {ok, {{_, 200, _}, _, _}} -> ok; {ok, {{_, Status, _}, _, _}} -> {error, {http_status, Status}}; {error, Reason} -> {error, Reason} end. %%% internal get_json(Url) -> case httpc:request(get, {Url, []}, [{timeout, 5000}], []) of {ok, {{_, 200, _}, _, Body}} -> try {ok, jsx:decode(list_to_binary(Body), [return_maps])} catch _:_ -> {error, invalid_json} end; {ok, {{_, Status, _}, _, _}} -> {error, {http_status, Status}}; {error, Reason} -> {error, Reason} end.