rage-rb 1.10.1 → 1.12.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 87a1aecc1f62917581dee82c9efe0d56cebc69046500eeaf7f2e73aa7f35f809
4
- data.tar.gz: 6e890b8641f214b2cfbebc2fd56bc1f5a32f2f8c2281cbf8f62d3a607d05c4ad
3
+ metadata.gz: 8c42516bec6fabfb0b06d9f11a5156298474a48f7988edba746122ae4ec172dc
4
+ data.tar.gz: 3b6b5b7692bb693ba53137d11bc1d7883ace69e496b5a99ec367da9a78ec4675
5
5
  SHA512:
6
- metadata.gz: c659a925cd991c383714d48e803a93edec1703c5e99aacd73d7c4748be00e77ec8077f6c86ec31c13c8fab373c9ef22bc11fec647b0bc233abec710967a827f4
7
- data.tar.gz: a0085405ad42e00730128e942b72abf9944f9344d2e9d5b178b393f8f019b517b430a0b3bf3ac7dd8b8e575392e4f79f7c0cc8531093be3d8100a549f13583f2
6
+ metadata.gz: b72d88f12c3608006637d822c7d3f955ef549e61ee644fc3d7748f507381cfe4917d3eea7f6cb75cf49f053f7ba34ce407a8ed06033a06e89e95208a15b6144e
7
+ data.tar.gz: f60d1950bce78665acfe6af71aeff1694692b6063cbf76e6c39a0af5dbcf8a79672b8603c0a39ce5d2b0beb0ecf61edfba310d6140fb94b899d85dbad0dd2c21
data/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.12.0] - 2025-01-21
4
+
5
+ ### Added
6
+
7
+ - Add Redis adapter (#114).
8
+ - Add global response tags (#110).
9
+ - Implement around_action callbacks (#107).
10
+
11
+ ### Fixed
12
+
13
+ - Support date types in Alba serializers (#112).
14
+
15
+ ## [1.11.0] - 2024-12-18
16
+
17
+ ### Added
18
+
19
+ - `Rage::OpenAPI` (#109).
20
+
21
+ ### Fixed
22
+
23
+ - Correctly handle ActiveRecord connections in the environments with `legacy_connection_handling == false` (#108).
24
+
3
25
  ## [1.10.1] - 2024-09-17
4
26
 
5
27
  ### Fixed
data/Gemfile CHANGED
@@ -19,4 +19,6 @@ group :test do
19
19
  gem "rbnacl"
20
20
  gem "domain_name"
21
21
  gem "websocket-client-simple"
22
+ gem "prism"
23
+ gem "redis-client"
22
24
  end
data/README.md CHANGED
@@ -6,8 +6,7 @@
6
6
  ![Tests](https://github.com/rage-rb/rage/actions/workflows/main.yml/badge.svg)
7
7
  ![Ruby Requirement](https://img.shields.io/badge/Ruby-3.1%2B-%23f40000)
8
8
 
9
-
10
- Inspired by [Deno](https://deno.com) and built on top of [Iodine](https://github.com/rage-rb/iodine), this is a Ruby web framework that is based on the following design principles:
9
+ Rage is a high-performance framework compatible with Rails, featuring [WebSocket](https://github.com/rage-rb/rage/wiki/WebSockets-guide) support and automatic generation of [OpenAPI](https://github.com/rage-rb/rage/wiki/OpenAPI-Guide) documentation for your APIs. The framework is built on top of [Iodine](https://github.com/rage-rb/iodine) and is based on the following design principles:
11
10
 
12
11
  * **Rails compatible API** - Rails' API is clean, straightforward, and simply makes sense. It was one of the reasons why Rails was so successful in the past.
13
12
 
@@ -46,6 +45,11 @@ Start coding!
46
45
 
47
46
  This gem is designed to be a drop-in replacement for Rails in API mode. Public API is expected to fully match Rails.
48
47
 
48
+ A Rage application can operate in two modes:
49
+
50
+ * **Rails Mode**: Integrate Rage into an existing Rails application to improve throughput and better handle traffic spikes. For more information, see [Rails Integration](https://github.com/rage-rb/rage/wiki/Rails-integration).
51
+ * **Standalone Mode**: Build high-performance services with minimal setup using Rage. To get started, run `rage new --help` for more details.
52
+
49
53
  Check out in-depth API docs for more information:
50
54
 
51
55
  - [Controller API](https://rage-rb.pages.dev/RageController/API)
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Rage::Cable::Adapters::Base
4
+ def pick_a_worker(&block)
5
+ _lock, lock_path = Tempfile.new.yield_self { |file| [file, file.path] }
6
+
7
+ Iodine.on_state(:on_start) do
8
+ if File.new(lock_path).flock(File::LOCK_EX | File::LOCK_NB)
9
+ if Rage.logger.debug?
10
+ puts "INFO: #{Process.pid} is managing #{self.class.name.split("::").last} subscriptions."
11
+ end
12
+ block.call
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ if !defined?(RedisClient)
6
+ fail <<~ERR
7
+
8
+ Redis adapter depends on the `redis-client` gem. Add the following line to your Gemfile:
9
+ gem "redis-client"
10
+
11
+ ERR
12
+ end
13
+
14
+ class Rage::Cable::Adapters::Redis < Rage::Cable::Adapters::Base
15
+ REDIS_STREAM_NAME = "rage:cable:messages"
16
+ DEFAULT_REDIS_OPTIONS = { reconnect_attempts: [0.05, 0.1, 0.5] }
17
+
18
+ def initialize(config)
19
+ @redis_stream = if (prefix = config.delete(:channel_prefix))
20
+ "#{prefix}:#{REDIS_STREAM_NAME}"
21
+ else
22
+ REDIS_STREAM_NAME
23
+ end
24
+
25
+ @redis_config = RedisClient.config(**DEFAULT_REDIS_OPTIONS.merge(config))
26
+ @server_uuid = SecureRandom.uuid
27
+
28
+ redis_version = get_redis_version
29
+ if redis_version < Gem::Version.create(5)
30
+ raise "Redis adapter only supports Redis 5+. Detected Redis version: #{redis_version}."
31
+ end
32
+
33
+ @trimming_strategy = redis_version < Gem::Version.create("6.2.0") ? :maxlen : :minid
34
+
35
+ pick_a_worker { poll }
36
+ end
37
+
38
+ def publish(stream_name, data)
39
+ message_uuid = SecureRandom.uuid
40
+
41
+ publish_redis.call(
42
+ "XADD",
43
+ @redis_stream,
44
+ trimming_method, "~", trimming_value,
45
+ "*",
46
+ "1", stream_name,
47
+ "2", data.to_json,
48
+ "3", @server_uuid,
49
+ "4", message_uuid
50
+ )
51
+ end
52
+
53
+ private
54
+
55
+ def publish_redis
56
+ @publish_redis ||= @redis_config.new_client
57
+ end
58
+
59
+ def trimming_method
60
+ @trimming_strategy == :maxlen ? "MAXLEN" : "MINID"
61
+ end
62
+
63
+ def trimming_value
64
+ @trimming_strategy == :maxlen ? "10000" : ((Time.now.to_f - 5 * 60) * 1000).to_i
65
+ end
66
+
67
+ def get_redis_version
68
+ service_redis = @redis_config.new_client
69
+ version = service_redis.call("INFO").match(/redis_version:([[:graph:]]+)/)[1]
70
+
71
+ Gem::Version.create(version)
72
+
73
+ rescue RedisClient::Error => e
74
+ puts "FATAL: Couldn't connect to Redis - all broadcasts will be limited to the current server."
75
+ puts e.backtrace.join("\n")
76
+ Gem::Version.create(5)
77
+
78
+ ensure
79
+ service_redis.close
80
+ end
81
+
82
+ def error_backoff_intervals
83
+ @error_backoff_intervals ||= Enumerator.new do |y|
84
+ y << 0.2 << 0.5 << 1 << 2 << 5
85
+ loop { y << 10 }
86
+ end
87
+ end
88
+
89
+ def poll
90
+ unless Fiber.scheduler
91
+ Fiber.set_scheduler(Rage::FiberScheduler.new)
92
+ end
93
+
94
+ Iodine.on_state(:start_shutdown) do
95
+ @stopping = true
96
+ end
97
+
98
+ Fiber.schedule do
99
+ read_redis = @redis_config.new_client
100
+ last_id = (Time.now.to_f * 1000).to_i
101
+ last_message_uuid = nil
102
+
103
+ loop do
104
+ data = read_redis.blocking_call(5, "XREAD", "COUNT", "100", "BLOCK", "5000", "STREAMS", @redis_stream, last_id)
105
+
106
+ if data
107
+ data[@redis_stream].each do |id, (_, stream_name, _, serialized_data, _, broadcaster_uuid, _, message_uuid)|
108
+ if broadcaster_uuid != @server_uuid && message_uuid != last_message_uuid
109
+ Rage.config.cable.protocol.broadcast(stream_name, JSON.parse(serialized_data))
110
+ end
111
+
112
+ last_id = id
113
+ last_message_uuid = message_uuid
114
+ end
115
+ end
116
+
117
+ rescue RedisClient::Error => e
118
+ Rage.logger.error("Subscriber error: #{e.message} (#{e.class})")
119
+ sleep error_backoff_intervals.next
120
+ rescue => e
121
+ @stopping ? break : raise(e)
122
+ else
123
+ error_backoff_intervals.rewind
124
+ end
125
+ end
126
+ end
127
+ end
@@ -8,11 +8,11 @@ module Rage::Cable
8
8
  # run Rage.cable.application
9
9
  # end
10
10
  def self.application
11
- protocol = Rage.config.cable.protocol
12
- protocol.init(__router)
11
+ # explicitly initialize the adapter
12
+ __adapter
13
13
 
14
- handler = __build_handler(protocol)
15
- accept_response = [0, protocol.protocol_definition, []]
14
+ handler = __build_handler(__protocol)
15
+ accept_response = [0, __protocol.protocol_definition, []]
16
16
 
17
17
  application = ->(env) do
18
18
  if env["rack.upgrade?"] == :websocket
@@ -31,6 +31,15 @@ module Rage::Cable
31
31
  @__router ||= Router.new
32
32
  end
33
33
 
34
+ # @private
35
+ def self.__protocol
36
+ @__protocol ||= Rage.config.cable.protocol.tap { |protocol| protocol.init(__router) }
37
+ end
38
+
39
+ def self.__adapter
40
+ @__adapter ||= Rage.config.cable.adapter
41
+ end
42
+
34
43
  # @private
35
44
  def self.__build_handler(protocol)
36
45
  klass = Class.new do
@@ -94,10 +103,14 @@ module Rage::Cable
94
103
  #
95
104
  # @param stream [String] the name of the stream
96
105
  # @param data [Object] the object to send to the clients. This will later be encoded according to the protocol used.
106
+ # @return [true]
97
107
  # @example
98
108
  # Rage.cable.broadcast("chat", { message: "A new member has joined!" })
99
109
  def self.broadcast(stream, data)
100
- Rage.config.cable.protocol.broadcast(stream, data)
110
+ __protocol.broadcast(stream, data)
111
+ __adapter&.publish(stream, data)
112
+
113
+ true
101
114
  end
102
115
 
103
116
  # @!parse [ruby]
@@ -120,6 +133,11 @@ module Rage::Cable
120
133
  # end
121
134
  # end
122
135
 
136
+ module Adapters
137
+ autoload :Base, "rage/cable/adapters/base"
138
+ autoload :Redis, "rage/cable/adapters/redis"
139
+ end
140
+
123
141
  module Protocol
124
142
  end
125
143
  end
@@ -418,7 +418,7 @@ class Rage::Cable::Channel
418
418
  # broadcast("notifications", { message: "A new member has joined!" })
419
419
  # end
420
420
  def broadcast(stream, data)
421
- Rage.config.cable.protocol.broadcast(stream, data)
421
+ Rage.cable.broadcast(stream, data)
422
422
  end
423
423
 
424
424
  # Transmit data to the current client.
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "zlib"
4
+
3
5
  ##
4
6
  # A protocol defines the structure, rules and semantics for exchanging data between the client and the server.
5
7
  # The class that defines a protocol should respond to the following methods:
@@ -17,6 +19,9 @@
17
19
  # * `on_shutdown`
18
20
  # * `on_close`
19
21
  #
22
+ # It is likely that all logic around `@subscription_identifiers` has nothing to do with the protocol itself and
23
+ # should be extracted into another class. We'll refactor this once we start working on a new protocol.
24
+ #
20
25
  class Rage::Cable::Protocol::ActioncableV1Json
21
26
  module TYPE
22
27
  WELCOME = "welcome"
@@ -55,14 +60,30 @@ class Rage::Cable::Protocol::ActioncableV1Json
55
60
  def self.init(router)
56
61
  @router = router
57
62
 
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)
63
+ Iodine.on_state(:on_start) do
64
+ ping_counter = Time.now.to_i
65
+
66
+ Iodine.run_every(3000) do
67
+ ping_counter += 1
68
+ Iodine.publish("cable:ping", { type: TYPE::PING, message: ping_counter }.to_json, Iodine::PubSub::PROCESS)
69
+ end
62
70
  end
63
71
 
64
72
  # Hash<String(stream name) => Array<Hash>(subscription params)>
65
73
  @subscription_identifiers = Hash.new { |hash, key| hash[key] = [] }
74
+
75
+ # this is a fallback to synchronize subscription identifiers across different worker processes;
76
+ # we expect connections to be distributed among all workers, so this code will almost never be called;
77
+ # we also synchronize subscriptions with the master process so that the forks that are spun up instead
78
+ # of the crashed ones also had access to the identifiers;
79
+ Iodine.subscribe("cable:synchronize") do |_, subscription_msg|
80
+ stream_name, params = Rage::ParamsParser.json_parse(subscription_msg)
81
+ @subscription_identifiers[stream_name] << params unless @subscription_identifiers[stream_name].include?(params)
82
+ end
83
+
84
+ Iodine.on_state(:on_finish) do
85
+ Iodine.unsubscribe("cable:synchronize")
86
+ end
66
87
  end
67
88
 
68
89
  # The method is called any time a new WebSocket connection is established.
@@ -147,8 +168,12 @@ class Rage::Cable::Protocol::ActioncableV1Json
147
168
  # @param name [String] the stream name
148
169
  # @param params [Hash] parameters associated with the client
149
170
  def self.subscribe(connection, name, params)
150
- connection.subscribe("cable:#{name}:#{params.hash}")
151
- @subscription_identifiers[name] << params unless @subscription_identifiers[name].include?(params)
171
+ connection.subscribe("cable:#{name}:#{Zlib.crc32(params.to_s)}")
172
+
173
+ unless @subscription_identifiers[name].include?(params)
174
+ @subscription_identifiers[name] << params
175
+ ::Iodine.publish("cable:synchronize", [name, params].to_json)
176
+ end
152
177
  end
153
178
 
154
179
  # Broadcast data to all clients connected to a stream.
@@ -160,7 +185,7 @@ class Rage::Cable::Protocol::ActioncableV1Json
160
185
 
161
186
  while i < identifiers.length
162
187
  params = identifiers[i]
163
- ::Iodine.publish("cable:#{name}:#{params.hash}", serialize(params, data))
188
+ ::Iodine.publish("cable:#{name}:#{Zlib.crc32(params.to_s)}", serialize(params, data))
164
189
  i += 1
165
190
  end
166
191
  end
@@ -37,6 +37,10 @@ class Rage::CodeLoader
37
37
  unless Rage.autoload?(:Cable) # the `Cable` component is loaded
38
38
  Rage::Cable.__router.reset
39
39
  end
40
+
41
+ unless Rage.autoload?(:OpenAPI) # the `OpenAPI` component is loaded
42
+ Rage::OpenAPI.__reset_data_cache
43
+ end
40
44
  end
41
45
 
42
46
  # in Rails mode - reset the routes; everything else will be done by Rails
@@ -49,6 +53,10 @@ class Rage::CodeLoader
49
53
  unless Rage.autoload?(:Cable) # the `Cable` component is loaded
50
54
  Rage::Cable.__router.reset
51
55
  end
56
+
57
+ unless Rage.autoload?(:OpenAPI) # the `OpenAPI` component is loaded
58
+ Rage::OpenAPI.__reset_data_cache
59
+ end
52
60
  end
53
61
 
54
62
  def reloading?
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "yaml"
4
+ require "erb"
5
+
3
6
  ##
4
7
  # `Rage.configure` can be used to adjust the behavior of your Rage application:
5
8
  #
@@ -122,6 +125,17 @@
122
125
  #
123
126
  # > Allows requests from any origin.
124
127
  #
128
+ # # OpenAPI Configuration
129
+ # • _config.openapi.tag_resolver_
130
+ #
131
+ # > Specifies the proc to build tags for API operations. The proc accepts the controller class, the symbol name of the action, and the default tag built by Rage.
132
+ #
133
+ # > ```ruby
134
+ # config.openapi.tag_resolver = proc do |controller, action, default_tag|
135
+ # # ...
136
+ # end
137
+ # > ```
138
+ #
125
139
  # # Transient Settings
126
140
  #
127
141
  # The settings described in this section should be configured using **environment variables** and are either temporary or will become the default in the future.
@@ -179,6 +193,10 @@ class Rage::Configuration
179
193
  @public_file_server ||= PublicFileServer.new
180
194
  end
181
195
 
196
+ def openapi
197
+ @openapi ||= OpenAPI.new
198
+ end
199
+
182
200
  def internal
183
201
  @internal ||= Internal.new
184
202
  end
@@ -218,6 +236,10 @@ class Rage::Configuration
218
236
  @middlewares = (@middlewares[0..index] + [[new_middleware, args, block]] + @middlewares[index + 1..]).uniq(&:first)
219
237
  end
220
238
 
239
+ def include?(middleware)
240
+ !!find_middleware_index(middleware) rescue false
241
+ end
242
+
221
243
  private
222
244
 
223
245
  def find_middleware_index(middleware)
@@ -258,12 +280,40 @@ class Rage::Configuration
258
280
  end
259
281
  end
260
282
  end
283
+
284
+ def config
285
+ @config ||= begin
286
+ config_file = Rage.root.join("config/cable.yml")
287
+
288
+ if config_file.exist?
289
+ yaml = ERB.new(config_file.read).result
290
+ YAML.safe_load(yaml, aliases: true, symbolize_names: true)[Rage.env.to_sym] || {}
291
+ else
292
+ {}
293
+ end
294
+ end
295
+ end
296
+
297
+ def adapter_config
298
+ config.except(:adapter)
299
+ end
300
+
301
+ def adapter
302
+ case config[:adapter]
303
+ when "redis"
304
+ Rage::Cable::Adapters::Redis.new(adapter_config)
305
+ end
306
+ end
261
307
  end
262
308
 
263
309
  class PublicFileServer
264
310
  attr_accessor :enabled
265
311
  end
266
312
 
313
+ class OpenAPI
314
+ attr_accessor :tag_resolver
315
+ end
316
+
267
317
  # @private
268
318
  class Internal
269
319
  attr_accessor :rails_mode