redisse 0.4.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.
@@ -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