rage-rb 1.6.0 → 1.8.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -0
  3. data/Gemfile +10 -7
  4. data/OVERVIEW.md +1 -1
  5. data/README.md +25 -9
  6. data/lib/rage/all.rb +1 -0
  7. data/lib/rage/cable/cable.rb +130 -0
  8. data/lib/rage/cable/channel.rb +452 -0
  9. data/lib/rage/cable/connection.rb +78 -0
  10. data/lib/rage/cable/protocol/actioncable_v1_json.rb +167 -0
  11. data/lib/rage/cable/router.rb +138 -0
  12. data/lib/rage/cli.rb +2 -1
  13. data/lib/rage/code_loader.rb +9 -0
  14. data/lib/rage/configuration.rb +53 -0
  15. data/lib/rage/controller/api.rb +51 -13
  16. data/lib/rage/cookies.rb +7 -9
  17. data/lib/rage/ext/active_record/connection_pool.rb +1 -1
  18. data/lib/rage/fiber.rb +3 -3
  19. data/lib/rage/fiber_scheduler.rb +1 -1
  20. data/lib/rage/logger/json_formatter.rb +1 -1
  21. data/lib/rage/logger/logger.rb +1 -1
  22. data/lib/rage/logger/text_formatter.rb +1 -1
  23. data/lib/rage/middleware/cors.rb +2 -2
  24. data/lib/rage/middleware/fiber_wrapper.rb +3 -1
  25. data/lib/rage/middleware/origin_validator.rb +38 -0
  26. data/lib/rage/middleware/reloader.rb +1 -1
  27. data/lib/rage/params_parser.rb +1 -1
  28. data/lib/rage/router/backend.rb +4 -6
  29. data/lib/rage/router/constrainer.rb +1 -1
  30. data/lib/rage/router/dsl.rb +7 -7
  31. data/lib/rage/router/dsl_plugins/legacy_hash_notation.rb +1 -1
  32. data/lib/rage/router/dsl_plugins/legacy_root_notation.rb +1 -1
  33. data/lib/rage/router/handler_storage.rb +1 -1
  34. data/lib/rage/session.rb +2 -2
  35. data/lib/rage/setup.rb +5 -1
  36. data/lib/rage/sidekiq_session.rb +1 -1
  37. data/lib/rage/version.rb +1 -1
  38. data/lib/rage-rb.rb +23 -15
  39. data/rage.gemspec +1 -1
  40. metadata +8 -2
