Class: Iodine::Http::WebsocketClient

Inherits:
Object
  • Object
show all
Defined in:
lib/iodine/http/websocket_client.rb

Overview

Create a simple Websocket Client(!).

This should be done from within an Iodine task, or the callbacks will not be called.

Use WebsocketClient.connect to initialize a client with all the callbacks needed.

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(request) ⇒ WebsocketClient

Returns a new instance of WebsocketClient.



13
14
15
16
17
18
19
20
21
22
# File 'lib/iodine/http/websocket_client.rb', line 13

def initialize request
	@response = nil
	@request = request
	@params = request[:ws_client_params]
	@on_message = @params[:on_message]
	raise "Websocket client must have an #on_message Proc or handler." unless @on_message && @on_message.respond_to?(:call)
	@on_open = @params[:on_open]
	@on_close = @params[:on_close]
	@renew = @params[:renew].to_i
end

Instance Attribute Details

#paramsObject

Returns the value of attribute params.



11
12
13
# File 'lib/iodine/http/websocket_client.rb', line 11

def params
  @params
end

#requestObject

return the HTTP’s handshake data, including any cookies sent by the server.



115
116
117
# File 'lib/iodine/http/websocket_client.rb', line 115

def request
  @request
end

#responseObject

Returns the value of attribute response.



11
12
13
# File 'lib/iodine/http/websocket_client.rb', line 11

def response
  @response
end

Class Method Details

.connect(url, options = {}, &block) ⇒ Iodine::Http::WebsocketClient

Create a simple Websocket Client(!).

This method accepts two parameters:

url

a String representing the URL of the websocket. i.e.: ‘ws://foo.bar.com:80/ws/path’

options

a Hash with options to be used. The options will be used to define the connection’s details (i.e. ssl etc’) and the Websocket callbacks (i.e. on_open(ws), on_close(ws), on_message(ws))

&block

an optional block that accepts one parameter (data) and will be used as the ‘#on_message(data)`

Acceptable options are:

on_open

the on_open callback. Must be an objects that answers ‘call(ws)`, usually a Proc.

on_message

the on_message callback. Must be an objects that answers ‘call(ws)`, usually a Proc.

on_close

the on_close callback - this will ONLY be called if the connection WASN’T renewed. Must be an objects that answers ‘call(ws)`, usually a Proc.

headers

a Hash of custom HTTP headers to be sent with the request. Header data, including cookie headers, should be correctly encoded.

cookies

a Hash of cookies to be sent with the request. cookie data will be encoded before being sent.

timeout

the number of seconds to wait before the connection is established. Defaults to 5 seconds.

every

this option, together with ‘:send` and `:renew`, implements a polling websocket. :every is the number of seconds between each polling event. without `:send`, this option will be ignored. defaults to nil.

send

a String to be sent or a Proc to be performed each polling interval. This option, together with ‘:every` and `:renew`, implements a polling websocket. without `:every`, this option will be ignored. defaults to nil. If `:send` is a Proc, it will be executed within the context of the websocket client object, with acess to the websocket client’s instance variables and methods.

renew

the number of times to attempt to renew the connection if the connection is terminated by the remote server. Attempts are made in 2 seconds interval. The default for a polling websocket is 5 attempts to renew. For all other clients, the default is 0 (no renewal).

The method will block until the connection is established or until 5 seconds have passed (the timeout). The method will either return a WebsocketClient instance object or raise an exception it the connection was unsuccessful.

Use Iodine::Http.ws_connect for a non-blocking initialization.

An #on_close callback will only be called if the connection isn’t or cannot be renewed. If the connection is renewed, the #on_open callback will be called again for a new Websocket client instance - but the #on_close callback will NOT be called.

Due to this design, the #on_open and #on_close methods should NOT be used for opening IO resources (i.e. file handles) nor for cleanup IF the ‘:renew` option is enabled.

An on_message Proc must be defined, or the method will fail.

The on_message Proc can be defined using the optional block:

Iodine::Http::WebsocketClient.connect("ws://localhost:3000/") {|data| write data} #echo example

OR, the on_message Proc can be defined using the options Hash:

Iodine::Http::WebsocketClient.connect("ws://localhost:3000/", on_open: -> {}, on_message: -> {|data| write data })

The #on_message(data), #on_open and #on_close methods will be executed within the context of the WebsocketClient object, and will have native acess to the Websocket response object.

