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.
@@ -44,7 +44,7 @@ class Rage::Cable::Protocols::RawWebSocketJson < Rage::Cable::Protocols::Base
44
44
  DEFAULT_PARAMS = {}.freeze
45
45
  private_constant :DEFAULT_PARAMS
46
46
 
47
- def self.init(router)
47
+ def self.init
48
48
  super
49
49
 
50
50
  @ping_connections = Set.new
@@ -61,8 +61,10 @@ class Rage::Cable::Protocols::RawWebSocketJson < Rage::Cable::Protocols::Base
61
61
  end
62
62
 
63
63
  # @param connection [Rage::Cable::WebSocketConnection] the connection object
64
- def self.on_open(connection)
65
- accepted = @router.process_connection(connection)
64
+ # @param env [Hash] the Rack environment
65
+ # @param router [Rage::Cable::Router] the Cable router
66
+ def self.on_open(connection, env, router)
67
+ accepted = router.process_connection(connection, env)
66
68
 
67
69
  unless accepted
68
70
  connection.write(MESSAGES::UNAUTHORIZED)
@@ -70,7 +72,7 @@ class Rage::Cable::Protocols::RawWebSocketJson < Rage::Cable::Protocols::Base
70
72
  return
71
73
  end
72
74
 
73
- channel_id = connection.env["PATH_INFO"].split("/")[-1]
75
+ channel_id = env["PATH_INFO"].split("/")[-1]
74
76
 
75
77
  channel_name = if channel_id.end_with?("Channel")
76
78
  channel_id
@@ -86,10 +88,10 @@ class Rage::Cable::Protocols::RawWebSocketJson < Rage::Cable::Protocols::Base
86
88
  "#{channel_id}Channel"
87
89
  end
88
90
 
89
- query_string = connection.env["QUERY_STRING"]
91
+ query_string = env["QUERY_STRING"]
90
92
  params = query_string == "" ? DEFAULT_PARAMS : Iodine::Rack::Utils.parse_nested_query(query_string)
91
93
 
92
- status = @router.process_subscription(connection, IDENTIFIER, channel_name, params)
94
+ status = router.process_subscription(connection, env, IDENTIFIER, channel_name, params)
93
95
 
94
96
  if status == :rejected
95
97
  connection.write(MESSAGES::REJECTED)
@@ -101,8 +103,10 @@ class Rage::Cable::Protocols::RawWebSocketJson < Rage::Cable::Protocols::Base
101
103
  end
102
104
 
103
105
  # @param connection [Rage::Cable::WebSocketConnection] the connection object
106
+ # @param env [Hash] the Rack environment
104
107
  # @param raw_data [String] the message body
105
- def self.on_message(connection, raw_data)
108
+ # @param router [Rage::Cable::Router] the Cable router
109
+ def self.on_message(connection, env, raw_data, router)
106
110
  if raw_data == "ping"
107
111
  @ping_connections << connection
108
112
  return
@@ -110,15 +114,17 @@ class Rage::Cable::Protocols::RawWebSocketJson < Rage::Cable::Protocols::Base
110
114
 
111
115
  data = JSON.parse(raw_data)
112
116
 
113
- message_status = @router.process_message(connection, IDENTIFIER, :receive, data)
117
+ message_status = router.process_message(connection, env, IDENTIFIER, :receive, data)
114
118
  unless message_status == :processed
115
119
  connection.write(MESSAGES::UNKNOWN)
116
120
  end
117
121
  end
118
122
 
119
123
  # @param connection [Rage::Cable::WebSocketConnection] the connection object
120
- def self.on_close(connection)
121
- @router.process_disconnection(connection)
124
+ # @param env [Hash] the Rack environment
125
+ # @param router [Rage::Cable::Router] the Cable router
126
+ def self.on_close(connection, env, router)
127
+ router.process_disconnection(connection, env)
122
128
  end
123
129
 
124
130
  # @param data [Object] the object to serialize
@@ -131,6 +137,12 @@ class Rage::Cable::Protocols::RawWebSocketJson < Rage::Cable::Protocols::Base
131
137
  false
132
138
  end
133
139
 
140
+ # A unique identifier of the protocol used in multi-server setups by {Rage::PubSub}.
141
+ # @return [String]
142
+ def self.protocol_id
143
+ "rawjson"
144
+ end
145
+
134
146
  # @private
135
147
  # The base implementation groups connection subscriptions by `params`;
