firehose 0.1.1 → 0.2.alpha.2

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.
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