rage-rb 1.28.2 → 1.29.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.
@@ -3,12 +3,20 @@
3
3
  require "resolv"
4
4
 
5
5
  class Rage::FiberScheduler
6
+ # @private
6
7
  MAX_READ = 65536
7
8
 
9
+ # @private
10
+ VERSION = defined?(IO::Buffer::VERSION) && IO::Buffer::VERSION >= 3 ? 4 : 3
11
+
12
+ # @private
13
+ attr_reader :root_fiber
14
+
8
15
  # Initialize the scheduler, storing the root fiber and an empty DNS cache.
9
16
  def initialize
10
17
  @root_fiber = Fiber.current
11
18
  @dns_cache = {}
19
+ @blocked = {}
12
20
  end
13
21
 
14
22
  # Wait for I/O events on a file descriptor, yielding the fiber until ready or timeout.
@@ -29,43 +37,50 @@ class Rage::FiberScheduler
29
37
  end
30
38
 
31
39
  # Read data from an I/O object into a buffer, pausing the fiber between reads.
32
- def io_read(io, buffer, length, offset = 0)
33
- length_to_read = if length == 0
34
- buffer.size > MAX_READ ? MAX_READ : buffer.size
35
- else
36
- length
40
+ if VERSION == 4
41
+ def io_read(io, buffer, offset, length)
42
+ ::Iodine::Scheduler.read(io.fileno, buffer, length, offset)
37
43
  end
38
-
39
- while true
40
- string = ::Iodine::Scheduler.read(io.fileno, length_to_read, offset)
41
-
42
- if string.nil?
43
- return offset
44
+ else
45
+ def io_read(io, buffer, length, offset = 0)
46
+ length_to_read = if length == 0
47
+ buffer.size > MAX_READ ? MAX_READ : buffer.size
48
+ else
49
+ length
44
50
  end
45
51
 
46
- if string.empty?
47
- return -Errno::EAGAIN::Errno
48
- end
52
+ while true
53
+ result = ::Iodine::Scheduler.read(io.fileno, buffer, length_to_read, offset)
49
54
 
50
- buffer.set_string(string, offset)
55
+ if result == 0
56
+ return offset
57
+ elsif result < 0
58
+ next if result == -Errno::EINTR::Errno
59
+ return result
60
+ end
51
61
 
52
- size = string.bytesize
53
- offset += size
54
- return offset if size < length_to_read || size >= buffer.size
62
+ offset += result
63
+ return offset if result < length_to_read || result >= buffer.size
55
64
 
56
- Fiber.pause
65
+ length_to_read = length_to_read.clamp(nil, buffer.size - offset)
66
+ Fiber.pause
67
+ end
57
68
  end
58
69
  end
59
70
 
60
71
  unless ENV["RAGE_DISABLE_IO_WRITE"]
61
72
  # Write data from a buffer to an I/O object.
62
- def io_write(io, buffer, length, offset = 0)
63
- bytes_to_write = length
64
- bytes_to_write = buffer.size if length == 0
65
-
66
- ::Iodine::Scheduler.write(io.fileno, buffer.get_string, bytes_to_write, offset)
73
+ if VERSION == 4
74
+ def io_write(io, buffer, offset, length)
75
+ ::Iodine::Scheduler.write(io.fileno, buffer, length, offset)
76
+ end
77
+ else
78
+ def io_write(io, buffer, length, offset = 0)
79
+ bytes_to_write = length
80
+ bytes_to_write = buffer.size if length == 0
67
81
 
68
- bytes_to_write - offset
82
+ ::Iodine::Scheduler.write_async(io.fileno, buffer, bytes_to_write, offset)
83
+ end
69
84
  end
70
85
  end
71
86
 
@@ -106,28 +121,27 @@ class Rage::FiberScheduler
106
121
 
107
122
  # Block the current fiber until unblocked or timeout.
108
123
  def block(_blocker, timeout = nil)
109
- f, fulfilled = Fiber.current, false
110
-
124
+ f = Fiber.current
111
125
  gen = (f.__wait_generation += 1)