136
148
  # however, with `RawWebSocketJson`, params are not part of the payload (see {serialize})
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Include this module in your connection class to enable server-initiated disconnections.
4
+ #
5
+ # This allows you to disconnect WebSocket connections from anywhere in your application
6
+ # using {Rage::Cable::Connection.disconnect Rage::Cable::Connection.disconnect}. A common
7
+ # use case is forcing a logout when a user's session is invalidated or their account is suspended.
8
+ #
9
+ # @example
10
+ # class RageCable::Connection < Rage::Cable::Connection
11
+ # include Rage::Cable::RemoteDisconnect
12
+ #
13
+ # identified_by :current_user
14
+ #
15
+ # def connect
16
+ # self.current_user = find_verified_user
17
+ # end
18
+ # end
19
+ #
20
+ # # Later, elsewhere in your application:
21
+ # RageCable::Connection.disconnect(current_user: user)
22
+ #
23
+ module Rage::Cable::RemoteDisconnect
24
+ def self.included(klass)
25
+ klass.extend(ClassMethods)
26
+ end
27
+
28
+ module ClassMethods
29
+ # Disconnect all connections matching the specified identifier.
30
+ # @example Disconnect all connections for a user
31
+ # RageCable::Connection.disconnect(current_user: User.find(1))
32
+ def disconnect(protocol_options: nil, **identified_by)
33
+ raise ArgumentError, "An identifier must be provided" if identified_by.empty?
34
+ raise ArgumentError, "Only one identifier can be provided" if identified_by.size > 1
35
+
36
+ id_pair = identified_by.first
37
+ unless __identifiers&.include?(id_pair[0])
38
+ raise ArgumentError, "Unknown identifier: #{id_pair[0]}"
39
+ end
40
+
41
+ message = if protocol_options
42
+ raise ArgumentError, "protocol_options must be a Hash" unless protocol_options.is_a?(Hash)
43
+ protocol_options.to_json
44
+ else
45
+ ""
46
+ end
47
+
48
+ Rage::PubSub.publish(message, topic: ["rage", name, id_pair])
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def __remote_disconnect_setup(connection, protocol)
55
+ __identified_by_map.each do |identified_by|
56
+ Rage::PubSub.subscribe(topic: ["rage", self.class.name, identified_by], subscription_id: connection) do |message|
57
+ protocol_options = JSON.parse(message, symbolize_names: true) unless message.empty?
58
+ protocol.on_remote_disconnect(connection, protocol_options)
59
+ connection.close
60
+ end
61
+ end
62
+ end
63
+
64
+ def __remote_disconnect_cleanup(connection)
65
+ __identified_by_map&.each do |identified_by|
66
+ Rage::PubSub.unsubscribe(topic: ["rage", self.class.name, identified_by], subscription_id: connection)
67
+ end
68
+ end
69
+ end
@@ -2,24 +2,25 @@
2
2
 
3
3
  class Rage::Cable::Router
4
4
  # @private
5
- def initialize
5
+ # @param protocol [Rage::Cable::Protocols::Base]
6
+ # @param namespace [Module]
7
+ def initialize(protocol, namespace)
6
8
  # Hash<String(channel name) => Proc(new channel instance)>
7
9
  @channels_map = {}
10
+ @protocol = protocol
11
+ @namespace = namespace
8
12
  init_connection_class
9
13
  end
10
14
 
11
15
  # Calls the `connect` method on the `Connection` class to handle authentication.
12
16
  #
13
17
  # @param connection [Rage::Cable::WebSocketConnection] the connection object
18
+ # @param env [Hash] the Rack environment
14
19
  # @return [true] if the connection was accepted
15
20
  # @return [false] if the connection was rejected
16
- def process_connection(connection)
17
- env = connection.env
18
-
21
+ def process_connection(connection, env)
19
22
  cable_connection = @connection_class.new(env)
20
- Rage::Telemetry.tracer.span_cable_connection_process(connection: cable_connection, action: :connect, env:) do
21
- cable_connection.connect
22
- end
23
+ cable_connection.__connect(connection, @protocol)
23
24
 
24
25
  if cable_connection.rejected?
25
26
  Rage.logger.debug { "An unauthorized connection attempt was rejected" }
@@ -34,6 +35,7 @@ class Rage::Cable::Router
34
35
  # Calls the `subscribed` method on the specified channel.
35
36
  #
36
37
  # @param connection [Rage::Cable::WebSocketConnection] the connection object
38
+ # @param env [Hash] the Rack environment
37
39
  # @param identifier [String] the identifier of the subscription
38
40
  # @param channel_name [String] the name of the channel class
39
41
  # @param params [Hash] the params hash associated with the subscription
@@ -41,10 +43,10 @@ class Rage::Cable::Router
41
43
  # @return [:invalid] if the subscription class does not exist
42
44
  # @return [:rejected] if the subscription was rejected
43
45
  # @return [:subscribed] if the subscription was accepted
44
- def process_subscription(connection, identifier, channel_name, params)
46
+ def process_subscription(connection, env, identifier, channel_name, params)
45
47
  channel_class = @channels_map[channel_name] || begin
46
48
  begin
