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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +14 -0
- data/Gemfile +10 -7
- data/OVERVIEW.md +1 -1
- data/README.md +25 -9
- data/lib/rage/all.rb +1 -0
- data/lib/rage/cable/cable.rb +130 -0
- data/lib/rage/cable/channel.rb +452 -0
- data/lib/rage/cable/connection.rb +78 -0
- data/lib/rage/cable/protocol/actioncable_v1_json.rb +167 -0
- data/lib/rage/cable/router.rb +138 -0
- data/lib/rage/cli.rb +2 -1
- data/lib/rage/code_loader.rb +9 -0
- data/lib/rage/configuration.rb +53 -0
- data/lib/rage/controller/api.rb +51 -13
- data/lib/rage/cookies.rb +7 -9
- data/lib/rage/ext/active_record/connection_pool.rb +1 -1
- data/lib/rage/fiber.rb +3 -3
- data/lib/rage/fiber_scheduler.rb +1 -1
- data/lib/rage/logger/json_formatter.rb +1 -1
- data/lib/rage/logger/logger.rb +1 -1
- data/lib/rage/logger/text_formatter.rb +1 -1
- data/lib/rage/middleware/cors.rb +2 -2
- data/lib/rage/middleware/fiber_wrapper.rb +3 -1
- data/lib/rage/middleware/origin_validator.rb +38 -0
- data/lib/rage/middleware/reloader.rb +1 -1
- data/lib/rage/params_parser.rb +1 -1
- data/lib/rage/router/backend.rb +4 -6
- data/lib/rage/router/constrainer.rb +1 -1
- data/lib/rage/router/dsl.rb +7 -7
- data/lib/rage/router/dsl_plugins/legacy_hash_notation.rb +1 -1
- data/lib/rage/router/dsl_plugins/legacy_root_notation.rb +1 -1
- data/lib/rage/router/handler_storage.rb +1 -1
- data/lib/rage/session.rb +2 -2
- data/lib/rage/setup.rb +5 -1
- data/lib/rage/sidekiq_session.rb +1 -1
- data/lib/rage/version.rb +1 -1
- data/lib/rage-rb.rb +23 -15
- data/rage.gemspec +1 -1
- 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
|