firehose 1.1.1 → 1.2.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.
Files changed (48) hide show
  1. checksums.yaml +15 -0
  2. data/.rbenv-version +1 -1
  3. data/README.md +1 -1
  4. data/config/rainbows.rb +4 -3
  5. data/lib/firehose.rb +6 -8
  6. data/lib/firehose/assets.rb +1 -3
  7. data/lib/firehose/cli.rb +6 -8
  8. data/lib/firehose/client.rb +2 -84
  9. data/lib/firehose/client/consumer.rb +94 -0
  10. data/lib/firehose/client/producer.rb +106 -0
  11. data/lib/firehose/logging.rb +1 -4
  12. data/lib/{rainbows_em_swf_policy.rb → firehose/patches/rainbows.rb} +4 -2
  13. data/lib/firehose/patches/swf_policy_request.rb +26 -0
  14. data/lib/{thin_em_swf_policy.rb → firehose/patches/thin.rb} +2 -1
  15. data/lib/firehose/rack.rb +12 -39
  16. data/lib/firehose/rack/app.rb +42 -0
  17. data/lib/firehose/rack/{consumer_app.rb → consumer.rb} +7 -5
  18. data/lib/firehose/rack/{ping_app.rb → ping.rb} +4 -2
  19. data/lib/firehose/rack/{publisher_app.rb → publisher.rb} +3 -3
  20. data/lib/firehose/server.rb +16 -42
  21. data/lib/firehose/server/app.rb +53 -0
  22. data/lib/firehose/server/channel.rb +80 -0
  23. data/lib/firehose/server/publisher.rb +134 -0
  24. data/lib/firehose/server/subscriber.rb +50 -0
  25. data/lib/firehose/version.rb +2 -2
  26. data/spec/integrations/integration_test_helper.rb +2 -2
  27. data/spec/integrations/shared_examples.rb +3 -3
  28. data/spec/lib/{client_spec.rb → client/consumer_spec.rb} +0 -0
  29. data/spec/lib/{producer_spec.rb → client/producer_spec.rb} +13 -13
  30. data/spec/lib/firehose_spec.rb +7 -0
  31. data/spec/lib/rack/{consumer_app_spec.rb → consumer_spec.rb} +2 -2
  32. data/spec/lib/rack/{ping_app_spec.rb → ping_spec.rb} +3 -3
  33. data/spec/lib/rack/{publisher_app_spec.rb → publisher_spec.rb} +3 -3
  34. data/spec/lib/server/app_spec.rb +1 -0
  35. data/spec/lib/{channel_spec.rb → server/channel_spec.rb} +4 -4
  36. data/spec/lib/{publisher_spec.rb → server/publisher_spec.rb} +9 -9
  37. data/spec/lib/{subscriber_spec.rb → server/subscriber_spec.rb} +4 -4
  38. data/spec/spec_helper.rb +0 -5
  39. metadata +38 -77
  40. data/lib/firehose/channel.rb +0 -84
  41. data/lib/firehose/default.rb +0 -8
  42. data/lib/firehose/producer.rb +0 -104
  43. data/lib/firehose/publisher.rb +0 -127
  44. data/lib/firehose/subscriber.rb +0 -54
  45. data/lib/firehose/swf_policy_request.rb +0 -23
  46. data/spec/lib/broker_spec.rb +0 -30
  47. data/spec/lib/consumer_spec.rb +0 -66
  48. data/spec/lib/default_spec.rb +0 -7
@@ -2,7 +2,7 @@
2
2
  # Enable this with something like this in your config/rainbows.rb file:
3
3
  #
4
4
  # after_fork do |server, worker|
5
- # require 'rainbows_em_swf_policy'
5
+ # require 'firehose/patches/rainbows'
6
6
  # end if ENV['RACK_ENV'] == 'development'
7
7
  #
8
8
  # You should only use this in development. It has not been well tested in a
@@ -15,12 +15,14 @@
15
15
  # http://www.adobe.com/devnet/flashplayer/articles/socket_policy_files.html
16
16
  # http://blog.vokle.com/index.php/2009/06/10/dealing-with-adobe-and-serving-socket-policy-servers-via-nginx-and-10-lines-of-code/
17
17
 
18
+ require 'firehose/patches/swf_policy_request'
18
19
  require 'rainbows'
20
+
19
21
  # Ensure the class already exists so we are overwriting it.
20
22
  Rainbows::EventMachine::Client
21
23
 
22
24
  class Rainbows::EventMachine::Client
