firehose 0.1.1 → 0.2.alpha.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (49) hide show
  1. data/.env.sample +10 -0
  2. data/.gitignore +2 -0
  3. data/Procfile +1 -1
  4. data/README.md +117 -11
  5. data/config/rainbows.rb +20 -0
  6. data/firehose.gemspec +9 -6
  7. data/lib/assets/flash/firehose/WebSocketMain.swf +0 -0
  8. data/lib/assets/javascripts/firehose.js.coffee +4 -1
  9. data/lib/assets/javascripts/firehose/consumer.js.coffee +3 -11
  10. data/lib/assets/javascripts/firehose/lib/jquery.cors.headers.js.coffee +16 -0
  11. data/lib/assets/javascripts/firehose/lib/swfobject.js +4 -0
  12. data/lib/assets/javascripts/firehose/lib/web_socket.js +389 -0
  13. data/lib/assets/javascripts/firehose/long_poll.js.coffee +42 -39
  14. data/lib/assets/javascripts/firehose/transport.js.coffee +1 -1
  15. data/lib/assets/javascripts/firehose/web_socket.js.coffee +8 -14
  16. data/lib/firehose.rb +12 -17
  17. data/lib/firehose/channel.rb +84 -0
  18. data/lib/firehose/cli.rb +57 -13
  19. data/lib/firehose/client.rb +92 -0
  20. data/lib/firehose/default.rb +2 -2
  21. data/lib/firehose/logging.rb +35 -0
  22. data/lib/firehose/producer.rb +1 -0
  23. data/lib/firehose/publisher.rb +56 -4
  24. data/lib/firehose/rack.rb +37 -120
  25. data/lib/firehose/rack/consumer_app.rb +143 -0
  26. data/lib/firehose/rack/ping_app.rb +84 -0
  27. data/lib/firehose/rack/publisher_app.rb +40 -0
  28. data/lib/firehose/server.rb +48 -0
  29. data/lib/firehose/subscriber.rb +54 -0
  30. data/lib/firehose/swf_policy_request.rb +23 -0
  31. data/lib/firehose/version.rb +2 -2
  32. data/lib/rainbows_em_swf_policy.rb +33 -0
  33. data/lib/thin_em_swf_policy.rb +26 -0
  34. data/spec/integrations/integration_test_helper.rb +16 -0
  35. data/spec/integrations/rainbows_spec.rb +7 -0
  36. data/spec/integrations/shared_examples.rb +111 -0
  37. data/spec/integrations/thin_spec.rb +5 -79
  38. data/spec/lib/channel_spec.rb +164 -0
  39. data/spec/lib/client_spec.rb +9 -0
  40. data/spec/lib/default_spec.rb +2 -2
  41. data/spec/lib/publisher_spec.rb +82 -0
  42. data/spec/lib/rack/consumer_app_spec.rb +11 -0
  43. data/spec/lib/rack/ping_app_spec.rb +28 -0
  44. data/spec/lib/rack/publisher_app_spec.rb +26 -0
  45. data/spec/lib/subscriber_spec.rb +69 -0
  46. data/spec/spec_helper.rb +49 -8
  47. metadata +114 -45
  48. data/config.ru +0 -6
  49. data/lib/firehose/subscription.rb +0 -77
@@ -1,4 +1,6 @@
1
1
  class Firehose.LongPoll extends Firehose.Transport
2
+ messageSequenceHeader: 'pragma'
3
+
2
4
  # CORS is supported in IE 8+
3
5
  @ieSupported: =>
4
6
  $.browser.msie and parseInt($.browser.version) > 7 and window.XDomainRequest
@@ -15,56 +17,43 @@ class Firehose.LongPoll extends Firehose.Transport
15
17
  # Protocol schema we should use for talking to WS server.
16
18
  @config.longPoll.url ||= "http:#{@config.uri}"
17
19
  # How many ms should we wait before timing out the AJAX connection?