112
- channel = f.__block_channel = "block:#{f.object_id}:#{gen}"
126
+ @blocked[f] = gen
113
127
 
114
- resume_fiber_block = proc do
115
- unless fulfilled
116
- fulfilled = true
117
- ::Iodine.defer { ::Iodine.unsubscribe(channel) }
118
- f.resume if f.alive? && gen == f.__wait_generation
119
- end
128
+ if timeout
129
+ ::Iodine.run_after((timeout * 1000).to_i) { unblock(nil, f) if gen == f.__wait_generation }
120
130
  end
121
131
 
122
- ::Iodine.subscribe(channel) { resume_fiber_block.call }
123
- ::Iodine.run_after((timeout * 1000).to_i) { resume_fiber_block.call } if timeout
124
-
125
132
  Fiber.yield
133
+ ensure
134
+ @blocked.delete(f)
126
135
  end
127
136
 
128
- # Unblock a fiber by publishing to its block channel.
137
+ # Unblock a fiber previously blocked by `#block`.
129
138
  def unblock(_blocker, fiber)
130
- ::Iodine.publish(fiber.__block_channel, "", Iodine::PubSub::PROCESS) if fiber.__block_channel
139
+ gen = @blocked.delete(fiber)
140
+ return unless gen
141
+
142
+ ::Iodine.defer do
143
+ fiber.resume if fiber.alive? && gen == fiber.__wait_generation
144
+ end
131
145
  end
132
146
 
133
147
  # Interrupt a fiber by incrementing its generation and raising an exception.
data/lib/rage/internal.rb CHANGED
@@ -56,6 +56,10 @@ class Rage::Internal
56
56
  "#{streamable.class.name}:#{streamable.id}"
57
57
  elsif streamable.is_a?(String) || streamable.is_a?(Symbol) || streamable.is_a?(Numeric)
58
58
  streamable
59
+ elsif streamable.is_a?(Array)
60
+ stream_name_for(streamable)
61
+ elsif streamable.is_a?(Hash)
62
+ stream_name_for(streamable.sort)
59
63
  else
60
64
  raise ArgumentError, "Unable to generate stream name. Expected an object that responds to `id`, got: #{streamable.class}"
61
65
  end
@@ -1,9 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Rage::OriginValidator
4
- def initialize(app, *allowed_origins)
4
+ def initialize(app, *allowed_origins, allow_same_origin_as_host: true)
5
5
  @app = app
6
- @validator = build_validator(allowed_origins)
6
+ @validator = build_validator(allowed_origins, allow_same_origin_as_host)
7
7
  end
8
8
 
9
9
  def call(env)
@@ -17,20 +17,43 @@ class Rage::OriginValidator
17
17
 
18
18
  private
19
19
 
20
- def build_validator(allowed_origins)
21
- if allowed_origins.empty?
20
+ def build_validator(allowed_origins, allow_same_origin_as_host)
21
+ if allowed_origins.empty? && !allow_same_origin_as_host
22
22
  ->(env) { false }
23
23
  else
24
- origins_eval = allowed_origins.map { |origin|
25
- origin.is_a?(Regexp) ?
26
- "origin =~ /#{origin.source}/.freeze" :
27
- "origin == '#{origin}'.freeze"
28
- }.join(" || ")
24
+ origins_eval = []
25
+
26
+ same_origin_eval = if allow_same_origin_as_host
27
+ origins_eval << "is_same_origin"
28
+
29
+ <<~RUBY
30
+ host = env["HTTP_HOST"]
31
+
32
+ is_same_origin = if host.nil?
33
+ false
34
+ elsif origin.start_with?("https://")
35
+ origin.size == host.size + 8 && origin.end_with?(host)
36
+ elsif origin.start_with?("http://")
37
+ origin.size == host.size + 7 && origin.end_with?(host)
38
+ end
39
+ RUBY
40
+ end
41
+
42
+ origins_eval += allowed_origins.map do |configured_origin|
43
+ configured_origin.is_a?(Regexp) ?
44
+ "origin =~ /#{configured_origin.source}/" :
45
+ "origin == '#{configured_origin}'"
46
+ end
47
+
48
+ eval <<~RUBY
49
+ # frozen_string_literal: true
29
50
 