23
- include Firehose::SwfPolicyRequest
25
+ include Firehose::Patches::SwfPolicyRequest
24
26
  alias_method :receive_data_without_swf_policy, :receive_data
25
27
  # Borrowed from: https://github.com/igrigorik/em-websocket/blob/3e7f7d7760cc23b9d1d34fc1c17bab4423b5d11a/lib/em-websocket/connection.rb#L104
26
28
  def receive_data(data)
@@ -0,0 +1,26 @@
1
+ module Firehose
2
+ module Patches
3
+ # Helpers for making Firehose work with Macromedia Flash Sockets. Since this doesn't use "normal" HTTP, we
4
+ # have to monkey patch both Rainbows and Thin to recognize when a request is for a SWF policy.
5
+ module SwfPolicyRequest
6
+ # Borrowed from: https://github.com/igrigorik/em-websocket/blob/3e7f7d7760cc23b9d1d34fc1c17bab4423b5d11a/lib/em-websocket/connection.rb#L104
7
+ def handle_swf_policy_request(data)
8
+ if data =~ /\A<policy-file-request\s*\/>/
9
+ Firehose.logger.debug "Received SWF Policy request: #{data.inspect}"
10
+ send_data policy
11
+ close_connection_after_writing
12
+ true
13
+ end
14
+ end
15
+
16
+ def policy
17
+ <<-EOS
18
+ <?xml version="1.0"?>
19
+ <cross-domain-policy>
20
+ <allow-access-from domain="*" to-ports="*"/>
21
+ </cross-domain-policy>
22
+ EOS
23
+ end
24
+ end
25
+ end
26
+ end
@@ -9,12 +9,13 @@
9
9
  # http://www.adobe.com/devnet/flashplayer/articles/socket_policy_files.html
10
10
  # http://blog.vokle.com/index.php/2009/06/10/dealing-with-adobe-and-serving-socket-policy-servers-via-nginx-and-10-lines-of-code/
11
11
 
12
+ require 'firehose/patches/swf_policy_request'
12
13
  require 'thin'
13
14
  # Ensure the class already exists so we are overwriting it.
14
15
  Thin::Connection
15
16
 
16
17
  class Thin::Connection
17
- include Firehose::SwfPolicyRequest
18
+ include Firehose::Patches::SwfPolicyRequest
18
19
  alias_method :receive_data_without_swf_policy, :receive_data
19
20
  def receive_data(data)
20
21
  if handle_swf_policy_request(data)
data/lib/firehose/rack.rb CHANGED
@@ -1,55 +1,28 @@
1
1
  module Firehose
2
2
  module Rack
3
- autoload :ConsumerApp, 'firehose/rack/consumer_app'
4
- autoload :PublisherApp, 'firehose/rack/publisher_app'
5
- autoload :PingApp, 'firehose/rack/ping_app'
3
+ autoload :Consumer, 'firehose/rack/consumer'
4
+ autoload :Publisher, 'firehose/rack/publisher'
5
+ autoload :Ping, 'firehose/rack/ping'
6
+ autoload :App, 'firehose/rack/app'
6
7
 
7
- # Evented web servers recognize this as a response deferral.
8
+ # Evented web servers recognize the -1 HTTP code as a response deferral, which
9
+ # is needed to stream responses via WebSockets or HTTP long polling.
8
10
  ASYNC_RESPONSE = [-1, {}, []].freeze
9
11
 
10
12
  # Normally we'd want to use a custom header to reduce the likelihood of some
11
13
  # HTTP middleware clobbering the value. But Safari seems to ignore our CORS
12
14
  # header instructions, so we are using 'pragma' because it is always allowed.
13
15
  LAST_MESSAGE_SEQUENCE_HEADER = 'Pragma'
14
- RACK_LAST_MESSAGE_SEQUENCE_HEADER = "HTTP_#{LAST_MESSAGE_SEQUENCE_HEADER.upcase.gsub('-', '_')}"
15
- # Don't cache in development mode
16
- CORS_OPTIONS_MAX_AGE = ENV['RACK_ENV'] == 'development' ? '1' : '1728000'
17
16
 
18
- # Allows the publisher and consumer to be mounted on the same port.
19
- class App
20
- def call(env)
21
- # Cache the parsed request so we don't need to re-parse it when we pass
22
- # control onto another app.
23
- req = env['parsed_request'] ||= ::Rack::Request.new(env)
24
- method = req.request_method
25
-
26
- case method
27
- when 'PUT'
28
- publisher.call(env)
29
- when 'HEAD'
30
- ping.call(env)
31
- else
32
- consumer.call(env)
33
- end
34
- end
35
-
36
-
37
- private
38
- def publisher
39
- @publisher ||= PublisherApp.new
40
- end
41
-
42
- def consumer
43
- @consumer ||= ConsumerApp.new
44
- end
17
+ # Rack wants the header to start with HTTP, so we deal with that here.
18
+ RACK_LAST_MESSAGE_SEQUENCE_HEADER = "HTTP_#{LAST_MESSAGE_SEQUENCE_HEADER.upcase.gsub('-', '_')}"
45
19
 