18
- @config.longPoll.timeout ||= 20000
20
+ @config.longPoll.timeout ||= 25000
19
21
 
20
22
  # TODO - What is @_lagTime for? Can't we just use the @_timeout value?
21
23
  # We use the lag time to make the client live longer than the server.
22
24
  @_lagTime = 5000
23
25
  @_timeout = @config.longPoll.timeout + @_lagTime
24
- @_offlineTimer
25
26
  @_okInterval = 0
26
27
 
27
- @registerIETransport()
28
-
29
- registerIETransport: =>
30
- if Firehose.LongPoll.ieSupported()
31
- # TODO - Ask Steel what this is for. Looks like some kind of polygot fill, but I want
32
- # to take the 'json' transport out and do that myself.
33
- $.ajaxTransport 'json', (options, orignalOptions, jqXhr) ->
34
- xdr = null
35
- send: (_, callback) ->
36
- xdr = new XDomainRequest()
37
- xdr.onload = ->
38
- statusCode = if xdr.responseText.length > 0 then 200 else 204
39
- callback(statusCode, 'success', text: xdr.responseText)
40
-
41
- xdr.onerror = xdr.ontimeout = ->
42
- callback(400, 'failed', text: xdr.responseText)
43
-
44
- xdr.open(options.type, options.url)
45
- xdr.send(options.data)
46
-
47
- abort: ->
48
- if xdr
49
- xdr.onerror = $.noop()
50
- xdr.abort()
51
-
52
- # also, override the support check
53
- $.support.cors = true;
54
-
28
+ @_isConnected = false
29
+
55
30
  connect: (delay = 0) =>
56
- @config.connected()
31
+ unless @_isConnected
32
+ @_isConnected = true
33
+ @config.connected()
57
34
  super(delay)
58
35
 
59
36
  _request: =>
37
+ # Set the Last Message Sequence in a query string.
38
+ # Ideally we'd use an HTTP header, but android devices don't let us
39
+ # set any HTTP headers for CORS requests.
40
+ data = @config.params
41
+ data.last_message_sequence = @_lastMessageSequence
42
+ # TODO: Some of these options will be deprecated in jQurey 1.8
43
+ # See: http://api.jquery.com/jQuery.ajax/#jqXHR
60
44
  $.ajax @config.longPoll.url,
61
45
  crossDomain: true
62
- cache: false
63
- data: @config.params
46
+ data: data
64
47
  timeout: @_timeout
65
48
  success: @_success
66
49
  error: @_error
67
-
50
+ complete: (jqXhr) =>
51
+ # Get the last sequence from the server if specified.
52
+ if jqXhr.status == 200
53
+ @_lastMessageSequence = jqXhr.getResponseHeader(@messageSequenceHeader)
54
+ if @_lastMessageSequence == null
55
+ console.log 'ERROR: Unable to get last message sequnce from header'
56
+
68
57
  _success: (data, status, jqXhr) =>
69
58
  # TODO we actually want to do this when the thing calls out... mmm right now it takes
70
59
  # up to 30s before we can call this thing.
@@ -76,16 +65,30 @@ class Firehose.LongPoll extends Firehose.Transport
76
65
  #
77
66
  # Why did we use a 204 and not a 408? Because FireFox is really stupid about 400 level error
78
67
  # codes and would claims its a 0 error code, which we use for something else. Firefox is IE
79
- # in thise case
68
+ # in this case
80
69
  @connect(@_okInterval)
81
70
  else
82
- @config.message(@config.parse(data))
71
+ @config.message(@config.parse(jqXhr.responseText))
83
72
  @connect(@_okInterval)
84
73
 
74
+ _ping: =>
75
+ # Ping long poll server to verify internet connectivity
76
+ # jQuery CORS doesn't support timeouts and there is no way to access xhr2 object
77
+ # directly so we can't manually set a timeout.
78
+ $.ajax @config.longPoll.url,
79
+ method: 'HEAD'
80
+ crossDomain: true
81
+ data: @config.params
82
+ success: @config.connected
83
+
85
84
  # We need this custom handler to have the connection status