47
- klass = Object.const_get(channel_name)
49
+ klass = @namespace.const_get(channel_name, false) unless channel_name.start_with?("::")
48
50
  rescue NameError
49
51
  nil
50
52
  end
@@ -54,18 +56,18 @@ class Rage::Cable::Router
54
56
  return :invalid
55
57
  end
56
58
 
57
- klass.__register_actions.tap do |available_actions|
58
- Rage.logger.debug { "Compiled #{channel_name}. Available remote actions: #{available_actions}." }
59
+ klass.__register_actions(@protocol).tap do |available_actions|
60
+ Rage.logger.debug { "Compiled #{klass.name}. Available remote actions: #{available_actions}." }
59
61
  end
60
62
 
61
63
  @channels_map[channel_name] = klass
62
64
  end
63
65
 
64
- channel = channel_class.new(connection, params, connection.env["rage.identified_by"])
66
+ channel = channel_class.new(connection, params, env["rage.identified_by"])
65
67
  channel.__run_action(:subscribed)
66
68
 
67
69
  if channel.subscription_rejected?
68
- Rage.logger.debug { "#{channel_name} is transmitting the subscription rejection" }
70
+ Rage.logger.debug { "#{channel_class} is transmitting the subscription rejection" }
69
71
  # if the subscription is rejected in the `subscribed` method, ActionCable will additionally run
70
72
  # the `unsubscribed` method; this makes little sense to me as the client was never subscribed in
71
73
  # the first place; additionally, I don't think this behaviour is documented anywhere;
@@ -73,15 +75,15 @@ class Rage::Cable::Router
73
75
  # channel.__run_action(:unsubscribed)
74
76
  :rejected
75
77
  else
76
- Rage.logger.debug { "#{channel_name} is transmitting the subscription confirmation" }
77
- connection.env["rage.cable"][identifier] = channel
78
+ Rage.logger.debug { "#{channel_class} is transmitting the subscription confirmation" }
79
+ env["rage.cable"][identifier] = channel
78
80
  :subscribed
79
81
  end
80
82
  end
81
83
 
82
84
  # Calls the handler method on the specified channel.
83
85
  #
84
- # @param connection [Rage::Cable::WebSocketConnection] the connection object
86
+ # @param env [Hash] the Rack environment
85
87
  # @param identifier [String] the identifier of the subscription
86
88
  # @param action_name [Symbol] the name of the handler method
87
89
  # @param data [Object] the data sent by the client
@@ -89,8 +91,8 @@ class Rage::Cable::Router
89
91
  # @return [:no_subscription] if the client is not subscribed to the specified channel
90
92
  # @return [:unknown_action] if the action does not exist on the specified channel
91
93
  # @return [:processed] if the message has been successfully processed
92
- def process_message(connection, identifier, action_name, data)
93
- channel = connection.env["rage.cable"][identifier]
94
+ def process_message(_, env, identifier, action_name, data)
95
+ channel = env["rage.cable"][identifier]
94
96
  unless channel
95
97
  Rage.logger.debug { "Unable to find the subscription" }
96
98
  return :no_subscription
@@ -105,25 +107,36 @@ class Rage::Cable::Router
105
107
  end
106
108
  end
107
109
 
110
+ # Runs the `unsubscribed` method on a specific channel without closing the connection.
111
+ #
112
+ # @param env [Hash] the Rack environment
113
+ # @param identifier [String] the identifier of the subscription
114
+ def process_unsubscription(_, env, identifier)
115
+ channel = env["rage.cable"].delete(identifier)
116
+ unless channel
117
+ Rage.logger.debug { "Unable to find the subscription" }
118
+ return
119
+ end
120
+
121
+ channel.__run_action(:unsubscribed)
122
+ end
123
+
108
124
  # Runs the `unsubscribed` methods on all the channels the client is subscribed to.
109
125
  #
110
126
  # @param connection [Rage::Cable::WebSocketConnection] the connection object
111
- def process_disconnection(connection)
112
- env = connection.env
113
-
127
+ # @param env [Hash] the Rack environment
128
+ def process_disconnection(connection, env)
114
129
  env["rage.cable"]&.each do |_, channel|
115
130
  channel.__run_action(:unsubscribed)
116
131
  end
117
132
 
118
133
  cable_connection = @connection_class.new(env, env["rage.identified_by"])
119
-
120
- Rage::Telemetry.tracer.span_cable_connection_process(connection: cable_connection, action: :disconnect, env:) do
121
- cable_connection.disconnect
122
- end
134
+ cable_connection.__disconnect(connection)
123
135
  end
124
136
 
125
137
  # @private
126
138
  def reset
139
+ @namespace = Object.const_get(@namespace.name) if @namespace.name
127
140
  @channels_map.clear
128
141
  init_connection_class
129
142
  end
@@ -131,10 +144,10 @@ class Rage::Cable::Router
131
144
  private