46
- def ping
47
- @ping ||= PingApp.new
48
- end
49
- end
20
+ # Disable CORS preflight caches for requests in development mode.
21
+ CORS_OPTIONS_MAX_AGE = ENV['RACK_ENV'] == 'development' ? '1' : '1728000'
50
22
 
51
23
  module Helpers
52
- # Calculates the content length for you
24
+ # Calculates the content of a message body for the response so that HTTP Keep-Alive
25
+ # connections work.
53
26
  def response(status, body='', headers={})
54
27
  headers = {'Content-Length' => body.size.to_s}.merge(headers)
55
28
  [status, headers, [body]]
@@ -0,0 +1,42 @@
1
+ module Firehose
2
+ module Rack
3
+ # Acts as the glue between the HTTP/WebSocket world and the Firehose::Server class,
4
+ # which talks directly to the Redis server. Also dispatches between HTTP and WebSocket
5
+ # transport handlers depending on the clients' request.
6
+ class App
7
+ def call(env)
8
+ # Cache the parsed request so we don't need to re-parse it when we pass
9
+ # control onto another app.
10
+ req = env['parsed_request'] ||= ::Rack::Request.new(env)
11
+ method = req.request_method
12
+
13
+ case method
14
+ when 'PUT'
15
+ # Firehose::Client::Publisher PUT's payloads to the server.
16
+ publisher.call(env)
17
+ when 'HEAD'
18
+ # HEAD requests are used to prevent sockets from timing out
19
+ # from inactivity
20
+ ping.call(env)
21
+ else
22
+ # TODO - 'harden' this up with a GET request and throw a "Bad Request"
23
+ # HTTP error code. I'd do it now but I'm in a plane and can't think of it.
24
+ consumer.call(env)
25
+ end
26
+ end
27
+
28
+ private
29
+ def publisher
30
+ @publisher ||= Publisher.new
31
+ end
32
+
33
+ def consumer
34
+ @consumer ||= Consumer.new
35
+ end
36
+
37
+ def ping
38
+ @ping ||= Ping.new
39
+ end
40
+ end
41
+ end
42
+ end
@@ -3,7 +3,10 @@ require 'json'
3
3
 
4
4
  module Firehose
5
5
  module Rack
6
- class ConsumerApp
6
+ # Handles a subscription request over HTTP or WebSockets depeding on its abilities and
7
+ # binds that to the Firehose::Server::Subscription class, which is bound to a channel that
8
+ # gets published to.
9
+ class Consumer
7
10
  def call(env)
8
11
  websocket_request?(env) ? websocket.call(env) : http_long_poll.call(env)
9
12
  end
@@ -47,7 +50,7 @@ module Firehose
47
50
  if last_sequence < 0
48
51
  env['async.callback'].call response(400, "The last_message_sequence parameter may not be less than zero", response_headers(env))
49
52
  else
50
- Channel.new(path).next_message(last_sequence, :timeout => TIMEOUT).callback do |message, sequence|
53
+ Server::Channel.new(path).next_message(last_sequence, :timeout => TIMEOUT).callback do |message, sequence|
51
54
  env['async.callback'].call response(200, wrap_frame(message, sequence), response_headers(env))
52
55
  end.errback do |e|
53
56
  if e == :timeout
@@ -107,11 +110,10 @@ module Firehose
107
110
  return @ws.rack_response
108
111
  end
109
112
 
110
- private
111
-
113
+ private
112
114
  def subscribe(last_sequence)
113
115
  @subscribed = true
114
- @channel = Channel.new @path
116
+ @channel = Server::Channel.new @path
115
117
  @deferrable = @channel.next_message last_sequence
116
118
  @deferrable.callback do |message, sequence|
117
119
  Firehose.logger.debug "WS sent `#{message}` to `#{@path}` with sequence `#{sequence}`"
@@ -1,6 +1,9 @@
1
1
  module Firehose
2
2
  module Rack
3
- class PingApp
3
+ # Allows the Firehose client to periodically "ping" the server
4
+ # so that the connection isn't timed out by browsers or proxies from
5
+ # inactivity.
6
+ class Ping
4
7
  attr_reader :redis