86
85
  # properly displayed
87
86
  _error: (jqXhr, status, error) =>
88
- clearTimeout(@_offlineTimer)
87
+ @_isConnected = false
89
88
  @config.disconnected()
90
- @_offlineTimer = setTimeout(@config.connected, @_retryDelay + @_lagTime)
91
- @connect(@_retryDelay)
89
+
90
+ # Ping the server to make sure this isn't a network connectivity error
91
+ setTimeout @_ping, @_retryDelay + @_lagTime
92
+
93
+ # Reconnect with delay
94
+ setTimeout @_request, @_retryDelay
@@ -32,7 +32,7 @@ class Firehose.Transport
32
32
  # Default connection established handler
33
33
  _open: (event) =>
34
34
  @_succeeded = true
35
- @config.connected()
35
+ @config.connected(@)
36
36
 
37
37
  # Default connection closed handler
38
38
  _close: (event) =>
@@ -1,11 +1,13 @@
1
- class Firehose.WebSocket extends Firehose.Transport
2
- @flashSupported: =>
3
- $.browser.msie
1
+ # Unfortunately this needs to a global variable. The only other option is to
2
+ # hack into the internals of the web_socket.js plugin we are using.
3
+ window.WEB_SOCKET_SWF_LOCATION = '/assets/firehose/WebSocketMain.swf' if !window.WEB_SOCKET_SWF_LOCATION
4
4
 
5
+ class Firehose.WebSocket extends Firehose.Transport
5
6
  @supported: =>
6
7
  # Compatibility reference: http://caniuse.com/websockets
7
- # Native websocket support + Flash web socket
8
- !!(window.WebSocket || (window["MozWebSocket"] and window.MozWebSocket) || WebSocket.flashSupported())
8
+ # We don't need to explicitly check for Flash web socket or MozWebSocket
9
+ # because web_socket.js has already handled that.
10
+ !!(window.WebSocket)
9
11
 
10
12
  constructor: (args) ->
11
13
  super args
@@ -14,14 +16,6 @@ class Firehose.WebSocket extends Firehose.Transport
14
16
  @config.webSocket ||= {}
15
17
  # Protocol schema we should use for talking to WS server.
16
18
  @config.webSocket.url ||= "ws:#{@config.uri}?#{$.param(@config.params)}"
17
- # Path of the swf WebSocket that we use in non-WS flash browsers.
18
- @config.webSocket.swf_path ||= "/flash/firehose/WebSocketMain.swf"
19
-
20
- # Set flash socket path for the WS SWF polyfill.
21
- WebSocket.__swfLocation = @config.webSocket.swf_path
22
-
23
- # Mozilla decided to have their own implementation of Web Sockets so detect for that.
24
- window.WebSocket = window.MozWebSocket if window["MozWebSocket"] and window.MozWebSocket
25
19
 
26
20
  _request: =>
27
21
  @socket = new window.WebSocket(@config.webSocket.url)
@@ -49,4 +43,4 @@ class Firehose.WebSocket extends Firehose.Transport
49
43
  @socket.close()
50
44
  delete(@socket)
51
45
 
52
- super
46
+ super
data/lib/firehose.rb CHANGED
@@ -1,24 +1,19 @@
1
- require 'firehose/version'
2
-
3
- require 'em-hiredis'
4
- require 'logger'
1
+ ENV['RACK_ENV'] ||= 'development'
5
2
 
6
- require 'firehose/rails' if defined?(::Rails::Engine)
3
+ require 'firehose/version'
4
+ require 'em-hiredis' # TODO Move this into a Redis module so that we can auto-load it. Lots of the CLI tools don't need this.
5
+ require 'firehose/logging'
6
+ require 'firehose/rails' if defined?(::Rails::Engine) # TODO Detect Sprockets instead of the jankin Rails::Engine test.
7
7
 
8
8
  module Firehose