132
145
 
133
146
  def init_connection_class
134
- @connection_class = if Object.const_defined?("RageCable::Connection")
135
- RageCable::Connection
136
- elsif Object.const_defined?("ApplicationCable::Connection")
137
- ApplicationCable::Connection
147
+ @connection_class = if @namespace.const_defined?("RageCable::Connection", false)
148
+ @namespace.const_get("RageCable::Connection", false)
149
+ elsif @namespace.const_defined?("ApplicationCable::Connection", false)
150
+ @namespace.const_get("ApplicationCable::Connection", false)
138
151
  else
139
152
  puts "WARNING: Could not find the RageCable connection class! All connections will be accepted by default."
140
153
  Rage::Cable::Connection
@@ -85,7 +85,7 @@ class Rage::CodeLoader
85
85
 
86
86
  def reload_components
87
87
  unless Rage.autoload?(:Cable) # the `Cable` component is loaded
88
- Rage::Cable.__router.reset
88
+ ObjectSpace.each_object(Rage::Cable::Router).each(&:reset)
89
89
  end
90
90
 
91
91
  unless Rage.autoload?(:OpenAPI) # the `OpenAPI` component is loaded
@@ -757,30 +757,31 @@ class Rage::Configuration
757
757
  # config.cable.protocol = :raw_websocket_json
758
758
  # end
759
759
  def protocol=(protocol)
760
- @protocol = case protocol
761
- when Class
762
- protocol
763
- when :actioncable_v1_json
764
- Rage::Cable::Protocols::ActioncableV1Json
765
- when :raw_websocket_json
766
- Rage::Cable::Protocols::RawWebSocketJson
767
- else
768
- raise ArgumentError, "Unknown protocol. Supported values are `:actioncable_v1_json` and `:raw_websocket_json`."
769
- end
760
+ @protocol = Rage::Cable.__protocol_for(protocol)
770
761
  end
771
762
 
772
763
  # @private
773
- def middlewares
774
- @middlewares ||= begin
775
- origin_middleware = if @disable_request_forgery_protection
776
- []
777
- else
778
- [[Rage::OriginValidator, Array(@allowed_request_origins), nil]]
779
- end
764
+ # Returns the list of middleware to be used with Cable applications.
765
+ #
766
+ # When `include_http_middleware` is `true`, the returned list includes both Cable-specific
767
+ # middleware and the application's HTTP middleware (excluding {Rage::FiberWrapper}).
768
+ # When `include_http_middleware` is `false`, only Cable-specific middleware is returned.
769
+ #
770
+ # @param include_http_middleware [Boolean] whether to include the application's HTTP middleware.
771
+ # @return [Array<Array>] an array of middleware definitions, where each definition is an array containing `[middleware_class, args, block]`
772
+ def middlewares(include_http_middleware: true)
773
+ cable_middlewares = if @disable_request_forgery_protection
774
+ []
775
+ else
776
+ [[Rage::OriginValidator, Array(@allowed_request_origins), nil]]
777
+ end
780
778
 
781
- origin_middleware + Rage.config.middleware.middlewares.reject do |middleware, _, _|
782
- middleware == Rage::FiberWrapper
779
+ if include_http_middleware
780
+ cable_middlewares + Rage.config.middleware.middlewares.reject do |m, _, _|
781
+ m == Rage::FiberWrapper
783
782
  end
783
+ else
784
+ cable_middlewares
784
785
  end
785
786
  end
786
787
  end
data/lib/rage/cookies.rb CHANGED
@@ -1,8 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "base64"
4
3
  require "time"
5
4
 
5
+ begin
6
+ require "base64"
7
+ rescue LoadError
8
+ fail <<~ERR
9
+
10
+ Rage depends on `base64` to encode cookies. Ensure the following line is added to your Gemfile:
11
+ gem "base64"
12
+
13
+ ERR
14
+ end
15
+
6
16
  if !defined?(DomainName)
7
17
  fail <<~ERR
8
18
 
data/lib/rage/daemon.rb CHANGED
@@ -159,11 +159,7 @@ class Rage::Daemon
159
159
  private
160
160
 
161
161
  def __execute
162
- Iodine.on_state(:start_shutdown) do
163
- @__stopping = true
164
- end
165
-
166
- Fiber.schedule do
162
+ f = Fiber.schedule do
167
163
  backoff = INITIAL_BACKOFF
168
164
 
169
165
  Rage.logger.with_context(daemon: name) do
@@ -193,6 +189,11 @@ class Rage::Daemon
193
189
  end
194
190
  end
195
191
  end
192
+
193
+ Iodine.on_state(:start_shutdown) do
194
+ @__stopping = true
195
+ f.kill
196
+ end
196
197
  end # class << self
197
198
 
198
199
  def __cleanup(instance)