diff --git a/CHANGELOG.md b/CHANGELOG.md index d93cf802..71e73279 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - [Cable] Serialize WS messages per connection (#400). - Update fiber scheduler to support Ruby 4.1 (#397). - Improve blocking mechanism for fibers (#401). +- Support multiple isolated cable apps (#406). ## [1.28.2] - 2026-10-09 diff --git a/lib/rage/cable/cable.rb b/lib/rage/cable/cable.rb index 674c5f88..a78962ed 100644 --- a/lib/rage/cable/cable.rb +++ b/lib/rage/cable/cable.rb @@ -38,9 +38,21 @@ module Rage::Cable # map "/cable" do # run Rage.cable.application # end - def self.application - handler = __build_handler(__protocol) - accept_response = [0, __protocol.protocol_definition, []] + def self.application(namespace: Object, protocol: nil) + unless namespace.is_a?(Module) + raise ArgumentError, "The `namespace` option must be a Module, got #{namespace.class}." + end + + protocol = __protocol_for(protocol || Rage.config.cable.protocol) + + unless __protocols.include?(protocol) + __protocols << protocol.tap(&:init) + @__adapter&.add_broadcaster(PUBSUB_BROADCASTER_ID, Relay) + end + + router = Router.new(protocol, namespace) + handler = __build_handler(protocol, router) + accept_response = [0, protocol.protocol_definition, []] application = ->(env) do Rage::Telemetry.tracer.span_cable_websocket_handshake(env:) do @@ -54,8 +66,11 @@ def self.application end end + application.define_singleton_method(:__rage_app_name) do + namespace == Object ? "Rage::Cable" : "Rage::Cable(namespace: #{namespace})" + end + chain = Rage.with_middlewares(application, Rage.config.cable.middlewares) - application.define_singleton_method(:__rage_app_name) { "Rage::Cable" } chain.define_singleton_method(:__rage_root_app) { application } chain @@ -64,25 +79,35 @@ def self.application # @private def self.__initialize if (adapter = Rage.config.pubsub.adapter) - adapter.add_broadcaster(PUBSUB_BROADCASTER_ID, __protocol) + adapter.add_broadcaster(PUBSUB_BROADCASTER_ID, Relay) @__adapter = adapter end end # @private - def self.__router - @__router ||= Router.new + def self.__protocols + @__protocols ||= [] end # @private - def self.__protocol - @__protocol ||= Rage.config.cable.protocol.tap { |protocol| protocol.init(__router) } + # @param protocol [:actioncable_v1_json, :raw_websocket_json, Class] + def self.__protocol_for(protocol) + case protocol + when Class + protocol + when :actioncable_v1_json + Protocols::ActioncableV1Json + when :raw_websocket_json + Protocols::RawWebSocketJson + else + raise ArgumentError, "Unknown protocol. Supported values are `:actioncable_v1_json` and `:raw_websocket_json`." + end end # @private - def self.__build_handler(protocol) + def self.__build_handler(protocol, router) klass = Class.new do - def initialize(protocol) + def initialize(protocol, router) Iodine.on_state(:on_start) do unless Fiber.scheduler Fiber.set_scheduler(Rage::FiberScheduler.new) @@ -90,29 +115,30 @@ def initialize(protocol) end @protocol = protocol + @router = router @log_processor = Rage.__log_processor end def on_open(connection) init_connection(connection) - schedule_processing(connection) { |env| @protocol.on_open(connection, env) } + schedule_processing(connection) { |env| @protocol.on_open(connection, env, @router) } end def on_message(connection, data) - schedule_processing(connection) { |env| @protocol.on_message(connection, env, data) } + schedule_processing(connection) { |env| @protocol.on_message(connection, env, data, @router) } end if protocol.respond_to?(:on_close) def on_close(connection) return unless ::Iodine.running? - schedule_processing(connection) { |env| @protocol.on_close(connection, env) } + schedule_processing(connection) { |env| @protocol.on_close(connection, env, @router) } connection.env["rage.cable.queue"] << nil end end if protocol.respond_to?(:on_shutdown) def on_shutdown(connection) - @protocol.on_shutdown(connection) + @protocol.on_shutdown(connection, @router) rescue => e log_error(e) end @@ -153,7 +179,7 @@ def log_error(e) end end - klass.new(protocol) + klass.new(protocol, router) end # Broadcast data directly to a named stream. @@ -165,13 +191,22 @@ def log_error(e) # Rage.cable.broadcast("chat", { message: "A new member has joined!" }) def self.broadcast(stream, data) Rage::Telemetry.tracer.span_cable_stream_broadcast(stream:) do - __protocol.broadcast(stream, data) + Relay.broadcast(stream, data) @__adapter&.publish(PUBSUB_BROADCASTER_ID, stream, data) end true end + # @private + module Relay + def self.broadcast(stream, data) + Rage::Cable.__protocols.each do |protocol| + protocol.broadcast(stream, data) + end + end + end + # @!parse [ruby] # # @abstract # class WebSocketConnection diff --git a/lib/rage/cable/channel.rb b/lib/rage/cable/channel.rb index e8290cb2..175f8217 100644 --- a/lib/rage/cable/channel.rb +++ b/lib/rage/cable/channel.rb @@ -4,19 +4,18 @@ class Rage::Cable::Channel class << self # @private - attr_reader :__prepared_actions - - # @private - attr_reader :__channels + attr_reader :__prepared_actions, :__channels, :__protocol # @private # returns a list of actions that can be called remotely - def __register_actions + def __register_actions(protocol) + @__protocol = protocol + actions = ( public_instance_methods(true) - Rage::Cable::Channel.public_instance_methods(true) ).reject { |m| m.start_with?("__rage_tmp") || m.start_with?("__run") } - actions.reject! { |m| m != :receive } unless Rage.cable.__protocol.supports_rpc? + actions.reject! { |m| m != :receive } unless @__protocol.supports_rpc? @__prepared_actions = (INTERNAL_ACTIONS + actions).each_with_object({}) do |action_name, memo| memo[action_name] = __register_action_proc(action_name) @@ -417,6 +416,13 @@ def params @__params end + # Get the Rack env hash that is attached to the underlying WebSocket connection. + # + # @return [Hash] + def env + @__connection.env + end + # Reject the subscription request. The method should only be called during the subscription # process (i.e. inside the {subscribed} method or {before_subscribe}/{after_subscribe} hooks). def reject @@ -449,7 +455,7 @@ def subscription_rejected? # Rage::Cable.broadcast("notifications", { message: "A new member has joined!" }) def stream_from(stream) raise ArgumentError, "Stream name must be a String" unless stream.is_a?(String) - Rage.cable.__protocol.subscribe(@__connection, stream, @__params) + self.class.__protocol.subscribe(@__connection, stream, @__params) end # Subscribe to a local stream. Local streams are associated with a specific channel instance and can be used to send data to the current channel only. @@ -490,7 +496,7 @@ def stream_for(streamable) # end def stop_stream_from(stream) raise ArgumentError, "Stream name must be a String" unless stream.is_a?(String) - Rage.cable.__protocol.unsubscribe(@__connection, stream, @__params) + self.class.__protocol.unsubscribe(@__connection, stream, @__params) end # Unsubscribe from a local stream. The counterpart to {stream_for}. @@ -527,7 +533,7 @@ def broadcast(stream, data) # transmit({ message: "Hello!" }) # end def transmit(data) - message = Rage.cable.__protocol.serialize(@__params, data) + message = self.class.__protocol.serialize(@__params, data) if @__is_subscribing # we expect a confirmation message to be sent as a result of a successful subscribe call; diff --git a/lib/rage/cable/protocols/actioncable_v1_json.rb b/lib/rage/cable/protocols/actioncable_v1_json.rb index 60b339e7..42ca1d46 100644 --- a/lib/rage/cable/protocols/actioncable_v1_json.rb +++ b/lib/rage/cable/protocols/actioncable_v1_json.rb @@ -67,9 +67,7 @@ def self.protocol_definition end # This method serves as a constructor to prepare the object or set up recurring tasks (e.g. heartbeats). - # - # @param router [Rage::Cable::Router] - def self.init(router) + def self.init super Iodine.on_state(:on_start) do @@ -87,9 +85,9 @@ def self.init(router) # # @param connection [Rage::Cable::WebSocketConnection] the connection object # @param env [Hash] the Rack environment - # @see Rage::Cable::Router - def self.on_open(connection, env) - accepted = @router.process_connection(env) + # @param router [Rage::Cable::Router] the Cable router + def self.on_open(connection, env, router) + accepted = router.process_connection(connection, env) if accepted connection.subscribe("cable:ping") @@ -106,8 +104,8 @@ def self.on_open(connection, env) # @param connection [Rage::Cable::WebSocketConnection] the connection object # @param env [Hash] the Rack environment # @param raw_data [String] the message body - # @see Rage::Cable::Router - def self.on_message(connection, env, raw_data) + # @param router [Rage::Cable::Router] the Cable router + def self.on_message(connection, env, raw_data, router) parsed_data = Rage::ParamsParser.json_parse(raw_data) command, identifier = parsed_data[:command], parsed_data[:identifier] @@ -115,7 +113,7 @@ def self.on_message(connection, env, raw_data) # process subscription messages if command == COMMAND::SUBSCRIBE - status = @router.process_subscription(connection, env, identifier, params[:channel], params) + status = router.process_subscription(connection, env, identifier, params[:channel], params) if status == :subscribed connection.write({ identifier: identifier, type: TYPE::CONFIRM }.to_json) elsif status == :rejected @@ -126,7 +124,7 @@ def self.on_message(connection, env, raw_data) return elsif command == COMMAND::UNSUBSCRIBE - @router.process_disconnection(env) + router.process_disconnection(connection, env) return end @@ -135,10 +133,10 @@ def self.on_message(connection, env, raw_data) data = JSON.parse(parsed_data[:data]) message_status = if command == COMMAND::MESSAGE && data.has_key?("action") - @router.process_message(env, identifier, data["action"].to_sym, data) + router.process_message(connection, env, identifier, data["action"].to_sym, data) elsif command == COMMAND::MESSAGE - @router.process_message(env, identifier, :receive, data) + router.process_message(connection, env, identifier, :receive, data) end unless message_status == :processed @@ -149,10 +147,11 @@ def self.on_message(connection, env, raw_data) # The method should process client disconnections and call {Rage::Cable::Router#process_disconnection}. # # @note This method is optional. + # @param connection [Rage::Cable::WebSocketConnection] the connection object # @param env [Hash] the Rack environment - # @see Rage::Cable::Router - def self.on_close(_, env) - @router.process_disconnection(env) + # @param router [Rage::Cable::Router] the Cable router + def self.on_close(connection, env, router) + router.process_disconnection(connection, env) end # Serialize a Ruby object into the format the client would understand. @@ -162,4 +161,10 @@ def self.on_close(_, env) def self.serialize(params, data) { identifier: params.to_json, message: data }.to_json end + + # A unique identifier of the protocol used in multi-server setups by {Rage::PubSub}. + # @return [String] + def self.protocol_id + "acv1" + end end diff --git a/lib/rage/cable/protocols/base.rb b/lib/rage/cable/protocols/base.rb index 28098194..ed32ad66 100644 --- a/lib/rage/cable/protocols/base.rb +++ b/lib/rage/cable/protocols/base.rb @@ -9,10 +9,12 @@ # * `on_open` # * `on_message` # * `serialize` +# * `protocol_id` # # The optional methods are: # # * `protocol_definition` +# * `supports_rpc?` # * `on_shutdown` # * `on_close` # @@ -21,26 +23,24 @@ class Rage::Cable::Protocols::Base HANDSHAKE_HEADERS = {} class << self - # @param router [Rage::Cable::Router] - def init(router) - @router = router - + def init # Hash Set(subscription params)> @subscription_identifiers = Hash.new { |hash, key| hash[key] = Set.new } + @sync_channel = "cable:#{protocol_id}:synchronize" Iodine.on_state(:pre_start) do # this is a fallback to synchronize subscription identifiers across different worker processes; # we expect connections to be distributed among all workers, so this code will almost never be called; # we also synchronize subscriptions with the master process so that the forks that are spun up instead # of the crashed ones also had access to the identifiers; - Iodine.subscribe("cable:synchronize") do |_, subscription_msg| + Iodine.subscribe(@sync_channel) do |_, subscription_msg| stream_name, params = Rage::ParamsParser.json_parse(subscription_msg) @subscription_identifiers[stream_name] << params end end Iodine.on_state(:on_finish) do - Iodine.unsubscribe("cable:synchronize") + Iodine.unsubscribe(@sync_channel) end end @@ -54,11 +54,11 @@ def protocol_definition # @param name [String] the stream name # @param params [Hash] parameters associated with the client def subscribe(connection, name, params) - connection.subscribe("cable:#{name}:#{stream_id(params)}") + connection.subscribe("cable:#{protocol_id}:#{name}:#{stream_id(params)}") unless @subscription_identifiers[name].include?(params) @subscription_identifiers[name] << params - ::Iodine.publish("cable:synchronize", [name, params].to_json) + ::Iodine.publish(@sync_channel, [name, params].to_json) end end @@ -68,7 +68,7 @@ def subscribe(connection, name, params) # @param name [String] the stream name # @param params [Hash] parameters associated with the client def unsubscribe(connection, name, params) - connection.unsubscribe("cable:#{name}:#{stream_id(params)}") + connection.unsubscribe("cable:#{protocol_id}:#{name}:#{stream_id(params)}") end # Broadcast data to all clients connected to a stream. @@ -77,7 +77,7 @@ def unsubscribe(connection, name, params) # @param data [Object] the data to send def broadcast(name, data) @subscription_identifiers[name].each do |params| - ::Iodine.publish("cable:#{name}:#{stream_id(params)}", serialize(params, data)) + ::Iodine.publish("cable:#{protocol_id}:#{name}:#{stream_id(params)}", serialize(params, data)) end end diff --git a/lib/rage/cable/protocols/raw_web_socket_json.rb b/lib/rage/cable/protocols/raw_web_socket_json.rb index 59c0e61d..09877356 100644 --- a/lib/rage/cable/protocols/raw_web_socket_json.rb +++ b/lib/rage/cable/protocols/raw_web_socket_json.rb @@ -44,7 +44,7 @@ module MESSAGES DEFAULT_PARAMS = {}.freeze private_constant :DEFAULT_PARAMS - def self.init(router) + def self.init super @ping_connections = Set.new @@ -62,8 +62,9 @@ def self.init(router) # @param connection [Rage::Cable::WebSocketConnection] the connection object # @param env [Hash] the Rack environment - def self.on_open(connection, env) - accepted = @router.process_connection(env) + # @param router [Rage::Cable::Router] the Cable router + def self.on_open(connection, env, router) + accepted = router.process_connection(connection, env) unless accepted connection.write(MESSAGES::UNAUTHORIZED) @@ -90,7 +91,7 @@ def self.on_open(connection, env) query_string = env["QUERY_STRING"] params = query_string == "" ? DEFAULT_PARAMS : Iodine::Rack::Utils.parse_nested_query(query_string) - status = @router.process_subscription(connection, env, IDENTIFIER, channel_name, params) + status = router.process_subscription(connection, env, IDENTIFIER, channel_name, params) if status == :rejected connection.write(MESSAGES::REJECTED) @@ -104,7 +105,8 @@ def self.on_open(connection, env) # @param connection [Rage::Cable::WebSocketConnection] the connection object # @param env [Hash] the Rack environment # @param raw_data [String] the message body - def self.on_message(connection, env, raw_data) + # @param router [Rage::Cable::Router] the Cable router + def self.on_message(connection, env, raw_data, router) if raw_data == "ping" @ping_connections << connection return @@ -112,15 +114,17 @@ def self.on_message(connection, env, raw_data) data = JSON.parse(raw_data) - message_status = @router.process_message(env, IDENTIFIER, :receive, data) + message_status = router.process_message(connection, env, IDENTIFIER, :receive, data) unless message_status == :processed connection.write(MESSAGES::UNKNOWN) end end + # @param connection [Rage::Cable::WebSocketConnection] the connection object # @param env [Hash] the Rack environment - def self.on_close(_, env) - @router.process_disconnection(env) + # @param router [Rage::Cable::Router] the Cable router + def self.on_close(connection, env, router) + router.process_disconnection(connection, env) end # @param data [Object] the object to serialize @@ -133,6 +137,12 @@ def self.supports_rpc? false end + # A unique identifier of the protocol used in multi-server setups by {Rage::PubSub}. + # @return [String] + def self.protocol_id + "rawjson" + end + # @private # The base implementation groups connection subscriptions by `params`; # however, with `RawWebSocketJson`, params are not part of the payload (see {serialize}) diff --git a/lib/rage/cable/router.rb b/lib/rage/cable/router.rb index 3651883e..86d09e6f 100644 --- a/lib/rage/cable/router.rb +++ b/lib/rage/cable/router.rb @@ -2,18 +2,23 @@ class Rage::Cable::Router # @private - def initialize + # @param protocol [Rage::Cable::Protocols::Base] + # @param namespace [Module] + def initialize(protocol, namespace) # Hash Proc(new channel instance)> @channels_map = {} + @protocol = protocol + @namespace = namespace init_connection_class end # Calls the `connect` method on the `Connection` class to handle authentication. # + # @param connection [Rage::Cable::WebSocketConnection] the connection object # @param env [Hash] the Rack environment # @return [true] if the connection was accepted # @return [false] if the connection was rejected - def process_connection(env) + def process_connection(connection, env) cable_connection = @connection_class.new(env) Rage::Telemetry.tracer.span_cable_connection_process(connection: cable_connection, action: :connect, env:) do cable_connection.connect @@ -43,7 +48,7 @@ def process_connection(env) def process_subscription(connection, env, identifier, channel_name, params) channel_class = @channels_map[channel_name] || begin begin - klass = Object.const_get(channel_name) + klass = @namespace.const_get(channel_name, false) unless channel_name.start_with?("::") rescue NameError nil end @@ -53,8 +58,8 @@ def process_subscription(connection, env, identifier, channel_name, params) return :invalid end - klass.__register_actions.tap do |available_actions| - Rage.logger.debug { "Compiled #{channel_name}. Available remote actions: #{available_actions}." } + klass.__register_actions(@protocol).tap do |available_actions| + Rage.logger.debug { "Compiled #{klass.name}. Available remote actions: #{available_actions}." } end @channels_map[channel_name] = klass @@ -64,7 +69,7 @@ def process_subscription(connection, env, identifier, channel_name, params) channel.__run_action(:subscribed) if channel.subscription_rejected? - Rage.logger.debug { "#{channel_name} is transmitting the subscription rejection" } + Rage.logger.debug { "#{channel_class} is transmitting the subscription rejection" } # if the subscription is rejected in the `subscribed` method, ActionCable will additionally run # the `unsubscribed` method; this makes little sense to me as the client was never subscribed in # the first place; additionally, I don't think this behaviour is documented anywhere; @@ -72,7 +77,7 @@ def process_subscription(connection, env, identifier, channel_name, params) # channel.__run_action(:unsubscribed) :rejected else - Rage.logger.debug { "#{channel_name} is transmitting the subscription confirmation" } + Rage.logger.debug { "#{channel_class} is transmitting the subscription confirmation" } env["rage.cable"][identifier] = channel :subscribed end @@ -88,7 +93,7 @@ def process_subscription(connection, env, identifier, channel_name, params) # @return [:no_subscription] if the client is not subscribed to the specified channel # @return [:unknown_action] if the action does not exist on the specified channel # @return [:processed] if the message has been successfully processed - def process_message(env, identifier, action_name, data) + def process_message(_, env, identifier, action_name, data) channel = env["rage.cable"][identifier] unless channel Rage.logger.debug { "Unable to find the subscription" } @@ -107,7 +112,7 @@ def process_message(env, identifier, action_name, data) # Runs the `unsubscribed` methods on all the channels the client is subscribed to. # # @param env [Hash] the Rack environment - def process_disconnection(env) + def process_disconnection(_, env) env["rage.cable"]&.each do |_, channel| channel.__run_action(:unsubscribed) end @@ -121,6 +126,7 @@ def process_disconnection(env) # @private def reset + @namespace = Object.const_get(@namespace.name) if @namespace.name @channels_map.clear init_connection_class end @@ -128,10 +134,10 @@ def reset private def init_connection_class - @connection_class = if Object.const_defined?("RageCable::Connection") - RageCable::Connection - elsif Object.const_defined?("ApplicationCable::Connection") - ApplicationCable::Connection + @connection_class = if @namespace.const_defined?("RageCable::Connection", false) + @namespace.const_get("RageCable::Connection", false) + elsif @namespace.const_defined?("ApplicationCable::Connection", false) + @namespace.const_get("ApplicationCable::Connection", false) else puts "WARNING: Could not find the RageCable connection class! All connections will be accepted by default." Rage::Cable::Connection diff --git a/lib/rage/code_loader.rb b/lib/rage/code_loader.rb index d948cadc..a3331daa 100644 --- a/lib/rage/code_loader.rb +++ b/lib/rage/code_loader.rb @@ -85,7 +85,7 @@ def configure_components def reload_components unless Rage.autoload?(:Cable) # the `Cable` component is loaded - Rage::Cable.__router.reset + ObjectSpace.each_object(Rage::Cable::Router).each(&:reset) end unless Rage.autoload?(:OpenAPI) # the `OpenAPI` component is loaded diff --git a/lib/rage/configuration.rb b/lib/rage/configuration.rb index f6b3ee64..6eb6f5fa 100644 --- a/lib/rage/configuration.rb +++ b/lib/rage/configuration.rb @@ -757,16 +757,7 @@ def protocol # config.cable.protocol = :raw_websocket_json # end def protocol=(protocol) - @protocol = case protocol - when Class - protocol - when :actioncable_v1_json - Rage::Cable::Protocols::ActioncableV1Json - when :raw_websocket_json - Rage::Cable::Protocols::RawWebSocketJson - else - raise ArgumentError, "Unknown protocol. Supported values are `:actioncable_v1_json` and `:raw_websocket_json`." - end + @protocol = Rage::Cable.__protocol_for(protocol) end # @private diff --git a/lib/rage/rspec.rb b/lib/rage/rspec.rb index 9b8ec170..e51b1f1f 100644 --- a/lib/rage/rspec.rb +++ b/lib/rage/rspec.rb @@ -256,11 +256,10 @@ def stub_connection(identified_by = {}) end def subscribe(params = {}) - __stub_cable_protocol! __stub_iodine_defer! channel_class = __channel_class - channel_class.__register_actions + channel_class.__register_actions(__cable_test_protocol) params = (params || {}).each_with_object({}) { |(k, v), memo| memo[k.to_sym] = v } params[:channel] = channel_class.name @@ -355,10 +354,6 @@ def __cable_cookie_uri URI.parse("http://#{default_host}/") end - def __stub_cable_protocol! - allow(Rage.cable).to receive(:__protocol).and_return(__cable_test_protocol) - end - def __stub_iodine_defer! allow(Iodine).to receive(:defer) do |&block| block&.call diff --git a/spec/cable/cable_spec.rb b/spec/cable/cable_spec.rb new file mode 100644 index 00000000..13cf6fe6 --- /dev/null +++ b/spec/cable/cable_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +RSpec.describe Rage::Cable do + describe ".application" do + context "with invalid namespace" do + it "raises ArgumentError when namespace is not a Module" do + expect { + described_class.application(namespace: "not_a_module") + }.to raise_error(ArgumentError, "The `namespace` option must be a Module, got String.") + end + + it "raises ArgumentError when namespace is an integer" do + expect { + described_class.application(namespace: 123) + }.to raise_error(ArgumentError, "The `namespace` option must be a Module, got Integer.") + end + end + end + + describe ".__protocol_for" do + it "returns ActioncableV1Json for :actioncable_v1_json symbol" do + expect(described_class.__protocol_for(:actioncable_v1_json)).to eq(Rage::Cable::Protocols::ActioncableV1Json) + end + + it "returns RawWebSocketJson for :raw_websocket_json symbol" do + expect(described_class.__protocol_for(:raw_websocket_json)).to eq(Rage::Cable::Protocols::RawWebSocketJson) + end + + it "returns the class directly when a Class is passed" do + expect(described_class.__protocol_for(Rage::Cable::Protocols::ActioncableV1Json)).to eq(Rage::Cable::Protocols::ActioncableV1Json) + end + + it "raises ArgumentError for unknown protocol" do + expect { + described_class.__protocol_for(:unknown_protocol) + }.to raise_error(ArgumentError, "Unknown protocol. Supported values are `:actioncable_v1_json` and `:raw_websocket_json`.") + end + + it "raises ArgumentError for nil protocol" do + expect { + described_class.__protocol_for(nil) + }.to raise_error(ArgumentError, "Unknown protocol. Supported values are `:actioncable_v1_json` and `:raw_websocket_json`.") + end + end + + describe ".__rage_app_name" do + let(:protocol) { double(protocol_definition: {}, init: nil) } + + before do + allow(Rage.config.cable).to receive(:protocol).and_return(:actioncable_v1_json) + allow(Rage.config.cable).to receive(:middlewares).and_return([]) + allow(Rage::Cable::Protocols::ActioncableV1Json).to receive(:init) + described_class.__protocols.clear + end + + after do + described_class.__protocols.clear + end + + it "returns 'Rage::Cable' when namespace is Object" do + app = described_class.application(namespace: Object) + root_app = app.__rage_root_app + + expect(root_app.__rage_app_name).to eq("Rage::Cable") + end + + it "returns namespaced name when namespace is custom module" do + custom_namespace = Module.new + stub_const("MyCustomCable", custom_namespace) + + app = described_class.application(namespace: MyCustomCable) + root_app = app.__rage_root_app + + expect(root_app.__rage_app_name).to eq("Rage::Cable(namespace: MyCustomCable)") + end + end + + describe Rage::Cable::Relay do + let(:protocol1) { double("Protocol1") } + let(:protocol2) { double("Protocol2") } + + describe ".broadcast" do + it "broadcasts to all registered protocols" do + allow(Rage::Cable).to receive(:__protocols).and_return([protocol1, protocol2]) + + expect(protocol1).to receive(:broadcast).with("test_stream", { message: "hello" }) + expect(protocol2).to receive(:broadcast).with("test_stream", { message: "hello" }) + + described_class.broadcast("test_stream", { message: "hello" }) + end + + it "does nothing when no protocols are registered" do + allow(Rage::Cable).to receive(:__protocols).and_return([]) + expect { described_class.broadcast("test_stream", { data: 1 }) }.not_to raise_error + end + end + end +end diff --git a/spec/cable/channel/actions_spec.rb b/spec/cable/channel/actions_spec.rb index 89b08e16..4b30bfbd 100644 --- a/spec/cable/channel/actions_spec.rb +++ b/spec/cable/channel/actions_spec.rb @@ -49,13 +49,19 @@ def receive end RSpec.describe Rage::Cable::Channel do - let(:klass_instance) { klass.tap(&:__register_actions).new(nil, nil, nil) } + let(:klass_instance) { klass.new(nil, nil, nil) } + let(:protocol) { double(supports_rpc?: true) } + let(:registered_actions) { klass.__register_actions(protocol) } + + before do + klass.__register_actions(protocol) + end context "with default actions" do let(:klass) { CableChannelActionsSpec::TestChannel } it "correctly registers actions" do - expect(klass.__register_actions).to be_empty + expect(registered_actions).to be_empty end describe "#__has_action?" do @@ -70,7 +76,7 @@ def receive let(:klass) { CableChannelActionsSpec::TestChannel1 } it "correctly registers actions" do - expect(klass.__register_actions).to eq(%i(receive)) + expect(registered_actions).to eq(%i(receive)) end describe "#__has_action?" do @@ -84,7 +90,7 @@ def receive let(:klass) { CableChannelActionsSpec::TestChannel2 } it "correctly registers actions" do - expect(klass.__register_actions).to eq(%i(receive)) + expect(registered_actions).to eq(%i(receive)) end describe "#__has_action?" do @@ -98,7 +104,7 @@ def receive let(:klass) { CableChannelActionsSpec::TestChannel3 } it "correctly registers actions" do - expect(klass.__register_actions).to match_array(%i(receive connect)) + expect(registered_actions).to match_array(%i(receive connect)) end describe "#__has_action?" do @@ -113,7 +119,7 @@ def receive let(:klass) { CableChannelActionsSpec::TestChannel4 } it "correctly registers actions" do - expect(klass.__register_actions).to be_empty + expect(registered_actions).to be_empty end end @@ -121,7 +127,7 @@ def receive let(:klass) { CableChannelActionsSpec::TestChannel5 } it "correctly registers actions" do - expect(klass.__register_actions).to eq([:receive]) + expect(registered_actions).to eq([:receive]) end describe "#__has_action?" do @@ -135,7 +141,7 @@ def receive before do allow_any_instance_of(Rage::Cable::Channel).to receive(:verifier).and_return(verifier) - klass.__register_actions + klass.__register_actions(protocol) end it "doesn't call before/after subscribe when calling custom action" do diff --git a/spec/cable/channel/attributes_spec.rb b/spec/cable/channel/attributes_spec.rb index c8a5bbaa..bdef6d5a 100644 --- a/spec/cable/channel/attributes_spec.rb +++ b/spec/cable/channel/attributes_spec.rb @@ -21,4 +21,14 @@ expect(subject).to be_subscription_rejected end end + + describe "#env" do + subject { described_class.new(connection, nil, nil) } + + let(:connection) { double(env: :test_env) } + + it "returns connection env" do + expect(subject.env).to eq(:test_env) + end + end end diff --git a/spec/cable/channel/data_spec.rb b/spec/cable/channel/data_spec.rb index ba530ae2..7a12d2fa 100644 --- a/spec/cable/channel/data_spec.rb +++ b/spec/cable/channel/data_spec.rb @@ -15,11 +15,13 @@ def receive(data) end RSpec.describe Rage::Cable::Channel do - subject { klass.tap(&:__register_actions).new(nil, nil, nil) } + subject { klass.new(nil, nil, nil) } let(:verifier) { double } + let(:protocol) { double(supports_rpc?: true) } before do + klass.__register_actions(protocol) allow_any_instance_of(Rage::Cable::Channel).to receive(:verifier).and_return(verifier) end diff --git a/spec/cable/channel/rescue_from_spec.rb b/spec/cable/channel/rescue_from_spec.rb index 9e7d0ed3..968b3aac 100644 --- a/spec/cable/channel/rescue_from_spec.rb +++ b/spec/cable/channel/rescue_from_spec.rb @@ -131,11 +131,13 @@ def subscribed end RSpec.describe Rage::Cable::Channel do - subject { klass.tap(&:__register_actions).new(nil, nil, nil).__run_action(:subscribed) } + subject { klass.new(nil, nil, nil).__run_action(:subscribed) } let(:verifier) { double } + let(:protocol) { double(supports_rpc?: true) } before do + klass.__register_actions(protocol) allow_any_instance_of(Rage::Cable::Channel).to receive(:verifier).and_return(verifier) end @@ -203,7 +205,11 @@ def subscribed end context "with custom action" do - subject { klass.tap(&:__register_actions).new(nil, nil, nil).__run_action(:receive) } + subject { klass.new(nil, nil, nil).__run_action(:receive) } + + before do + klass.__register_actions(protocol) + end let(:klass) { CableChannelRescueFromSpec::TestChannel8 } diff --git a/spec/cable/channel/stop_stream_for_spec.rb b/spec/cable/channel/stop_stream_for_spec.rb index 979c9336..4e5588b4 100644 --- a/spec/cable/channel/stop_stream_for_spec.rb +++ b/spec/cable/channel/stop_stream_for_spec.rb @@ -37,12 +37,15 @@ def initialize(id) let(:identified_by) { {} } before do - allow(Rage.cable).to receive(:__protocol).and_return(protocol) allow(Rage.cable).to receive(:broadcast) end describe "#stop_stream_for" do - subject { klass.tap(&:__register_actions).new(connection, params, identified_by) } + subject { klass.new(connection, params, identified_by) } + + before do + klass.__register_actions(protocol) + end context "with an object that responds to id" do let(:klass) { CableChannelStopStreamForSpec::TestChannel } diff --git a/spec/cable/channel/stop_stream_from_spec.rb b/spec/cable/channel/stop_stream_from_spec.rb index 608e0484..9d98f3c9 100644 --- a/spec/cable/channel/stop_stream_from_spec.rb +++ b/spec/cable/channel/stop_stream_from_spec.rb @@ -19,11 +19,11 @@ def leave let(:identified_by) { {} } before do - allow(Rage.cable).to receive(:__protocol).and_return(protocol) + klass.__register_actions(protocol) end describe "#stop_stream_from" do - subject { klass.tap(&:__register_actions).new(connection, params, identified_by) } + subject { klass.new(connection, params, identified_by) } let(:klass) { CableChannelStopStreamFromSpec::TestChannel } diff --git a/spec/cable/channel/stream_for_spec.rb b/spec/cable/channel/stream_for_spec.rb index 36954714..e20b35f7 100644 --- a/spec/cable/channel/stream_for_spec.rb +++ b/spec/cable/channel/stream_for_spec.rb @@ -29,12 +29,15 @@ def initialize(id) let(:identified_by) { {} } before do - allow(Rage.cable).to receive(:__protocol).and_return(protocol) allow(Rage.cable).to receive(:broadcast) end describe "#stream_for" do - subject { klass.tap(&:__register_actions).new(connection, params, identified_by) } + subject { klass.new(connection, params, identified_by) } + + before do + klass.__register_actions(protocol) + end context "with an object that responds to id" do let(:klass) { CableChannelStreamForSpec::TestChannel } diff --git a/spec/cable/channel/subscribe_spec.rb b/spec/cable/channel/subscribe_spec.rb index bf94c72c..de3513c0 100644 --- a/spec/cable/channel/subscribe_spec.rb +++ b/spec/cable/channel/subscribe_spec.rb @@ -182,11 +182,13 @@ class TestChannel14 < TestChannel13 end RSpec.describe Rage::Cable::Channel do - subject { klass.tap(&:__register_actions).new(nil, nil, nil).__run_action(:subscribed) } + subject { klass.new(nil, nil, nil).__run_action(:subscribed) } let(:verifier) { double } + let(:protocol) { double(supports_rpc?: true) } before do + klass.__register_actions(protocol) allow_any_instance_of(Rage::Cable::Channel).to receive(:verifier).and_return(verifier) end diff --git a/spec/cable/channel/unsubscribe_spec.rb b/spec/cable/channel/unsubscribe_spec.rb index 73e80a6c..d38814bc 100644 --- a/spec/cable/channel/unsubscribe_spec.rb +++ b/spec/cable/channel/unsubscribe_spec.rb @@ -54,11 +54,13 @@ def verify_after_unsubscribe end RSpec.describe Rage::Cable::Channel do - subject { klass.tap(&:__register_actions).new(nil, nil, nil).__run_action(:unsubscribed) } + subject { klass.new(nil, nil, nil).__run_action(:unsubscribed) } let(:verifier) { double } + let(:protocol) { double(supports_rpc?: true) } before do + klass.__register_actions(protocol) allow_any_instance_of(Rage::Cable::Channel).to receive(:verifier).and_return(verifier) end diff --git a/spec/cable/router_spec.rb b/spec/cable/router_spec.rb index 379c6f28..9aab603d 100644 --- a/spec/cable/router_spec.rb +++ b/spec/cable/router_spec.rb @@ -11,8 +11,9 @@ end describe "#process_connection" do - subject { described_class.new.process_connection(env) } + subject { described_class.new(nil, Object).process_connection(connection, env) } + let(:connection) { double } let(:cable_connection_class) { double } let(:cable_connection_instance) { double(connect: nil, __identified_by_map: :test_identified_by) } @@ -55,11 +56,15 @@ end describe "#process_subscription" do - subject { described_class.new.process_subscription(connection, env, :test_identifier, channel_name, :test_params) } + subject { described_class.new(nil, Object).process_subscription(connection, env, :test_identifier, channel_name, :test_params) } let(:channel_name) { "TestChannel" } - let(:cable_channel_class) { double } - let(:channel) { instance_double("Rage::Cable::Channel") } + let(:cable_channel_class) { double(name: "TestChannel") } + let(:channel) { double("Rage::Cable::Channel") } + + before do + allow(cable_channel_class).to receive(:__protocol=) + end context "with correct channel name" do before do @@ -115,7 +120,7 @@ end describe "#process_message" do - subject { described_class.new.process_message(env, :test_identifier, :test_action, :test_data) } + subject { described_class.new(nil, Object).process_message(nil, env, :test_identifier, :test_action, :test_data) } context "with existing subscription" do let(:channel) { double } @@ -153,7 +158,7 @@ end describe "#process_disconnection" do - subject { described_class.new.process_disconnection(env) } + subject { described_class.new(nil, Object).process_disconnection(nil, env) } context "with existing subscription" do let(:channel) { double } @@ -194,4 +199,183 @@ end end end + + describe "namespace support" do + let(:custom_namespace) do + Module.new do + def self.name + "CustomNamespace" + end + end + end + + describe "#process_subscription with namespace" do + let(:channel_class) do + Class.new(Rage::Cable::Channel) do + def self.name + "ChatChannel" + end + end + end + let(:protocol) { double(supports_rpc?: true) } + + before do + env["rage.cable"] = {} + env["rage.identified_by"] = {} + custom_namespace.const_set(:ChatChannel, channel_class) + end + + after do + custom_namespace.send(:remove_const, :ChatChannel) if custom_namespace.const_defined?(:ChatChannel) + end + + it "looks up channels within the namespace" do + router = described_class.new(protocol, custom_namespace) + result = router.process_subscription(connection, env, :test_identifier, "ChatChannel", {}) + + expect(result).to eq(:subscribed) + end + + it "does not find channels outside the namespace" do + stub_const("GlobalChannel", Class.new(Rage::Cable::Channel)) + router = described_class.new(protocol, custom_namespace) + result = router.process_subscription(connection, env, :test_identifier, "GlobalChannel", {}) + + expect(result).to eq(:invalid) + end + end + + describe "#init_connection_class with namespace" do + let(:namespaced_connection_class) do + Class.new(Rage::Cable::Connection) + end + + context "with RageCable::Connection in namespace" do + before do + rage_cable_module = Module.new + rage_cable_module.const_set(:Connection, namespaced_connection_class) + custom_namespace.const_set(:RageCable, rage_cable_module) + end + + after do + custom_namespace.send(:remove_const, :RageCable) if custom_namespace.const_defined?(:RageCable) + end + + it "uses the namespaced connection class" do + router = described_class.new(nil, custom_namespace) + cable_connection_instance = double(connect: nil, rejected?: false, __identified_by_map: {}) + expect(namespaced_connection_class).to receive(:new).and_return(cable_connection_instance) + + router.process_connection(nil, env) + end + end + + context "with ApplicationCable::Connection in namespace" do + let(:app_cable_connection_class) { Class.new(Rage::Cable::Connection) } + + before do + app_cable_module = Module.new + app_cable_module.const_set(:Connection, app_cable_connection_class) + custom_namespace.const_set(:ApplicationCable, app_cable_module) + end + + after do + custom_namespace.send(:remove_const, :ApplicationCable) if custom_namespace.const_defined?(:ApplicationCable) + end + + it "uses the namespaced ApplicationCable connection class" do + router = described_class.new(nil, custom_namespace) + cable_connection_instance = double(connect: nil, rejected?: false, __identified_by_map: {}) + expect(app_cable_connection_class).to receive(:new).and_return(cable_connection_instance) + + router.process_connection(nil, env) + end + end + + context "with no connection class in namespace" do + it "falls back to default Rage::Cable::Connection" do + expect { + described_class.new(nil, custom_namespace) + }.to output(/WARNING: Could not find the RageCable connection class/).to_stdout + end + end + end + + describe "#reset with namespace" do + let(:protocol) { double(supports_rpc?: true) } + + it "clears the channels map on reset" do + # Use a real constant for the namespace so reset can find it + reset_namespace = Module.new + stub_const("ResetTestNamespace", reset_namespace) + + channel_class = Class.new(Rage::Cable::Channel) + stub_const("ResetTestNamespace::ResetTestChannel", channel_class) + + env["rage.cable"] = {} + env["rage.identified_by"] = {} + + router = described_class.new(protocol, ResetTestNamespace) + + # Subscribe to cache the channel + router.process_subscription(connection, env, :test_identifier, "ResetTestChannel", {}) + + # Reset the router + router.reset + + # The channel should be re-registered on next subscription + expect(channel_class).to receive(:__register_actions).with(protocol).and_call_original + router.process_subscription(connection, env, :test_identifier2, "ResetTestChannel", {}) + end + + it "re-initializes the connection class on reset" do + reset_namespace = Module.new + stub_const("ResetConnNamespace", reset_namespace) + + connection_class = Class.new(Rage::Cable::Connection) + rage_cable_module = Module.new + stub_const("ResetConnNamespace::RageCable", rage_cable_module) + stub_const("ResetConnNamespace::RageCable::Connection", connection_class) + + env["rage.cable"] = {} + env["rage.identified_by"] = {} + + router = described_class.new(protocol, ResetConnNamespace) + + # Verify initial connection class is used + cable_connection_instance = double(connect: nil, rejected?: false, __identified_by_map: {}) + expect(connection_class).to receive(:new).and_return(cable_connection_instance) + router.process_connection(nil, env) + + # Create a new connection class and replace the old one + new_connection_class = Class.new(Rage::Cable::Connection) + ResetConnNamespace::RageCable.send(:remove_const, :Connection) + ResetConnNamespace::RageCable.const_set(:Connection, new_connection_class) + + router.reset + + # After reset, the new connection class should be used + new_cable_connection_instance = double(connect: nil, rejected?: false, __identified_by_map: {}) + expect(new_connection_class).to receive(:new).and_return(new_cable_connection_instance) + + router.process_connection(nil, env) + end + + it "handles anonymous namespaces without name" do + anonymous_namespace = Module.new + # Anonymous modules have name = nil + + env["rage.cable"] = {} + env["rage.identified_by"] = {} + + channel_class = Class.new(Rage::Cable::Channel) + anonymous_namespace.const_set(:TestChannel, channel_class) + + router = described_class.new(protocol, anonymous_namespace) + + # Reset should not raise for anonymous namespace + expect { router.reset }.not_to raise_error + end + end + end end diff --git a/spec/integration/test_app/config/routes.rb b/spec/integration/test_app/config/routes.rb index d3928ac2..ea7f144e 100644 --- a/spec/integration/test_app/config/routes.rb +++ b/spec/integration/test_app/config/routes.rb @@ -63,4 +63,13 @@ get "session", to: "sessions#show" end end + + if ENV["ENABLE_ISOLATED_CABLE_APPS"] + require_relative "../lib/presence_cable_raw/rage_cable/connection" + require_relative "../lib/presence_cable_raw/status_channel" + mount Rage::Cable.application(namespace: PresenceCableRaw, protocol: :raw_websocket_json), at: "presence_raw" + + require_relative "../lib/presence_cable_ac/status_channel" + mount Rage::Cable.application(namespace: PresenceCableAC, protocol: :actioncable_v1_json), at: "presence_ac" + end end diff --git a/spec/integration/test_app/lib/.keep b/spec/integration/test_app/lib/.keep deleted file mode 100644 index e69de29b..00000000 diff --git a/spec/integration/test_app/lib/presence_cable_ac/status_channel.rb b/spec/integration/test_app/lib/presence_cable_ac/status_channel.rb new file mode 100644 index 00000000..929eea5e --- /dev/null +++ b/spec/integration/test_app/lib/presence_cable_ac/status_channel.rb @@ -0,0 +1,11 @@ +module PresenceCableAC + class StatusChannel < Rage::Cable::Channel + def subscribed + stream_from "presence" + end + + def unsubscribed + stop_stream_from "presence" + end + end +end diff --git a/spec/integration/test_app/lib/presence_cable_raw/rage_cable/connection.rb b/spec/integration/test_app/lib/presence_cable_raw/rage_cable/connection.rb new file mode 100644 index 00000000..9adbdbb5 --- /dev/null +++ b/spec/integration/test_app/lib/presence_cable_raw/rage_cable/connection.rb @@ -0,0 +1,17 @@ +module PresenceCableRaw + module RageCable + class Connection < Rage::Cable::Connection + identified_by :current_account + + def connect + account_id = params[:account_id] + + if account_id + self.current_account = account_id + else + reject_unauthorized_connection + end + end + end + end +end diff --git a/spec/integration/test_app/lib/presence_cable_raw/status_channel.rb b/spec/integration/test_app/lib/presence_cable_raw/status_channel.rb new file mode 100644 index 00000000..c357eefb --- /dev/null +++ b/spec/integration/test_app/lib/presence_cable_raw/status_channel.rb @@ -0,0 +1,11 @@ +module PresenceCableRaw + class StatusChannel < Rage::Cable::Channel + def subscribed + stream_from "presence" + end + + def unsubscribed + stop_stream_from "presence" + end + end +end diff --git a/spec/integration/websockets/isolated_apps_spec.rb b/spec/integration/websockets/isolated_apps_spec.rb new file mode 100644 index 00000000..88b420d9 --- /dev/null +++ b/spec/integration/websockets/isolated_apps_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +RSpec.describe "Isolated Apps" do + before :all do + skip("skipping websocket tests") unless ENV["ENABLE_EXTERNAL_TESTS"] == "true" + end + + before :all do + launch_server(env: { + "WEBSOCKETS_PROTOCOL" => "actioncable_v1_json", + "ENABLE_ISOLATED_CABLE_APPS" => "1", + "ENABLE_REDIS_ADAPTER" => "1" + }) + end + + after :all do + stop_server + end + + let(:headers) { { Origin: "localhost:3000" } } + + it "rejects a connection with no account_id" do + with_websocket_connection("ws://localhost:3000/presence_raw/status", headers:) do |client| + expect(client.messages[0]).to include("unauthorized") + end + end + + it "opens a connection" do + with_websocket_connection("ws://localhost:3000/presence_raw/status?account_id=1", headers:) do |client| + expect(client.messages).to be_empty + expect(client).to be_connected + end + end + + it "broadcasts messages to a specific app" do + client = with_websocket_connection("ws://localhost:3000/presence_raw/status?account_id=1", headers:) + + Bundler.with_unbundled_env do + system({ "ENABLE_REDIS_ADAPTER" => "1" }, "ruby -e 'require_relative(\"config/application\"); Rage.cable.broadcast(\"presence\", { is_present: true })'", chdir: "spec/integration/test_app") + end + + expect(client.messages.last).to include("is_present") + end + + it "broadcasts messages between apps respecting protocols" do + client_raw = with_websocket_connection("ws://localhost:3000/presence_raw/status?account_id=1", headers:) + + client_ac = with_websocket_connection("ws://localhost:3000/presence_ac/status", headers:) + client_ac.send({ identifier: { client: "1", channel: "StatusChannel" }.to_json, command: "subscribe" }.to_json) + + Bundler.with_unbundled_env do + system({ "ENABLE_REDIS_ADAPTER" => "1" }, "ruby -e 'require_relative(\"config/application\"); Rage.cable.broadcast(\"presence\", { is_present: true })'", chdir: "spec/integration/test_app") + end + + expect(client_raw.messages.last).to eq({ is_present: true }.to_json) + expect(client_ac.messages.last).to match(/channel\W+StatusChannel\W+message\W+is_present\W+true/) + end +end diff --git a/spec/telemetry/spans_spec.rb b/spec/telemetry/spans_spec.rb index 534c2aa9..36607a67 100644 --- a/spec/telemetry/spans_spec.rb +++ b/spec/telemetry/spans_spec.rb @@ -289,7 +289,7 @@ def index context "with connect action" do it "passes correct arguments" do - router = Rage::Cable::Router.new + router = Rage::Cable::Router.new(nil, Object) expect(verifier).to receive(:call).with({ id: "cable.connection.process", @@ -300,13 +300,13 @@ def index request: instance_of(Rage::Request) }) - router.process_connection(ws_env) + router.process_connection(nil, ws_env) end end context "with disconnect action" do it "passes correct arguments" do - router = Rage::Cable::Router.new + router = Rage::Cable::Router.new(nil, Object) expect(verifier).to receive(:call).with({ id: "cable.connection.process", @@ -317,7 +317,7 @@ def index request: instance_of(Rage::Request) }) - router.process_disconnection(ws_env) + router.process_disconnection(nil, ws_env) end end end @@ -334,7 +334,7 @@ def receive before do stub_const("MyTestChannel", channel_class) - channel_class.__register_actions + channel_class.__register_actions(Rage::Cable::Protocols::ActioncableV1Json) end it "passes correct arguments" do