9
- autoload :Subscription, 'firehose/subscription'
9
+ autoload :Subscriber, 'firehose/subscriber'
10
10
  autoload :Publisher, 'firehose/publisher'
11
- autoload :Producer, 'firehose/producer'
11
+ autoload :Producer, 'firehose/producer' # TODO Move this into the Firehose::Client namespace.
12
12
  autoload :Default, 'firehose/default'
13
13
  autoload :Rack, 'firehose/rack'
14
14
  autoload :CLI, 'firehose/cli'
15
-
16
- # Logging
17
- def self.logger
18
- @logger ||= Logger.new($stdout)
19
- end
20
-
21
- def self.logger=(logger)
22
- @logger = logger
23
- end
24
- end
15
+ autoload :Client, 'firehose/client'
16
+ autoload :Server, 'firehose/server'
17
+ autoload :Channel, 'firehose/channel'
18
+ autoload :SwfPolicyRequest, 'firehose/swf_policy_request'
19
+ end
@@ -0,0 +1,84 @@
1
+ module Firehose
2
+ class Channel
3
+ attr_reader :channel_key, :redis, :subscriber, :list_key, :sequence_key
4
+
5
+ def self.redis
6
+ @redis ||= EM::Hiredis.connect
7
+ end
8
+
9
+ def self.subscriber
10
+ @subscriber ||= Subscriber.new(EM::Hiredis.connect)
11
+ end
12
+
13
+
14
+ def initialize(channel_key, redis=self.class.redis, subscriber=self.class.subscriber)
15
+ @channel_key, @redis, @subscriber = channel_key, redis, subscriber
16
+
17
+ @list_key, @sequence_key = key(channel_key, :list), key(channel_key, :sequence)
18
+ end
19
+
20
+ def next_message(last_sequence=nil, options={})
21
+ last_sequence = last_sequence.to_i
22
+
23
+ deferrable = EM::DefaultDeferrable.new
24
+ # TODO - Think this through a little harder... maybe some tests ol buddy!
25
+ deferrable.errback {|e| EM.next_tick { raise e } unless [:timeout, :disconnect].include?(e) }
26
+
27
+
28
+ # TODO: Use HSET so we don't have to pull 100 messages back every time.
29
+ redis.multi
30
+ redis.get(sequence_key).
31
+ errback {|e| deferrable.fail e }
32
+ redis.lrange(list_key, 0, Firehose::Publisher::MAX_MESSAGES).
33
+ errback {|e| deferrable.fail e }
34
+ redis.exec.callback do |(sequence, message_list)|
35
+ Firehose.logger.debug "exec returened: `#{sequence}` and `#{message_list.inspect}`"
36
+ sequence = sequence.to_i
37
+
38
+ if sequence.nil? || (diff = sequence - last_sequence) <= 0
39
+ Firehose.logger.debug "No message available yet, subscribing. sequence: `#{sequence}`"
40
+ # Either this resource has never been seen before or we are all caught up.
41
+ # Subscribe and hope something gets published to this end-point.
42
+ subscribe(deferrable, options[:timeout])
43
+ elsif last_sequence > 0 && diff < Firehose::Publisher::MAX_MESSAGES
44
+ # The client is kinda-sorta running behind, but has a chance to catch
45
+ # up. Catch them up FTW.
46
+ # But we won't "catch them up" if last_sequence was zero/nil because
47
+ # that implies the client is connecting for the 1st time.
48
+ message = message_list[diff-1]
49
+ Firehose.logger.debug "Sending old message `#{message}` and sequence `#{sequence}` to client directly. Client is `#{diff}` behind, at `#{last_sequence}`."
50
+ deferrable.succeed message, last_sequence + 1
51
+ else
52
+ # The client is hopelessly behind and underwater. Just reset
53
+ # their whole world with the lastest message.
54
+ message = message_list[0]
55
+ Firehose.logger.debug "Sending latest message `#{message}` and sequence `#{sequence}` to client directly."
56
+ deferrable.succeed message, sequence
57
+ end
58
+ end.errback {|e| deferrable.fail e }
59
+
60
+ deferrable
61
+ end
62
+
63
+ def unsubscribe(deferrable)
64
+ subscriber.unsubscribe channel_key, deferrable
65
+ end
66
+
67
+ private
68
+ def key(*segments)
69
+ segments.unshift(:firehose).join(':')
70
+ end
71
+
72
+ def subscribe(deferrable, timeout=nil)
73
+ subscriber.subscribe(channel_key, deferrable)
74
+ if timeout
75
+ timer = EventMachine::Timer.new(timeout) do
76
+ deferrable.fail :timeout
77
+ unsubscribe deferrable
78
+ end
79
+ # Cancel the timer if when the deferrable succeeds
80
+ deferrable.callback { timer.cancel }
81
+ end
82
+ end
83
+ end
84
+ end
data/lib/firehose/cli.rb CHANGED
@@ -1,28 +1,72 @@
1
1
  require 'thor'
