redisse 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,39 @@
1
+ require 'rack/test'
2
+
3
+ $app, _opts = Rack::Builder.parse_file __dir__ + '/../config.ru'
4
+
5
+ describe "Example App" do
6
+ include Rack::Test::Methods
7
+
8
+ def app
9
+ $app
10
+ end
11
+
12
+ describe "/publish" do
13
+ context "basic" do
14
+ before do
15
+ Redisse.test_mode!
16
+ end
17
+
18
+ it "publishes the message to the channel" do
19
+ post "/publish", channel: 'global', message: 'Hello'
20
+ expect(Redisse.published.size).to be == 1
21
+ event = Redisse.published.first
22
+ expect(event.channel).to be == 'global'
23
+ expect(event.type).to be == :message
24
+ expect(event.data).to be == 'Hello'
25
+ end
26
+ end
27
+
28
+ context "filtered" do
29
+ before do
30
+ Redisse.test_filter = :unused_type
31
+ end
32
+
33
+ it "publishes the message with the 'message' type" do
34
+ post "/publish", channel: 'global', message: 'Hello'
35
+ expect(Redisse.published.size).to be == 0
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,181 @@
1
+ require 'redisse/version'
2
+ require 'redisse/publisher'
3
+ require 'redis'
4
+
5
+ # Public: A HTTP API to serve Server-Sent Events via a Redis backend.
6
+ module Redisse
7
+ # Public: Gets/Sets the String URL of the Redis server to connect to.
8
+ #
9
+ # Note that while the Redis pubsub mechanism works outside of the Redis key
10
+ # namespace and ignores the database (the path part of the URL), the
11
+ # database will still be used to store an history of the events sent to
12
+ # support Last-Event-Id.
13
+ #
14
+ # Defaults to the REDISSE_REDIS environment variable and if it is not set, to
15
+ # redis://localhost:6379/.
16
+ attr_accessor :redis_server
17
+
18
+ # Public: The port on which the server listens.
19
+ #
20
+ # Defaults to the REDISSE_PORT environment variable and if it is not set, to
21
+ # 8080.
22
+ attr_accessor :default_port
23
+
24
+ # Public: The internal URL hierarchy to redirect to with X-Accel-Redirect.
25
+ #
26
+ # When this property is set, Redisse will work totally differently. Your Ruby
27
+ # code will not be loaded by the events server itself, but only by the
28
+ # {#redirect_endpoint} Rack app that you will have to route to in your Rack
29
+ # app (e.g. using +map+ in +config.ru+) and this endpoint will redirect to
30
+ # this internal URL hierarchy.
31
+ #
32
+ # Defaults to /redisse.
33
+ attr_accessor :nginx_internal_url
34
+
35
+ # Public: Send an event to subscribers, of the given type.
36
+ #
37
+ # All browsers subscribing to the events server will receive a Server-Sent
38
+ # Event of the chosen type.
39
+ #
40
+ # channel - The channel to publish the message to.
41
+ # type_message - The type of the event and the content of the message, as a
42
+ # Hash of form { type => message } or simply the message as
43
+ # a String, for the default event type :message.
44
+ #
45
+ # Examples
46
+ #
47
+ # Redisse.publish(:global, notice: 'This is a server-sent event.')
48
+ # Redisse.publish(:global, 'Hello, World!')
49
+ #
50
+ # # on the browser side:
51
+ # var source = new EventSource(eventsURL);
52
+ # source.addEventListener('notice', function(e) {
53
+ # console.log(e.data) // logs 'This is a server-sent event.'
54
+ # }, false)
55
+ # source.addEventListener('message', function(e) {
56
+ # console.log(e.data) // logs 'Hello, World!'
57
+ # }, false)
58
+ def publish(channel, message)
59
+ type, message = Hash(message).first if message.respond_to?(:to_h)
60
+ type ||= :message
61
+ publisher.publish(channel, message, type)
62
+ end
63
+
64
+ # Public: The list of channels to subscribe to.
65
+ #
66
+ # Once {Redisse.channels} has been called, the given block is this method.
67
+ # The block must satisfy this interface:
68
+ #
69
+ # env - The Rack environment for this request.
70
+ #
71
+ # Returns an Array of String naming the channels to subscribe to.
72
+ #
73
+ # Raises NotImplementedError unless {Redisse.channels} has been called.
74
+ def channels(env)
75
+ raise NotImplementedError, "you must call Redisse.channels first"
76
+ end
77
+
78
+ # Public: Use test mode.
79
+ #
80
+ # Instead of actually publishing to Redis, events will be stored in
81
+ # {#published} to use for tests.
82
+ #
83
+ # Must be called before each test in order for published events to be
84
+ # emptied.
85
+ #
86
+ # See also {#test_filter=}.
87
+ #
88
+ # Examples
89
+ #
90
+ # # RSpec
91
+ # before { Redisse.test_mode! }
92
+ def test_mode!
93
+ @publisher = TestPublisher.new
94
+ end
95
+
96
+ # Public: Filter events stored in test mode.
97
+ #
98
+ # If set, only events whose type match with the filter are stored in
99
+ # {#published}. A filter matches by using case equality, which allows using
100
+ # a simple Symbol or a Proc for more advanced filters:
101
+ #
102
+ # Automatically sets {#test_mode!}, so it also clears the previous events.
103
+ #
104
+ # Examples
105
+ #
106
+ # Redisse.test_filter = -> type { %i(foo baz).include? type }
107
+ # Redisse.publish :global, foo: 'stored'
108
+ # Redisse.publish :global, bar: 'skipped'
109
+ # Redisse.publish :global, baz: 'stored'
110
+ # Redisse.published.size # => 2
111
+ def test_filter=(filter)
112
+ test_mode!
113
+ publisher.filter = filter
114
+ end
115
+
116
+ # Public: Returns the published events.
117
+ #
118
+ # Fails unless {#test_mode!} is set.
119
+ def published
120
+ fail "Call #{self}.test_mode! first" unless publisher.respond_to?(:published)
121
+ publisher.published
122
+ end
123
+
124
+ # Internal: List of middlewares defined with {#use}.
125
+ #
126
+ # Used by Goliath to build the server.
127
+ def middlewares
128
+ @middlewares ||= []
129
+ end
130
+
131
+ # Public: Define a middleware for the server.
132
+ #
133
+ # See {https://github.com/postrank-labs/goliath/wiki/Middleware Goliath middlewares}.
134
+ #
135
+ # Examples
136
+ #
137
+ # Redisse.use MyMiddleware, foo: true
138
+ def use(middleware, *args, &block)
139
+ middlewares << [middleware, args, block]
140
+ end
141
+
142
+ # Public: Define a Goliath plugin to run with the server.
143
+ #
144
+ # See {https://github.com/postrank-labs/goliath/wiki/Plugins Goliath plugins}.
145
+ def plugin(name, *args)
146
+ plugins << [name, args]
147
+ end
148
+
149
+ # Public: The Rack application that redirects to {#nginx_internal_url}.
150
+ #
151
+ # If you set {#nginx_internal_url}, you need to call this Rack application
152
+ # to redirect to the Redisse server.
153
+ #
154
+ # Also note that when using the redirect endpoint, two channel names are
155
+ # reserved, and cannot be used: +polling+ and +lastEventId+.
156
+ #
157
+ # Examples
158
+ #
159
+ # map "/events" { run Redisse.redirect_endpoint }
160
+ def redirect_endpoint
161
+ @redirect_endpoint ||= RedirectEndpoint.new self
162
+ end
163
+
164
+ autoload :RedirectEndpoint, __dir__ + '/redisse/redirect_endpoint'
165
+
166
+ private
167
+
168
+ def plugins
169
+ @plugins ||= []
170
+ end
171
+
172
+ def publisher
173
+ @publisher ||= RedisPublisher.new(redis)
174
+ end
175
+
176
+ def redis
177
+ @redis ||= Redis.new(url: redis_server)
178
+ end
179
+ end
180
+
181
+ require 'redisse/configuration'
@@ -0,0 +1,47 @@
1
+ module Redisse
2
+ extend self
3
+
4
+ # Public: Define the list of channels to subscribe to.
5
+ #
6
+ # Calls the given block with a Rack environment, the block is expected to
7
+ # return a list of channels the current user has access to. The list is then
8
+ # coerced using +Kernel#Array+.
9
+ #
10
+ # Once the block is defined, other calls will be handled by the block
11
+ # directly, as if the method had been redefined directly. It simply gives a
12
+ # nicer API:
13
+ #
14
+ # Redisse.channels do |env|
15
+ # end
16
+ #
17
+ # vs
18
+ #
19
+ # def Redisse.channels(env)
20
+ # end
21
+ #
22
+ # block - The block that lists the channels for the given Rack environment.
23
+ #
24
+ # Examples
25
+ #
26
+ # Redisse.channels do |env|
27
+ # %w( comment post )
28
+ # end
29
+ # # will result in subscriptions to 'comment' and 'post' channels.
30
+ #
31
+ # Redisse.channels({})
32
+ # # => ["comment", "post"]
33
+ def self.channels(*, &block)
34
+ if block
35
+ # overwrite method with block
36
+ define_singleton_method :channels, &block
37
+ else
38
+ super
39
+ end
40
+ end
41
+
42
+ self.redis_server = ENV['REDISSE_REDIS'] ||
43
+ 'redis://localhost:6379/'
44
+ self.default_port = ENV['REDISSE_PORT'] ||
45
+ 8080
46
+ self.nginx_internal_url = '/redisse'
47
+ end
@@ -0,0 +1,70 @@
1
+ require 'redisse/server_sent_events'
2
+ require 'json'
3
+
4
+ module Redisse
5
+ # Internal: Publisher that pushes to Redis with history.
6
+ class RedisPublisher
7
+ include ServerSentEvents
8
+
9
+ REDISSE_LAST_EVENT_ID = 'redisse:lastEventId'.freeze
10
+ HISTORY_SIZE = 100
11
+
12
+ def initialize(redis)
13
+ @redis = redis or raise 'RedisPublisher needs a Redis client'
14
+ end
15
+
16
+ def publish(channel, data, type)
17
+ event_id = @redis.incr(REDISSE_LAST_EVENT_ID)
18
+ event = server_sent_event(data, type: type, id: event_id)
19
+ @redis.publish(channel, event)
20
+ @redis.zadd(channel, event_id, event)
21
+ @redis.zremrangebyrank(channel, 0, -1-HISTORY_SIZE)
22
+ event_id
23
+ end
24
+ end
25
+
26
+ # Internal: Publisher that stores events in memory for easy testing.
27
+ #
28
+ # See {Redisse#test_mode! Redisse#test_mode!}.
29
+ class TestPublisher
30
+ def initialize
31
+ @published = []
32
+ end
33
+
34
+ attr_reader :published
35
+
36
+ attr_accessor :filter
37
+
38
+ def publish(channel, data, type)
39
+ return if filter && !(filter === type)
40
+ @published << TestEvent.new(channel, data, type)
41
+ end
42
+ end
43
+
44
+ # Define then reopen instead of using the block of Struct.new for YARD.
45
+ TestEvent = Struct.new :channel, :data, :type
46
+
47
+ # Public: An event in test mode.
48
+ #
49
+ # You can re-open or add modules to this class if you want to add behavior
50
+ # to events found in {Redisse#published Redisse#published} for easier
51
+ # testing.
52
+ #
53
+ # Examples
54
+ #
55
+ # class Redisse::TestEvent
56
+ # def yml
57
+ # YAML.load data
58
+ # end
59
+ #
60
+ # def private?
61
+ # channel.start_with? 'private'
62
+ # end
63
+ # end
64
+ class TestEvent
65
+ # Public: Helper method to parse the Event data as JSON.
66
+ def json
67
+ JSON.parse(data)
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,45 @@
1
+ require 'uri'
2
+
3
+ module Redisse
4
+
5
+ # Public: Rack app that redirects to the Redisse server via X-Accel-Redirect.
6
+ class RedirectEndpoint
7
+
8
+ def initialize(redisse)
9
+ @redisse = redisse
10
+ self.base_url = redisse.nginx_internal_url
11
+ end
12
+
13
+ def call(env)
14
+ response = Rack::Response.new
15
+ response['X-Accel-Redirect'] = redirect_url(env)
16
+ response
17
+ end
18
+
19
+ private
20
+
21
+ def redirect_url(env)
22
+ channels = @redisse.channels(env)
23
+ fail 'Wrong channel "polling"' if channels.include? 'polling'
24
+ fail 'Reserved channel "lastEventId"' if channels.include? 'lastEventId'
25
+ @base_url + '?' + URI.encode_www_form(redirect_options(env) + channels)
26
+ end
27
+
28
+ def redirect_options(env)
29
+ params = URI.decode_www_form(env['QUERY_STRING'])
30
+ [].tap do |options|
31
+ options << 'polling'.freeze if params.assoc('polling'.freeze)
32
+ last_event_id_param = params.assoc('lastEventId')
33
+ options << last_event_id_param if last_event_id_param
34
+ end
35
+ end
36
+
37
+ def base_url=(url)
38
+ url = String(url)
39
+ url += "/" unless url.end_with? '/'
40
+ @base_url = url
41
+ end
42
+
43
+ end
44
+
45
+ end
@@ -0,0 +1,205 @@
1
+ require 'redisse'
2
+ require 'goliath/api'
3
+ require 'rack/accept_media_types'
4
+ require 'goliath/runner'
5
+ require 'em-hiredis'
6
+
7
+ module Redisse
8
+
9
+ # Public: Run the server.
10
+ #
11
+ # If you use the provided binary you don't need to call this method.
12
+ #
13
+ # By default, the {#channels} method is called directly.
14
+ #
15
+ # If {#nginx_internal_url} is set, the channels will actually come from the
16
+ # internal redirect URL generated in the Rack app by {#redirect_endpoint}.
17
+ def run
18
+ run_as_standalone if nginx_internal_url
19
+ server = Server.new(self)
20
+ runner = Goliath::Runner.new(ARGV, server)
21
+ runner.app = Goliath::Rack::Builder.build(self, server)
22
+ runner.load_plugins([Server::Stats] + plugins)
23
+ runner.run
24
+ end
25
+
26
+ private
27
+
28
+ # Internal: Redefine {#channels} to find channels in the redirect URL.
29
+ def run_as_standalone
30
+ channels do |env|
31
+ query_string = env['QUERY_STRING'] || ''
32
+ channels = query_string.split('&').map { |channel|
33
+ URI.decode_www_form_component(channel)
34
+ }
35
+ channels.delete('polling')
36
+ channels.delete_if {|channel| channel.start_with?('lastEventId=') }
37
+ end
38
+ end
39
+
40
+ # Internal: Goliath::API class that defines the server.
41
+ #
42
+ # See {Redisse#run}.
43
+ class Server < Goliath::API
44
+ require 'redisse/server/stats'
45
+ require 'redisse/server/responses'
46
+ include Responses
47
+ require 'redisse/server/redis'
48
+ include Redis
49
+
50
+ # Public: Delay between receiving a message and closing the connection.
51
+ #
52
+ # Closing the connection is necessary when using long polling, because the
53
+ # client is not able to read the data before the connection is closed. But
54
+ # instead of closing immediately, we delay a bit closing the connection to
55
+ # give a chance for several messages to be sent in a row.
56
+ LONG_POLLING_DELAY = 1
57
+
58
+ # Public: The period between heartbeats in seconds.
59
+ HEARTBEAT_PERIOD = 15
60
+
61
+ def initialize(redisse)
62
+ @redisse = redisse
63
+ super()
64
+ end
65
+
66
+ def response(env)
67
+ return not_acceptable unless acceptable?(env)
68
+ channels = Array(redisse.channels(env))
69
+ return not_found if channels.empty?
70
+ subscribe(env, channels) or return service_unavailable
71
+ send_history_events(env, channels)
72
+ heartbeat(env)
73
+ streaming_response(200, {
74
+ 'Content-Type' => 'text/event-stream',
75
+ 'Cache-Control' => 'no-cache',
76
+ 'X-Accel-Buffering' => 'no',
77
+ })
78
+ end
79
+
80
+ def on_close(env)
81
+ env.status[:stats][:connected] -= 1
82
+ env.status[:stats][:served] += 1
83
+ unsubscribe(env)
84
+ stop_heartbeat(env)
85
+ end
86
+
87
+ private
88
+
89
+ attr_reader :redisse
90
+
91
+ def subscribe(env, channels)
92
+ return unless pubsub { env.stream_close }
93
+ env.status[:stats][:connected] += 1
94
+ env.logger.debug { "Subscribing to #{channels}" }
95
+ env_sender = -> event { send_event(env, event) }
96
+ pubsub_subcribe(channels, env_sender)
97
+ env['redisse.unsubscribe'.freeze] = -> do
98
+ pubsub_unsubscribe_proc(channels, env_sender)
99
+ end
100
+ true
101
+ end
102
+
103
+ def heartbeat(env)
104
+ env['redisse.heartbeat_timer'.freeze] = EM.add_periodic_timer(HEARTBEAT_PERIOD) do
105
+ env.logger.debug "Sending heartbeat".freeze
106
+ env.stream_send(": hb\n".freeze)
107
+ end
108
+ end
109
+
110
+ def stop_heartbeat(env)
111
+ return unless timer = env['redisse.heartbeat_timer'.freeze]
112
+ env.logger.debug "Stopping heartbeat".freeze
113
+ timer.cancel
114
+ end
115
+
116
+ def unsubscribe(env)
117
+ return unless unsubscribe = env['redisse.unsubscribe'.freeze]
118
+ env['redisse.unsubscribe'.freeze] = nil
119
+ env.logger.debug "Unsubscribing".freeze
120
+ unsubscribe.call
121
+ end
122
+
123
+ def send_event(env, event)
124
+ env.status[:stats][:events] += 1
125
+ env.logger.debug { "Sending:\n#{event.chomp.chomp}" }
126
+ env.stream_send(event)
127
+ return unless long_polling?(env)
128
+ env["redisse.long_polling_timer".freeze] ||= EM.add_timer(LONG_POLLING_DELAY) do
129
+ env.stream_close
130
+ end
131
+ end
132
+
133
+ def long_polling?(env)
134
+ key = "redisse.long_polling".freeze
135
+ env.fetch(key) do
136
+ env[key] = Rack::Request.new(env).GET.keys.include?('polling')
137
+ end
138
+ end
139
+
140
+ def send_history_events(env, channels)
141
+ last_event_id = last_event_id(env)
142
+ return unless last_event_id
143
+ EM::Synchrony.next_tick do
144
+ events = events_for_channels(channels, last_event_id)
145
+ env.logger.debug { "Sending #{events.size} history events" }
146
+ if (first = events.first) && first.start_with?('type: missedevents')
147
+ env.status[:stats][:missing] += 1
148
+ end
149
+ events.each { |event| send_event(env, event) }
150
+ end
151
+ end
152
+
153
+ def last_event_id(env)
154
+ last_event_id = env['HTTP_LAST_EVENT_ID'] ||
155
+ Rack::Request.new(env).GET['lastEventId']
156
+ last_event_id = last_event_id.to_i
157
+ last_event_id.nonzero? && last_event_id
158
+ end
159
+
160
+ def events_for_channels(channels, last_event_id)
161
+ events_with_ids = channels.each_with_object([]) { |channel, events|
162
+ channel_events = events_for_channel(channel, last_event_id)
163
+ events.concat(channel_events)
164
+ }.sort_by!(&:last)
165
+ handle_missing_events(events_with_ids, last_event_id)
166
+ events_with_ids.map(&:first)
167
+ end
168
+
169
+ def handle_missing_events(events_with_ids, last_event_id)
170
+ first_event, first_event_id = events_with_ids.first
171
+ return unless first_event
172
+ if first_event_id == last_event_id
173
+ events_with_ids.shift
174
+ else
175
+ event = ServerSentEvents.server_sent_event(nil, type: :missedevents)
176
+ events_with_ids.unshift([event])
177
+ end
178
+ end
179
+
180
+ def events_for_channel(channel, last_event_id)
181
+ df = redis.zrangebyscore(channel, last_event_id, '+inf', 'withscores')
182
+ events_scores = EM::Synchrony.sync(df)
183
+ events_scores.each_slice(2).map do |event, score|
184
+ [event, score.to_i]
185
+ end
186
+ end
187
+
188
+ def acceptable?(env)
189
+ accept_media_types = Rack::AcceptMediaTypes.new(env['HTTP_ACCEPT'])
190
+ accept_media_types.include?('text/event-stream')
191
+ end
192
+
193
+ public
194
+
195
+ def options_parser(opts, options)
196
+ opts.on '--redis REDIS_URL', 'URL of the Redis connection' do |url|
197
+ redisse.redis_server = url
198
+ end
199
+ default_port = redisse.default_port
200
+ return unless default_port
201
+ options[:port] = default_port
202
+ end
203
+
204
+ end
205
+ end