Class: Ferrum::Client::WebSocket

Inherits:
Object
  • Object
show all
Defined in:
lib/ferrum/client/web_socket.rb

Overview

Low-level WebSocket connection to the browser's CDP endpoint. Opens the raw TCP/TLS socket, drives the websocket-driver handshake and framing, and exposes a queue of parsed incoming messages alongside methods to send commands and close the connection.

Constant Summary collapse

WEBSOCKET_BUG_SLEEP =
0.05
DEFAULT_PORTS =
{ "ws" => 80, "wss" => 443 }.freeze
SKIP_LOGGING_SCREENSHOTS =
!ENV["FERRUM_LOGGING_SCREENSHOTS"]

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(url, max_receive_size, logger) ⇒ WebSocket

Returns a new instance of WebSocket.



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
# File 'lib/ferrum/client/web_socket.rb', line 22

def initialize(url, max_receive_size, logger)
  @url    = url
  @logger = logger
  uri     = URI.parse(@url)
  port    = uri.port || DEFAULT_PORTS[uri.scheme]

  if port == 443 || url.scheme == "wss"
    tcp = TCPSocket.new(uri.host, port)
    ssl_context = OpenSSL::SSL::SSLContext.new
    @sock = OpenSSL::SSL::SSLSocket.new(tcp, ssl_context)
    @sock.sync_close = true
    @sock.connect
  else
    @sock = TCPSocket.new(uri.host, port)
  end

  max_receive_size ||= ::WebSocket::Driver::MAX_LENGTH
  @driver = ::WebSocket::Driver.client(self, max_length: max_receive_size)
  # websocket-driver holds no locks and is called from many threads: commands from
  # callers, pong/close replies from the reader. One lock keeps frames from interleaving.
  @driver_mutex = Mutex.new
  @messages = Queue.new

  @screenshot_commands = Concurrent::Hash.new if SKIP_LOGGING_SCREENSHOTS

  @driver.on(:open,    &method(:on_open))
  @driver.on(:message, &method(:on_message))
  @driver.on(:close,   &method(:on_close))

  start

  @driver_mutex.synchronize { @driver.start }
end

Instance Attribute Details

#messagesObject (readonly)

Returns the value of attribute messages.



20
21
22
# File 'lib/ferrum/client/web_socket.rb', line 20

def messages
  @messages
end

#urlObject (readonly)

Returns the value of attribute url.



20
21
22
# File 'lib/ferrum/client/web_socket.rb', line 20

def url
  @url
end

Instance Method Details

#closevoid

This method returns an undefined value.

Closes the websocket connection by sending a close frame.



140
141
142
# File 'lib/ferrum/client/web_socket.rb', line 140

def close
  @driver_mutex.synchronize { @driver.close }
end

#on_close(_event) ⇒ void

This method returns an undefined value.

Handles the driver's :close event: closes the message queue and underlying socket, then kills the reader thread.



96
97
98
99
100
# File 'lib/ferrum/client/web_socket.rb', line 96

def on_close(_event)
  @messages.close
  @sock.close
  @thread.kill
end

#on_message(event) ⇒ void

This method returns an undefined value.

Handles the driver's :message event: parses the incoming frame as JSON and pushes it onto #messages. Malformed payloads are dropped rather than raised, to avoid crashing the reader thread.



73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
# File 'lib/ferrum/client/web_socket.rb', line 73

def on_message(event)
  data = safely_parse_json(event.data)
  output = event.data
  if SKIP_LOGGING_SCREENSHOTS && @screenshot_commands[data&.dig("id")]
    @screenshot_commands.delete(data&.dig("id"))
    output.sub!(/{"data":"[^"]*"}/, %("Set FERRUM_LOGGING_SCREENSHOTS=true to see screenshots in Base64"))
  end

  @logger&.puts("    ◀ #{Utils::ElapsedTime.elapsed_time} #{output}\n")

  # If we couldn't parse JSON data for some reason (parse error or deeply nested object) we
  # don't push response to @messages. Worse that could happen we raise timeout error due to command didn't return
  # anything or skip the background notification, but at least we don't crash the thread that crashes the main
  # thread and the application.
  @messages.push(data) if data
end

#on_open(_event) ⇒ void

This method returns an undefined value.

Handles the driver's :open event.



61
62
63
64
# File 'lib/ferrum/client/web_socket.rb', line 61

def on_open(_event)
  # https://github.com/faye/websocket-driver-ruby/issues/46
  sleep(WEBSOCKET_BUG_SLEEP)
end

#send_message(data) ⇒ void

This method returns an undefined value.

Serializes a CDP command to JSON and sends it as a websocket text frame.

Parameters:

  • data (Hash)

    The message to send, must include an :id key.



111
112
113
114
115
116
117
# File 'lib/ferrum/client/web_socket.rb', line 111

def send_message(data)
  @screenshot_commands[data[:id]] = true if SKIP_LOGGING_SCREENSHOTS

  json = data.to_json
  @driver_mutex.synchronize { @driver.text(json) }
  @logger&.puts("\n\n▶ #{Utils::ElapsedTime.elapsed_time} #{json}")
end

#write(data) ⇒ void

This method returns an undefined value.

Writes raw bytes to the underlying socket. Called by websocket-driver to emit frames. Closes #messages instead of raising if the connection has already been torn down.

Parameters:

  • data (String)

    The raw bytes to write.



129
130
131
132
133
# File 'lib/ferrum/client/web_socket.rb', line 129

def write(data)
  @sock.write(data)
rescue EOFError, Errno::ECONNRESET, Errno::EPIPE, IOError # rubocop:disable Lint/ShadowedException
  @messages.close
end