rage-rb 1.6.0 → 1.8.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 (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