SNAPKITTYWEST commited on
Commit
ceece2e
·
verified ·
1 Parent(s): 43d2fa5

Add src/hk_cdp_client.erl

Browse files
Files changed (1) hide show
  1. src/hk_cdp_client.erl +147 -0
src/hk_cdp_client.erl ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ %%%-------------------------------------------------------------------
2
+ %%% @doc HK-BROWSER :: low-level Chrome DevTools Protocol transport.
3
+ %%%
4
+ %%% One `hk_cdp_client' process owns one CDP WebSocket connection to
5
+ %%% a single browser target (tab). It is a thin JSON-RPC layer:
6
+ %%%
7
+ %%% - `command/3' sends `{"id": N, "method": M, "params": P}' and
8
+ %%% blocks the caller until the matching `{"id": N, ...}'
9
+ %%% response frame arrives (or `TimeoutMs' elapses).
10
+ %%% - Frames that carry a `"method"' instead of an `"id"' are CDP
11
+ %%% *events* (e.g. `"Page.loadEventFired"'); these are forwarded
12
+ %%% as `{cdp_event, self(), Method, Params}' to the owning
13
+ %%% `hk_browser_session' (the client's own pid is included since
14
+ %%% one session owns several clients, one per tab, and the
15
+ %%% session needs to know which tab an event belongs to), which
16
+ %%% is what lets navigation completion be observed rather than
17
+ %%% guessed at with a sleep.
18
+ %%%
19
+ %%% This module deliberately knows nothing about HyperKitty's typed
20
+ %%% browser operations (navigate/click/type/...) -- that mapping
21
+ %%% lives in `hk_browser_session'. Keeping the CDP transport and the
22
+ %%% typed capability surface in separate modules is what the spec's
23
+ %%% "treat Chromium as an execution substrate controlled through a
24
+ %%% well-defined browser protocol rather than mixing browser state
25
+ %%% directly into application state" requirement means concretely.
26
+ %%% @end
27
+ %%%-------------------------------------------------------------------
28
+ -module(hk_cdp_client).
29
+ -behaviour(gen_server).
30
+
31
+ -export([start_link/2, command/3, command/4, close/1]).
32
+ -export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2]).
33
+
34
+ -record(state, {
35
+ owner :: pid(),
36
+ conn :: pid(),
37
+ stream_ref :: reference(),
38
+ next_id = 1 :: pos_integer(),
39
+ pending = #{} :: #{pos_integer() => {pid(), reference()}}
40
+ }).
41
+
42
+ %% @doc Connect to a target's `webSocketDebuggerUrl' (as returned by
43
+ %% Chrome's `/json/new' HTTP endpoint). `Owner' receives
44
+ %% `{cdp_event, Method, Params}' for every unsolicited CDP event.
45
+ -spec start_link(binary(), pid()) -> {ok, pid()} | {error, term()}.
46
+ start_link(WsUrl, Owner) ->
47
+ gen_server:start_link(?MODULE, {WsUrl, Owner}, []).
48
+
49
+ -spec command(pid(), binary(), map()) -> {ok, map()} | {error, term()}.
50
+ command(Pid, Method, Params) ->
51
+ command(Pid, Method, Params, 15000).
52
+
53
+ -spec command(pid(), binary(), map(), timeout()) -> {ok, map()} | {error, term()}.
54
+ command(Pid, Method, Params, TimeoutMs) ->
55
+ try
56
+ gen_server:call(Pid, {command, Method, Params}, TimeoutMs)
57
+ catch
58
+ exit:{timeout, _} -> {error, cdp_timeout}
59
+ end.
60
+
61
+ -spec close(pid()) -> ok.
62
+ close(Pid) ->
63
+ gen_server:cast(Pid, close).
64
+
65
+ %%% gen_server callbacks
66
+
67
+ init({WsUrl, Owner}) ->
68
+ case parse_ws_url(WsUrl) of
69
+ {ok, Host, Port, Path} ->
70
+ case gun:open(Host, Port, #{protocols => [http]}) of
71
+ {ok, ConnPid} ->
72
+ {ok, _} = gun:await_up(ConnPid, 10000),
73
+ StreamRef = gun:ws_upgrade(ConnPid, Path),
74
+ receive
75
+ {gun_upgrade, ConnPid, StreamRef, [<<"websocket">>], _} ->
76
+ {ok, #state{owner = Owner, conn = ConnPid, stream_ref = StreamRef}};
77
+ {gun_response, ConnPid, _, _, Status, _} ->
78
+ {stop, {ws_upgrade_failed, Status}};
79
+ {gun_error, ConnPid, StreamRef, Reason} ->
80
+ {stop, {ws_upgrade_error, Reason}}
81
+ after 10000 ->
82
+ {stop, ws_upgrade_timeout}
83
+ end;
84
+ {error, Reason} ->
85
+ {stop, {connect_failed, Reason}}
86
+ end;
87
+ {error, Reason} ->
88
+ {stop, {bad_ws_url, Reason}}
89
+ end.
90
+
91
+ handle_call({command, Method, Params}, From, State = #state{next_id = Id, pending = Pending}) ->
92
+ Frame = jsx:encode(#{id => Id, method => Method, params => Params}),
93
+ gun:ws_send(State#state.conn, State#state.stream_ref, {text, Frame}),
94
+ {noreply, State#state{next_id = Id + 1, pending = Pending#{Id => From}}}.
95
+
96
+ handle_cast(close, State) ->
97
+ gun:close(State#state.conn),
98
+ {stop, normal, State}.
99
+
100
+ handle_info({gun_ws, ConnPid, StreamRef, {text, Frame}},
101
+ State = #state{conn = ConnPid, stream_ref = StreamRef}) ->
102
+ handle_frame(jsx:decode(Frame, [return_maps]), State);
103
+ handle_info({gun_down, ConnPid, _, _Reason, _}, State = #state{conn = ConnPid}) ->
104
+ fail_all_pending(State#state.pending, cdp_connection_down),
105
+ State#state.owner ! {cdp_event, self(), <<"__connection_down">>, #{}},
106
+ {noreply, State#state{pending = #{}}};
107
+ handle_info(_Info, State) ->
108
+ {noreply, State}.
109
+
110
+ terminate(_Reason, State) ->
111
+ fail_all_pending(State#state.pending, cdp_client_terminated),
112
+ ok.
113
+
114
+ %%% internal
115
+
116
+ handle_frame(#{<<"id">> := Id} = Msg, State = #state{pending = Pending}) ->
117
+ case maps:take(Id, Pending) of
118
+ {From, Pending2} ->
119
+ Reply = case Msg of
120
+ #{<<"error">> := Err} -> {error, Err};
121
+ #{<<"result">> := Result} -> {ok, Result};
122
+ _ -> {ok, #{}}
123
+ end,
124
+ gen_server:reply(From, Reply),
125
+ {noreply, State#state{pending = Pending2}};
126
+ error ->
127
+ {noreply, State}
128
+ end;
129
+ handle_frame(#{<<"method">> := Method} = Msg, State) ->
130
+ Params = maps:get(<<"params">>, Msg, #{}),
131
+ State#state.owner ! {cdp_event, self(), Method, Params},
132
+ {noreply, State};
133
+ handle_frame(_Other, State) ->
134
+ {noreply, State}.
135
+
136
+ fail_all_pending(Pending, Reason) ->
137
+ maps:foreach(fun(_Id, From) -> gen_server:reply(From, {error, Reason}) end, Pending).
138
+
139
+ parse_ws_url(Url) ->
140
+ case uri_string:parse(Url) of
141
+ #{host := Host, port := Port, path := Path} ->
142
+ {ok, binary_to_list(Host), Port, binary_to_list(Path)};
143
+ #{host := Host, path := Path} ->
144
+ {ok, binary_to_list(Host), 80, binary_to_list(Path)};
145
+ {error, Reason, _} ->
146
+ {error, Reason}
147
+ end.