rage-rb 1.7.0 → 1.8.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 61d3c256494b63668809f556d221c9dd86b1542ce30051c689e1f30a7b1aca00
4
- data.tar.gz: 268fc8606e772b06985effe6918270f2ecb1c21b191bfa59ae9a74c0eeb8f459
3
+ metadata.gz: 4adc4048bfa84558a6b86b15cbcab8e6153858878425bde66db78a975fa46f46
4
+ data.tar.gz: 46bef9f42fdd681bdf593f037a73ca8ae2d435acad9eed5ddf4b3740bf61b601
5
5
  SHA512:
6
- metadata.gz: 0136a9c93cb94dc161367ecac78b3d106404e4450fb1f4c6df8d6e38400da242fdaeebf54a7ed18ae5fc0037bea9e62ae94b5870883a2adb493e5721059a9eb8
7
- data.tar.gz: c10509dbc8ae25ae0f1c19b35903664863fb07fb81409fd6cc16b962bde35d46f7bb552562994757b74fcc4d32f6ef9970a66f0893d0c36f11da5c27a7f2b4df
6
+ metadata.gz: 754b954d46d065020e0c2184c04f1a13d0c0a64ddf11d1b034062df4cc669460c259032a8bf40a61c876579f1774e50e9b57181d4d3e1301c4610577e11c6734
7
+ data.tar.gz: 4207556922f8fd2c82d07ba8664f53847dc1e5afb6bb5d38260ac0ccd428dd011fd8c6bf34f69aa75af97044a838df23c6c36b5baae1ae7cd3d4c08455fc699b
data/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.8.0] - 2024-08-06
4
+
5
+ ### Added
6
+
7
+ - Support WebSockets (#88).
8
+
3
9
  ## [1.7.0] - 2024-07-30
4
10
 
5
11
  ### Added
data/Gemfile CHANGED
@@ -18,4 +18,5 @@ group :test do
18
18
  gem "connection_pool", "~> 2.0"
19
19
  gem "rbnacl"
20
20
  gem "domain_name"
21
+ gem "websocket-client-simple"
21
22
  end
data/OVERVIEW.md CHANGED
@@ -31,7 +31,7 @@ class UsersController < RageController::API
31
31
  end
32
32
  ```
33
33
 
34
- Before processing requests to `UsersController#show`, Rage has to [register](https://github.com/rage-rb/rage/blob/master/lib/rage/controller/api.rb#L10) the show action. Registering means defining a new method that will look like this:
34
+ Before processing requests to `UsersController#show`, Rage has to [register](https://github.com/rage-rb/rage/blob/master/lib/rage/controller/api.rb#L11) the show action. Registering means defining a new method that will look like this:
35
35
 
36
36
  ```ruby
37
37
  class UsersController
data/README.md CHANGED
@@ -44,7 +44,7 @@ Start coding!
44
44
 
45
45
  ## Getting Started
46
46
 
47
- This gem is designed to be a drop-in replacement for Rails in API mode. Public API is mostly expected to match Rails, however, sometimes it's a little bit more strict.
47
+ This gem is designed to be a drop-in replacement for Rails in API mode. Public API is expected to fully match Rails.
48
48
 
49
49
  Check out in-depth API docs for more information:
50
50
 
@@ -60,6 +60,8 @@ Also, see the following integration guides:
60
60
  - [Rails integration](https://github.com/rage-rb/rage/wiki/Rails-integration)
61
61
  - [RSpec integration](https://github.com/rage-rb/rage/wiki/RSpec-integration)
62
62
 
63
+ If you are a first-time contributor, make sure to check the [overview doc](https://github.com/rage-rb/rage/blob/master/OVERVIEW.md) that shows how Rage's core components interact with each other.
64
+
63
65
  ### Example
64
66
 
65
67
  A sample controller could look like this:
@@ -157,7 +159,7 @@ class BenchmarksController < ApplicationController
157
159
  end
158
160
  ```
159
161
 
160
- ![Requests per second-2](https://github.com/user-attachments/assets/b7ee0bff-e7c8-4fd4-a565-ce0b67a6320e)
162
+ ![Requests per second](https://github.com/user-attachments/assets/04678788-0034-4db4-9582-d0bc16fd9e28)
161
163
 
162
164
  ## Upcoming releases
163
165
 
data/lib/rage/all.rb CHANGED
@@ -26,6 +26,7 @@ require_relative "logger/text_formatter"
26
26
  require_relative "logger/json_formatter"
27
27
  require_relative "logger/logger"
28
28
 
29
+ require_relative "middleware/origin_validator"
29
30
  require_relative "middleware/fiber_wrapper"
30
31
  require_relative "middleware/cors"
31
32
  require_relative "middleware/reloader"
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rage::Cable
4
+ # Create a new Cable application.
5
+ #
6
+ # @example
7
+ # map "/cable" do
8
+ # run Rage.cable.application
9
+ # end
10
+ def self.application
11
+ protocol = Rage.config.cable.protocol
12
+ protocol.init(__router)
13
+
14
+ handler = __build_handler(protocol)
15
+ accept_response = [0, protocol.protocol_definition, []]
16
+
17
+ application = ->(env) do
18
+ if env["rack.upgrade?"] == :websocket
19
+ env["rack.upgrade"] = handler
20
+ accept_response
21
+ else
22
+ [426, { "Connection" => "Upgrade", "Upgrade" => "websocket" }, []]
23
+ end
24
+ end
25
+
26
+ Rage.with_middlewares(application, Rage.config.cable.middlewares)
27
+ end
28
+
29
+ # @private
30
+ def self.__router
31
+ @__router ||= Router.new
32
+ end
33
+
34
+ # @private
35
+ def self.__build_handler(protocol)
36
+ klass = Class.new do
37
+ def initialize(protocol)
38
+ Iodine.on_state(:on_start) do
39
+ unless Fiber.scheduler
40
+ Fiber.set_scheduler(Rage::FiberScheduler.new)
41
+ end
42
+ end
43
+
44
+ @protocol = protocol
45
+ end
46
+
47
+ def on_open(connection)
48
+ Fiber.schedule do
49
+ @protocol.on_open(connection)
50
+ rescue => e
51
+ log_error(e)
52
+ end
53
+ end
54
+
55
+ def on_message(connection, data)
56
+ Fiber.schedule do
57
+ @protocol.on_message(connection, data)
58
+ rescue => e
59
+ log_error(e)
60
+ end
61
+ end
62
+
63
+ if protocol.respond_to?(:on_close)
64
+ def on_close(connection)
65
+ return unless ::Iodine.running?
66
+
67
+ Fiber.schedule do
68
+ @protocol.on_close(connection)
69
+ rescue => e
70
+ log_error(e)
71
+ end
72
+ end
73
+ end
74
+
75
+ if protocol.respond_to?(:on_shutdown)
76
+ def on_shutdown(connection)
77
+ @protocol.on_shutdown(connection)
78
+ rescue => e
79
+ log_error(e)
80
+ end
81
+ end
82
+
83
+ private
84
+
85
+ def log_error(e)
86
+ Rage.logger.error("Unhandled exception has occured - #{e.class} (#{e.message}):\n#{e.backtrace.join("\n")}")
87
+ end
88
+ end
89
+
90
+ klass.new(protocol)
91
+ end
92
+
93
+ # Broadcast data directly to a named stream.
94
+ #
95
+ # @param stream [String] the name of the stream
96
+ # @param data [Object] the object to send to the clients. This will later be encoded according to the protocol used.
97
+ # @example
98
+ # Rage.cable.broadcast("chat", { message: "A new member has joined!" })
99
+ def self.broadcast(stream, data)
100
+ Rage.config.cable.protocol.broadcast(stream, data)
101
+ end
102
+
103
+ # @!parse [ruby]
104
+ # # @abstract
105
+ # class WebSocketConnection
106
+ # # Write data to the connection.
107
+ # #
108
+ # # @param data [String] the data to write
109
+ # def write(data)
110
+ # end
111
+ #
112
+ # # Subscribe to a channel.
113
+ # #
114
+ # # @param name [String] the channel name
115
+ # def subscribe(name)
116
+ # end
117
+ #
118
+ # # Close the connection.
119
+ # def close
120
+ # end
121
+ # end
122
+
123
+ module Protocol
124
+ end
125
+ end
126
+
127
+ require_relative "protocol/actioncable_v1_json"
128
+ require_relative "channel"
129
+ require_relative "connection"
130
+ require_relative "router"
@@ -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