message_bus 2.2.0.pre.1 → 2.2.0.pre.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.

Potentially problematic release.


This version of message_bus might be problematic. Click here for more details.

@@ -33,6 +33,19 @@ services:
33
33
  - bundle:/usr/local/bundle
34
34
  ports:
35
35
  - 8808:8808
36
+ example:
37
+ build:
38
+ context: .
39
+ command: bash -c "cd examples/diagnostics && bundle install && bundle exec rackup --server puma --host 0.0.0.0"
40
+ environment:
41
+ BUNDLE_TO: /usr/local/bundle
42
+ REDISURL: redis://redis:6379
43
+ volumes:
44
+ - .:/usr/src/app
45
+ - bundle_config:/home/src/app/.bundle
46
+ - bundle:/usr/local/bundle
47
+ ports:
48
+ - 9292:9292
36
49
  redis:
37
50
  image: redis:5.0
38
51
  volumes:
@@ -0,0 +1,5 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gem 'message_bus', path: '../..'
4
+ gem 'redis'
5
+ gem 'puma'
@@ -0,0 +1,21 @@
1
+ require 'message_bus'
2
+
3
+ MessageBus.configure(backend: :redis, url: ENV['REDISURL'])
4
+ MessageBus.enable_diagnostics
5
+
6
+ MessageBus.user_id_lookup do |_env|
7
+ 1
8
+ end
9
+
10
+ MessageBus.is_admin_lookup do |_env|
11
+ true
12
+ end
13
+
14
+ use MessageBus::Rack::Middleware
15
+ run lambda { |_env|
16
+ [
17
+ 200,
18
+ { "Content-Type" => "text/html" },
19
+ ['Howdy. Check out <a href="/message-bus/_diagnostics">the diagnostics UI</a>.']
20
+ ]
21
+ }
@@ -0,0 +1,337 @@
1
+ require 'securerandom'
2
+ require 'net/http'
3
+ require 'json'
4
+ require 'uri'
5
+ require 'message_bus/http_client/channel'
6
+
7
+ module MessageBus
8
+ # MessageBus client that enables subscription via long polling with support
9
+ # for chunked encoding. Falls back to normal polling if long polling is not
10
+ # available.
11
+ #
12
+ # @!attribute [r] channels
13
+ # @return [Hash] a map of the channels that the client is subscribed to
14
+ # @!attribute [r] stats
15
+ # @return [Stats] a Struct containing the statistics of failed and successful
16
+ # polling requests
17
+ #
18
+ # @!attribute enable_long_polling
19
+ # @return [Boolean] whether long polling is enabled
20
+ # @!attribute status
21
+ # @return [HTTPClient::STOPPED, HTTPClient::STARTED] the status of the client.
22
+ # @!attribute enable_chunked_encoding
23
+ # @return [Boolean] whether chunked encoding is enabled
24
+ # @!attribute min_poll_interval
25
+ # @return [Float] the min poll interval for long polling
26
+ # @!attribute max_poll_interval
27
+ # @return [Float] the max poll interval for long polling
28
+ # @!attribute background_callback_interval
29
+ # @return [Float] the polling interval
30
+ class HTTPClient
31
+ class InvalidChannel < StandardError; end
32
+ class MissingBlock < StandardError; end
33
+
34
+ attr_reader :channels,
35
+ :stats
36
+
37
+ attr_accessor :enable_long_polling,
38
+ :status,
39
+ :enable_chunked_encoding,
40
+ :min_poll_interval,
41
+ :max_poll_interval,
42
+ :background_callback_interval
43
+
44
+ CHUNK_SEPARATOR = "\r\n|\r\n".freeze
45
+ private_constant :CHUNK_SEPARATOR
46
+ STATUS_CHANNEL = "/__status".freeze
47
+ private_constant :STATUS_CHANNEL
48
+
49
+ STOPPED = 0
50
+ STARTED = 1
51
+
52
+ Stats = Struct.new(:failed, :success)
53
+ private_constant :Stats
54
+
55
+ # @param base_url [String] Base URL of the message_bus server to connect to
56
+ # @param enable_long_polling [Boolean] Enable long polling
57
+ # @param enable_chunked_encoding [Boolean] Enable chunk encoding
58
+ # @param min_poll_interval [Float, Integer] Min poll interval when long polling
59
+ # @param max_poll_interval [Float, Integer] Max poll interval when long polling.
60
+ # When requests fail, the client will backoff and this is the upper limit.
61
+ # @param background_callback_interval [Float, Integer] Interval to poll when
62
+ # when polling.
63
+ # @param headers [Hash] extra HTTP headers to be set on the polling requests.
64
+ #
65
+ # @return [Object] Instance of MessageBus::HTTPClient
66
+ def initialize(base_url, enable_long_polling: true,
67
+ enable_chunked_encoding: true,
68
+ min_poll_interval: 0.1,
69
+ max_poll_interval: 180,
70
+ background_callback_interval: 60,
71
+ headers: {})
72
+
73
+ @uri = URI(base_url)
74
+ @enable_long_polling = enable_long_polling
75
+ @enable_chunked_encoding = enable_chunked_encoding
76
+ @min_poll_interval = min_poll_interval
77
+ @max_poll_interval = max_poll_interval
78
+ @background_callback_interval = background_callback_interval
79
+ @headers = headers
80
+ @client_id = SecureRandom.hex
81
+ @channels = {}
82
+ @status = STOPPED
83
+ @mutex = Mutex.new
84
+ @stats = Stats.new(0, 0)
85
+ end
86
+
87
+ # Starts a background thread that polls the message bus endpoint
88
+ # for the given base_url.
89
+ #
90
+ # Intervals for long polling can be configured via min_poll_interval and
91
+ # max_poll_interval.
92
+ #
93
+ # Intervals for polling can be configured via background_callback_interval.
94
+ #
95
+ # @return [Object] Instance of MessageBus::HTTPClient
96
+ def start
97
+ @mutex.synchronize do
98
+ return if started?
99
+
100
+ @status = STARTED
101
+
102
+ thread = Thread.new do
103
+ begin
104
+ while started?
105
+ unless @channels.empty?
106
+ poll
107
+ @stats.success += 1
108
+ @stats.failed = 0
109
+ end
110
+
111
+ sleep interval
112
+ end
113
+ rescue StandardError => e
114
+ @stats.failed += 1
115
+ warn("#{e.class} #{e.message}: #{e.backtrace.join("\n")}")
116
+ retry
117
+ ensure
118
+ stop
119
+ end
120
+ end
121
+
122
+ thread.abort_on_exception = true
123
+ end
124
+
125
+ self
126
+ end
127
+
128
+ # Stops the client from polling the message bus endpoint.
129
+ #
130
+ # @return [Integer] the current status of the client
131
+ def stop
132
+ @status = STOPPED
133
+ end
134
+
135
+ # Subscribes to a channel which executes the given callback when a message
136
+ # is published to the channel
137
+ #
138
+ # @example Subscribing to a channel for message
139
+ # client = MessageBus::HTTPClient.new('http://some.test.com')
140
+ #
141
+ # client.subscribe("/test") do |payload, _message_id, _global_id|
142
+ # puts payload
143
+ # end
144
+ #
145
+ # A last_message_id may be provided.
146
+ # * -1 will subscribe to all new messages
147
+ # * -2 will recieve last message + all new messages
148
+ # * -3 will recieve last 2 message + all new messages
149
+ #
150
+ # @example Subscribing to a channel with `last_message_id`
151
+ # client.subscribe("/test", last_message_id: -2) do |payload|
152
+ # puts payload
153
+ # end
154
+ #
155
+ # @param channel [String] channel to listen for messages on
156
+ # @param last_message_id [Integer] last message id to start polling on.
157
+ #
158
+ # @yield [data, message_id, global_id]
159
+ # callback to be executed whenever a message is received
160
+ #
161
+ # @yieldparam data [Hash] data payload of the message received on the channel
162
+ # @yieldparam message_id [Integer] id of the message in the channel
163
+ # @yieldparam global_id [Integer] id of the message in the global backlog
164
+ # @yieldreturn [void]
165
+ #
166
+ # @return [Integer] the current status of the client
167
+ def subscribe(channel, last_message_id: nil, &callback)
168
+ raise InvalidChannel unless channel.to_s.start_with?("/")
169
+ raise MissingBlock unless block_given?
170
+
171
+ last_message_id = -1 if last_message_id && !last_message_id.is_a?(Integer)
172
+
173
+ @channels[channel] ||= Channel.new
174
+ channel = @channels[channel]
175
+ channel.last_message_id = last_message_id if last_message_id
176
+ channel.callbacks.push(callback)
177
+ start if stopped?
178
+ end
179
+
180
+ # unsubscribes from a channel
181
+ #
182
+ # @example Unsubscribing from a channel
183
+ # client = MessageBus::HTTPClient.new('http://some.test.com')
184
+ # callback = -> { |payload| puts payload }
185
+ # client.subscribe("/test", &callback)
186
+ # client.unsubscribe("/test")
187
+ #
188
+ # If a callback is given, only the specific callback will be unsubscribed.
189
+ #
190
+ # @example Unsubscribing a callback from a channel
191
+ # client.unsubscribe("/test", &callback)
192
+ #
193
+ # When the client does not have any channels left, it will stop polling and
194
+ # waits until a new subscription is started.
195
+ #
196
+ # @param channel [String] channel to unsubscribe
197
+ # @yield [data, global_id, message_id] specific callback to unsubscribe
198
+ #
199
+ # @return [Integer] the current status of the client
200
+ def unsubscribe(channel, &callback)
201
+ if callback
202
+ @channels[channel].callbacks.delete(callback)
203
+ remove_channel(channel) if @channels[channel].callbacks.empty?
204
+ else
205
+ remove_channel(channel)
206
+ end
207
+
208
+ stop if @channels.empty?
209
+ @status
210
+ end
211
+
212
+ private
213
+
214
+ def stopped?
215
+ @status == STOPPED
216
+ end
217
+
218
+ def started?
219
+ @status == STARTED
220
+ end
221
+
222
+ def remove_channel(channel)
223
+ @channels.delete(channel)
224
+ end
225
+
226
+ def interval
227
+ if @enable_long_polling
228
+ if (failed_count = @stats.failed) > 2
229
+ (@min_poll_interval * failed_count).clamp(
230
+ @min_poll_interval, @max_poll_interval
231
+ )
232
+ else
233
+ @min_poll_interval
234
+ end
235
+ else
236
+ @background_callback_interval
237
+ end
238
+ end
239
+
240
+ def poll
241
+ http = Net::HTTP.new(@uri.host, @uri.port)
242
+ http.use_ssl = true if @uri.scheme == 'https'
243
+ request = Net::HTTP::Post.new(request_path, headers)
244
+ request.body = poll_payload
245
+
246
+ if @enable_long_polling
247
+ buffer = ''
248
+
249
+ http.request(request) do |response|
250
+ response.read_body do |chunk|
251
+ unless chunk.empty?
252
+ buffer << chunk
253
+ process_buffer(buffer)
254
+ end
255
+ end
256
+ end
257
+ else
258
+ response = http.request(request)
259
+ notify_channels(JSON.parse(response.body))
260
+ end
261
+ end
262
+
263
+ def is_chunked?
264
+ !headers["Dont-Chunk"]
265
+ end
266
+
267
+ def process_buffer(buffer)
268
+ index = buffer.index(CHUNK_SEPARATOR)
269
+
270
+ if is_chunked?
271
+ return unless index
272
+
273
+ messages = buffer[0..(index - 1)]
274
+ buffer.slice!("#{messages}#{CHUNK_SEPARATOR}")
275
+ else
276
+ messages = buffer[0..-1]
277
+ buffer.slice!(messages)
278
+ end
279
+
280
+ notify_channels(JSON.parse(messages))
281
+ end
282
+
283
+ def notify_channels(messages)
284
+ messages.each do |message|
285
+ current_channel = message['channel']
286
+
287
+ if current_channel == STATUS_CHANNEL
288
+ message["data"].each do |channel_name, last_message_id|
289
+ if (channel = @channels[channel_name])
290
+ channel.last_message_id = last_message_id
291
+ end
292
+ end
293
+ else
294
+ @channels.each do |channel_name, channel|
295
+ next unless channel_name == current_channel
296
+
297
+ channel.last_message_id = message['message_id']
298
+
299
+ channel.callbacks.each do |callback|
300
+ callback.call(
301
+ message['data'],
302
+ channel.last_message_id,
303
+ message['global_id']
304
+ )
305
+ end
306
+ end
307
+ end
308
+ end
309
+ end
310
+
311
+ def poll_payload
312
+ payload = {}
313
+
314
+ @channels.each do |channel_name, channel|
315
+ payload[channel_name] = channel.last_message_id
316
+ end
317
+
318
+ payload.to_json
319
+ end
320
+
321
+ def request_path
322
+ "/message-bus/#{@client_id}/poll"
323
+ end
324
+
325
+ def headers
326
+ headers = {}
327
+ headers['Content-Type'] = 'application/json'
328
+ headers['X-Silence-logger'] = 'true'
329
+
330
+ if !@enable_long_polling || !@enable_chunked_encoding
331
+ headers['Dont-Chunk'] = 'true'
332
+ end
333
+
334
+ headers.merge!(@headers)
335
+ end
336
+ end
337
+ end
@@ -0,0 +1,13 @@
1
+ module MessageBus
2
+ class HTTPClient
3
+ # @private
4
+ class Channel
5
+ attr_accessor :last_message_id, :callbacks
6
+
7
+ def initialize(last_message_id: -1, callbacks: [])
8
+ @last_message_id = last_message_id
9
+ @callbacks = callbacks
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MessageBus
4
+ class HTTPClient
5
+ VERSION = '1.0.0.pre1'
6
+ end
7
+ end
@@ -42,10 +42,7 @@ class MessageBus::Rack::Diagnostics
42
42
  if asset && !asset !~ /\//