5
8
 
6
9
  def initialize(redis=nil)
@@ -12,7 +15,6 @@ module Firehose
12
15
  ASYNC_RESPONSE
13
16
  end
14
17
 
15
-
16
18
  # Encapsulate this in a class so we aren't passing a bunch of variables around
17
19
  class PingCheck
18
20
  include Firehose::Rack::Helpers
@@ -1,6 +1,6 @@
1
1
  module Firehose
2
2
  module Rack
3
- class PublisherApp
3
+ class Publisher
4
4
  include Firehose::Rack::Helpers
5
5
 
6
6
  def call(env)
@@ -44,9 +44,9 @@ module Firehose
44
44
  end
45
45
  end
46
46
 
47
- private
47
+ private
48
48
  def publisher
49
- @publisher ||= Firehose::Publisher.new
49
+ @publisher ||= Firehose::Server::Publisher.new
50
50
  end
51
51
  end
52
52
  end
@@ -1,48 +1,22 @@
1
1
  require 'faye/websocket'
2
+ require 'em-hiredis'
2
3
 
3
- module Firehose
4
- class Server
5
- def initialize(opts={})
6
- @port = opts[:port] || Firehose::Default::URI.port
7
- @host = opts[:host] || Firehose::Default::URI.host
8
- @server = opts[:server] || :rainbows
9
-
10
- Firehose.logger.info "Starting #{Firehose::VERSION} '#{Firehose::CODENAME}', in #{ENV['RACK_ENV']}"
11
- end
12
-
13
- def start
14
- self.send("start_#{@server}")
15
- end
16
-
17
- private
18
- def start_rainbows
19
- require 'rainbows'
20
- Faye::WebSocket.load_adapter('rainbows')
21
-
22
- rackup = Unicorn::Configurator::RACKUP
23
- rackup[:port] = @port if @port
24
- rackup[:host] = @host if @host
25
- rackup[:set_listener] = true
26
- opts = rackup[:options]
27
- opts[:config_file] = File.expand_path('../../../config/rainbows.rb', __FILE__)
4
+ # Set the EM::Hiredis logger to be the same as the Firehose logger.
5
+ EM::Hiredis.logger = Firehose.logger
28
6
 
29
- server = Rainbows::HttpServer.new(Firehose::Rack::App.new, opts)
30
- server.start.join
31
- end
32
-
33
- def start_thin
34
- require 'thin'
35
- require 'thin_em_swf_policy' if ENV['RACK_ENV'] == 'development'
36
-
37
- Faye::WebSocket.load_adapter('thin')
38
-
39
- # TODO: See if we can just set Thin to use Firehose.logger instead of
40
- # printing out messages by itself.
41
- Thin::Logging.silent = true if Firehose.logger.level == Logger::ERROR
42
-
43
- server = Thin::Server.new(@host, @port) do
44
- run Firehose::Rack::App.new
45
- end.start
7
+ module Firehose
8
+ # Firehose components that sit between the Rack HTTP software and the Redis server.
9
+ # This mostly handles message sequencing and different HTTP channel names.
10
+ module Server
11
+ autoload :Subscriber, 'firehose/server/subscriber'
12
+ autoload :Publisher, 'firehose/server/publisher'
13
+ autoload :Channel, 'firehose/server/channel'
14
+ autoload :App, 'firehose/server/app'
15
+
16
+ # Generates keys for all firehose interactions with Redis. Ensures a root
17
+ # key of `firehose`
18
+ def self.key(*segments)
19
+ segments.unshift(:firehose).join(':')
46
20
  end
47
21
  end
48
22
  end