After the WebsocketClient had been created, it’s possible to update the #on_message and #on_close methods:

# updates #on_message
wsclient.on_message do |data|
     response << "I'll disconnect on the next message!"
     # updates #on_message again.
     on_message {|data| disconnect }
end

!!please be aware that the Websockt Client will not attempt to verify SSL certificates, so that even SSL connections are vulnerable to a possible man in the middle attack.

Returns:



177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
# File 'lib/iodine/http/websocket_client.rb', line 177

def self.connect url, options={}, &block
	socket = nil
	options = options.dup
	options[:on_message] ||= block
	raise "No #on_message handler defined! please pass a block or define an #on_message handler!" unless options[:on_message]
	url = URI.parse(url) unless url.is_a?(URI)
	options[:url] = url
	options[:renew] ||= 5 if options[:every] && options[:send]

	ssl = url.scheme == "https" || url.scheme == "wss"

	url.port ||= ssl ? 443 : 80
	url.path = '/' if url.path.to_s.empty?
	socket = TCPSocket.new(url.host, url.port)
	if ssl
		context = OpenSSL::SSL::SSLContext.new
		context.cert_store = OpenSSL::X509::Store.new
		context.cert_store.set_default_paths
		context.set_params verify_mode: (options[:verify_mode] || OpenSSL::SSL::VERIFY_NONE) # OpenSSL::SSL::VERIFY_PEER #OpenSSL::SSL::VERIFY_NONE
		ssl = OpenSSL::SSL::SSLSocket.new(socket, context)
		ssl.sync_close = true
		ssl.connect
	end
	# prep custom headers
	custom_headers = ''
	custom_headers = options[:headers] if options[:headers].is_a?(String)
	options[:headers].each {|k, v| custom_headers << "#{k.to_s}: #{v.to_s}\r\n"} if options[:headers].is_a?(Hash)
	options[:cookies].each {|k, v| raise 'Illegal cookie name' if k.to_s.match(/[\x00-\x20\(\)<>@,;:\\\"\/\[\]\?\=\{\}\s]/); custom_headers << "Cookie: #{ k }=#{ Iodine::Http::Request.encode_url v }\r\n"} if options[:cookies].is_a?(Hash)

	# send protocol upgrade request
	websocket_key = [(Array.new(16) {rand 255} .pack 'c*' )].pack('m0*')
	(ssl || socket).write "GET #{url.path}#{url.query.to_s.empty? ? '' : ('?' + url.query)} HTTP/1.1\r\nHost: #{url.host}#{url.port ? (':'+url.port.to_s) : ''}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nOrigin: #{options[:ssl_client] ? 'https' : 'http'}://#{url.host}\r\nSec-WebSocket-Key: #{websocket_key}\r\nSec-WebSocket-Version: 13\r\n#{custom_headers}\r\n"
	# wait for answer - make sure we don't over-read
	# (a websocket message might be sent immidiately after connection is established)
	reply = ''
	reply.force_encoding(::Encoding::ASCII_8BIT)
	stop_time = Time.now + (options[:timeout] || 5)
	stop_reply = "\r\n\r\n"
	sleep 0.2
	until reply[-4..-1] == stop_reply
		begin
			reply << ( ssl ? ssl.read_nonblock(1) : socket.recv_nonblock(1) )
		rescue Errno::EWOULDBLOCK => e
			raise "Websocket client handshake timed out (HTTP reply not recieved)\n\n Got Only: #{reply}" if Time.now >= stop_time
			IO.select [socket], nil, nil, (options[:timeout] || 5)
			retry
		end
		raise "Connection failed" if socket.closed?
	end
	# review reply
	raise "Connection Refused. Reply was:\r\n #{reply}" unless reply.lines[0].match(/^HTTP\/[\d\.]+ 101/i)
	raise 'Websocket Key Authentication failed.' unless reply.match(/^Sec-WebSocket-Accept:[\s]*([^\s]*)/i) && reply.match(/^Sec-WebSocket-Accept:[\s]*([^\s]*)/i)[1] == Digest::SHA1.base64digest(websocket_key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11')
	# read the body's data and parse any incoming data.
	request = Iodine::Http::Request.new
	request[:method] = 'GET'
	request['host'] = "#{url.host}:#{url.port}"
	request[:query] = url.path
	request[:version] = '1.1'
	reply = StringIO.new reply
	reply.gets

	until reply.eof?
		until request[:headers_complete] || (l = reply.gets).nil?
			if l.include? ':'
				l = l.strip.split(/:[\s]?/, 2)
				l[0].strip! ; l[0].downcase!;
				request[l[0]] ? (request[l[0]].is_a?(Array) ? (request[l[0]] << l[1]) : request[l[0]] = [request[l[0]], l[1] ]) : (request[l[0]] = l[1])
			elsif l =~ /^[\r]?\n/
				request[:headers_complete] = true
			else
				#protocol error
				raise 'Protocol Error, closing connection.'
				return close
			end
		end
	end
	reply.string.clear

	request[:ws_client_params] = options
	client = self.new(request)
	Iodine::Http::Websockets.new( ( ssl || socket), client, request )

	return client	

	rescue => e
		(ssl || socket).tap {|io| next if io.nil?; io.close unless io.closed?}
		raise e
end

Instance Method Details

#<<(data) ⇒ true, false Also known as: write

Sends data through the socket. a shortcut for ws_client.response <<

Returns:

  • (true, false)

    Returns the true if the data was actually sent or nil if no data was sent.



93
94
95
96
# File 'lib/iodine/http/websocket_client.rb', line 93

def << data
	raise 'Cannot send data when the connection is closed.' if closed?
	@io << data
end

#closeObject

closes the connection, if open



100
101
102
# File 'lib/iodine/http/websocket_client.rb', line 100

def close
	@io.close if @io
end

#closed?Boolean

checks if the socket is open (if the websocket was terminated abnormally, this might returs true when it should be false).

Returns:

  • (Boolean)


105
106
107
# File 'lib/iodine/http/websocket_client.rb', line 105

def closed?
	@io.io.closed? if @io && @io.io
end

#cookiesObject

return a Hash with the HTTP cookies recieved during the HTTP’s handshake.



119
120
121
# File 'lib/iodine/http/websocket_client.rb', line 119

def cookies
	@request.cookies
end

#on(event_name, &block) ⇒ Object



24
25
26
27
28
29
30
31
32
33
34
35
# File 'lib/iodine/http/websocket_client.rb', line 24

def on event_name, &block
	return false unless block
	case event_name
	when :message
		@on_message = block
	when :close
		@on_close = block
	when :open
		raise 'The on_open even is invalid at this point.'
	end
								
end

#on_close(&block) ⇒ Object



66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
# File 'lib/iodine/http/websocket_client.rb', line 66

def on_close(&block)
	return @on_close = block if block
	if @renew > 0
		renew_proc = Proc.new do
			begin
				Iodine::Http::WebsocketClient.connect(@params[:url], @params)
			rescue
				@renew -= 1
				if @renew <= 0
					Iodine.fatal "WebsocketClient renewal FAILED for #{@params[:url]}"
					instance_exec(&@on_close) if @on_close
				else
					Iodine.run_after 2, &renew_proc
					Iodine.warn "WebsocketClient renewal failed for #{@params[:url]}, #{@renew} attempts left"
				end
				false
			end
		end
		renew_proc.call
	else
		instance_exec(&@on_close) if @on_close
	end
end

#on_message(data = nil, &block) ⇒ Object



37
38
39
40
41
42
43
# File 'lib/iodine/http/websocket_client.rb', line 37

def on_message(data = nil, &block)
	unless data
		@on_message = block if block
		return @on_message
	end
	instance_exec( data, &@on_message) 
end

#on_openObject



45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
# File 'lib/iodine/http/websocket_client.rb', line 45

def on_open
	raise 'The on_open even is invalid at this point.' if block_given?
	@io = @request[:io]
	Iodine::Http::Request.parse @request
	instance_exec(&@on_open) if @on_open
	if request[:ws_client_params][:every] && @params[:send]
		raise TypeError, "Websocket Client `:send` should be either a String or a Proc object." unless @params[:send].is_a?(String) || @params[:send].is_a?(Proc)
		Iodine.run_every @params[:every], self, @params do |ws, client_params, timer|
			if ws.closed?
				timer.stop!
				next
			end
			if client_params[:send].is_a?(String)
				ws.write client_params[:send]
			elsif client_params[:send].is_a?(Proc)
				ws.instance_exec(&client_params[:send])
			end
		end
	end
end

#ssl?Boolean

checks if this is an SSL websocket connection.

Returns:

  • (Boolean)


110
111
112
# File 'lib/iodine/http/websocket_client.rb', line 110

def ssl?
	@request.ssl?
end