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.
- 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
|