30
- eval <<-RUBY
31
51
  ->(env) do
32
- origin = env["HTTP_ORIGIN".freeze]
33
- #{origins_eval}
52
+ origin = env["HTTP_ORIGIN"]
53
+ return false unless origin
54
+
55
+ #{same_origin_eval}
56
+ #{origins_eval.join(" || ")}
34
57
  end
35
58
  RUBY
36
59
  end
@@ -1,5 +1,16 @@
1
+ # frozen_string_literal: true
2
+
1
3
  ##
2
- # The module provides the support for multi-server setups for `Rage::Cable` and `Rage::SSE`. It allows for broadcasting messages across multiple servers or from different runtimes, e.g. Sidekiq.
4
+ # The module enables communication between Rage instances across worker processes and nodes.
5
+ #
6
+ # It provides the infrastructure for multi-server setups for `Rage::Cable` and `Rage::SSE`, allowing broadcasting
7
+ # messages across multiple servers or from different runtimes, e.g. Sidekiq. It also exposes a low-level API via
8
+ # the {subscribe}, {unsubscribe}, and {publish} methods for direct inter-process communication.
9
+ #
10
+ # @note The low-level API is primarily intended for integrations and gems. Most applications should use
11
+ # higher-level abstractions like {Rage::Cable Rage::Cable} or {Rage::SSE Rage::SSE} instead.
12
+ #
13
+ # ## Configuration
3
14
  #
4
15
  # To use the module, add the `redis-client` gem to your Gemfile and create the environment-specific configuration in `config/pubsub.yml`:
5
16
  #
@@ -19,7 +30,141 @@
19
30
  # The rest of the options are passed directly to `redis-client`.
20
31
  #
21
32
  module Rage::PubSub