2
- require 'thin'
2
+ require 'eventmachine'
3
+ require 'uri'
4
+
5
+ # Enable native
6
+ EM.kqueue if EM.kqueue?
7
+ EM.epoll if EM.epoll?
3
8
 
4
9
  module Firehose
5
10
  class CLI < Thor
6
- desc "version", "display the current version"
11
+ def initialize(*args)
12
+ super
13
+ # Disable buffering to $stdio for Firehose.logger
14
+ $stdout.sync = true
15
+ end
16
+
17
+ desc "version", "Display the current version."
7
18
  def version
8
19
  puts %[Firehose #{Firehose::VERSION} "#{Firehose::CODENAME}"]
9
20
  end
10
21
 
11
- desc "server", "starts the firehose server"
12
- method_option :port, :type => :numeric, :default => Firehose::Default::URI.port, :required => true, :aliases => '-p'
13
- method_option :host, :type => :string, :default => '0.0.0.0', :required => true, :aliases => '-h'
14
-
22
+ desc "server", "Start an instance of a server."
23
+ method_option :port, :type => :numeric, :default => ENV['PORT'] || Firehose::Default::URI.port, :required => false, :aliases => '-p'
24
+ method_option :host, :type => :string, :default => ENV['HOST'] || Firehose::Default::URI.host, :required => false, :aliases => '-h'
25
+ method_option :server, :type => :string, :default => ENV['SERVER'] ||'rainbows', :required => false, :aliases => '-s'
15
26
  def server
16
- server = Thin::Server.new(options[:host], options[:port]) do
17
- run Firehose::Rack::App.new
18
- end
19
-
20
27
  begin
21
- server.start!
22
- rescue
28
+ Firehose::Server.new(options).start
29
+ rescue => e
23
30
  Firehose.logger.error "#{e.message}: #{e.backtrace}"
24
- raise
31
+ raise e
32
+ end
33
+ end
34
+
35
+ desc "consume URI", "Consume messages from a resource."
36
+ method_option :concurrency, :type => :numeric, :default => 1, :aliases => '-c'
37
+ def consume(uri)
38
+ EM.run do
39
+ options[:concurrency].times { Firehose::Client::Consumer.parse(uri).request }
25
40
  end
26
41
  end
42
+
43
+ desc "publish URI [PAYLOAD]", "Publish messages to a resource."
44
+ method_option :interval, :type => :numeric, :aliases => '-i'
45
+ method_option :times, :type => :numeric, :aliases => '-n'
46
+ def publish(uri, payload=nil)
47
+ payload ||= $stdin.read
48
+ client = Firehose::Producer.new(uri)
49
+ path = URI.parse(uri).path
50
+ times = options[:times]
51
+
52
+ EM.run do
53
+ # TODO I think this can be cleaned up so the top-level if/else can be ditched.
54
+ if interval = options[:interval]
55
+ # Publish messages at a forced interval.
56
+ EM.add_periodic_timer interval do
57
+ client.publish(payload).to(path)
58
+ EM.stop if times && (times-=1).zero?
59
+ end
60
+ else
61
+ # Publish messages as soon as the last message was published.
62
+ worker = Proc.new do
63
+ client.publish(payload).to(path)
64
+ times && (times-=1).zero? ? EM.stop : worker.call
65
+ end
66
+ worker.call
67
+ end
68
+ end
69
+ end
70
+
27
71
  end
28
72
  end
@@ -0,0 +1,92 @@
1
+ require 'em-http'
2
+ require 'faye/websocket'
3
+
4
+ module Firehose
5
+ # Ruby clients that connect to Firehose to either publish or consume messages.
6
+ module Client
7
+ # TODO - Move the Firehose producer.rb file/class in here and rename to Firehose::Client::Producer::Http.new() ..
8
+ module Producer
9
+ end
10
+
11
+ # TODO - Test this libs. I had to throw these quickly into our app so that we could get
12
+ # some stress testing out of the way.
13
+ # TODO - Replace the integration test clients with these guys. You'll want to refactor each
14
+ # transport to use on(:message), on(:conncect), and on(:disconnect) callbacks.
15
+ module Consumer
16
+ TransportNotSupportedError = Class.new(RuntimeError)
17
+
18
+ # Build up a benchmark client based on a given URI. Accepts ws:// and http:// for now.
19
+ def self.parse(uri)
20
+ case transport = URI.parse(uri).scheme
21
+ when 'ws'
22
+ Consumer::WebSocket.new(uri)
23
+ when 'http'
24
+ Consumer::HttpLongPoll.new(uri)
25
+ else
26
+ raise TransportNotSupportedError.new("Transport #{transport.inspect} not supported.")
27
+ end
28
+ end
29
+
30
+ # Connect to Firehose via WebSockets and consume messages.
31
+ class WebSocket
32
+ attr_reader :url, :logger
33
+
34
+ def initialize(url, logger = Firehose.logger)
35
+ @url, @logger = url, logger
36
+ end
37
+
38
+ def request
39
+ ws = Faye::WebSocket::Client.new(url)
40
+ ws.onmessage = lambda do |event|
41
+ logger.info "WS | #{event.data[0...40].inspect}"
42
+ end
43
+ ws.onclose = lambda do |event|
44
+ logger.info "WS | Closed"
45
+ end
46
+ ws.onerror do
47
+ logger.error "WS | Failed"
48
+ end
49
+ end
50
+ end
51
+
52
+ # Connect to Firehose via HTTP Long Polling and consume messages.
53
+ class HttpLongPoll
54
+ JITTER = 0.003
55
+
56
+ attr_reader :url, :logger
57
+
58
+ def initialize(url, logger = Firehose.logger)
59
+ @url, @logger = url, logger
60
+ end
61
+
62
+ def request(last_sequence=0)
63
+ http = EM::HttpRequest.new(url, :inactivity_timeout => 0).get(:query => {'last_message_sequence' => last_sequence})
64
+ http.callback do
65
+ case status = http.response_header.status
66
+ when 200
67
+ next_sequence = http.response_header['Pragma'].to_i
68
+ logger.info "HTTP 200 | Next Sequence: #{next_sequence} - #{http.response[0...40].inspect}"
69
+ EM::add_timer(jitter) { request next_sequence }
70
+ when 204
71
+ logger.info "HTTP 204 | Last Sequence #{last_sequence}"
72
+ EM::add_timer(jitter) { request last_sequence }
73
+ else
74
+ logger.error "HTTP #{status} | Failed"
75
+ end
76
+ end
77
+ http.errback do
78
+ logger.error "Connection Failed"
79
+ end
80
+ end
81
+
82
+
83
+ private
84
+ # Random jitter between long poll requests.
85
+ def jitter
86
+ rand*JITTER
87
+ end
88
+ end
89
+
90
+ end
91
+ end
92
+ end