File size: 3,989 Bytes
0196580 7074ec9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 | % 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.
|