@@ -0,0 +1,452 @@
1
+ require "set"
2
+
3
+ class Rage::Cable::Channel
4
+ # @private
5
+ INTERNAL_ACTIONS = [:subscribed, :unsubscribed]
6
+
7
+ class << self
8
+ # @private
9
+ attr_reader :__prepared_actions
10
+
11
+ # @private
12
+ attr_reader :__channels
13
+
14
+ # @private
15
+ # returns a list of actions that can be called remotely
16
+ def __register_actions
17
+ actions = (
18
+ public_instance_methods(true) - Rage::Cable::Channel.public_instance_methods(true)
19
+ ).reject { |m| m.start_with?("__rage_tmp") || m.start_with?("__run") }
20
+
21
+ @__prepared_actions = (INTERNAL_ACTIONS + actions).each_with_object({}) do |action_name, memo|
22
+ memo[action_name] = __register_action_proc(action_name)
23
+ end
24
+
25
+ actions - INTERNAL_ACTIONS
26
+ end
27
+
28
+ # @private
29
+ # rubocop:disable Layout/HeredocIndentation, Layout/IndentationWidth, Layout/EndAlignment, Layout/ElseAlignment
30
+ def __register_action_proc(action_name)
31
+ if action_name == :subscribed && @__hooks
32
+ before_subscribe_chunk = if @__hooks[:before_subscribe]
33
+ lines = @__hooks[:before_subscribe].map do |h|
34
+ condition = if h[:if] && h[:unless]
35
+ "if #{h[:if]} && !#{h[:unless]}"
36
+ elsif h[:if]
37
+ "if #{h[:if]}"
38
+ elsif h[:unless]
39
+ "unless #{h[:unless]}"
40
+ end
41
+
42
+ <<~RUBY
43
+ #{h[:name]} #{condition}
44
+ return if @__subscription_rejected
45
+ RUBY
46
+ end
47
+
48
+ lines.join("\n")
49
+ end
50
+
51
+ after_subscribe_chunk = if @__hooks[:after_subscribe]
52
+ lines = @__hooks[:after_subscribe].map do |h|
53
+ condition = if h[:if] && h[:unless]
54
+ "if #{h[:if]} && !#{h[:unless]}"
55
+ elsif h[:if]
56
+ "if #{h[:if]}"
57
+ elsif h[:unless]
58
+ "unless #{h[:unless]}"
59
+ end
60
+
61
+ <<~RUBY
62
+ #{h[:name]} #{condition}
63
+ RUBY
64
+ end
65
+
66
+ lines.join("\n")
67
+ end
68
+ end
69
+
70
+ if action_name == :unsubscribed && @__hooks
71
+ before_unsubscribe_chunk = if @__hooks[:before_unsubscribe]
72
+ lines = @__hooks[:before_unsubscribe].map do |h|
73
+ condition = if h[:if] && h[:unless]
74
+ "if #{h[:if]} && !#{h[:unless]}"
75
+ elsif h[:if]
76
+ "if #{h[:if]}"
77
+ elsif h[:unless]
78
+ "unless #{h[:unless]}"
79
+ end
80
+
81
+ <<~RUBY
82
+ #{h[:name]} #{condition}
83
+ RUBY
84
+ end
85
+
86
+ lines.join("\n")
87
+ end
88
+
89
+ after_unsubscribe_chunk = if @__hooks[:after_unsubscribe]
90
+ lines = @__hooks[:after_unsubscribe].map do |h|
91
+ condition = if h[:if] && h[:unless]
92
+ "if #{h[:if]} && !#{h[:unless]}"
93
+ elsif h[:if]
94
+ "if #{h[:if]}"
95
+ elsif h[:unless]
96
+ "unless #{h[:unless]}"
97
+ end
98
+
99
+ <<~RUBY
100
+ #{h[:name]} #{condition}
101
+ RUBY
102
+ end
103
+
104
+ lines.join("\n")
105
+ end
106
+ end
107
+
108
+ rescue_handlers_chunk = if @__rescue_handlers
109
+ lines = @__rescue_handlers.map do |klasses, handler|
110
+ <<~RUBY
111
+ rescue #{klasses.join(", ")} => __e
112
+ #{instance_method(handler).arity == 0 ? handler : "#{handler}(__e)"}
113
+ RUBY
114
+ end
115
+
116
+ lines.join("\n")
117
+ else
118
+ ""
119
+ end
120
+
121
+ periodic_timers_chunk = if @__periodic_timers
122
+ set_up_periodic_timers
123
+
124
+ if action_name == :subscribed
125
+ <<~RUBY
126
+ self.class.__channels << self unless subscription_rejected?
127
+ RUBY
128
+ elsif action_name == :unsubscribed
129
+ <<~RUBY
130
+ self.class.__channels.delete(self)
131
+ RUBY
132
+ end
133
+ else
134
+ ""
135
+ end
136
+
137
+ is_subscribing = action_name == :subscribed
138
+ activerecord_loaded = defined?(::ActiveRecord)
139
+
140
+ method_name = class_eval <<~RUBY, __FILE__, __LINE__ + 1
141
+ def __run_#{action_name}(data)
142
+ #{if is_subscribing
143
+ <<~RUBY
144
+ @__is_subscribing = true
145
+ RUBY
146
+ end}
147
+
148
+ #{before_subscribe_chunk}
149
+ #{before_unsubscribe_chunk}
150
+
151
+ #{if instance_method(action_name).arity == 0
152
+ <<~RUBY
153
+ #{action_name}
154
+ RUBY
155
+ else
156
+ <<~RUBY
157
+ #{action_name}(data)
158
+ RUBY
159
+ end}
160
+
161
+ #{after_subscribe_chunk}
162
+ #{after_unsubscribe_chunk}
163
+ #{periodic_timers_chunk}
164
+ #{rescue_handlers_chunk}
165
+
166
+ #{if activerecord_loaded
167
+ <<~RUBY
168
+ ensure
169
+ if ActiveRecord::Base.connection_pool.active_connection?
170
+ ActiveRecord::Base.connection_handler.clear_active_connections!
171
+ end
172
+ RUBY
173
+ end}
174
+ end
175
+ RUBY
176
+
177
+ eval("->(channel, data) { channel.#{method_name}(data) }")
178
+ end
179
+ # rubocop:enable all
180
+
181
+ # @private
182
+ def __prepare_id_method(method_name)
183
+ define_method(method_name) do
184
+ @__identified_by[method_name]
185
+ end
186
+ end
187
+
188
+ # Register a new `before_subscribe` hook that will be called before the {subscribed} method.
189
+ #
190
+ # @example
191
+ # before_subscribe :my_method
192
+ # @example
193
+ # before_subscribe do
194
+ # ...
195
+ # end
196
+ # @example
197
+ # before_subscribe :my_method, if: -> { ... }
198
+ def before_subscribe(action_name = nil, **opts, &block)
199
+ add_action(:before_subscribe, action_name, **opts, &block)
200
+ end
201
+
202
+ # Register a new `after_subscribe` hook that will be called after the {subscribed} method.
203
+ #
204
+ # @example
205
+ # after_subscribe do
206
+ # ...
207
+ # end
208
+ # @example
209
+ # after_subscribe :my_method, unless: :subscription_rejected?
210
+ # @note This callback will be triggered even if the subscription was rejected with the {reject} method.
211
+ def after_subscribe(action_name = nil, **opts, &block)
212
+ add_action(:after_subscribe, action_name, **opts, &block)
213
+ end
214
+
215
+ # Register a new `before_unsubscribe` hook that will be called before the {unsubscribed} method.
216
+ def before_unsubscribe(action_name = nil, **opts, &block)
217
+ add_action(:before_unsubscribe, action_name, **opts, &block)
218
+ end
219
+
220
+ # Register a new `after_unsubscribe` hook that will be called after the {unsubscribed} method.
221
+ def after_unsubscribe(action_name = nil, **opts, &block)
222
+ add_action(:after_unsubscribe, action_name, **opts, &block)
223
+ end
224
+
225
+ # Register an exception handler.
226
+ #
227
+ # @param klasses [Class, Array<Class>] exception classes to watch on
228
+ # @param with [Symbol] the name of a handler method. The method can take one argument, which is the raised exception. Alternatively, you can pass a block, which can also take one argument.
229
+ # @example
230
+ # rescue_from StandardError, with: :report_error
231
+ #
232
+ # private
233
+ #
234
+ # def report_error(e)
235
+ # SomeExternalBugtrackingService.notify(e)
236
+ # end
237
+ # @example
238
+ # rescue_from StandardError do |e|
239
+ # SomeExternalBugtrackingService.notify(e)
240
+ # end
241
+ def rescue_from(*klasses, with: nil, &block)
242
+ unless with
243
+ if block_given?
244
+ with = define_tmp_method(block)
245
+ else
246
+ raise ArgumentError, "No handler provided. Pass the `with` keyword argument or provide a block."
247
+ end
248
+ end
249
+
250
+ if @__rescue_handlers.nil?
251
+ @__rescue_handlers = []
252
+ elsif @__rescue_handlers.frozen?
253
+ @__rescue_handlers = @__rescue_handlers.dup
254
+ end
255
+
256
+ @__rescue_handlers.unshift([klasses, with])
257
+ end
258
+
259
+ # Set up a timer to periodically perform a task on the channel. Accepts a method name or a block.
260
+ #
261
+ # @param method_name [Symbol, nil] the name of the method to call
262
+ # @param every [Integer] the calling period in seconds
263
+ # @example
264
+ # periodically every: 3.minutes do
265
+ # transmit({ action: :update_count, count: current_count })
266
+ # end
267
+ # @example
268
+ # periodically :update_count, every: 3.minutes
269
+ def periodically(method_name = nil, every:, &block)
270
+ callback_name = if block_given?
271
+ raise ArgumentError, "Pass the `method_name` argument or provide a block, not both" if method_name
272
+ define_tmp_method(block)
273
+ elsif method_name.is_a?(Symbol)
274
+ define_tmp_method(eval("-> { #{method_name} }"))
275
+ else
276
+ raise ArgumentError, "Expected a Symbol method name, got #{method_name.inspect}"
277
+ end
278
+
279
+ unless every.is_a?(Numeric) && every > 0
280
+ raise ArgumentError, "Expected every: to be a positive number of seconds, got #{every.inspect}"
281
+ end
282
+
283
+ callback = eval("->(channel) { channel.#{callback_name} }")
284
+
285
+ if @__periodic_timers.nil?
286
+ @__periodic_timers = []
287
+ elsif @__periodic_timers.frozen?
288
+ @__periodic_timers = @__periodic_timers.dup
289
+ end
290
+
291
+ @__periodic_timers << [callback, every]
292
+ end
293
+
294
+ protected
295
+
296
+ def set_up_periodic_timers
297
+ return if @__periodic_timers_set_up
298
+
299
+ @__channels = Set.new
300
+
301
+ @__periodic_timers.each do |callback, every|
302
+ ::Iodine.run_every((every * 1000).to_i) do
303
+ slice_length = (@__channels.length / 20.0).ceil
304
+
305
+ if slice_length != 0
306
+ @__channels.each_slice(slice_length) do |slice|
307
+ Fiber.schedule do
308
+ slice.each { |channel| callback.call(channel) }
309
+ rescue => e
310
+ Rage.logger.error("Unhandled exception has occured - #{e.class} (#{e.message}):\n#{e.backtrace.join("\n")}")
311
+ end
312
+ end
313
+ end
314
+ end
315
+ end
316
+
317
+ @__periodic_timers_set_up = true
318
+ end
319
+
320
+ def add_action(action_type, action_name = nil, **opts, &block)
321
+ if block_given?
322
+ action_name = define_tmp_method(block)
323
+ elsif action_name.nil?
324
+ raise ArgumentError, "No handler provided. Pass the `action_name` parameter or provide a block."
325
+ end
326
+
327
+ _if, _unless = opts.values_at(:if, :unless)
328
+
329
+ action = {
330
+ name: action_name,
331
+ if: _if,
332
+ unless: _unless
333
+ }
334
+
335
+ action[:if] = define_tmp_method(action[:if]) if action[:if].is_a?(Proc)
336
+ action[:unless] = define_tmp_method(action[:unless]) if action[:unless].is_a?(Proc)
337
+
338
+ if @__hooks.nil?
339
+ @__hooks = {}
340
+ elsif @__hooks[action_type] && @__hooks.frozen?
341
+ @__hooks = @__hooks.dup
342
+ @__hooks[action_type] = @__hooks[action_type].dup
343
+ end
344
+
345
+ if @__hooks[action_type].nil?
346
+ @__hooks[action_type] = [action]
347
+ elsif (i = @__hooks[action_type].find_index { |a| a[:name] == action_name })
348
+ @__hooks[action_type][i] = action
349
+ else
350
+ @__hooks[action_type] << action
351
+ end
352
+ end
353
+
354
+ attr_writer :__hooks, :__rescue_handlers, :__periodic_timers
355
+
356
+ def inherited(klass)
357
+ klass.__hooks = @__hooks.freeze
358
+ klass.__rescue_handlers = @__rescue_handlers.freeze
359
+ klass.__periodic_timers = @__periodic_timers.freeze
360
+ end
361
+
362
+ @@__tmp_name_seed = ("a".."i").to_a.permutation
363
+
364
+ def define_tmp_method(block)
365
+ name = @@__tmp_name_seed.next.join
366
+ define_method("__rage_tmp_#{name}", block)
367
+ end
368
+ end # class << self
369
+
370
+ # @private
371
+ def __has_action?(action_name)
372
+ !INTERNAL_ACTIONS.include?(action_name) && self.class.__prepared_actions.has_key?(action_name)
373
+ end
374
+
375
+ # @private
376
+ def __run_action(action_name, data = nil)
377
+ self.class.__prepared_actions[action_name].call(self, data)
378
+ end
379
+
380
+ # @private
381
+ def initialize(connection, params, identified_by)
382
+ @__connection = connection
383
+ @__params = params
384
+ @__identified_by = identified_by
385
+ end
386
+
387
+ # Get the params hash passed in during the subscription process.
388
+ #
389
+ # @return [Hash{Symbol=>String,Array,Hash,Numeric,NilClass,TrueClass,FalseClass}]
390
+ def params
391
+ @__params
392
+ end
393
+
394
+ # Reject the subscription request. The method should only be called during the subscription
395
+ # process (i.e. inside the {subscribed} method or {before_subscribe}/{after_subscribe} hooks).
396
+ def reject
397
+ @__subscription_rejected = true
398
+ end
399
+
400
+ # Checks whether the {reject} method has been called.
401
+ #
402
+ # @return [Boolean]
403
+ def subscription_rejected?
404
+ !!@__subscription_rejected
405
+ end
406
+
407
+ # Subscribe to a stream.
408
+ #
409
+ # @param stream [String] the name of the stream
410
+ def stream_from(stream)
411
+ Rage.config.cable.protocol.subscribe(@__connection, stream, @__params)
412
+ end
413
+
414
+ # Broadcast data to all the clients subscribed to a stream.
415
+ #
416
+ # @param stream [String] the name of the stream
417
+ # @param data [Object] the data to send to the clients
418
+ # @example
419
+ # def subscribed
420
+ # broadcast("notifications", { message: "A new member has joined!" })
421
+ # end
422
+ def broadcast(stream, data)
423
+ Rage.config.cable.protocol.broadcast(stream, data)
424
+ end
425
+
426
+ # Transmit data to the current client.
427
+ #
428
+ # @param data [Object] the data to send to the client
429
+ # @example
430
+ # def subscribed
431
+ # transmit({ message: "Hello!" })
432
+ # end
433
+ def transmit(data)
434
+ message = Rage.config.cable.protocol.serialize(@__params, data)
435
+
436
+ if @__is_subscribing
437
+ # we expect a confirmation message to be sent as a result of a successful subscribe call;
438
+ # this will make sure `transmit` calls send data after the confirmation;
439
+ ::Iodine.defer { @__connection.write(message) }
440
+ else
441
+ @__connection.write(message)
442
+ end
443
+ end
444
+
445
+ # Called once a client has become a subscriber of the channel.
446
+ def subscribed
447
+ end
448
+
449
+ # Called once a client unsubscribes from the channel.
450
+ def unsubscribed
451
+ end
452
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Rage::Cable::Connection
4
+ # @private
5
+ attr_reader :__identified_by_map
6
+
7
+ # Mark a key as being a connection identifier index that can then be used to find the specific connection again later.
8
+ # Common identifiers are `current_user` and `current_account`, but could be anything.
9
+ #
10
+ # @param identifiers [Symbol,Array<Symbol>]
11
+ def self.identified_by(*identifiers)
12
+ identifiers.each do |method_name|
13
+ define_method(method_name) do
14
+ @__identified_by_map[method_name]
15
+ end
16
+
17
+ define_method("#{method_name}=") do |data|
18
+ @__identified_by_map[method_name] = data
19
+ end
20
+
21
+ Rage::Cable::Channel.__prepare_id_method(method_name)
22
+ end
23
+ end
24
+
25
+ # @private
26
+ def initialize(env, identified_by = {})
27
+ @__env = env
28
+ @__identified_by_map = identified_by
29
+ end
30
+
31
+ # @private
32
+ def connect
33
+ end
34
+
35
+ # Reject the WebSocket connection.
36
+ def reject_unauthorized_connection
37
+ @rejected = true
38
+ end
39
+
40
+ def rejected?
41
+ !!@rejected
42
+ end
43
+
44
+ # Get the request object. See {Rage::Request}.
45
+ #
46
+ # @return [Rage::Request]
47
+ def request
48
+ @__request ||= Rage::Request.new(@__env)
49
+ end
50
+
51
+ # Get the cookie object. See {Rage::Cookies}.
52
+ #
53
+ # @return [Rage::Cookies]
54
+ def cookies
55
+ @__cookies ||= Rage::Cookies.new(@__env, ReadOnlyHash.new)
56
+ end
57
+
58
+ # Get the session object. See {Rage::Session}.
59
+ #
60
+ # @return [Rage::Session]
61
+ def session
62
+ @__session ||= Rage::Session.new(cookies)
63
+ end
64
+
65
+ # Get URL query parameters.
66
+ #
67
+ # @return [Hash{Symbol=>String,Array,Hash}]
68
+ def params
69
+ @__params ||= Iodine::Rack::Utils.parse_nested_query(@__env["QUERY_STRING"])
70
+ end
71
+
72
+ # @private
73
+ class ReadOnlyHash < Hash
74
+ def []=(_, _)
75
+ raise "Cookies cannot be set for WebSocket clients"
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ ##
4
+ # A protocol defines the structure, rules and semantics for exchanging data between the client and the server.
5
+ # The class that defines a protocol should respond to the following methods:
6
+ #
7
+ # * `protocol_definition`
8
+ # * `init`
9
+ # * `on_open`
10
+ # * `on_message`
11
+ # * `serialize`
12
+ # * `subscribe`
13
+ # * `broadcast`
14
+ #
15
+ # The two optional methods are:
16
+ #
17
+ # * `on_shutdown`
18
+ # * `on_close`
19
+ #
20
+ class Rage::Cable::Protocol::ActioncableV1Json
21
+ module TYPE
22
+ WELCOME = "welcome"
23
+ DISCONNECT = "disconnect"
24
+ PING = "ping"
25
+ CONFIRM = "confirm_subscription"
26
+ REJECT = "reject_subscription"
27
+ end
28
+
29
+ module REASON
30
+ UNAUTHORIZED = "unauthorized"
31
+ INVALID = "invalid_request"
32
+ end
33
+
34
+ module COMMAND
35
+ SUBSCRIBE = "subscribe"
36
+ MESSAGE = "message"
37
+ end
38
+
39
+ module MESSAGES
40
+ WELCOME = { type: TYPE::WELCOME }.to_json
41
+ UNAUTHORIZED = { type: TYPE::DISCONNECT, reason: REASON::UNAUTHORIZED, reconnect: false }.to_json
42
+ INVALID = { type: TYPE::DISCONNECT, reason: REASON::INVALID, reconnect: true }.to_json
43
+ end
44
+
45
+ HANDSHAKE_HEADERS = { "Sec-WebSocket-Protocol" => "actioncable-v1-json" }
46
+
47
+ # The method defines the headers to send to the client after the handshake process.
48
+ def self.protocol_definition
49
+ HANDSHAKE_HEADERS
50
+ end
51
+
52
+ # This method serves as a constructor to prepare the object or set up recurring tasks (e.g. heartbeats).
53
+ #
54
+ # @param router [Rage::Cable::Router]
55
+ def self.init(router)
56
+ @router = router
57
+
58
+ ping_counter = Time.now.to_i
59
+ ::Iodine.run_every(3000) do
60
+ ping_counter += 1
61
+ ::Iodine.publish("cable:ping", { type: TYPE::PING, message: ping_counter }.to_json)
62
+ end
63
+
64
+ # Hash<String(stream name) => Array<Hash>(subscription params)>
65
+ @subscription_identifiers = Hash.new { |hash, key| hash[key] = [] }
66
+ end
67
+
68
+ # The method is called any time a new WebSocket connection is established.
69
+ # It is expected to call {Rage::Cable::Router#process_connection} and handle its return value.
70
+ #
71
+ # @param connection [Rage::Cable::WebSocketConnection] the connection object
72
+ # @see Rage::Cable::Router
73
+ def self.on_open(connection)
74
+ accepted = @router.process_connection(connection)
75
+
76
+ if accepted
77
+ connection.subscribe("cable:ping")
78
+ connection.write(MESSAGES::WELCOME)
79
+ else
80
+ connection.write(MESSAGES::UNAUTHORIZED)
81
+ connection.close
82
+ end
83
+ end
84
+
85
+ # The method processes messages from existing connections. It should parse the message, call either
86
+ # {Rage::Cable::Router#process_subscription} or {Rage::Cable::Router#process_message}, and handle its return value.
87
+ #
88
+ # @param connection [Rage::Cable::WebSocketConnection] the connection object
89
+ # @param raw_data [String] the message body
90
+ # @see Rage::Cable::Router
91
+ def self.on_message(connection, raw_data)
92
+ parsed_data = Rage::ParamsParser.json_parse(raw_data)
93
+
94
+ command, identifier = parsed_data[:command], parsed_data[:identifier]
95
+ params = Rage::ParamsParser.json_parse(identifier)
96
+
97
+ # process subscription messages
98
+ if command == COMMAND::SUBSCRIBE
99
+ status = @router.process_subscription(connection, identifier, params[:channel], params)
100
+ if status == :subscribed
101
+ connection.write({ identifier: identifier, type: TYPE::CONFIRM }.to_json)
102
+ elsif status == :rejected
103
+ connection.write({ identifier: identifier, type: TYPE::REJECT }.to_json)
104
+ elsif status == :invalid
105
+ connection.write(MESSAGES::INVALID)
106
+ end
107
+
108
+ return
109
+ end
110
+
111
+ # process data messages;
112
+ # plain `JSON` is used here to conform with the ActionCable API that passes `data` as a Hash with string keys;
113
+ data = JSON.parse(parsed_data[:data])
114
+
115
+ message_status = if command == COMMAND::MESSAGE && data.has_key?("action")
116
+ @router.process_message(connection, identifier, data["action"].to_sym, data)
117
+
118
+ elsif command == COMMAND::MESSAGE
119
+ @router.process_message(connection, identifier, :receive, data)
120
+ end
121
+
122
+ unless message_status == :processed
123
+ connection.write(MESSAGES::INVALID)
124
+ end
125
+ end
126
+
127
+ # The method should process client disconnections and call {Rage::Cable::Router#process_message}.
128
+ #
129
+ # @note This method is optional.
130
+ # @param connection [Rage::Cable::WebSocketConnection] the connection object
131
+ # @see Rage::Cable::Router
132
+ def self.on_close(connection)
133
+ @router.process_disconnection(connection)
134
+ end
135
+
136
+ # Serialize a Ruby object into the format the client would understand.
137
+ #
138
+ # @param params [Hash] parameters associated with the client
139
+ # @param data [Object] the object to serialize
140
+ def self.serialize(params, data)
141
+ { identifier: params.to_json, message: data }.to_json
142
+ end
143
+
144
+ # Subscribe to a stream.
145
+ #
146
+ # @param connection [Rage::Cable::WebSocketConnection] the connection object
147
+ # @param name [String] the stream name
148
+ # @param params [Hash] parameters associated with the client
149
+ def self.subscribe(connection, name, params)
150
+ connection.subscribe("cable:#{name}:#{params.hash}")
151
+ @subscription_identifiers[name] << params unless @subscription_identifiers[name].include?(params)
152
+ end
153
+
154
+ # Broadcast data to all clients connected to a stream.
155
+ #
156
+ # @param name [String] the stream name
157
+ # @param data [Object] the data to send
158
+ def self.broadcast(name, data)
159
+ i, identifiers = 0, @subscription_identifiers[name]
160
+
161
+ while i < identifiers.length
162
+ params = identifiers[i]
163
+ ::Iodine.publish("cable:#{name}:#{params.hash}", serialize(params, data))
164
+ i += 1
165
+ end
166
+ end
167
+ end