43
43
  content = asset_contents(asset)
44
44
  split = asset.split('.')
45
- if split[1] == 'handlebars'
46
- content = translate_handlebars(split[0], content)
47
- end
48
- return [200, { 'content-type' => 'text/javascript;' }, [content]]
45
+ return [200, { 'Content-Type' => 'application/javascript;charset=UTF-8' }, [content]]
49
46
  end
50
47
 
51
48
  return [404, {}, ['not found']]
@@ -53,16 +50,16 @@ class MessageBus::Rack::Diagnostics
53
50
 
54
51
  private
55
52
 
56
- def js_asset(name)
57
- return generate_script_tag(name) unless @bus.cache_assets
53
+ def js_asset(name, type = "text/javascript")
54
+ return generate_script_tag(name, type) unless @bus.cache_assets
58
55
 
59
56
  @@asset_cache ||= {}
60
- @@asset_cache[name] ||= generate_script_tag(name)
57
+ @@asset_cache[name] ||= generate_script_tag(name, type)
61
58
  @@asset_cache[name]
62
59
  end
63
60
 
64
- def generate_script_tag(name)
65
- "<script src='/message-bus/_diagnostics/assets/#{name}?#{file_hash(name)}' type='text/javascript'></script>"
61
+ def generate_script_tag(name, type)
62
+ "<script src='/message-bus/_diagnostics/assets/#{name}?#{file_hash(name)}' type='#{type}'></script>"
66
63
  end
67
64
 
68
65
  def file_hash(asset)
@@ -87,24 +84,14 @@ class MessageBus::Rack::Diagnostics
87
84
  <body>
88
85
  <div id="app"></div>
89
86
  #{js_asset "jquery-1.8.2.js"}
90
- #{js_asset "handlebars.js"}
91
- #{js_asset "ember.js"}
87
+ #{js_asset "react.js"}
88
+ #{js_asset "react-dom.js"}
89
+ #{js_asset "babel.min.js"}
92
90
  #{js_asset "message-bus.js"}
93
- #{js_asset "application.handlebars"}
94
- #{js_asset "index.handlebars"}
95
- #{js_asset "application.js"}
91
+ #{js_asset "application.jsx", "text/jsx"}
96
92
  </body>
97
93
  </html>
98
94
  HTML
99
95
  return [200, { "content-type" => "text/html;" }, [html]]
100
96
  end
101
-
102
- def translate_handlebars(name, content)
103
- "Ember.TEMPLATES['#{name}'] = Ember.Handlebars.compile(#{indent(content).inspect});"
104
- end
105
-
106
- # from ember-rails
107
- def indent(string)
108
- string.gsub(/$(.)/m, "\\1 ").strip
109
- end
110
97
  end