@@ -0,0 +1,53 @@
1
+ module Firehose
2
+ module Server
3
+ # Configure servers that are booted with-out going through Rack. This is mostly used by
4
+ # the `firehose server` CLI command or for testing. Production configurations are likely
5
+ # to boot with custom rack configurations.
6
+ class App
7
+ def initialize(opts={})
8
+ @port = opts[:port] || Firehose::URI.port
9
+ @host = opts[:host] || Firehose::URI.host
10
+ @server = opts[:server] || :rainbows
11
+
12
+ Firehose.logger.info "Starting #{Firehose::VERSION} '#{Firehose::CODENAME}', in #{ENV['RACK_ENV']}"
13
+ end
14
+
15
+ def start
16
+ self.send("start_#{@server}")
17
+ end
18
+
19
+ private
20
+ # Boot the Firehose server with the Rainbows app server.
21
+ def start_rainbows
22
+ require 'rainbows'
23
+ Faye::WebSocket.load_adapter('rainbows')
24
+
25
+ rackup = Unicorn::Configurator::RACKUP
26
+ rackup[:port] = @port if @port
27
+ rackup[:host] = @host if @host
28
+ rackup[:set_listener] = true
29
+ opts = rackup[:options]
30
+ opts[:config_file] = File.expand_path('../../../../config/rainbows.rb', __FILE__)
31
+
32
+ server = Rainbows::HttpServer.new(Firehose::Rack::App.new, opts)
33
+ server.start.join
34
+ end
35
+
36
+ # Boot the Firehose server with the Thin app server.
37
+ def start_thin
38
+ require 'thin'
39
+ require 'firehose/patches/thin' if ENV['RACK_ENV'] == 'development'
40
+
41
+ Faye::WebSocket.load_adapter('thin')
42
+
43
+ # TODO: See if we can just set Thin to use Firehose.logger instead of
44
+ # printing out messages by itself.
45
+ Thin::Logging.silent = true if Firehose.logger.level == Logger::ERROR
46
+
47
+ server = Thin::Server.new(@host, @port) do
48
+ run Firehose::Rack::App.new
49
+ end.start
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,80 @@
1
+ module Firehose
2
+ module Server
3
+ # Connects to a specific channel on Redis and listens for messages to notify subscribers.
4
+ class Channel
5
+ attr_reader :channel_key, :redis, :subscriber, :list_key, :sequence_key
6
+
7
+ def self.redis
8
+ @redis ||= EM::Hiredis.connect
9
+ end
10
+
11
+ def self.subscriber
12
+ @subscriber ||= Server::Subscriber.new(EM::Hiredis.connect)
13
+ end
14
+
15
+ def initialize(channel_key, redis=self.class.redis, subscriber=self.class.subscriber)
16
+ @channel_key, @redis, @subscriber = channel_key, redis, subscriber
17
+ @list_key, @sequence_key = Server.key(channel_key, :list), Server.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
+ # TODO: Use HSET so we don't have to pull 100 messages back every time.
28
+ redis.multi
29
+ redis.get(sequence_key).
30
+ errback {|e| deferrable.fail e }
31
+ redis.lrange(list_key, 0, Server::Publisher::MAX_MESSAGES).
32
+ errback {|e| deferrable.fail e }
33
+ redis.exec.callback do |(sequence, message_list)|
34
+ Firehose.logger.debug "exec returened: `#{sequence}` and `#{message_list.inspect}`"
35
+ sequence = sequence.to_i
36
+
37
+ if sequence.nil? || (diff = sequence - last_sequence) <= 0
38
+ Firehose.logger.debug "No message available yet, subscribing. sequence: `#{sequence}`"
39
+ # Either this resource has never been seen before or we are all caught up.
40
+ # Subscribe and hope something gets published to this end-point.
41
+ subscribe(deferrable, options[:timeout])
42
+ elsif last_sequence > 0 && diff < Server::Publisher::MAX_MESSAGES
43
+ # The client is kinda-sorta running behind, but has a chance to catch
44
+ # up. Catch them up FTW.
45
+ # But we won't "catch them up" if last_sequence was zero/nil because
46
+ # that implies the client is connecting for the 1st time.
47
+ message = message_list[diff-1]
48
+ Firehose.logger.debug "Sending old message `#{message}` and sequence `#{sequence}` to client directly. Client is `#{diff}` behind, at `#{last_sequence}`."
49
+ deferrable.succeed message, last_sequence + 1
50
+ else
51
+ # The client is hopelessly behind and underwater. Just reset
52
+ # their whole world with the lastest message.
53
+ message = message_list[0]
54
+ Firehose.logger.debug "Sending latest message `#{message}` and sequence `#{sequence}` to client directly."
55
+ deferrable.succeed message, sequence
56
+ end
57
+ end.errback {|e| deferrable.fail e }
58
+
59
+ deferrable
60
+ end
61
+
62
+ def unsubscribe(deferrable)
63
+ subscriber.unsubscribe channel_key, deferrable
64
+ end
65
+
66
+ private
67
+ def subscribe(deferrable, timeout=nil)
68
+ subscriber.subscribe(channel_key, deferrable)
69
+ if timeout
70
+ timer = EventMachine::Timer.new(timeout) do
71
+ deferrable.fail :timeout
72
+ unsubscribe deferrable
73
+ end
74
+ # Cancel the timer if when the deferrable succeeds
75
+ deferrable.callback { timer.cancel }
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end