ferrum 0.17.2 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +4 -2
- data/lib/ferrum/accessibility/ax_node.rb +108 -0
- data/lib/ferrum/accessibility.rb +106 -0
- data/lib/ferrum/browser/binary.rb +42 -2
- data/lib/ferrum/browser/command.rb +20 -2
- data/lib/ferrum/browser/options/base.rb +66 -0
- data/lib/ferrum/browser/options/chrome.rb +35 -2
- data/lib/ferrum/browser/options.rb +41 -2
- data/lib/ferrum/browser/process/killer.rb +188 -0
- data/lib/ferrum/browser/process.rb +128 -72
- data/lib/ferrum/browser/xvfb.rb +24 -0
- data/lib/ferrum/browser.rb +47 -17
- data/lib/ferrum/client/subscriber.rb +65 -2
- data/lib/ferrum/client/web_socket.rb +126 -13
- data/lib/ferrum/client.rb +225 -12
- data/lib/ferrum/context.rb +113 -7
- data/lib/ferrum/contexts.rb +188 -19
- data/lib/ferrum/cookies/cookie.rb +6 -0
- data/lib/ferrum/cookies.rb +5 -0
- data/lib/ferrum/dialog.rb +18 -2
- data/lib/ferrum/downloads.rb +54 -1
- data/lib/ferrum/errors.rb +103 -4
- data/lib/ferrum/frame/dom.rb +65 -0
- data/lib/ferrum/frame/runtime.rb +57 -15
- data/lib/ferrum/frame.rb +58 -1
- data/lib/ferrum/headers.rb +6 -0
- data/lib/ferrum/interceptable.rb +83 -0
- data/lib/ferrum/keyboard.rb +25 -0
- data/lib/ferrum/mouse.rb +6 -0
- data/lib/ferrum/network/auth_request.rb +81 -2
- data/lib/ferrum/network/error.rb +15 -0
- data/lib/ferrum/network/exchange.rb +10 -0
- data/lib/ferrum/network/intercepted_request.rb +94 -2
- data/lib/ferrum/network/request.rb +1 -1
- data/lib/ferrum/network/response.rb +2 -0
- data/lib/ferrum/network.rb +121 -9
- data/lib/ferrum/node.rb +305 -15
- data/lib/ferrum/page/animation.rb +5 -0
- data/lib/ferrum/page/frames.rb +69 -7
- data/lib/ferrum/page/screencast.rb +5 -0
- data/lib/ferrum/page/screenshot.rb +55 -23
- data/lib/ferrum/page/stream.rb +71 -0
- data/lib/ferrum/page/tracing.rb +6 -0
- data/lib/ferrum/page.rb +148 -45
- data/lib/ferrum/proxy.rb +52 -2
- data/lib/ferrum/rgba.rb +10 -0
- data/lib/ferrum/target.rb +130 -3
- data/lib/ferrum/utils/attempt.rb +20 -0
- data/lib/ferrum/utils/elapsed_time.rb +38 -0
- data/lib/ferrum/utils/event.rb +14 -0
- data/lib/ferrum/utils/platform.rb +21 -0
- data/lib/ferrum/utils/thread.rb +15 -1
- data/lib/ferrum/version.rb +1 -1
- data/lib/ferrum/worker.rb +125 -0
- data/lib/ferrum.rb +7 -0
- metadata +6 -16
- data/lib/ferrum/browser/options/firefox.rb +0 -38
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
module Ferrum
|
|
4
4
|
class Client
|
|
5
|
+
#
|
|
6
|
+
# Dispatches incoming CDP events to registered callbacks. Messages are
|
|
7
|
+
# queued and processed on dedicated threads, with `Fetch.requestPaused`
|
|
8
|
+
# and `Fetch.authRequired` given priority so request interception isn't
|
|
9
|
+
# delayed behind other events.
|
|
10
|
+
#
|
|
5
11
|
class Subscriber
|
|
6
12
|
INTERRUPTIONS = %w[Fetch.requestPaused Fetch.authRequired].freeze
|
|
7
13
|
|
|
@@ -13,6 +19,16 @@ module Ferrum
|
|
|
13
19
|
start
|
|
14
20
|
end
|
|
15
21
|
|
|
22
|
+
#
|
|
23
|
+
# Enqueues an incoming CDP message for dispatch to subscribers.
|
|
24
|
+
# `Fetch.requestPaused`/`Fetch.authRequired` messages jump the regular
|
|
25
|
+
# queue so request interception isn't delayed behind other events.
|
|
26
|
+
#
|
|
27
|
+
# @param [Hash] message
|
|
28
|
+
# The raw CDP message, as parsed from the websocket.
|
|
29
|
+
#
|
|
30
|
+
# @return [void]
|
|
31
|
+
#
|
|
16
32
|
def <<(message)
|
|
17
33
|
if INTERRUPTIONS.include?(message["method"])
|
|
18
34
|
@priority.push(message)
|
|
@@ -21,26 +37,68 @@ module Ferrum
|
|
|
21
37
|
end
|
|
22
38
|
end
|
|
23
39
|
|
|
40
|
+
#
|
|
41
|
+
# Registers a callback for a CDP event.
|
|
42
|
+
#
|
|
43
|
+
# @param [String] event
|
|
44
|
+
# The event key, as built by {SessionClient.event_name}.
|
|
45
|
+
#
|
|
46
|
+
# @return [Integer]
|
|
47
|
+
# The callback's index within the event's callback list, used to
|
|
48
|
+
# unsubscribe via {#off}.
|
|
49
|
+
#
|
|
24
50
|
def on(event, &block)
|
|
25
51
|
@on[event] ||= Concurrent::Array.new
|
|
26
52
|
@on[event] << block
|
|
27
53
|
@on[event].index(block)
|
|
28
54
|
end
|
|
29
55
|
|
|
56
|
+
#
|
|
57
|
+
# Unregisters a callback for a CDP event.
|
|
58
|
+
#
|
|
59
|
+
# @param [String] event
|
|
60
|
+
# The event key, as built by {SessionClient.event_name}.
|
|
61
|
+
#
|
|
62
|
+
# @param [Integer] id
|
|
63
|
+
# The callback's index, as returned by {#on}.
|
|
64
|
+
#
|
|
65
|
+
# @return [Boolean]
|
|
66
|
+
#
|
|
30
67
|
def off(event, id)
|
|
31
68
|
@on[event].delete_at(id)
|
|
32
69
|
true
|
|
33
70
|
end
|
|
34
71
|
|
|
72
|
+
#
|
|
73
|
+
# Whether there's at least one callback registered for the event.
|
|
74
|
+
#
|
|
75
|
+
# @param [String] event
|
|
76
|
+
# The event key, as built by {SessionClient.event_name}.
|
|
77
|
+
#
|
|
78
|
+
# @return [Boolean]
|
|
79
|
+
#
|
|
35
80
|
def subscribed?(event)
|
|
36
81
|
@on.key?(event)
|
|
37
82
|
end
|
|
38
83
|
|
|
84
|
+
#
|
|
85
|
+
# Stops the regular and priority dispatch threads.
|
|
86
|
+
#
|
|
87
|
+
# @return [void]
|
|
88
|
+
#
|
|
39
89
|
def close
|
|
40
90
|
@regular_thread&.kill
|
|
41
91
|
@priority_thread&.kill
|
|
42
92
|
end
|
|
43
93
|
|
|
94
|
+
#
|
|
95
|
+
# Removes all callbacks registered for a given session.
|
|
96
|
+
#
|
|
97
|
+
# @param [String] session_id
|
|
98
|
+
# The session id to match against registered event keys.
|
|
99
|
+
#
|
|
100
|
+
# @return [void]
|
|
101
|
+
#
|
|
44
102
|
def clear(session_id:)
|
|
45
103
|
@on.delete_if { |k, _| k.match?(session_id) }
|
|
46
104
|
end
|
|
@@ -48,7 +106,7 @@ module Ferrum
|
|
|
48
106
|
private
|
|
49
107
|
|
|
50
108
|
def start
|
|
51
|
-
@regular_thread = Utils::Thread.spawn
|
|
109
|
+
@regular_thread = Utils::Thread.spawn do
|
|
52
110
|
loop do
|
|
53
111
|
message = @regular.pop
|
|
54
112
|
break unless message
|
|
@@ -57,7 +115,7 @@ module Ferrum
|
|
|
57
115
|
end
|
|
58
116
|
end
|
|
59
117
|
|
|
60
|
-
@priority_thread = Utils::Thread.spawn
|
|
118
|
+
@priority_thread = Utils::Thread.spawn do
|
|
61
119
|
loop do
|
|
62
120
|
message = @priority.pop
|
|
63
121
|
break unless message
|
|
@@ -75,6 +133,11 @@ module Ferrum
|
|
|
75
133
|
@on[event]&.each_with_index do |block, index|
|
|
76
134
|
# In case of multiple callbacks we provide current index and total
|
|
77
135
|
block.call(params, index, total)
|
|
136
|
+
rescue StandardError => e
|
|
137
|
+
# A raising callback used to terminate the dispatch thread, and with it every
|
|
138
|
+
# event for the rest of the browser's life: targets stopped being registered
|
|
139
|
+
# and any page created later raised `NoSuchTargetError`. Report and carry on.
|
|
140
|
+
warn("Ferrum: #{event} callback raised #{e.class}: #{e.message}\n #{e.backtrace&.first}")
|
|
78
141
|
end
|
|
79
142
|
end
|
|
80
143
|
end
|
|
@@ -6,6 +6,12 @@ require "websocket/driver"
|
|
|
6
6
|
|
|
7
7
|
module Ferrum
|
|
8
8
|
class Client
|
|
9
|
+
#
|
|
10
|
+
# Low-level WebSocket connection to the browser's CDP endpoint. Opens
|
|
11
|
+
# the raw TCP/TLS socket, drives the `websocket-driver` handshake and
|
|
12
|
+
# framing, and exposes a queue of parsed incoming messages alongside
|
|
13
|
+
# methods to send commands and close the connection.
|
|
14
|
+
#
|
|
9
15
|
class WebSocket
|
|
10
16
|
WEBSOCKET_BUG_SLEEP = 0.05
|
|
11
17
|
DEFAULT_PORTS = { "ws" => 80, "wss" => 443 }.freeze
|
|
@@ -30,7 +36,10 @@ module Ferrum
|
|
|
30
36
|
end
|
|
31
37
|
|
|
32
38
|
max_receive_size ||= ::WebSocket::Driver::MAX_LENGTH
|
|
33
|
-
@driver
|
|
39
|
+
@driver = ::WebSocket::Driver.client(self, max_length: max_receive_size)
|
|
40
|
+
# websocket-driver holds no locks and is called from many threads: commands from
|
|
41
|
+
# callers, pong/close replies from the reader. One lock keeps frames from interleaving.
|
|
42
|
+
@driver_mutex = Mutex.new
|
|
34
43
|
@messages = Queue.new
|
|
35
44
|
|
|
36
45
|
@screenshot_commands = Concurrent::Hash.new if SKIP_LOGGING_SCREENSHOTS
|
|
@@ -41,22 +50,28 @@ module Ferrum
|
|
|
41
50
|
|
|
42
51
|
start
|
|
43
52
|
|
|
44
|
-
@driver.start
|
|
53
|
+
@driver_mutex.synchronize { @driver.start }
|
|
45
54
|
end
|
|
46
55
|
|
|
56
|
+
#
|
|
57
|
+
# Handles the driver's `:open` event.
|
|
58
|
+
#
|
|
59
|
+
# @return [void]
|
|
60
|
+
#
|
|
47
61
|
def on_open(_event)
|
|
48
62
|
# https://github.com/faye/websocket-driver-ruby/issues/46
|
|
49
63
|
sleep(WEBSOCKET_BUG_SLEEP)
|
|
50
64
|
end
|
|
51
65
|
|
|
66
|
+
#
|
|
67
|
+
# Handles the driver's `:message` event: parses the incoming frame
|
|
68
|
+
# as JSON and pushes it onto {#messages}. Malformed payloads are
|
|
69
|
+
# dropped rather than raised, to avoid crashing the reader thread.
|
|
70
|
+
#
|
|
71
|
+
# @return [void]
|
|
72
|
+
#
|
|
52
73
|
def on_message(event)
|
|
53
74
|
data = safely_parse_json(event.data)
|
|
54
|
-
# If we couldn't parse JSON data for some reason (parse error or deeply nested object) we
|
|
55
|
-
# don't push response to @messages. Worse that could happen we raise timeout error due to command didn't return
|
|
56
|
-
# anything or skip the background notification, but at least we don't crash the thread that crashes the main
|
|
57
|
-
# thread and the application.
|
|
58
|
-
@messages.push(data) if data
|
|
59
|
-
|
|
60
75
|
output = event.data
|
|
61
76
|
if SKIP_LOGGING_SCREENSHOTS && @screenshot_commands[data&.dig("id")]
|
|
62
77
|
@screenshot_commands.delete(data&.dig("id"))
|
|
@@ -64,43 +79,141 @@ module Ferrum
|
|
|
64
79
|
end
|
|
65
80
|
|
|
66
81
|
@logger&.puts(" ◀ #{Utils::ElapsedTime.elapsed_time} #{output}\n")
|
|
82
|
+
|
|
83
|
+
# If we couldn't parse JSON data for some reason (parse error or deeply nested object) we
|
|
84
|
+
# don't push response to @messages. Worse that could happen we raise timeout error due to command didn't return
|
|
85
|
+
# anything or skip the background notification, but at least we don't crash the thread that crashes the main
|
|
86
|
+
# thread and the application.
|
|
87
|
+
@messages.push(data) if data
|
|
67
88
|
end
|
|
68
89
|
|
|
90
|
+
#
|
|
91
|
+
# Handles the driver's `:close` event: closes the message queue and
|
|
92
|
+
# underlying socket, then kills the reader thread.
|
|
93
|
+
#
|
|
94
|
+
# @return [void]
|
|
95
|
+
#
|
|
69
96
|
def on_close(_event)
|
|
70
97
|
@messages.close
|
|
71
98
|
@sock.close
|
|
72
99
|
@thread.kill
|
|
73
100
|
end
|
|
74
101
|
|
|
75
|
-
|
|
102
|
+
#
|
|
103
|
+
# Serializes a CDP command to JSON and sends it as a websocket text
|
|
104
|
+
# frame.
|
|
105
|
+
#
|
|
106
|
+
# @param [Hash] data
|
|
107
|
+
# The message to send, must include an `:id` key.
|
|
108
|
+
#
|
|
109
|
+
# @param [Numeric, nil] timeout
|
|
110
|
+
# How long the browser may go without accepting any of the frame's
|
|
111
|
+
# bytes. `nil` waits for as long as it takes.
|
|
112
|
+
#
|
|
113
|
+
# @return [void]
|
|
114
|
+
#
|
|
115
|
+
# @raise [Ferrum::DeadBrowserError]
|
|
116
|
+
# If the browser accepted no data for `timeout` seconds. The connection
|
|
117
|
+
# is closed, since the frame was cut off midway.
|
|
118
|
+
#
|
|
119
|
+
def send_message(data, timeout = nil)
|
|
76
120
|
@screenshot_commands[data[:id]] = true if SKIP_LOGGING_SCREENSHOTS
|
|
77
121
|
|
|
78
122
|
json = data.to_json
|
|
79
|
-
@
|
|
123
|
+
@driver_mutex.synchronize do
|
|
124
|
+
@write_timeout = timeout
|
|
125
|
+
@driver.text(json)
|
|
126
|
+
ensure
|
|
127
|
+
@write_timeout = nil
|
|
128
|
+
end
|
|
80
129
|
@logger&.puts("\n\n▶ #{Utils::ElapsedTime.elapsed_time} #{json}")
|
|
81
130
|
end
|
|
82
131
|
|
|
132
|
+
#
|
|
133
|
+
# Writes raw bytes to the underlying socket. Called by
|
|
134
|
+
# `websocket-driver` to emit frames. Closes {#messages} instead of
|
|
135
|
+
# raising if the connection has already been torn down.
|
|
136
|
+
#
|
|
137
|
+
# A browser that stops reading its socket (hung, or stopped) would block
|
|
138
|
+
# a plain write forever, holding the lock every other command needs to be
|
|
139
|
+
# sent. So the write gives up once the browser accepts no data for the
|
|
140
|
+
# timeout of the command being sent.
|
|
141
|
+
#
|
|
142
|
+
# @param [String] data
|
|
143
|
+
# The raw bytes to write.
|
|
144
|
+
#
|
|
145
|
+
# @return [void]
|
|
146
|
+
#
|
|
147
|
+
# @raise [Ferrum::DeadBrowserError]
|
|
148
|
+
# If the browser accepted no data for the command's timeout.
|
|
149
|
+
#
|
|
83
150
|
def write(data)
|
|
84
|
-
|
|
151
|
+
until data.empty?
|
|
152
|
+
written = @sock.write_nonblock(data, exception: false)
|
|
153
|
+
if written.is_a?(Integer)
|
|
154
|
+
data = data.byteslice(written..)
|
|
155
|
+
elsif !wait_for_socket(written)
|
|
156
|
+
abandon_stalled_write
|
|
157
|
+
end
|
|
158
|
+
end
|
|
85
159
|
rescue EOFError, Errno::ECONNRESET, Errno::EPIPE, IOError # rubocop:disable Lint/ShadowedException
|
|
86
160
|
@messages.close
|
|
87
161
|
end
|
|
88
162
|
|
|
163
|
+
#
|
|
164
|
+
# Closes the websocket connection by sending a close frame.
|
|
165
|
+
#
|
|
166
|
+
# @return [void]
|
|
167
|
+
#
|
|
89
168
|
def close
|
|
90
|
-
@driver.close
|
|
169
|
+
@driver_mutex.synchronize { @driver.close }
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
#
|
|
173
|
+
# Closes the underlying socket whether or not the close handshake ever
|
|
174
|
+
# completed.
|
|
175
|
+
#
|
|
176
|
+
# {#close} only sends a close frame; the socket itself is closed by
|
|
177
|
+
# {#on_close}, which runs when the browser answers with a close frame of
|
|
178
|
+
# its own. A browser that is already dead never answers, so without this
|
|
179
|
+
# the socket stays open until GC finalizes it -- long enough to exhaust
|
|
180
|
+
# the process's file descriptors when browsers are restarted in a loop.
|
|
181
|
+
#
|
|
182
|
+
# @return [void]
|
|
183
|
+
#
|
|
184
|
+
def force_close
|
|
185
|
+
@sock.close unless @sock.closed?
|
|
186
|
+
rescue IOError, SystemCallError
|
|
187
|
+
# nop
|
|
91
188
|
end
|
|
92
189
|
|
|
93
190
|
private
|
|
94
191
|
|
|
192
|
+
def wait_for_socket(state)
|
|
193
|
+
readers, writers = state == :wait_readable ? [[@sock], nil] : [nil, [@sock]]
|
|
194
|
+
IO.select(readers, writers, nil, @write_timeout)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def abandon_stalled_write
|
|
198
|
+
timeout = @write_timeout
|
|
199
|
+
@messages.close
|
|
200
|
+
@sock.close
|
|
201
|
+
raise DeadBrowserError, "Browser accepted no data for #{timeout}s and is likely stuck, connection closed"
|
|
202
|
+
end
|
|
203
|
+
|
|
95
204
|
def start
|
|
96
205
|
@thread = Utils::Thread.spawn do
|
|
97
206
|
loop do
|
|
98
207
|
data = @sock.readpartial(512)
|
|
99
208
|
break unless data
|
|
100
209
|
|
|
101
|
-
@driver.parse(data)
|
|
210
|
+
@driver_mutex.synchronize { @driver.parse(data) }
|
|
102
211
|
end
|
|
103
212
|
rescue EOFError, Errno::ECONNRESET, Errno::EPIPE, IOError # rubocop:disable Lint/ShadowedException
|
|
213
|
+
# The browser went away, nothing to report.
|
|
214
|
+
rescue StandardError => e
|
|
215
|
+
warn("Ferrum: websocket reader stopped, #{e.class}: #{e.message}\n #{e.backtrace&.first}")
|
|
216
|
+
ensure
|
|
104
217
|
@messages.close
|
|
105
218
|
end
|
|
106
219
|
end
|
data/lib/ferrum/client.rb
CHANGED
|
@@ -7,9 +7,29 @@ require "ferrum/client/web_socket"
|
|
|
7
7
|
require "ferrum/utils/thread"
|
|
8
8
|
|
|
9
9
|
module Ferrum
|
|
10
|
+
#
|
|
11
|
+
# A thin wrapper around {Client} that scopes commands and events to a
|
|
12
|
+
# single CDP session (a `sessionId` obtained via `Target.attachToTarget`).
|
|
13
|
+
# Targets connect through one of these rather than the top-level {Client}
|
|
14
|
+
# directly, so their commands/events don't leak into other sessions.
|
|
15
|
+
# Method calls not defined here are forwarded to the underlying {Client}.
|
|
16
|
+
#
|
|
10
17
|
class SessionClient
|
|
11
18
|
attr_reader :client, :session_id
|
|
12
19
|
|
|
20
|
+
#
|
|
21
|
+
# Builds the internal event key used to route an event to a specific
|
|
22
|
+
# session, joining the CDP event name with a session id (or leaving it
|
|
23
|
+
# session-less when `session_id` is `nil`).
|
|
24
|
+
#
|
|
25
|
+
# @param [String] event
|
|
26
|
+
# The CDP event name, e.g. `"Page.loadEventFired"`.
|
|
27
|
+
#
|
|
28
|
+
# @param [String, nil] session_id
|
|
29
|
+
# The target session id, or `nil` for the browser-wide session.
|
|
30
|
+
#
|
|
31
|
+
# @return [String]
|
|
32
|
+
#
|
|
13
33
|
def self.event_name(event, session_id)
|
|
14
34
|
[event, session_id].compact.join("_")
|
|
15
35
|
end
|
|
@@ -19,31 +39,93 @@ module Ferrum
|
|
|
19
39
|
@session_id = session_id
|
|
20
40
|
end
|
|
21
41
|
|
|
22
|
-
|
|
42
|
+
#
|
|
43
|
+
# Sends a CDP command scoped to this session.
|
|
44
|
+
#
|
|
45
|
+
# @param [String] method
|
|
46
|
+
# The CDP method name, e.g. `"Page.navigate"`.
|
|
47
|
+
#
|
|
48
|
+
# @param [Boolean] async
|
|
49
|
+
# Whether to send the command without waiting for a response.
|
|
50
|
+
#
|
|
51
|
+
# @param [Hash] params
|
|
52
|
+
# The command's parameters.
|
|
53
|
+
#
|
|
54
|
+
# @param [Numeric, nil] timeout
|
|
55
|
+
# How long to wait for this command's response, overriding
|
|
56
|
+
# {Browser::Options#protocol_timeout}. See {Client#send_message}.
|
|
57
|
+
#
|
|
58
|
+
# @return [Boolean, Hash]
|
|
59
|
+
# `true` when sent asynchronously, otherwise the command's result.
|
|
60
|
+
#
|
|
61
|
+
def command(method, async: false, timeout: nil, **params)
|
|
23
62
|
message = build_message(method, params)
|
|
24
|
-
@client.send_message(message, async: async)
|
|
63
|
+
@client.send_message(message, async: async, timeout: timeout)
|
|
25
64
|
end
|
|
26
65
|
|
|
66
|
+
#
|
|
67
|
+
# Subscribes to a CDP event scoped to this session.
|
|
68
|
+
#
|
|
69
|
+
# @param [String] event
|
|
70
|
+
# The CDP event name.
|
|
71
|
+
#
|
|
72
|
+
# @return [Integer]
|
|
73
|
+
# The subscription id, used to unsubscribe via {#off}.
|
|
74
|
+
#
|
|
27
75
|
def on(event, &)
|
|
28
76
|
@client.on(event_name(event), &)
|
|
29
77
|
end
|
|
30
78
|
|
|
79
|
+
#
|
|
80
|
+
# Unsubscribes from a CDP event scoped to this session.
|
|
81
|
+
#
|
|
82
|
+
# @param [String] event
|
|
83
|
+
# The CDP event name.
|
|
84
|
+
#
|
|
85
|
+
# @param [Integer] id
|
|
86
|
+
# The subscription id returned by {#on}.
|
|
87
|
+
#
|
|
88
|
+
# @return [void]
|
|
89
|
+
#
|
|
31
90
|
def off(event, id)
|
|
32
91
|
@client.off(event_name(event), id)
|
|
33
92
|
end
|
|
34
93
|
|
|
94
|
+
# Whether there's at least one callback registered for the event, scoped
|
|
95
|
+
# to this session.
|
|
96
|
+
#
|
|
97
|
+
# @param [String] event
|
|
98
|
+
# The CDP event name.
|
|
99
|
+
#
|
|
100
|
+
# @return [Boolean]
|
|
35
101
|
def subscribed?(event)
|
|
36
102
|
@client.subscribed?(event_name(event))
|
|
37
103
|
end
|
|
38
104
|
|
|
105
|
+
# Supports {#method_missing} delegation by reporting the underlying
|
|
106
|
+
# {Client}'s methods as responded to.
|
|
107
|
+
#
|
|
108
|
+
# @return [Boolean]
|
|
39
109
|
def respond_to_missing?(name, include_private)
|
|
40
110
|
@client.respond_to?(name, include_private)
|
|
41
111
|
end
|
|
42
112
|
|
|
113
|
+
#
|
|
114
|
+
# Delegates any method not defined on `SessionClient` to the underlying
|
|
115
|
+
# {Client}, e.g. `subscribed?`.
|
|
116
|
+
#
|
|
117
|
+
# @return [untyped]
|
|
118
|
+
#
|
|
43
119
|
def method_missing(name, ...)
|
|
44
120
|
@client.send(name, ...)
|
|
45
121
|
end
|
|
46
122
|
|
|
123
|
+
#
|
|
124
|
+
# Removes all event callbacks registered for this session from the
|
|
125
|
+
# underlying client's subscriber.
|
|
126
|
+
#
|
|
127
|
+
# @return [void]
|
|
128
|
+
#
|
|
47
129
|
def close
|
|
48
130
|
@client.subscriber.clear(session_id: session_id)
|
|
49
131
|
end
|
|
@@ -59,15 +141,22 @@ module Ferrum
|
|
|
59
141
|
end
|
|
60
142
|
end
|
|
61
143
|
|
|
144
|
+
#
|
|
145
|
+
# The low-level CDP client. Owns the {WebSocket} connection to the browser,
|
|
146
|
+
# assigns command ids, matches responses back to their pending commands and
|
|
147
|
+
# dispatches incoming events to the {Subscriber}. {SessionClient} builds on
|
|
148
|
+
# top of it to scope commands/events to a particular target's session.
|
|
149
|
+
#
|
|
62
150
|
class Client
|
|
63
151
|
extend Forwardable
|
|
64
152
|
|
|
65
|
-
delegate %i[
|
|
153
|
+
delegate %i[protocol_timeout protocol_timeout=] => :options
|
|
66
154
|
|
|
67
155
|
attr_reader :ws_url, :options, :subscriber
|
|
68
156
|
|
|
69
157
|
def initialize(ws_url, options)
|
|
70
158
|
@command_id = 0
|
|
159
|
+
@command_id_mutex = Mutex.new
|
|
71
160
|
@ws_url = ws_url
|
|
72
161
|
@options = options
|
|
73
162
|
@pendings = Concurrent::Hash.new
|
|
@@ -77,24 +166,75 @@ module Ferrum
|
|
|
77
166
|
start
|
|
78
167
|
end
|
|
79
168
|
|
|
80
|
-
|
|
169
|
+
#
|
|
170
|
+
# Sends a CDP command to the browser-wide session.
|
|
171
|
+
#
|
|
172
|
+
# @param [String] method
|
|
173
|
+
# The CDP method name, e.g. `"Target.createTarget"`.
|
|
174
|
+
#
|
|
175
|
+
# @param [Boolean] async
|
|
176
|
+
# Whether to send the command without waiting for a response.
|
|
177
|
+
#
|
|
178
|
+
# @param [Hash] params
|
|
179
|
+
# The command's parameters.
|
|
180
|
+
#
|
|
181
|
+
# @param [Numeric, nil] timeout
|
|
182
|
+
# How long to wait for this command's response, overriding
|
|
183
|
+
# {Browser::Options#protocol_timeout}. See {#send_message}.
|
|
184
|
+
#
|
|
185
|
+
# @return [Boolean, Hash]
|
|
186
|
+
# `true` when sent asynchronously, otherwise the command's result.
|
|
187
|
+
#
|
|
188
|
+
def command(method, async: false, timeout: nil, **params)
|
|
81
189
|
message = build_message(method, params)
|
|
82
|
-
send_message(message, async: async)
|
|
190
|
+
send_message(message, async: async, timeout: timeout)
|
|
83
191
|
end
|
|
84
192
|
|
|
85
|
-
|
|
193
|
+
#
|
|
194
|
+
# Sends a raw CDP message over the websocket. Synchronous calls block
|
|
195
|
+
# until a matching response arrives, or `timeout` elapses, defaulting to
|
|
196
|
+
# `protocol_timeout` (delegated to {Browser::Options#protocol_timeout}).
|
|
197
|
+
# That default is the transport-level budget for internal CDP bookkeeping
|
|
198
|
+
# (e.g. `Target.createTarget`). {Page#command} overrides
|
|
199
|
+
# this back to `timeout`, or a caller-supplied budget (e.g. `#pdf`/
|
|
200
|
+
# `#screenshot`'s own `timeout:` argument), for the user-facing commands
|
|
201
|
+
# it issues -- some of which (e.g. `Page.navigate`, `Page.printToPDF`)
|
|
202
|
+
# rely on their own response latency to detect a stuck operation.
|
|
203
|
+
#
|
|
204
|
+
# @param [Hash] message
|
|
205
|
+
# The message to send, must include an `:id` key.
|
|
206
|
+
#
|
|
207
|
+
# @param [Boolean] async
|
|
208
|
+
# Whether to return immediately instead of waiting for a response.
|
|
209
|
+
#
|
|
210
|
+
# @param [Numeric, nil] timeout
|
|
211
|
+
# How long to wait for the browser to take the message, and then for its
|
|
212
|
+
# response. Defaults to `protocol_timeout`.
|
|
213
|
+
#
|
|
214
|
+
# @return [Boolean, Hash]
|
|
215
|
+
# `true` when sent asynchronously, otherwise the parsed `"result"`
|
|
216
|
+
# from the response.
|
|
217
|
+
#
|
|
218
|
+
def send_message(message, async:, timeout: nil)
|
|
219
|
+
timeout ||= protocol_timeout
|
|
220
|
+
|
|
86
221
|
if async
|
|
87
|
-
@ws.send_message(message)
|
|
222
|
+
@ws.send_message(message, timeout)
|
|
88
223
|
true
|
|
89
224
|
else
|
|
225
|
+
raise DeadBrowserError if @ws.messages.closed?
|
|
226
|
+
|
|
90
227
|
pending = Concurrent::IVar.new
|
|
91
228
|
@pendings[message[:id]] = pending
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
229
|
+
begin
|
|
230
|
+
@ws.send_message(message, timeout)
|
|
231
|
+
data = pending.value!(timeout)
|
|
232
|
+
ensure
|
|
233
|
+
@pendings.delete(message[:id])
|
|
234
|
+
end
|
|
95
235
|
|
|
96
236
|
raise DeadBrowserError if data.nil? && @ws.messages.closed?
|
|
97
|
-
raise TimeoutError unless data
|
|
237
|
+
raise TimeoutError.new(message[:method], session_id: message[:sessionId]) unless data
|
|
98
238
|
|
|
99
239
|
error, response = data.values_at("error", "result")
|
|
100
240
|
raise_browser_error(error) if error
|
|
@@ -102,30 +242,77 @@ module Ferrum
|
|
|
102
242
|
end
|
|
103
243
|
end
|
|
104
244
|
|
|
245
|
+
#
|
|
246
|
+
# Subscribes to a CDP event.
|
|
247
|
+
#
|
|
248
|
+
# @param [String] event
|
|
249
|
+
# The CDP event name.
|
|
250
|
+
#
|
|
251
|
+
# @return [Integer]
|
|
252
|
+
# The subscription id, used to unsubscribe via {#off}.
|
|
253
|
+
#
|
|
105
254
|
def on(event, &)
|
|
106
255
|
@subscriber.on(event, &)
|
|
107
256
|
end
|
|
108
257
|
|
|
258
|
+
#
|
|
259
|
+
# Unsubscribes from a CDP event.
|
|
260
|
+
#
|
|
261
|
+
# @param [String] event
|
|
262
|
+
# The CDP event name.
|
|
263
|
+
#
|
|
264
|
+
# @param [Integer] id
|
|
265
|
+
# The subscription id returned by {#on}.
|
|
266
|
+
#
|
|
267
|
+
# @return [void]
|
|
268
|
+
#
|
|
109
269
|
def off(event, id)
|
|
110
270
|
@subscriber.off(event, id)
|
|
111
271
|
end
|
|
112
272
|
|
|
273
|
+
# Whether there's at least one callback registered for the event.
|
|
274
|
+
#
|
|
275
|
+
# @param [String] event
|
|
276
|
+
# The CDP event name.
|
|
277
|
+
#
|
|
278
|
+
# @return [Boolean]
|
|
113
279
|
def subscribed?(event)
|
|
114
280
|
@subscriber.subscribed?(event)
|
|
115
281
|
end
|
|
116
282
|
|
|
283
|
+
#
|
|
284
|
+
# Builds a client scoped to a given CDP session, e.g. a browsing
|
|
285
|
+
# context created via `Target.attachToTarget`.
|
|
286
|
+
#
|
|
287
|
+
# @param [String] session_id
|
|
288
|
+
# The CDP session id to scope commands and events to.
|
|
289
|
+
#
|
|
290
|
+
# @return [SessionClient]
|
|
291
|
+
#
|
|
117
292
|
def session(session_id)
|
|
118
293
|
SessionClient.new(self, session_id)
|
|
119
294
|
end
|
|
120
295
|
|
|
296
|
+
#
|
|
297
|
+
# Closes the underlying websocket, drops pending commands and stops
|
|
298
|
+
# the message-processing thread and subscriber.
|
|
299
|
+
#
|
|
300
|
+
# @return [void]
|
|
301
|
+
#
|
|
121
302
|
def close
|
|
122
303
|
@ws.close
|
|
123
304
|
# Give a thread some time to handle a tail of messages
|
|
124
305
|
@pendings.clear
|
|
125
306
|
@thread.kill unless @thread.join(1)
|
|
126
307
|
@subscriber.close
|
|
308
|
+
@ws.force_close
|
|
127
309
|
end
|
|
128
310
|
|
|
311
|
+
#
|
|
312
|
+
# Custom inspection that exposes internal state useful for debugging.
|
|
313
|
+
#
|
|
314
|
+
# @return [String]
|
|
315
|
+
#
|
|
129
316
|
def inspect
|
|
130
317
|
"#<#{self.class} " \
|
|
131
318
|
"@command_id=#{@command_id.inspect} " \
|
|
@@ -133,6 +320,17 @@ module Ferrum
|
|
|
133
320
|
"@ws=#{@ws.inspect}>"
|
|
134
321
|
end
|
|
135
322
|
|
|
323
|
+
#
|
|
324
|
+
# Builds a CDP message hash with a fresh, thread-safe command id.
|
|
325
|
+
#
|
|
326
|
+
# @param [String] method
|
|
327
|
+
# The CDP method name.
|
|
328
|
+
#
|
|
329
|
+
# @param [Hash] params
|
|
330
|
+
# The command's parameters.
|
|
331
|
+
#
|
|
332
|
+
# @return [Hash]
|
|
333
|
+
#
|
|
136
334
|
def build_message(method, params)
|
|
137
335
|
{ method: method, params: params }.merge(id: next_command_id)
|
|
138
336
|
end
|
|
@@ -151,11 +349,26 @@ module Ferrum
|
|
|
151
349
|
@pendings[message["id"]]&.set(message)
|
|
152
350
|
end
|
|
153
351
|
end
|
|
352
|
+
rescue StandardError => e
|
|
353
|
+
# Nothing delivers responses once this thread is gone, so the connection
|
|
354
|
+
# is done for. Close it rather than leave every command hanging.
|
|
355
|
+
warn("Ferrum: dispatch stopped, #{e.class}: #{e.message}\n #{e.backtrace&.first}")
|
|
356
|
+
@ws.messages.close
|
|
357
|
+
ensure
|
|
358
|
+
release_pendings
|
|
154
359
|
end
|
|
155
360
|
end
|
|
156
361
|
|
|
362
|
+
# Nothing is going to answer the commands still in flight once the socket
|
|
363
|
+
# is gone, release them instead of making each wait out its timeout.
|
|
364
|
+
def release_pendings
|
|
365
|
+
@pendings.each_value { |pending| pending.try_set(nil) }
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# Locked so two concurrent commands never share an id and read each
|
|
369
|
+
# other's responses from @pendings.
|
|
157
370
|
def next_command_id
|
|
158
|
-
@command_id += 1
|
|
371
|
+
@command_id_mutex.synchronize { @command_id += 1 }
|
|
159
372
|
end
|
|
160
373
|
|
|
161
374
|
def raise_browser_error(error)
|