33
+ BROADCASTER_ID = "pubsub"
34
+ private_constant :BROADCASTER_ID
35
+
36
+ module Relay
37
+ def self.broadcast(channel, data)
38
+ Iodine.publish(channel, data)
39
+ end
40
+ end
41
+ private_constant :Relay
42
+
43
+ class << self
44
+ # @private
45
+ def __initialize
46
+ if (adapter = Rage.config.pubsub.adapter)
47
+ adapter.add_broadcaster(BROADCASTER_ID, Relay)
48
+ @__adapter = adapter
49
+ end
50
+ end
51
+
52
+ # Subscribe to messages on a topic.
53
+ #
54
+ # @param topic [String, Array, #id] The channel name to subscribe to. Can be a string, an array,
55
+ # an Active Record model, or any object responding to `id`.
56
+ # @param subscription_id [Object] A unique identifier for this subscription within the topic.
57
+ # Used to manage multiple subscriptions to the same topic and for cleanup via {unsubscribe}.
58
+ # @yieldparam message [String] The raw message string.
59
+ # @raise [ArgumentError] If a subscription with the same `topic` and `subscription_id` already exists.
60
+ #
61
+ # @example Subscribe to order updates
62
+ # Rage::PubSub.subscribe(topic: "orders", subscription_id: :order_listener) do |message|
63
+ # data = JSON.parse(message)
64
+ # puts "Received order update: #{data}"
65
+ # end
66
+ def subscribe(topic:, subscription_id:, &block)
67
+ serialized_topic = Rage::Internal.stream_name_for(topic)
68
+ topic_subscriptions = (subscriptions[serialized_topic] ||= {})
69
+
70
+ if topic_subscriptions.has_key?(subscription_id)
71
+ raise ArgumentError, "A subscription with topic #{topic.inspect} and subscription_id #{subscription_id.inspect} already exists"
72
+ end
73
+
74
+ topic_subscriptions[subscription_id] = block
75
+ subscribe_to_topic(serialized_topic) if topic_subscriptions.size == 1
76
+
77
+ true
78
+ end
79
+
80
+ # Remove a subscription from a topic.
81
+ #
82
+ # @param topic [String, Array, #id] The channel name to unsubscribe from. Must match the value
83
+ # used when calling {subscribe}.
84
+ # @param subscription_id [Object, nil] The identifier of the subscription to remove. Omit the identifier to remove all subscriptions for the topic.
85
+ #
86
+ # @example Unsubscribe from order updates
87
+ # Rage::PubSub.unsubscribe(topic: "orders", subscription_id: :order_listener)
88
+ def unsubscribe(topic:, subscription_id: nil)
89
+ serialized_topic = Rage::Internal.stream_name_for(topic)
90
+ topic_subscriptions = subscriptions[serialized_topic]
91
+
92
+ return false if topic_subscriptions.nil? || (subscription_id && !topic_subscriptions.has_key?(subscription_id))
93
+
94
+ if subscription_id
95
+ topic_subscriptions.delete(subscription_id)
96
+ unsubscribe_from_topic(serialized_topic) if topic_subscriptions.empty?
97
+ else
98
+ unsubscribe_from_topic(serialized_topic)
99
+ end
100
+
101
+ true
102
+ end
103
+
104
+ # Publish a message to all subscribers of a topic.
105
+ #
106
+ # Messages are delivered to all subscribers across worker processes and nodes (when a PubSub
107
+ # adapter is configured).
108
+ #
109
+ # @param message [String] The message to publish.
110
+ # @param topic [String, Array, #id] The channel name to publish to. Can be a string, an array,
111
+ # an Active Record model, or any object responding to `id`.
112
+ # @raise [ArgumentError] If message is not a String.
113
+ #
114
+ # @example Publish an order update
115
+ # Rage::PubSub.publish(JSON.generate({ id: 123, status: "shipped" }), topic: "orders")
116
+ #
117
+ # @example Publish to a model-based topic
118
+ # Rage::PubSub.publish("refresh", topic: current_user)
119
+ def publish(message, topic:)
120
+ raise ArgumentError, "Message must be a String, got #{message.class}" unless message.is_a?(String)
121
+
122
+ serialized_topic = Rage::Internal.stream_name_for(topic)
123
+ channel = "pubsub:#{serialized_topic}"
124
+
125
+ Iodine.publish(channel, message) if Iodine.running?
126
+ @__adapter&.publish(BROADCASTER_ID, channel, message)
127
+
128
+ true
129
+ end
130
+
131
+ private
132
+
133
+ def subscriptions
134
+ @subscriptions ||= {}
135
+ end
136
+
137
+ def subscribe_to_topic(serialized_topic)
138
+ Iodine.subscribe("pubsub:#{serialized_topic}") do |_, message|
139
+ topic_subscriptions = subscriptions[serialized_topic]
140
+
141
+ topic_subscriptions&.each do |subscription_id, callback|
142
+ callback.call(message)
143
+ rescue => e
144
+ Rage.logger.error(
145
+ "PubSub callback failed with exception: #{e.class} (#{e.message}):\n#{e.backtrace.join("\n")}",
146
+ subscription_id:
147
+ )
148
+ end
149
+ end
150
+ end
151
+
152
+ def unsubscribe_from_topic(serialized_topic)
153
+ subscriptions.delete(serialized_topic)
154
+
155
+ Iodine.defer do
156
+ Iodine.unsubscribe("pubsub:#{serialized_topic}") unless subscriptions.has_key?(serialized_topic)
157
+ end
158
+ end
159
+ end # class << self
160
+
22
161
  module Adapters
23
162
  autoload :Redis, "rage/pubsub/adapters/redis"
24
163
  end
25
164
  end
165
+
166
+ if Rage.config.internal.initialized?
167
+ Rage::PubSub.__initialize
168
+ else
169
+ Rage.config.after_initialize { Rage::PubSub.__initialize }
170
+ end
@@ -11,7 +11,10 @@ class Rage::Router::DSL
11
11
  end
12
12
 
13
13
  def draw(&block)
14
- Handler.new(@router).instance_eval(&block)
14
+ handler = Handler.new(@router)
15
+ Rage::Extension.__routes.each { |block| handler.instance_eval(&block) }
16
+ handler.instance_eval(&block)
17
+
15
18
  # propagate route definitions to Rails for `rails routes` to work
16
19
  Rails.application.routes.draw(&block) if Rage.config.internal.rails_mode
17
20
  end
data/lib/rage/rspec.rb CHANGED
@@ -256,11 +256,10 @@ module RageCableHelpers
256
256
  end
257
257
 
258
258
  def subscribe(params = {})
259
- __stub_cable_protocol!
260
259
  __stub_iodine_defer!
261
260
 
262
261
  channel_class = __channel_class
263
- channel_class.__register_actions
262
+ channel_class.__register_actions(__cable_test_protocol)
264
263
 
265
264
  params = (params || {}).each_with_object({}) { |(k, v), memo| memo[k.to_sym] = v }
266
265
  params[:channel] = channel_class.name
@@ -355,10 +354,6 @@ module RageCableHelpers
355
354
  URI.parse("http://#{default_host}/")
356
355
  end
357
356
 
358
- def __stub_cable_protocol!
359
- allow(Rage.cable).to receive(:__protocol).and_return(__cable_test_protocol)
360
- end
361
-
362
357
  def __stub_iodine_defer!
363
358
  allow(Iodine).to receive(:defer) do |&block|
364
359
  block&.call
data/lib/rage/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rage
4
- VERSION = "1.28.2"
4
+ VERSION = "1.29.0"
5
5
  end
data/lib/rage-rb.rb CHANGED
@@ -8,7 +8,12 @@ require "pathname"
8
8
  module Rage
9
9
  # Builds the Rage application with the configured middlewares.
10
10
  def self.application
11
- with_middlewares(Application.new(__router), config.middleware.middlewares)
11
+ middleware = config.middleware.middlewares
12
+ if Fiber.scheduler && Fiber.current != Fiber.scheduler.root_fiber
13
+ middleware = middleware.reject { |m, _, _| m == Rage::FiberWrapper }
14
+ end
15
+
16
+ with_middlewares(Application.new(__router), middleware)
12
17
  end
13
18
 
14
19
  # Builds the Rage application which delegates Rails requests to `Rails.application`.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rage-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.28.2
4
+ version: 1.29.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Roman Samoilov
@@ -43,14 +43,14 @@ dependencies:
43
43
  requirements:
44
44
  - - "~>"
45
45
  - !ruby/object:Gem::Version
46
- version: '5.6'
46
+ version: '6.3'
47
47
  type: :runtime
48
48
  prerelease: false
49
49
  version_requirements: !ruby/object:Gem::Requirement
50
50
  requirements:
51
51
  - - "~>"
52
52
  - !ruby/object:Gem::Version
53
- version: '5.6'
53
+ version: '6.3'
54
54
  - !ruby/object:Gem::Dependency
55
55
  name: zeitwerk
56
56
  requirement: !ruby/object:Gem::Requirement
@@ -150,6 +150,7 @@ files:
150
150
  - lib/rage/cable/protocols/actioncable_v1_json.rb
151
151
  - lib/rage/cable/protocols/base.rb
152
152
  - lib/rage/cable/protocols/raw_web_socket_json.rb
153
+ - lib/rage/cable/remote_disconnect.rb
153
154
  - lib/rage/cable/router.rb
154
155
  - lib/rage/cli.rb
155
156
  - lib/rage/cli/base.rb
@@ -166,6 +167,8 @@ files:
166
167
  - lib/rage/deferred/backends/disk.rb
167
168
  - lib/rage/deferred/backends/nil.rb
168
169
  - lib/rage/deferred/context.rb
170
+ - lib/rage/deferred/dead_task.rb
171
+ - lib/rage/deferred/dead_tasks.rb
169
172
  - lib/rage/deferred/deferred.rb
170
173
  - lib/rage/deferred/metadata.rb
171
174
  - lib/rage/deferred/middleware_chain.rb