aboutsummaryrefslogtreecommitdiffstats
path: root/actioncable/lib
diff options
context:
space:
mode:
Diffstat (limited to 'actioncable/lib')
-rw-r--r--actioncable/lib/action_cable.rb56
-rw-r--r--actioncable/lib/action_cable/channel.rb17
-rw-r--r--actioncable/lib/action_cable/channel/base.rb305
-rw-r--r--actioncable/lib/action_cable/channel/broadcasting.rb31
-rw-r--r--actioncable/lib/action_cable/channel/callbacks.rb37
-rw-r--r--actioncable/lib/action_cable/channel/naming.rb25
-rw-r--r--actioncable/lib/action_cable/channel/periodic_timers.rb78
-rw-r--r--actioncable/lib/action_cable/channel/streams.rb176
-rw-r--r--actioncable/lib/action_cable/channel/test_case.rb271
-rw-r--r--actioncable/lib/action_cable/connection.rb21
-rw-r--r--actioncable/lib/action_cable/connection/authorization.rb15
-rw-r--r--actioncable/lib/action_cable/connection/base.rb257
-rw-r--r--actioncable/lib/action_cable/connection/client_socket.rb157
-rw-r--r--actioncable/lib/action_cable/connection/identification.rb47
-rw-r--r--actioncable/lib/action_cable/connection/internal_channel.rb45
-rw-r--r--actioncable/lib/action_cable/connection/message_buffer.rb54
-rw-r--r--actioncable/lib/action_cable/connection/stream.rb117
-rw-r--r--actioncable/lib/action_cable/connection/stream_event_loop.rb136
-rw-r--r--actioncable/lib/action_cable/connection/subscriptions.rb79
-rw-r--r--actioncable/lib/action_cable/connection/tagged_logger_proxy.rb42
-rw-r--r--actioncable/lib/action_cable/connection/web_socket.rb41
-rw-r--r--actioncable/lib/action_cable/engine.rb79
-rw-r--r--actioncable/lib/action_cable/gem_version.rb17
-rw-r--r--actioncable/lib/action_cable/helpers/action_cable_helper.rb42
-rw-r--r--actioncable/lib/action_cable/remote_connections.rb71
-rw-r--r--actioncable/lib/action_cable/server.rb17
-rw-r--r--actioncable/lib/action_cable/server/base.rb89
-rw-r--r--actioncable/lib/action_cable/server/broadcasting.rb54
-rw-r--r--actioncable/lib/action_cable/server/configuration.rb56
-rw-r--r--actioncable/lib/action_cable/server/connections.rb36
-rw-r--r--actioncable/lib/action_cable/server/worker.rb77
-rw-r--r--actioncable/lib/action_cable/server/worker/active_record_connection_management.rb21
-rw-r--r--actioncable/lib/action_cable/subscription_adapter.rb12
-rw-r--r--actioncable/lib/action_cable/subscription_adapter/async.rb29
-rw-r--r--actioncable/lib/action_cable/subscription_adapter/base.rb30
-rw-r--r--actioncable/lib/action_cable/subscription_adapter/channel_prefix.rb28
-rw-r--r--actioncable/lib/action_cable/subscription_adapter/inline.rb37
-rw-r--r--actioncable/lib/action_cable/subscription_adapter/postgresql.rb130
-rw-r--r--actioncable/lib/action_cable/subscription_adapter/redis.rb179
-rw-r--r--actioncable/lib/action_cable/subscription_adapter/subscriber_map.rb59
-rw-r--r--actioncable/lib/action_cable/subscription_adapter/test.rb40
-rw-r--r--actioncable/lib/action_cable/test_case.rb11
-rw-r--r--actioncable/lib/action_cable/test_helper.rb133
-rw-r--r--actioncable/lib/action_cable/version.rb10
-rw-r--r--actioncable/lib/rails/generators/channel/USAGE12
-rw-r--r--actioncable/lib/rails/generators/channel/channel_generator.rb50
-rw-r--r--actioncable/lib/rails/generators/channel/templates/application_cable/channel.rb.tt4
-rw-r--r--actioncable/lib/rails/generators/channel/templates/application_cable/connection.rb.tt4
-rw-r--r--actioncable/lib/rails/generators/channel/templates/channel.rb.tt16
-rw-r--r--actioncable/lib/rails/generators/channel/templates/javascript/channel.js.tt20
-rw-r--r--actioncable/lib/rails/generators/channel/templates/javascript/consumer.js.tt6
-rw-r--r--actioncable/lib/rails/generators/channel/templates/javascript/index.js.tt5
52 files changed, 3381 insertions, 0 deletions
diff --git a/actioncable/lib/action_cable.rb b/actioncable/lib/action_cable.rb
new file mode 100644
index 0000000000..d261d4112e
--- /dev/null
+++ b/actioncable/lib/action_cable.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+#--
+# Copyright (c) 2015-2018 Basecamp, LLC
+#
+# Permission is hereby granted, free of charge, to any person obtaining
+# a copy of this software and associated documentation files (the
+# "Software"), to deal in the Software without restriction, including
+# without limitation the rights to use, copy, modify, merge, publish,
+# distribute, sublicense, and/or sell copies of the Software, and to
+# permit persons to whom the Software is furnished to do so, subject to
+# the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+#++
+
+require "active_support"
+require "active_support/rails"
+require "action_cable/version"
+
+module ActionCable
+ extend ActiveSupport::Autoload
+
+ INTERNAL = {
+ message_types: {
+ welcome: "welcome",
+ ping: "ping",
+ confirmation: "confirm_subscription",
+ rejection: "reject_subscription"
+ },
+ default_mount_path: "/cable",
+ protocols: ["actioncable-v1-json", "actioncable-unsupported"].freeze
+ }
+
+ # Singleton instance of the server
+ module_function def server
+ @server ||= ActionCable::Server::Base.new
+ end
+
+ autoload :Server
+ autoload :Connection
+ autoload :Channel
+ autoload :RemoteConnections
+ autoload :SubscriptionAdapter
+ autoload :TestHelper
+ autoload :TestCase
+end
diff --git a/actioncable/lib/action_cable/channel.rb b/actioncable/lib/action_cable/channel.rb
new file mode 100644
index 0000000000..d5118b9dc9
--- /dev/null
+++ b/actioncable/lib/action_cable/channel.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module ActionCable
+ module Channel
+ extend ActiveSupport::Autoload
+
+ eager_autoload do
+ autoload :Base
+ autoload :Broadcasting
+ autoload :Callbacks
+ autoload :Naming
+ autoload :PeriodicTimers
+ autoload :Streams
+ autoload :TestCase
+ end
+ end
+end
diff --git a/actioncable/lib/action_cable/channel/base.rb b/actioncable/lib/action_cable/channel/base.rb
new file mode 100644
index 0000000000..70c93ec0f3
--- /dev/null
+++ b/actioncable/lib/action_cable/channel/base.rb
@@ -0,0 +1,305 @@
+# frozen_string_literal: true
+
+require "set"
+
+module ActionCable
+ module Channel
+ # The channel provides the basic structure of grouping behavior into logical units when communicating over the WebSocket connection.
+ # You can think of a channel like a form of controller, but one that's capable of pushing content to the subscriber in addition to simply
+ # responding to the subscriber's direct requests.
+ #
+ # Channel instances are long-lived. A channel object will be instantiated when the cable consumer becomes a subscriber, and then
+ # lives until the consumer disconnects. This may be seconds, minutes, hours, or even days. That means you have to take special care
+ # not to do anything silly in a channel that would balloon its memory footprint or whatever. The references are forever, so they won't be released
+ # as is normally the case with a controller instance that gets thrown away after every request.
+ #
+ # Long-lived channels (and connections) also mean you're responsible for ensuring that the data is fresh. If you hold a reference to a user
+ # record, but the name is changed while that reference is held, you may be sending stale data if you don't take precautions to avoid it.
+ #
+ # The upside of long-lived channel instances is that you can use instance variables to keep reference to objects that future subscriber requests
+ # can interact with. Here's a quick example:
+ #
+ # class ChatChannel < ApplicationCable::Channel
+ # def subscribed
+ # @room = Chat::Room[params[:room_number]]
+ # end
+ #
+ # def speak(data)
+ # @room.speak data, user: current_user
+ # end
+ # end
+ #
+ # The #speak action simply uses the Chat::Room object that was created when the channel was first subscribed to by the consumer when that
+ # subscriber wants to say something in the room.
+ #
+ # == Action processing
+ #
+ # Unlike subclasses of ActionController::Base, channels do not follow a RESTful
+ # constraint form for their actions. Instead, Action Cable operates through a
+ # remote-procedure call model. You can declare any public method on the
+ # channel (optionally taking a <tt>data</tt> argument), and this method is
+ # automatically exposed as callable to the client.
+ #
+ # Example:
+ #
+ # class AppearanceChannel < ApplicationCable::Channel
+ # def subscribed
+ # @connection_token = generate_connection_token
+ # end
+ #
+ # def unsubscribed
+ # current_user.disappear @connection_token
+ # end
+ #
+ # def appear(data)
+ # current_user.appear @connection_token, on: data['appearing_on']
+ # end
+ #
+ # def away
+ # current_user.away @connection_token
+ # end
+ #
+ # private
+ # def generate_connection_token
+ # SecureRandom.hex(36)
+ # end
+ # end
+ #
+ # In this example, the subscribed and unsubscribed methods are not callable methods, as they
+ # were already declared in ActionCable::Channel::Base, but <tt>#appear</tt>
+ # and <tt>#away</tt> are. <tt>#generate_connection_token</tt> is also not
+ # callable, since it's a private method. You'll see that appear accepts a data
+ # parameter, which it then uses as part of its model call. <tt>#away</tt>
+ # does not, since it's simply a trigger action.
+ #
+ # Also note that in this example, <tt>current_user</tt> is available because
+ # it was marked as an identifying attribute on the connection. All such
+ # identifiers will automatically create a delegation method of the same name
+ # on the channel instance.
+ #
+ # == Rejecting subscription requests
+ #
+ # A channel can reject a subscription request in the #subscribed callback by
+ # invoking the #reject method:
+ #
+ # class ChatChannel < ApplicationCable::Channel
+ # def subscribed
+ # @room = Chat::Room[params[:room_number]]
+ # reject unless current_user.can_access?(@room)
+ # end
+ # end
+ #
+ # In this example, the subscription will be rejected if the
+ # <tt>current_user</tt> does not have access to the chat room. On the
+ # client-side, the <tt>Channel#rejected</tt> callback will get invoked when
+ # the server rejects the subscription request.
+ class Base
+ include Callbacks
+ include PeriodicTimers
+ include Streams
+ include Naming
+ include Broadcasting
+
+ attr_reader :params, :connection, :identifier
+ delegate :logger, to: :connection
+
+ class << self
+ # A list of method names that should be considered actions. This
+ # includes all public instance methods on a channel, less
+ # any internal methods (defined on Base), adding back in
+ # any methods that are internal, but still exist on the class
+ # itself.
+ #
+ # ==== Returns
+ # * <tt>Set</tt> - A set of all methods that should be considered actions.
+ def action_methods
+ @action_methods ||= begin
+ # All public instance methods of this class, including ancestors
+ methods = (public_instance_methods(true) -
+ # Except for public instance methods of Base and its ancestors
+ ActionCable::Channel::Base.public_instance_methods(true) +
+ # Be sure to include shadowed public instance methods of this class
+ public_instance_methods(false)).uniq.map(&:to_s)
+ methods.to_set
+ end
+ end
+
+ private
+ # action_methods are cached and there is sometimes need to refresh
+ # them. ::clear_action_methods! allows you to do that, so next time
+ # you run action_methods, they will be recalculated.
+ def clear_action_methods! # :doc:
+ @action_methods = nil
+ end
+
+ # Refresh the cached action_methods when a new action_method is added.
+ def method_added(name) # :doc:
+ super
+ clear_action_methods!
+ end
+ end
+
+ def initialize(connection, identifier, params = {})
+ @connection = connection
+ @identifier = identifier
+ @params = params
+
+ # When a channel is streaming via pubsub, we want to delay the confirmation
+ # transmission until pubsub subscription is confirmed.
+ #
+ # The counter starts at 1 because it's awaiting a call to #subscribe_to_channel
+ @defer_subscription_confirmation_counter = Concurrent::AtomicFixnum.new(1)
+
+ @reject_subscription = nil
+ @subscription_confirmation_sent = nil
+
+ delegate_connection_identifiers
+ end
+
+ # Extract the action name from the passed data and process it via the channel. The process will ensure
+ # that the action requested is a public method on the channel declared by the user (so not one of the callbacks
+ # like #subscribed).
+ def perform_action(data)
+ action = extract_action(data)
+
+ if processable_action?(action)
+ payload = { channel_class: self.class.name, action: action, data: data }
+ ActiveSupport::Notifications.instrument("perform_action.action_cable", payload) do
+ dispatch_action(action, data)
+ end
+ else
+ logger.error "Unable to process #{action_signature(action, data)}"
+ end
+ end
+
+ # This method is called after subscription has been added to the connection
+ # and confirms or rejects the subscription.
+ def subscribe_to_channel
+ run_callbacks :subscribe do
+ subscribed
+ end
+
+ reject_subscription if subscription_rejected?
+ ensure_confirmation_sent
+ end
+
+ # Called by the cable connection when it's cut, so the channel has a chance to cleanup with callbacks.
+ # This method is not intended to be called directly by the user. Instead, overwrite the #unsubscribed callback.
+ def unsubscribe_from_channel # :nodoc:
+ run_callbacks :unsubscribe do
+ unsubscribed
+ end
+ end
+
+ private
+ # Called once a consumer has become a subscriber of the channel. Usually the place to setup any streams
+ # you want this channel to be sending to the subscriber.
+ def subscribed # :doc:
+ # Override in subclasses
+ end
+
+ # Called once a consumer has cut its cable connection. Can be used for cleaning up connections or marking
+ # users as offline or the like.
+ def unsubscribed # :doc:
+ # Override in subclasses
+ end
+
+ # Transmit a hash of data to the subscriber. The hash will automatically be wrapped in a JSON envelope with
+ # the proper channel identifier marked as the recipient.
+ def transmit(data, via: nil) # :doc:
+ status = "#{self.class.name} transmitting #{data.inspect.truncate(300)}"
+ status += " (via #{via})" if via
+ logger.debug(status)
+
+ payload = { channel_class: self.class.name, data: data, via: via }
+ ActiveSupport::Notifications.instrument("transmit.action_cable", payload) do
+ connection.transmit identifier: @identifier, message: data
+ end
+ end
+
+ def ensure_confirmation_sent # :doc:
+ return if subscription_rejected?
+ @defer_subscription_confirmation_counter.decrement
+ transmit_subscription_confirmation unless defer_subscription_confirmation?
+ end
+
+ def defer_subscription_confirmation! # :doc:
+ @defer_subscription_confirmation_counter.increment
+ end
+
+ def defer_subscription_confirmation? # :doc:
+ @defer_subscription_confirmation_counter.value > 0
+ end
+
+ def subscription_confirmation_sent? # :doc:
+ @subscription_confirmation_sent
+ end
+
+ def reject # :doc:
+ @reject_subscription = true
+ end
+
+ def subscription_rejected? # :doc:
+ @reject_subscription
+ end
+
+ def delegate_connection_identifiers
+ connection.identifiers.each do |identifier|
+ define_singleton_method(identifier) do
+ connection.send(identifier)
+ end
+ end
+ end
+
+ def extract_action(data)
+ (data["action"].presence || :receive).to_sym
+ end
+
+ def processable_action?(action)
+ self.class.action_methods.include?(action.to_s) unless subscription_rejected?
+ end
+
+ def dispatch_action(action, data)
+ logger.info action_signature(action, data)
+
+ if method(action).arity == 1
+ public_send action, data
+ else
+ public_send action
+ end
+ end
+
+ def action_signature(action, data)
+ (+"#{self.class.name}##{action}").tap do |signature|
+ if (arguments = data.except("action")).any?
+ signature << "(#{arguments.inspect})"
+ end
+ end
+ end
+
+ def transmit_subscription_confirmation
+ unless subscription_confirmation_sent?
+ logger.info "#{self.class.name} is transmitting the subscription confirmation"
+
+ ActiveSupport::Notifications.instrument("transmit_subscription_confirmation.action_cable", channel_class: self.class.name) do
+ connection.transmit identifier: @identifier, type: ActionCable::INTERNAL[:message_types][:confirmation]
+ @subscription_confirmation_sent = true
+ end
+ end
+ end
+
+ def reject_subscription
+ connection.subscriptions.remove_subscription self
+ transmit_subscription_rejection
+ end
+
+ def transmit_subscription_rejection
+ logger.info "#{self.class.name} is transmitting the subscription rejection"
+
+ ActiveSupport::Notifications.instrument("transmit_subscription_rejection.action_cable", channel_class: self.class.name) do
+ connection.transmit identifier: @identifier, type: ActionCable::INTERNAL[:message_types][:rejection]
+ end
+ end
+ end
+ end
+end
diff --git a/actioncable/lib/action_cable/channel/broadcasting.rb b/actioncable/lib/action_cable/channel/broadcasting.rb
new file mode 100644
index 0000000000..9a96720f4a
--- /dev/null
+++ b/actioncable/lib/action_cable/channel/broadcasting.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+require "active_support/core_ext/object/to_param"
+
+module ActionCable
+ module Channel
+ module Broadcasting
+ extend ActiveSupport::Concern
+
+ delegate :broadcasting_for, to: :class
+
+ module ClassMethods
+ # Broadcast a hash to a unique broadcasting for this <tt>model</tt> in this channel.
+ def broadcast_to(model, message)
+ ActionCable.server.broadcast(broadcasting_for([ channel_name, model ]), message)
+ end
+
+ def broadcasting_for(model) #:nodoc:
+ case
+ when model.is_a?(Array)
+ model.map { |m| broadcasting_for(m) }.join(":")
+ when model.respond_to?(:to_gid_param)
+ model.to_gid_param
+ else
+ model.to_param
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/actioncable/lib/action_cable/channel/callbacks.rb b/actioncable/lib/action_cable/channel/callbacks.rb
new file mode 100644
index 0000000000..e4cb19b26a
--- /dev/null
+++ b/actioncable/lib/action_cable/channel/callbacks.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+require "active_support/callbacks"
+
+module ActionCable
+ module Channel
+ module Callbacks
+ extend ActiveSupport::Concern
+ include ActiveSupport::Callbacks
+
+ included do
+ define_callbacks :subscribe
+ define_callbacks :unsubscribe
+ end
+
+ module ClassMethods
+ def before_subscribe(*methods, &block)
+ set_callback(:subscribe, :before, *methods, &block)
+ end
+
+ def after_subscribe(*methods, &block)
+ set_callback(:subscribe, :after, *methods, &block)
+ end
+ alias_method :on_subscribe, :after_subscribe
+
+ def before_unsubscribe(*methods, &block)
+ set_callback(:unsubscribe, :before, *methods, &block)
+ end
+
+ def after_unsubscribe(*methods, &block)
+ set_callback(:unsubscribe, :after, *methods, &block)
+ end
+ alias_method :on_unsubscribe, :after_unsubscribe
+ end
+ end
+ end
+end
diff --git a/actioncable/lib/action_cable/channel/naming.rb b/actioncable/lib/action_cable/channel/naming.rb
new file mode 100644
index 0000000000..9c324a2a53
--- /dev/null
+++ b/actioncable/lib/action_cable/channel/naming.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module ActionCable
+ module Channel
+ module Naming
+ extend ActiveSupport::Concern
+
+ module ClassMethods
+ # Returns the name of the channel, underscored, without the <tt>Channel</tt> ending.
+ # If the channel is in a namespace, then the namespaces are represented by single
+ # colon separators in the channel name.
+ #
+ # ChatChannel.channel_name # => 'chat'
+ # Chats::AppearancesChannel.channel_name # => 'chats:appearances'
+ # FooChats::BarAppearancesChannel.channel_name # => 'foo_chats:bar_appearances'
+ def channel_name
+ @channel_name ||= name.sub(/Channel$/, "").gsub("::", ":").underscore
+ end
+ end
+
+ # Delegates to the class' <tt>channel_name</tt>
+ delegate :channel_name, to: :class
+ end
+ end
+end
diff --git a/actioncable/lib/action_cable/channel/periodic_timers.rb b/actioncable/lib/action_cable/channel/periodic_timers.rb
new file mode 100644
index 0000000000..830b3efa3c
--- /dev/null
+++ b/actioncable/lib/action_cable/channel/periodic_timers.rb
@@ -0,0 +1,78 @@
+# frozen_string_literal: true
+
+module ActionCable
+ module Channel
+ module PeriodicTimers
+ extend ActiveSupport::Concern
+
+ included do
+ class_attribute :periodic_timers, instance_reader: false, default: []
+
+ after_subscribe :start_periodic_timers
+ after_unsubscribe :stop_periodic_timers
+ end
+
+ module ClassMethods
+ # Periodically performs a task on the channel, like updating an online
+ # user counter, polling a backend for new status messages, sending
+ # regular "heartbeat" messages, or doing some internal work and giving
+ # progress updates.
+ #
+ # Pass a method name or lambda argument or provide a block to call.
+ # Specify the calling period in seconds using the <tt>every:</tt>
+ # keyword argument.
+ #
+ # periodically :transmit_progress, every: 5.seconds
+ #
+ # periodically every: 3.minutes do
+ # transmit action: :update_count, count: current_count
+ # end
+ #
+ def periodically(callback_or_method_name = nil, every:, &block)
+ callback =
+ if block_given?
+ raise ArgumentError, "Pass a block or provide a callback arg, not both" if callback_or_method_name
+ block
+ else
+ case callback_or_method_name
+ when Proc
+ callback_or_method_name
+ when Symbol
+ -> { __send__ callback_or_method_name }
+ else
+ raise ArgumentError, "Expected a Symbol method name or a Proc, got #{callback_or_method_name.inspect}"
+ end
+ end
+
+ unless every.kind_of?(Numeric) && every > 0
+ raise ArgumentError, "Expected every: to be a positive number of seconds, got #{every.inspect}"
+ end
+
+ self.periodic_timers += [[ callback, every: every ]]
+ end
+ end
+
+ private
+ def active_periodic_timers
+ @active_periodic_timers ||= []
+ end
+
+ def start_periodic_timers
+ self.class.periodic_timers.each do |callback, options|
+ active_periodic_timers << start_periodic_timer(callback, every: options.fetch(:every))
+ end
+ end
+
+ def start_periodic_timer(callback, every:)
+ connection.server.event_loop.timer every do
+ connection.worker_pool.async_exec self, connection: connection, &callback
+ end
+ end
+
+ def stop_periodic_timers
+ active_periodic_timers.each { |timer| timer.shutdown }
+ active_periodic_timers.clear
+ end
+ end
+ end
+end
diff --git a/actioncable/lib/action_cable/channel/streams.rb b/actioncable/lib/action_cable/channel/streams.rb
new file mode 100644
index 0000000000..81c2c38064
--- /dev/null
+++ b/actioncable/lib/action_cable/channel/streams.rb
@@ -0,0 +1,176 @@
+# frozen_string_literal: true
+
+module ActionCable
+ module Channel
+ # Streams allow channels to route broadcastings to the subscriber. A broadcasting is, as discussed elsewhere, a pubsub queue where any data
+ # placed into it is automatically sent to the clients that are connected at that time. It's purely an online queue, though. If you're not
+ # streaming a broadcasting at the very moment it sends out an update, you will not get that update, even if you connect after it has been sent.
+ #
+ # Most commonly, the streamed broadcast is sent straight to the subscriber on the client-side. The channel just acts as a connector between
+ # the two parties (the broadcaster and the channel subscriber). Here's an example of a channel that allows subscribers to get all new
+ # comments on a given page:
+ #
+ # class CommentsChannel < ApplicationCable::Channel
+ # def follow(data)
+ # stream_from "comments_for_#{data['recording_id']}"
+ # end
+ #
+ # def unfollow
+ # stop_all_streams
+ # end
+ # end
+ #
+ # Based on the above example, the subscribers of this channel will get whatever data is put into the,
+ # let's say, <tt>comments_for_45</tt> broadcasting as soon as it's put there.
+ #
+ # An example broadcasting for this channel looks like so:
+ #
+ # ActionCable.server.broadcast "comments_for_45", author: 'DHH', content: 'Rails is just swell'
+ #
+ # If you have a stream that is related to a model, then the broadcasting used can be generated from the model and channel.
+ # The following example would subscribe to a broadcasting like <tt>comments:Z2lkOi8vVGVzdEFwcC9Qb3N0LzE</tt>.
+ #
+ # class CommentsChannel < ApplicationCable::Channel
+ # def subscribed
+ # post = Post.find(params[:id])
+ # stream_for post
+ # end
+ # end
+ #
+ # You can then broadcast to this channel using:
+ #
+ # CommentsChannel.broadcast_to(@post, @comment)
+ #
+ # If you don't just want to parlay the broadcast unfiltered to the subscriber, you can also supply a callback that lets you alter what is sent out.
+ # The below example shows how you can use this to provide performance introspection in the process:
+ #
+ # class ChatChannel < ApplicationCable::Channel
+ # def subscribed
+ # @room = Chat::Room[params[:room_number]]
+ #
+ # stream_for @room, coder: ActiveSupport::JSON do |message|
+ # if message['originated_at'].present?
+ # elapsed_time = (Time.now.to_f - message['originated_at']).round(2)
+ #
+ # ActiveSupport::Notifications.instrument :performance, measurement: 'Chat.message_delay', value: elapsed_time, action: :timing
+ # logger.info "Message took #{elapsed_time}s to arrive"
+ # end
+ #
+ # transmit message
+ # end
+ # end
+ # end
+ #
+ # You can stop streaming from all broadcasts by calling #stop_all_streams.
+ module Streams
+ extend ActiveSupport::Concern
+
+ included do
+ on_unsubscribe :stop_all_streams
+ end
+
+ # Start streaming from the named <tt>broadcasting</tt> pubsub queue. Optionally, you can pass a <tt>callback</tt> that'll be used
+ # instead of the default of just transmitting the updates straight to the subscriber.
+ # Pass <tt>coder: ActiveSupport::JSON</tt> to decode messages as JSON before passing to the callback.
+ # Defaults to <tt>coder: nil</tt> which does no decoding, passes raw messages.
+ def stream_from(broadcasting, callback = nil, coder: nil, &block)
+ broadcasting = String(broadcasting)
+
+ # Don't send the confirmation until pubsub#subscribe is successful
+ defer_subscription_confirmation!
+
+ # Build a stream handler by wrapping the user-provided callback with
+ # a decoder or defaulting to a JSON-decoding retransmitter.
+ handler = worker_pool_stream_handler(broadcasting, callback || block, coder: coder)
+ streams << [ broadcasting, handler ]
+
+ connection.server.event_loop.post do
+ pubsub.subscribe(broadcasting, handler, lambda do
+ ensure_confirmation_sent
+ logger.info "#{self.class.name} is streaming from #{broadcasting}"
+ end)
+ end
+ end
+
+ # Start streaming the pubsub queue for the <tt>model</tt> in this channel. Optionally, you can pass a
+ # <tt>callback</tt> that'll be used instead of the default of just transmitting the updates straight
+ # to the subscriber.
+ #
+ # Pass <tt>coder: ActiveSupport::JSON</tt> to decode messages as JSON before passing to the callback.
+ # Defaults to <tt>coder: nil</tt> which does no decoding, passes raw messages.
+ def stream_for(model, callback = nil, coder: nil, &block)
+ stream_from(broadcasting_for([ channel_name, model ]), callback || block, coder: coder)
+ end
+
+ # Unsubscribes all streams associated with this channel from the pubsub queue.
+ def stop_all_streams
+ streams.each do |broadcasting, callback|
+ pubsub.unsubscribe broadcasting, callback
+ logger.info "#{self.class.name} stopped streaming from #{broadcasting}"
+ end.clear
+ end
+
+ private
+ delegate :pubsub, to: :connection
+
+ def streams
+ @_streams ||= []
+ end
+
+ # Always wrap the outermost handler to invoke the user handler on the
+ # worker pool rather than blocking the event loop.
+ def worker_pool_stream_handler(broadcasting, user_handler, coder: nil)
+ handler = stream_handler(broadcasting, user_handler, coder: coder)
+
+ -> message do
+ connection.worker_pool.async_invoke handler, :call, message, connection: connection
+ end
+ end
+
+ # May be overridden to add instrumentation, logging, specialized error
+ # handling, or other forms of handler decoration.
+ #
+ # TODO: Tests demonstrating this.
+ def stream_handler(broadcasting, user_handler, coder: nil)
+ if user_handler
+ stream_decoder user_handler, coder: coder
+ else
+ default_stream_handler broadcasting, coder: coder
+ end
+ end
+
+ # May be overridden to change the default stream handling behavior
+ # which decodes JSON and transmits to the client.
+ #
+ # TODO: Tests demonstrating this.
+ #
+ # TODO: Room for optimization. Update transmit API to be coder-aware
+ # so we can no-op when pubsub and connection are both JSON-encoded.
+ # Then we can skip decode+encode if we're just proxying messages.
+ def default_stream_handler(broadcasting, coder:)
+ coder ||= ActiveSupport::JSON
+ stream_transmitter stream_decoder(coder: coder), broadcasting: broadcasting
+ end
+
+ def stream_decoder(handler = identity_handler, coder:)
+ if coder
+ -> message { handler.(coder.decode(message)) }
+ else
+ handler
+ end
+ end
+
+ def stream_transmitter(handler = identity_handler, broadcasting:)
+ via = "streamed from #{broadcasting}"
+
+ -> (message) do
+ transmit handler.(message), via: via
+ end
+ end
+
+ def identity_handler
+ -> message { message }
+ end
+ end
+ end
+end
diff --git a/actioncable/lib/action_cable/channel/test_case.rb b/actioncable/lib/action_cable/channel/test_case.rb
new file mode 100644
index 0000000000..dd2762ccd0
--- /dev/null
+++ b/actioncable/lib/action_cable/channel/test_case.rb
@@ -0,0 +1,271 @@
+# frozen_string_literal: true
+
+require "active_support"
+require "active_support/test_case"
+require "active_support/core_ext/hash/indifferent_access"
+require "json"
+
+module ActionCable
+ module Channel
+ class NonInferrableChannelError < ::StandardError
+ def initialize(name)
+ super "Unable to determine the channel to test from #{name}. " +
+ "You'll need to specify it using `tests YourChannel` in your " +
+ "test case definition."
+ end
+ end
+
+ # Stub `stream_from` to track streams for the channel.
+ # Add public aliases for `subscription_confirmation_sent?` and
+ # `subscription_rejected?`.
+ module ChannelStub
+ def confirmed?
+ subscription_confirmation_sent?
+ end
+
+ def rejected?
+ subscription_rejected?
+ end
+
+ def stream_from(broadcasting, *)
+ streams << broadcasting
+ end
+
+ def stop_all_streams
+ @_streams = []
+ end
+
+ def streams
+ @_streams ||= []
+ end
+
+ # Make periodic timers no-op
+ def start_periodic_timers; end
+ alias stop_periodic_timers start_periodic_timers
+ end
+
+ class ConnectionStub
+ attr_reader :transmissions, :identifiers, :subscriptions, :logger
+
+ def initialize(identifiers = {})
+ @transmissions = []
+
+ identifiers.each do |identifier, val|
+ define_singleton_method(identifier) { val }
+ end
+
+ @subscriptions = ActionCable::Connection::Subscriptions.new(self)
+ @identifiers = identifiers.keys
+ @logger = ActiveSupport::TaggedLogging.new ActiveSupport::Logger.new(StringIO.new)
+ end
+
+ def transmit(cable_message)
+ transmissions << cable_message.with_indifferent_access
+ end
+ end
+
+ # Superclass for Action Cable channel functional tests.
+ #
+ # == Basic example
+ #
+ # Functional tests are written as follows:
+ # 1. First, one uses the +subscribe+ method to simulate subscription creation.
+ # 2. Then, one asserts whether the current state is as expected. "State" can be anything:
+ # transmitted messages, subscribed streams, etc.
+ #
+ # For example:
+ #
+ # class ChatChannelTest < ActionCable::Channel::TestCase
+ # def test_subscribed_with_room_number
+ # # Simulate a subscription creation
+ # subscribe room_number: 1
+ #
+ # # Asserts that the subscription was successfully created
+ # assert subscription.confirmed?
+ #
+ # # Asserts that the channel subscribes connection to a stream
+ # assert_equal "chat_1", streams.last
+ # end
+ #
+ # def test_does_not_subscribe_without_room_number
+ # subscribe
+ #
+ # # Asserts that the subscription was rejected
+ # assert subscription.rejected?
+ # end
+ # end
+ #
+ # You can also perform actions:
+ # def test_perform_speak
+ # subscribe room_number: 1
+ #
+ # perform :speak, message: "Hello, Rails!"
+ #
+ # assert_equal "Hello, Rails!", transmissions.last["text"]
+ # end
+ #
+ # == Special methods
+ #
+ # ActionCable::Channel::TestCase will also automatically provide the following instance
+ # methods for use in the tests:
+ #
+ # <b>connection</b>::
+ # An ActionCable::Channel::ConnectionStub, representing the current HTTP connection.
+ # <b>subscription</b>::
+ # An instance of the current channel, created when you call `subscribe`.
+ # <b>transmissions</b>::
+ # A list of all messages that have been transmitted into the channel.
+ # <b>streams</b>::
+ # A list of all created streams subscriptions (as identifiers) for the subscription.
+ #
+ #
+ # == Channel is automatically inferred
+ #
+ # ActionCable::Channel::TestCase will automatically infer the channel under test
+ # from the test class name. If the channel cannot be inferred from the test
+ # class name, you can explicitly set it with +tests+.
+ #
+ # class SpecialEdgeCaseChannelTest < ActionCable::Channel::TestCase
+ # tests SpecialChannel
+ # end
+ #
+ # == Specifying connection identifiers
+ #
+ # You need to set up your connection manually to provide values for the identifiers.
+ # To do this just use:
+ #
+ # stub_connection(user: users[:john])
+ #
+ # == Testing broadcasting
+ #
+ # ActionCable::Channel::TestCase enhances ActionCable::TestHelper assertions (e.g.
+ # +assert_broadcasts+) to handle broadcasting to models:
+ #
+ #
+ # # in your channel
+ # def speak(data)
+ # broadcast_to room, text: data["message"]
+ # end
+ #
+ # def test_speak
+ # subscribe room_id: rooms[:chat].id
+ #
+ # assert_broadcasts_on(rooms[:chat], text: "Hello, Rails!") do
+ # perform :speak, message: "Hello, Rails!"
+ # end
+ # end
+ class TestCase < ActiveSupport::TestCase
+ module Behavior
+ extend ActiveSupport::Concern
+
+ include ActiveSupport::Testing::ConstantLookup
+ include ActionCable::TestHelper
+
+ CHANNEL_IDENTIFIER = "test_stub"
+
+ included do
+ class_attribute :_channel_class
+
+ attr_reader :connection, :subscription
+ delegate :streams, to: :subscription
+
+ ActiveSupport.run_load_hooks(:action_cable_channel_test_case, self)
+ end
+
+ module ClassMethods
+ def tests(channel)
+ case channel
+ when String, Symbol
+ self._channel_class = channel.to_s.camelize.constantize
+ when Module
+ self._channel_class = channel
+ else
+ raise NonInferrableChannelError.new(channel)
+ end
+ end
+
+ def channel_class
+ if channel = self._channel_class
+ channel
+ else
+ tests determine_default_channel(name)
+ end
+ end
+
+ def determine_default_channel(name)
+ channel = determine_constant_from_test_name(name) do |constant|
+ Class === constant && constant < ActionCable::Channel::Base
+ end
+ raise NonInferrableChannelError.new(name) if channel.nil?
+ channel
+ end
+ end
+
+ # Setup test connection with the specified identifiers:
+ #
+ # class ApplicationCable < ActionCable::Connection::Base
+ # identified_by :user, :token
+ # end
+ #
+ # stub_connection(user: users[:john], token: 'my-secret-token')
+ def stub_connection(identifiers = {})
+ @connection = ConnectionStub.new(identifiers)
+ end
+
+ # Subscribe to the channel under test. Optionally pass subscription parameters as a Hash.
+ def subscribe(params = {})
+ @connection ||= stub_connection
+ @subscription = self.class.channel_class.new(connection, CHANNEL_IDENTIFIER, params.with_indifferent_access)
+ @subscription.singleton_class.include(ChannelStub)
+ @subscription.subscribe_to_channel
+ @subscription
+ end
+
+ # Unsubscribe the subscription under test.
+ def unsubscribe
+ check_subscribed!
+ subscription.unsubscribe_from_channel
+ end
+
+ # Perform action on a channel.
+ #
+ # NOTE: Must be subscribed.
+ def perform(action, data = {})
+ check_subscribed!
+ subscription.perform_action(data.stringify_keys.merge("action" => action.to_s))
+ end
+
+ # Returns messages transmitted into channel
+ def transmissions
+ # Return only directly sent message (via #transmit)
+ connection.transmissions.map { |data| data["message"] }.compact
+ end
+
+ # Enhance TestHelper assertions to handle non-String
+ # broadcastings
+ def assert_broadcasts(stream_or_object, *args)
+ super(broadcasting_for(stream_or_object), *args)
+ end
+
+ def assert_broadcast_on(stream_or_object, *args)
+ super(broadcasting_for(stream_or_object), *args)
+ end
+
+ private
+ def check_subscribed!
+ raise "Must be subscribed!" if subscription.nil? || subscription.rejected?
+ end
+
+ def broadcasting_for(stream_or_object)
+ return stream_or_object if stream_or_object.is_a?(String)
+
+ self.class.channel_class.broadcasting_for(
+ [self.class.channel_class.channel_name, stream_or_object]
+ )
+ end
+ end
+
+ include Behavior
+ end
+ end
+end
diff --git a/actioncable/lib/action_cable/connection.rb b/actioncable/lib/action_cable/connection.rb
new file mode 100644
index 0000000000..804b89a707
--- /dev/null
+++ b/actioncable/lib/action_cable/connection.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module ActionCable
+ module Connection
+ extend ActiveSupport::Autoload
+
+ eager_autoload do
+ autoload :Authorization
+ autoload :Base
+ autoload :ClientSocket
+ autoload :Identification
+ autoload :InternalChannel
+ autoload :MessageBuffer
+ autoload :Stream
+ autoload :StreamEventLoop
+ autoload :Subscriptions
+ autoload :TaggedLoggerProxy
+ autoload :WebSocket
+ end
+ end
+end
diff --git a/actioncable/lib/action_cable/connection/authorization.rb b/actioncable/lib/action_cable/connection/authorization.rb
new file mode 100644
index 0000000000..aef3386f71
--- /dev/null
+++ b/actioncable/lib/action_cable/connection/authorization.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module ActionCable
+ module Connection
+ module Authorization
+ class UnauthorizedError < StandardError; end
+
+ # Closes the WebSocket connection if it is open and returns a 404 "File not Found" response.
+ def reject_unauthorized_connection
+ logger.error "An unauthorized connection attempt was rejected"
+ raise UnauthorizedError
+ end
+ end
+ end
+end
diff --git a/actioncable/lib/action_cable/connection/base.rb b/actioncable/lib/action_cable/connection/base.rb
new file mode 100644
index 0000000000..11a1f1a5e8
--- /dev/null
+++ b/actioncable/lib/action_cable/connection/base.rb
@@ -0,0 +1,257 @@
+# frozen_string_literal: true
+
+require "action_dispatch"
+
+module ActionCable
+ module Connection
+ # For every WebSocket connection the Action Cable server accepts, a Connection object will be instantiated. This instance becomes the parent
+ # of all of the channel subscriptions that are created from there on. Incoming messages are then routed to these channel subscriptions
+ # based on an identifier sent by the Action Cable consumer. The Connection itself does not deal with any specific application logic beyond
+ # authentication and authorization.
+ #
+ # Here's a basic example:
+ #
+ # module ApplicationCable
+ # class Connection < ActionCable::Connection::Base
+ # identified_by :current_user
+ #
+ # def connect
+ # self.current_user = find_verified_user
+ # logger.add_tags current_user.name
+ # end
+ #
+ # def disconnect
+ # # Any cleanup work needed when the cable connection is cut.
+ # end
+ #
+ # private
+ # def find_verified_user
+ # User.find_by_identity(cookies.encrypted[:identity_id]) ||
+ # reject_unauthorized_connection
+ # end
+ # end
+ # end
+ #
+ # First, we declare that this connection can be identified by its current_user. This allows us to later be able to find all connections
+ # established for that current_user (and potentially disconnect them). You can declare as many
+ # identification indexes as you like. Declaring an identification means that an attr_accessor is automatically set for that key.
+ #
+ # Second, we rely on the fact that the WebSocket connection is established with the cookies from the domain being sent along. This makes
+ # it easy to use signed cookies that were set when logging in via a web interface to authorize the WebSocket connection.
+ #
+ # Finally, we add a tag to the connection-specific logger with the name of the current user to easily distinguish their messages in the log.
+ #
+ # Pretty simple, eh?
+ class Base
+ include Identification
+ include InternalChannel
+ include Authorization
+
+ attr_reader :server, :env, :subscriptions, :logger, :worker_pool, :protocol
+ delegate :event_loop, :pubsub, to: :server
+
+ def initialize(server, env, coder: ActiveSupport::JSON)
+ @server, @env, @coder = server, env, coder
+
+ @worker_pool = server.worker_pool
+ @logger = new_tagged_logger
+
+ @websocket = ActionCable::Connection::WebSocket.new(env, self, event_loop)
+ @subscriptions = ActionCable::Connection::Subscriptions.new(self)
+ @message_buffer = ActionCable::Connection::MessageBuffer.new(self)
+
+ @_internal_subscriptions = nil
+ @started_at = Time.now
+ end
+
+ # Called by the server when a new WebSocket connection is established. This configures the callbacks intended for overwriting by the user.
+ # This method should not be called directly -- instead rely upon on the #connect (and #disconnect) callbacks.
+ def process #:nodoc:
+ logger.info started_request_message
+
+ if websocket.possible? && allow_request_origin?
+ respond_to_successful_request
+ else
+ respond_to_invalid_request
+ end
+ end
+
+ # Decodes WebSocket messages and dispatches them to subscribed channels.
+ # WebSocket message transfer encoding is always JSON.
+ def receive(websocket_message) #:nodoc:
+ send_async :dispatch_websocket_message, websocket_message
+ end
+
+ def dispatch_websocket_message(websocket_message) #:nodoc:
+ if websocket.alive?
+ subscriptions.execute_command decode(websocket_message)
+ else
+ logger.error "Ignoring message processed after the WebSocket was closed: #{websocket_message.inspect})"
+ end
+ end
+
+ def transmit(cable_message) # :nodoc:
+ websocket.transmit encode(cable_message)
+ end
+
+ # Close the WebSocket connection.
+ def close
+ websocket.close
+ end
+
+ # Invoke a method on the connection asynchronously through the pool of thread workers.
+ def send_async(method, *arguments)
+ worker_pool.async_invoke(self, method, *arguments)
+ end
+
+ # Return a basic hash of statistics for the connection keyed with <tt>identifier</tt>, <tt>started_at</tt>, <tt>subscriptions</tt>, and <tt>request_id</tt>.
+ # This can be returned by a health check against the connection.
+ def statistics
+ {
+ identifier: connection_identifier,
+ started_at: @started_at,
+ subscriptions: subscriptions.identifiers,
+ request_id: @env["action_dispatch.request_id"]
+ }
+ end
+
+ def beat
+ transmit type: ActionCable::INTERNAL[:message_types][:ping], message: Time.now.to_i
+ end
+
+ def on_open # :nodoc:
+ send_async :handle_open
+ end
+
+ def on_message(message) # :nodoc:
+ message_buffer.append message
+ end
+
+ def on_error(message) # :nodoc:
+ # log errors to make diagnosing socket errors easier
+ logger.error "WebSocket error occurred: #{message}"
+ end
+
+ def on_close(reason, code) # :nodoc:
+ send_async :handle_close
+ end
+
+ private
+ attr_reader :websocket
+ attr_reader :message_buffer
+
+ # The request that initiated the WebSocket connection is available here. This gives access to the environment, cookies, etc.
+ def request # :doc:
+ @request ||= begin
+ environment = Rails.application.env_config.merge(env) if defined?(Rails.application) && Rails.application
+ ActionDispatch::Request.new(environment || env)
+ end
+ end
+
+ # The cookies of the request that initiated the WebSocket connection. Useful for performing authorization checks.
+ def cookies # :doc:
+ request.cookie_jar
+ end
+
+ def encode(cable_message)
+ @coder.encode cable_message
+ end
+
+ def decode(websocket_message)
+ @coder.decode websocket_message
+ end
+
+ def handle_open
+ @protocol = websocket.protocol
+ connect if respond_to?(:connect)
+ subscribe_to_internal_channel
+ send_welcome_message
+
+ message_buffer.process!
+ server.add_connection(self)
+ rescue ActionCable::Connection::Authorization::UnauthorizedError
+ respond_to_invalid_request
+ end
+
+ def handle_close
+ logger.info finished_request_message
+
+ server.remove_connection(self)
+
+ subscriptions.unsubscribe_from_all
+ unsubscribe_from_internal_channel
+
+ disconnect if respond_to?(:disconnect)
+ end
+
+ def send_welcome_message
+ # Send welcome message to the internal connection monitor channel.
+ # This ensures the connection monitor state is reset after a successful
+ # websocket connection.
+ transmit type: ActionCable::INTERNAL[:message_types][:welcome]
+ end
+
+ def allow_request_origin?
+ return true if server.config.disable_request_forgery_protection
+
+ proto = Rack::Request.new(env).ssl? ? "https" : "http"
+ if server.config.allow_same_origin_as_host && env["HTTP_ORIGIN"] == "#{proto}://#{env['HTTP_HOST']}"
+ true
+ elsif Array(server.config.allowed_request_origins).any? { |allowed_origin| allowed_origin === env["HTTP_ORIGIN"] }
+ true
+ else
+ logger.error("Request origin not allowed: #{env['HTTP_ORIGIN']}")
+ false
+ end
+ end
+
+ def respond_to_successful_request
+ logger.info successful_request_message
+ websocket.rack_response
+ end
+
+ def respond_to_invalid_request
+ close if websocket.alive?
+
+ logger.error invalid_request_message
+ logger.info finished_request_message
+ [ 404, { "Content-Type" => "text/plain" }, [ "Page not found" ] ]
+ end
+
+ # Tags are declared in the server but computed in the connection. This allows us per-connection tailored tags.
+ def new_tagged_logger
+ TaggedLoggerProxy.new server.logger,
+ tags: server.config.log_tags.map { |tag| tag.respond_to?(:call) ? tag.call(request) : tag.to_s.camelize }
+ end
+
+ def started_request_message
+ 'Started %s "%s"%s for %s at %s' % [
+ request.request_method,
+ request.filtered_path,
+ websocket.possible? ? " [WebSocket]" : "[non-WebSocket]",
+ request.ip,
+ Time.now.to_s ]
+ end
+
+ def finished_request_message
+ 'Finished "%s"%s for %s at %s' % [
+ request.filtered_path,
+ websocket.possible? ? " [WebSocket]" : "[non-WebSocket]",
+ request.ip,
+ Time.now.to_s ]
+ end
+
+ def invalid_request_message
+ "Failed to upgrade to WebSocket (REQUEST_METHOD: %s, HTTP_CONNECTION: %s, HTTP_UPGRADE: %s)" % [
+ env["REQUEST_METHOD"], env["HTTP_CONNECTION"], env["HTTP_UPGRADE"]
+ ]
+ end
+
+ def successful_request_message
+ "Successfully upgraded to WebSocket (REQUEST_METHOD: %s, HTTP_CONNECTION: %s, HTTP_UPGRADE: %s)" % [
+ env["REQUEST_METHOD"], env["HTTP_CONNECTION"], env["HTTP_UPGRADE"]
+ ]
+ end
+ end
+ end
+end
diff --git a/actioncable/lib/action_cable/connection/client_socket.rb b/actioncable/lib/action_cable/connection/client_socket.rb
new file mode 100644
index 0000000000..4b1964c4ae
--- /dev/null
+++ b/actioncable/lib/action_cable/connection/client_socket.rb
@@ -0,0 +1,157 @@
+# frozen_string_literal: true
+
+require "websocket/driver"
+
+module ActionCable
+ module Connection
+ #--
+ # This class is heavily based on faye-websocket-ruby
+ #
+ # Copyright (c) 2010-2015 James Coglan
+ class ClientSocket # :nodoc:
+ def self.determine_url(env)
+ scheme = secure_request?(env) ? "wss:" : "ws:"
+ "#{ scheme }//#{ env['HTTP_HOST'] }#{ env['REQUEST_URI'] }"
+ end
+
+ def self.secure_request?(env)
+ return true if env["HTTPS"] == "on"
+ return true if env["HTTP_X_FORWARDED_SSL"] == "on"
+ return true if env["HTTP_X_FORWARDED_SCHEME"] == "https"
+ return true if env["HTTP_X_FORWARDED_PROTO"] == "https"
+ return true if env["rack.url_scheme"] == "https"
+
+ false
+ end
+
+ CONNECTING = 0
+ OPEN = 1
+ CLOSING = 2
+ CLOSED = 3
+
+ attr_reader :env, :url
+
+ def initialize(env, event_target, event_loop, protocols)
+ @env = env
+ @event_target = event_target
+ @event_loop = event_loop
+
+ @url = ClientSocket.determine_url(@env)
+
+ @driver = @driver_started = nil
+ @close_params = ["", 1006]
+
+ @ready_state = CONNECTING
+
+ # The driver calls +env+, +url+, and +write+
+ @driver = ::WebSocket::Driver.rack(self, protocols: protocols)
+
+ @driver.on(:open) { |e| open }
+ @driver.on(:message) { |e| receive_message(e.data) }
+ @driver.on(:close) { |e| begin_close(e.reason, e.code) }
+ @driver.on(:error) { |e| emit_error(e.message) }
+
+ @stream = ActionCable::Connection::Stream.new(@event_loop, self)
+ end
+
+ def start_driver
+ return if @driver.nil? || @driver_started
+ @stream.hijack_rack_socket
+
+ if callback = @env["async.callback"]
+ callback.call([101, {}, @stream])
+ end
+
+ @driver_started = true
+ @driver.start
+ end
+
+ def rack_response
+ start_driver
+ [ -1, {}, [] ]
+ end
+
+ def write(data)
+ @stream.write(data)
+ rescue => e
+ emit_error e.message
+ end
+
+ def transmit(message)
+ return false if @ready_state > OPEN
+ case message
+ when Numeric then @driver.text(message.to_s)
+ when String then @driver.text(message)
+ when Array then @driver.binary(message)
+ else false
+ end
+ end
+
+ def close(code = nil, reason = nil)
+ code ||= 1000
+ reason ||= ""
+
+ unless code == 1000 || (code >= 3000 && code <= 4999)
+ raise ArgumentError, "Failed to execute 'close' on WebSocket: " \
+ "The code must be either 1000, or between 3000 and 4999. " \
+ "#{code} is neither."
+ end
+
+ @ready_state = CLOSING unless @ready_state == CLOSED
+ @driver.close(reason, code)
+ end
+
+ def parse(data)
+ @driver.parse(data)
+ end
+
+ def client_gone
+ finalize_close
+ end
+
+ def alive?
+ @ready_state == OPEN
+ end
+
+ def protocol
+ @driver.protocol
+ end
+
+ private
+ def open
+ return unless @ready_state == CONNECTING
+ @ready_state = OPEN
+
+ @event_target.on_open
+ end
+
+ def receive_message(data)
+ return unless @ready_state == OPEN
+
+ @event_target.on_message(data)
+ end
+
+ def emit_error(message)
+ return if @ready_state >= CLOSING
+
+ @event_target.on_error(message)
+ end
+
+ def begin_close(reason, code)
+ return if @ready_state == CLOSED
+ @ready_state = CLOSING
+ @close_params = [reason, code]
+
+ @stream.shutdown if @stream
+ finalize_close
+ end
+
+ def finalize_close
+ return if @ready_state == CLOSED
+ @ready_state = CLOSED
+
+ @event_target.on_close(*@close_params)
+ end
+ end
+ end
+end
diff --git a/actioncable/lib/action_cable/connection/identification.rb b/actioncable/lib/action_cable/connection/identification.rb
new file mode 100644
index 0000000000..cc544685dd
--- /dev/null
+++ b/actioncable/lib/action_cable/connection/identification.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+require "set"
+
+module ActionCable
+ module Connection
+ module Identification
+ extend ActiveSupport::Concern
+
+ included do
+ class_attribute :identifiers, default: Set.new
+ end
+
+ module ClassMethods
+ # Mark a key as being a connection identifier index that can then be used to find the specific connection again later.
+ # Common identifiers are current_user and current_account, but could be anything, really.
+ #
+ # Note that anything marked as an identifier will automatically create a delegate by the same name on any
+ # channel instances created off the connection.
+ def identified_by(*identifiers)
+ Array(identifiers).each { |identifier| attr_accessor identifier }
+ self.identifiers += identifiers
+ end
+ end
+
+ # Return a single connection identifier that combines the value of all the registered identifiers into a single gid.
+ def connection_identifier
+ unless defined? @connection_identifier
+ @connection_identifier = connection_gid identifiers.map { |id| instance_variable_get("@#{id}") }.compact
+ end
+
+ @connection_identifier
+ end
+
+ private
+ def connection_gid(ids)
+ ids.map do |o|
+ if o.respond_to? :to_gid_param
+ o.to_gid_param
+ else
+ o.to_s
+ end
+ end.sort.join(":")
+ end
+ end
+ end
+end
diff --git a/actioncable/lib/action_cable/connection/internal_channel.rb b/actioncable/lib/action_cable/connection/internal_channel.rb
new file mode 100644
index 0000000000..f03904137b
--- /dev/null
+++ b/actioncable/lib/action_cable/connection/internal_channel.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+module ActionCable
+ module Connection
+ # Makes it possible for the RemoteConnection to disconnect a specific connection.
+ module InternalChannel
+ extend ActiveSupport::Concern
+
+ private
+ def internal_channel
+ "action_cable/#{connection_identifier}"
+ end
+
+ def subscribe_to_internal_channel
+ if connection_identifier.present?
+ callback = -> (message) { process_internal_message decode(message) }
+ @_internal_subscriptions ||= []
+ @_internal_subscriptions << [ internal_channel, callback ]
+
+ server.event_loop.post { pubsub.subscribe(internal_channel, callback) }
+ logger.info "Registered connection (#{connection_identifier})"
+ end
+ end
+
+ def unsubscribe_from_internal_channel
+ if @_internal_subscriptions.present?
+ @_internal_subscriptions.each { |channel, callback| server.event_loop.post { pubsub.unsubscribe(channel, callback) } }
+ end
+ end
+
+ def process_internal_message(message)
+ case message["type"]
+ when "disconnect"
+ logger.info "Removing connection (#{connection_identifier})"
+ websocket.close
+ end
+ rescue Exception => e
+ logger.error "There was an exception - #{e.class}(#{e.message})"
+ logger.error e.backtrace.join("\n")
+
+ close
+ end
+ end
+ end
+end
diff --git a/actioncable/lib/action_cable/connection/message_buffer.rb b/actioncable/lib/action_cable/connection/message_buffer.rb
new file mode 100644
index 0000000000..965841b67e
--- /dev/null
+++ b/actioncable/lib/action_cable/connection/message_buffer.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+module ActionCable
+ module Connection
+ # Allows us to buffer messages received from the WebSocket before the Connection has been fully initialized, and is ready to receive them.
+ class MessageBuffer # :nodoc:
+ def initialize(connection)
+ @connection = connection
+ @buffered_messages = []
+ end
+
+ def append(message)
+ if valid? message
+ if processing?
+ receive message
+ else
+ buffer message
+ end
+ else
+ connection.logger.error "Couldn't handle non-string message: #{message.class}"
+ end
+ end
+
+ def processing?
+ @processing
+ end
+
+ def process!
+ @processing = true
+ receive_buffered_messages
+ end
+
+ private
+ attr_reader :connection
+ attr_reader :buffered_messages
+
+ def valid?(message)
+ message.is_a?(String)
+ end
+
+ def receive(message)
+ connection.receive message
+ end
+
+ def buffer(message)
+ buffered_messages << message
+ end
+
+ def receive_buffered_messages
+ receive buffered_messages.shift until buffered_messages.empty?
+ end
+ end
+ end
+end
diff --git a/actioncable/lib/action_cable/connection/stream.rb b/actioncable/lib/action_cable/connection/stream.rb
new file mode 100644
index 0000000000..e658948a55
--- /dev/null
+++ b/actioncable/lib/action_cable/connection/stream.rb
@@ -0,0 +1,117 @@
+# frozen_string_literal: true
+
+require "thread"
+
+module ActionCable
+ module Connection
+ #--
+ # This class is heavily based on faye-websocket-ruby
+ #
+ # Copyright (c) 2010-2015 James Coglan
+ class Stream # :nodoc:
+ def initialize(event_loop, socket)
+ @event_loop = event_loop
+ @socket_object = socket
+ @stream_send = socket.env["stream.send"]
+
+ @rack_hijack_io = nil
+ @write_lock = Mutex.new
+
+ @write_head = nil
+ @write_buffer = Queue.new
+ end
+
+ def each(&callback)
+ @stream_send ||= callback
+ end
+
+ def close
+ shutdown
+ @socket_object.client_gone
+ end
+
+ def shutdown
+ clean_rack_hijack
+ end
+
+ def write(data)
+ if @stream_send
+ return @stream_send.call(data)
+ end
+
+ if @write_lock.try_lock
+ begin
+ if @write_head.nil? && @write_buffer.empty?
+ written = @rack_hijack_io.write_nonblock(data, exception: false)
+
+ case written
+ when :wait_writable
+ # proceed below
+ when data.bytesize
+ return data.bytesize
+ else
+ @write_head = data.byteslice(written, data.bytesize)
+ @event_loop.writes_pending @rack_hijack_io
+
+ return data.bytesize
+ end
+ end
+ ensure
+ @write_lock.unlock
+ end
+ end
+
+ @write_buffer << data
+ @event_loop.writes_pending @rack_hijack_io
+
+ data.bytesize
+ rescue EOFError, Errno::ECONNRESET
+ @socket_object.client_gone
+ end
+
+ def flush_write_buffer
+ @write_lock.synchronize do
+ loop do
+ if @write_head.nil?
+ return true if @write_buffer.empty?
+ @write_head = @write_buffer.pop
+ end
+
+ written = @rack_hijack_io.write_nonblock(@write_head, exception: false)
+ case written
+ when :wait_writable
+ return false
+ when @write_head.bytesize
+ @write_head = nil
+ else
+ @write_head = @write_head.byteslice(written, @write_head.bytesize)
+ return false
+ end
+ end
+ end
+ end
+
+ def receive(data)
+ @socket_object.parse(data)
+ end
+
+ def hijack_rack_socket
+ return unless @socket_object.env["rack.hijack"]
+
+ # This should return the underlying io according to the SPEC:
+ @rack_hijack_io = @socket_object.env["rack.hijack"].call
+ # Retain existing behaviour if required:
+ @rack_hijack_io ||= @socket_object.env["rack.hijack_io"]
+
+ @event_loop.attach(@rack_hijack_io, self)
+ end
+
+ private
+ def clean_rack_hijack
+ return unless @rack_hijack_io
+ @event_loop.detach(@rack_hijack_io, self)
+ @rack_hijack_io = nil
+ end
+ end
+ end
+end
diff --git a/actioncable/lib/action_cable/connection/stream_event_loop.rb b/actioncable/lib/action_cable/connection/stream_event_loop.rb
new file mode 100644
index 0000000000..d95afc50ba
--- /dev/null
+++ b/actioncable/lib/action_cable/connection/stream_event_loop.rb
@@ -0,0 +1,136 @@
+# frozen_string_literal: true
+
+require "nio"
+require "thread"
+
+module ActionCable
+ module Connection
+ class StreamEventLoop
+ def initialize
+ @nio = @executor = @thread = nil
+ @map = {}
+ @stopping = false
+ @todo = Queue.new
+
+ @spawn_mutex = Mutex.new
+ end
+
+ def timer(interval, &block)
+ Concurrent::TimerTask.new(execution_interval: interval, &block).tap(&:execute)
+ end
+
+ def post(task = nil, &block)
+ task ||= block
+
+ spawn
+ @executor << task
+ end
+
+ def attach(io, stream)
+ @todo << lambda do
+ @map[io] = @nio.register(io, :r)
+ @map[io].value = stream
+ end
+ wakeup
+ end
+
+ def detach(io, stream)
+ @todo << lambda do
+ @nio.deregister io
+ @map.delete io
+ io.close
+ end
+ wakeup
+ end
+
+ def writes_pending(io)
+ @todo << lambda do
+ if monitor = @map[io]
+ monitor.interests = :rw
+ end
+ end
+ wakeup
+ end
+
+ def stop
+ @stopping = true
+ wakeup if @nio
+ end
+
+ private
+ def spawn
+ return if @thread && @thread.status
+
+ @spawn_mutex.synchronize do
+ return if @thread && @thread.status
+
+ @nio ||= NIO::Selector.new
+
+ @executor ||= Concurrent::ThreadPoolExecutor.new(
+ min_threads: 1,
+ max_threads: 10,
+ max_queue: 0,
+ )
+
+ @thread = Thread.new { run }
+
+ return true
+ end
+ end
+
+ def wakeup
+ spawn || @nio.wakeup
+ end
+
+ def run
+ loop do
+ if @stopping
+ @nio.close
+ break
+ end
+
+ until @todo.empty?
+ @todo.pop(true).call
+ end
+
+ next unless monitors = @nio.select
+
+ monitors.each do |monitor|
+ io = monitor.io
+ stream = monitor.value
+
+ begin
+ if monitor.writable?
+ if stream.flush_write_buffer
+ monitor.interests = :r
+ end
+ next unless monitor.readable?
+ end
+
+ incoming = io.read_nonblock(4096, exception: false)
+ case incoming
+ when :wait_readable
+ next
+ when nil
+ stream.close
+ else
+ stream.receive incoming
+ end
+ rescue
+ # We expect one of EOFError or Errno::ECONNRESET in
+ # normal operation (when the client goes away). But if
+ # anything else goes wrong, this is still the best way
+ # to handle it.
+ begin
+ stream.close
+ rescue
+ @nio.deregister io
+ @map.delete io
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/actioncable/lib/action_cable/connection/subscriptions.rb b/actioncable/lib/action_cable/connection/subscriptions.rb
new file mode 100644
index 0000000000..1ad8d05107
--- /dev/null
+++ b/actioncable/lib/action_cable/connection/subscriptions.rb
@@ -0,0 +1,79 @@
+# frozen_string_literal: true
+
+require "active_support/core_ext/hash/indifferent_access"
+
+module ActionCable
+ module Connection
+ # Collection class for all the channel subscriptions established on a given connection. Responsible for routing incoming commands that arrive on
+ # the connection to the proper channel.
+ class Subscriptions # :nodoc:
+ def initialize(connection)
+ @connection = connection
+ @subscriptions = {}
+ end
+
+ def execute_command(data)
+ case data["command"]
+ when "subscribe" then add data
+ when "unsubscribe" then remove data
+ when "message" then perform_action data
+ else
+ logger.error "Received unrecognized command in #{data.inspect}"
+ end
+ rescue Exception => e
+ logger.error "Could not execute command from (#{data.inspect}) [#{e.class} - #{e.message}]: #{e.backtrace.first(5).join(" | ")}"
+ end
+
+ def add(data)
+ id_key = data["identifier"]
+ id_options = ActiveSupport::JSON.decode(id_key).with_indifferent_access
+
+ return if subscriptions.key?(id_key)
+
+ subscription_klass = id_options[:channel].safe_constantize
+
+ if subscription_klass && ActionCable::Channel::Base >= subscription_klass
+ subscription = subscription_klass.new(connection, id_key, id_options)
+ subscriptions[id_key] = subscription
+ subscription.subscribe_to_channel
+ else
+ logger.error "Subscription class not found: #{id_options[:channel].inspect}"
+ end
+ end
+
+ def remove(data)
+ logger.info "Unsubscribing from channel: #{data['identifier']}"
+ remove_subscription find(data)
+ end
+
+ def remove_subscription(subscription)
+ subscription.unsubscribe_from_channel
+ subscriptions.delete(subscription.identifier)
+ end
+
+ def perform_action(data)
+ find(data).perform_action ActiveSupport::JSON.decode(data["data"])
+ end
+
+ def identifiers
+ subscriptions.keys
+ end
+
+ def unsubscribe_from_all
+ subscriptions.each { |id, channel| remove_subscription(channel) }
+ end
+
+ private
+ attr_reader :connection, :subscriptions
+ delegate :logger, to: :connection
+
+ def find(data)
+ if subscription = subscriptions[data["identifier"]]
+ subscription
+ else
+ raise "Unable to find subscription with identifier: #{data['identifier']}"
+ end
+ end
+ end
+ end
+end
diff --git a/actioncable/lib/action_cable/connection/tagged_logger_proxy.rb b/actioncable/lib/action_cable/connection/tagged_logger_proxy.rb
new file mode 100644
index 0000000000..85831806a9
--- /dev/null
+++ b/actioncable/lib/action_cable/connection/tagged_logger_proxy.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+module ActionCable
+ module Connection
+ # Allows the use of per-connection tags against the server logger. This wouldn't work using the traditional
+ # <tt>ActiveSupport::TaggedLogging</tt> enhanced Rails.logger, as that logger will reset the tags between requests.
+ # The connection is long-lived, so it needs its own set of tags for its independent duration.
+ class TaggedLoggerProxy
+ attr_reader :tags
+
+ def initialize(logger, tags:)
+ @logger = logger
+ @tags = tags.flatten
+ end
+
+ def add_tags(*tags)
+ @tags += tags.flatten
+ @tags = @tags.uniq
+ end
+
+ def tag(logger)
+ if logger.respond_to?(:tagged)
+ current_tags = tags - logger.formatter.current_tags
+ logger.tagged(*current_tags) { yield }
+ else
+ yield
+ end
+ end
+
+ %i( debug info warn error fatal unknown ).each do |severity|
+ define_method(severity) do |message|
+ log severity, message
+ end
+ end
+
+ private
+ def log(type, message) # :doc:
+ tag(@logger) { @logger.send type, message }
+ end
+ end
+ end
+end
diff --git a/actioncable/lib/action_cable/connection/web_socket.rb b/actioncable/lib/action_cable/connection/web_socket.rb
new file mode 100644
index 0000000000..31f29fdd2f
--- /dev/null
+++ b/actioncable/lib/action_cable/connection/web_socket.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+require "websocket/driver"
+
+module ActionCable
+ module Connection
+ # Wrap the real socket to minimize the externally-presented API
+ class WebSocket # :nodoc:
+ def initialize(env, event_target, event_loop, protocols: ActionCable::INTERNAL[:protocols])
+ @websocket = ::WebSocket::Driver.websocket?(env) ? ClientSocket.new(env, event_target, event_loop, protocols) : nil
+ end
+
+ def possible?
+ websocket
+ end
+
+ def alive?
+ websocket && websocket.alive?
+ end
+
+ def transmit(data)
+ websocket.transmit data
+ end
+
+ def close
+ websocket.close
+ end
+
+ def protocol
+ websocket.protocol
+ end
+
+ def rack_response
+ websocket.rack_response
+ end
+
+ private
+ attr_reader :websocket
+ end
+ end
+end
diff --git a/actioncable/lib/action_cable/engine.rb b/actioncable/lib/action_cable/engine.rb
new file mode 100644
index 0000000000..53cbb597cd
--- /dev/null
+++ b/actioncable/lib/action_cable/engine.rb
@@ -0,0 +1,79 @@
+# frozen_string_literal: true
+
+require "rails"
+require "action_cable"
+require "action_cable/helpers/action_cable_helper"
+require "active_support/core_ext/hash/indifferent_access"
+
+module ActionCable
+ class Engine < Rails::Engine # :nodoc:
+ config.action_cable = ActiveSupport::OrderedOptions.new
+ config.action_cable.mount_path = ActionCable::INTERNAL[:default_mount_path]
+
+ config.eager_load_namespaces << ActionCable
+
+ initializer "action_cable.helpers" do
+ ActiveSupport.on_load(:action_view) do
+ include ActionCable::Helpers::ActionCableHelper
+ end
+ end
+
+ initializer "action_cable.logger" do
+ ActiveSupport.on_load(:action_cable) { self.logger ||= ::Rails.logger }
+ end
+
+ initializer "action_cable.set_configs" do |app|
+ options = app.config.action_cable
+ options.allowed_request_origins ||= /https?:\/\/localhost:\d+/ if ::Rails.env.development?
+
+ app.paths.add "config/cable", with: "config/cable.yml"
+
+ ActiveSupport.on_load(:action_cable) do
+ if (config_path = Pathname.new(app.config.paths["config/cable"].first)).exist?
+ self.cable = Rails.application.config_for(config_path).with_indifferent_access
+ end
+
+ previous_connection_class = connection_class
+ self.connection_class = -> { "ApplicationCable::Connection".safe_constantize || previous_connection_class.call }
+
+ options.each { |k, v| send("#{k}=", v) }
+ end
+ end
+
+ initializer "action_cable.routes" do
+ config.after_initialize do |app|
+ config = app.config
+ unless config.action_cable.mount_path.nil?
+ app.routes.prepend do
+ mount ActionCable.server => config.action_cable.mount_path, internal: true
+ end
+ end
+ end
+ end
+
+ initializer "action_cable.set_work_hooks" do |app|
+ ActiveSupport.on_load(:action_cable) do
+ ActionCable::Server::Worker.set_callback :work, :around, prepend: true do |_, inner|
+ app.executor.wrap do
+ # If we took a while to get the lock, we may have been halted
+ # in the meantime. As we haven't started doing any real work
+ # yet, we should pretend that we never made it off the queue.
+ unless stopping?
+ inner.call
+ end
+ end
+ end
+
+ wrap = lambda do |_, inner|
+ app.executor.wrap(&inner)
+ end
+ ActionCable::Channel::Base.set_callback :subscribe, :around, prepend: true, &wrap
+ ActionCable::Channel::Base.set_callback :unsubscribe, :around, prepend: true, &wrap
+
+ app.reloader.before_class_unload do
+ ActionCable.server.restart
+ end
+ end
+ end
+ end
+end
diff --git a/actioncable/lib/action_cable/gem_version.rb b/actioncable/lib/action_cable/gem_version.rb
new file mode 100644
index 0000000000..cd1d9bccef
--- /dev/null
+++ b/actioncable/lib/action_cable/gem_version.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module ActionCable
+ # Returns the version of the currently loaded Action Cable as a <tt>Gem::Version</tt>.
+ def self.gem_version
+ Gem::Version.new VERSION::STRING
+ end
+
+ module VERSION
+ MAJOR = 6
+ MINOR = 0
+ TINY = 0
+ PRE = "alpha"
+
+ STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".")
+ end
+end
diff --git a/actioncable/lib/action_cable/helpers/action_cable_helper.rb b/actioncable/lib/action_cable/helpers/action_cable_helper.rb
new file mode 100644
index 0000000000..df16c02e83
--- /dev/null
+++ b/actioncable/lib/action_cable/helpers/action_cable_helper.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+module ActionCable
+ module Helpers
+ module ActionCableHelper
+ # Returns an "action-cable-url" meta tag with the value of the URL specified in your
+ # configuration. Ensure this is above your JavaScript tag:
+ #
+ # <head>
+ # <%= action_cable_meta_tag %>
+ # <%= javascript_include_tag 'application', 'data-turbolinks-track' => 'reload' %>
+ # </head>
+ #
+ # This is then used by Action Cable to determine the URL of your WebSocket server.
+ # Your CoffeeScript can then connect to the server without needing to specify the
+ # URL directly:
+ #
+ # #= require cable
+ # @App = {}
+ # App.cable = Cable.createConsumer()
+ #
+ # Make sure to specify the correct server location in each of your environment
+ # config files:
+ #
+ # config.action_cable.mount_path = "/cable123"
+ # <%= action_cable_meta_tag %> would render:
+ # => <meta name="action-cable-url" content="/cable123" />
+ #
+ # config.action_cable.url = "ws://actioncable.com"
+ # <%= action_cable_meta_tag %> would render:
+ # => <meta name="action-cable-url" content="ws://actioncable.com" />
+ #
+ def action_cable_meta_tag
+ tag "meta", name: "action-cable-url", content: (
+ ActionCable.server.config.url ||
+ ActionCable.server.config.mount_path ||
+ raise("No Action Cable URL configured -- please configure this at config.action_cable.url")
+ )
+ end
+ end
+ end
+end
diff --git a/actioncable/lib/action_cable/remote_connections.rb b/actioncable/lib/action_cable/remote_connections.rb
new file mode 100644
index 0000000000..283400d9e7
--- /dev/null
+++ b/actioncable/lib/action_cable/remote_connections.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+require "active_support/core_ext/module/redefine_method"
+
+module ActionCable
+ # If you need to disconnect a given connection, you can go through the
+ # RemoteConnections. You can find the connections you're looking for by
+ # searching for the identifier declared on the connection. For example:
+ #
+ # module ApplicationCable
+ # class Connection < ActionCable::Connection::Base
+ # identified_by :current_user
+ # ....
+ # end
+ # end
+ #
+ # ActionCable.server.remote_connections.where(current_user: User.find(1)).disconnect
+ #
+ # This will disconnect all the connections established for
+ # <tt>User.find(1)</tt>, across all servers running on all machines, because
+ # it uses the internal channel that all of these servers are subscribed to.
+ class RemoteConnections
+ attr_reader :server
+
+ def initialize(server)
+ @server = server
+ end
+
+ def where(identifier)
+ RemoteConnection.new(server, identifier)
+ end
+
+ private
+ # Represents a single remote connection found via <tt>ActionCable.server.remote_connections.where(*)</tt>.
+ # Exists solely for the purpose of calling #disconnect on that connection.
+ class RemoteConnection
+ class InvalidIdentifiersError < StandardError; end
+
+ include Connection::Identification, Connection::InternalChannel
+
+ def initialize(server, ids)
+ @server = server
+ set_identifier_instance_vars(ids)
+ end
+
+ # Uses the internal channel to disconnect the connection.
+ def disconnect
+ server.broadcast internal_channel, type: "disconnect"
+ end
+
+ # Returns all the identifiers that were applied to this connection.
+ redefine_method :identifiers do
+ server.connection_identifiers
+ end
+
+ protected
+ attr_reader :server
+
+ private
+ def set_identifier_instance_vars(ids)
+ raise InvalidIdentifiersError unless valid_identifiers?(ids)
+ ids.each { |k, v| instance_variable_set("@#{k}", v) }
+ end
+
+ def valid_identifiers?(ids)
+ keys = ids.keys
+ identifiers.all? { |id| keys.include?(id) }
+ end
+ end
+ end
+end
diff --git a/actioncable/lib/action_cable/server.rb b/actioncable/lib/action_cable/server.rb
new file mode 100644
index 0000000000..8d485a44f6
--- /dev/null
+++ b/actioncable/lib/action_cable/server.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module ActionCable
+ module Server
+ extend ActiveSupport::Autoload
+
+ eager_autoload do
+ autoload :Base
+ autoload :Broadcasting
+ autoload :Connections
+ autoload :Configuration
+
+ autoload :Worker
+ autoload :ActiveRecordConnectionManagement, "action_cable/server/worker/active_record_connection_management"
+ end
+ end
+end
diff --git a/actioncable/lib/action_cable/server/base.rb b/actioncable/lib/action_cable/server/base.rb
new file mode 100644
index 0000000000..1ee03f6dfc
--- /dev/null
+++ b/actioncable/lib/action_cable/server/base.rb
@@ -0,0 +1,89 @@
+# frozen_string_literal: true
+
+require "monitor"
+
+module ActionCable
+ module Server
+ # A singleton ActionCable::Server instance is available via ActionCable.server. It's used by the Rack process that starts the Action Cable server, but
+ # is also used by the user to reach the RemoteConnections object, which is used for finding and disconnecting connections across all servers.
+ #
+ # Also, this is the server instance used for broadcasting. See Broadcasting for more information.
+ class Base
+ include ActionCable::Server::Broadcasting
+ include ActionCable::Server::Connections
+
+ cattr_accessor :config, instance_accessor: true, default: ActionCable::Server::Configuration.new
+
+ def self.logger; config.logger; end
+ delegate :logger, to: :config
+
+ attr_reader :mutex
+
+ def initialize
+ @mutex = Monitor.new
+ @remote_connections = @event_loop = @worker_pool = @pubsub = nil
+ end
+
+ # Called by Rack to setup the server.
+ def call(env)
+ setup_heartbeat_timer
+ config.connection_class.call.new(self, env).process
+ end
+
+ # Disconnect all the connections identified by +identifiers+ on this server or any others via RemoteConnections.
+ def disconnect(identifiers)
+ remote_connections.where(identifiers).disconnect
+ end
+
+ def restart
+ connections.each(&:close)
+
+ @mutex.synchronize do
+ # Shutdown the worker pool
+ @worker_pool.halt if @worker_pool
+ @worker_pool = nil
+
+ # Shutdown the pub/sub adapter
+ @pubsub.shutdown if @pubsub
+ @pubsub = nil
+ end
+ end
+
+ # Gateway to RemoteConnections. See that class for details.
+ def remote_connections
+ @remote_connections || @mutex.synchronize { @remote_connections ||= RemoteConnections.new(self) }
+ end
+
+ def event_loop
+ @event_loop || @mutex.synchronize { @event_loop ||= ActionCable::Connection::StreamEventLoop.new }
+ end
+
+ # The worker pool is where we run connection callbacks and channel actions. We do as little as possible on the server's main thread.
+ # The worker pool is an executor service that's backed by a pool of threads working from a task queue. The thread pool size maxes out
+ # at 4 worker threads by default. Tune the size yourself with <tt>config.action_cable.worker_pool_size</tt>.
+ #
+ # Using Active Record, Redis, etc within your channel actions means you'll get a separate connection from each thread in the worker pool.
+ # Plan your deployment accordingly: 5 servers each running 5 Puma workers each running an 8-thread worker pool means at least 200 database
+ # connections.
+ #
+ # Also, ensure that your database connection pool size is as least as large as your worker pool size. Otherwise, workers may oversubscribe
+ # the database connection pool and block while they wait for other workers to release their connections. Use a smaller worker pool or a larger
+ # database connection pool instead.
+ def worker_pool
+ @worker_pool || @mutex.synchronize { @worker_pool ||= ActionCable::Server::Worker.new(max_size: config.worker_pool_size) }
+ end
+
+ # Adapter used for all streams/broadcasting.
+ def pubsub
+ @pubsub || @mutex.synchronize { @pubsub ||= config.pubsub_adapter.new(self) }
+ end
+
+ # All of the identifiers applied to the connection class associated with this server.
+ def connection_identifiers
+ config.connection_class.call.identifiers
+ end
+ end
+
+ ActiveSupport.run_load_hooks(:action_cable, Base.config)
+ end
+end
diff --git a/actioncable/lib/action_cable/server/broadcasting.rb b/actioncable/lib/action_cable/server/broadcasting.rb
new file mode 100644
index 0000000000..bc54d784b3
--- /dev/null
+++ b/actioncable/lib/action_cable/server/broadcasting.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+module ActionCable
+ module Server
+ # Broadcasting is how other parts of your application can send messages to a channel's subscribers. As explained in Channel, most of the time, these
+ # broadcastings are streamed directly to the clients subscribed to the named broadcasting. Let's explain with a full-stack example:
+ #
+ # class WebNotificationsChannel < ApplicationCable::Channel
+ # def subscribed
+ # stream_from "web_notifications_#{current_user.id}"
+ # end
+ # end
+ #
+ # # Somewhere in your app this is called, perhaps from a NewCommentJob:
+ # ActionCable.server.broadcast \
+ # "web_notifications_1", { title: "New things!", body: "All that's fit for print" }
+ #
+ # # Client-side CoffeeScript, which assumes you've already requested the right to send web notifications:
+ # App.cable.subscriptions.create "WebNotificationsChannel",
+ # received: (data) ->
+ # new Notification data['title'], body: data['body']
+ module Broadcasting
+ # Broadcast a hash directly to a named <tt>broadcasting</tt>. This will later be JSON encoded.
+ def broadcast(broadcasting, message, coder: ActiveSupport::JSON)
+ broadcaster_for(broadcasting, coder: coder).broadcast(message)
+ end
+
+ # Returns a broadcaster for a named <tt>broadcasting</tt> that can be reused. Useful when you have an object that
+ # may need multiple spots to transmit to a specific broadcasting over and over.
+ def broadcaster_for(broadcasting, coder: ActiveSupport::JSON)
+ Broadcaster.new(self, String(broadcasting), coder: coder)
+ end
+
+ private
+ class Broadcaster
+ attr_reader :server, :broadcasting, :coder
+
+ def initialize(server, broadcasting, coder:)
+ @server, @broadcasting, @coder = server, broadcasting, coder
+ end
+
+ def broadcast(message)
+ server.logger.debug "[ActionCable] Broadcasting to #{broadcasting}: #{message.inspect}"
+
+ payload = { broadcasting: broadcasting, message: message, coder: coder }
+ ActiveSupport::Notifications.instrument("broadcast.action_cable", payload) do
+ encoded = coder ? coder.encode(message) : message
+ server.pubsub.broadcast broadcasting, encoded
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/actioncable/lib/action_cable/server/configuration.rb b/actioncable/lib/action_cable/server/configuration.rb
new file mode 100644
index 0000000000..26209537df
--- /dev/null
+++ b/actioncable/lib/action_cable/server/configuration.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+module ActionCable
+ module Server
+ # An instance of this configuration object is available via ActionCable.server.config, which allows you to tweak Action Cable configuration
+ # in a Rails config initializer.
+ class Configuration
+ attr_accessor :logger, :log_tags
+ attr_accessor :connection_class, :worker_pool_size
+ attr_accessor :disable_request_forgery_protection, :allowed_request_origins, :allow_same_origin_as_host
+ attr_accessor :cable, :url, :mount_path
+
+ def initialize
+ @log_tags = []
+
+ @connection_class = -> { ActionCable::Connection::Base }
+ @worker_pool_size = 4
+
+ @disable_request_forgery_protection = false
+ @allow_same_origin_as_host = true
+ end
+
+ # Returns constant of subscription adapter specified in config/cable.yml.
+ # If the adapter cannot be found, this will default to the Redis adapter.
+ # Also makes sure proper dependencies are required.
+ def pubsub_adapter
+ adapter = (cable.fetch("adapter") { "redis" })
+
+ # Require the adapter itself and give useful feedback about
+ # 1. Missing adapter gems and
+ # 2. Adapter gems' missing dependencies.
+ path_to_adapter = "action_cable/subscription_adapter/#{adapter}"
+ begin
+ require path_to_adapter
+ rescue LoadError => e
+ # We couldn't require the adapter itself. Raise an exception that
+ # points out config typos and missing gems.
+ if e.path == path_to_adapter
+ # We can assume that a non-builtin adapter was specified, so it's
+ # either misspelled or missing from Gemfile.
+ raise e.class, "Could not load the '#{adapter}' Action Cable pubsub adapter. Ensure that the adapter is spelled correctly in config/cable.yml and that you've added the necessary adapter gem to your Gemfile.", e.backtrace
+
+ # Bubbled up from the adapter require. Prefix the exception message
+ # with some guidance about how to address it and reraise.
+ else
+ raise e.class, "Error loading the '#{adapter}' Action Cable pubsub adapter. Missing a gem it depends on? #{e.message}", e.backtrace
+ end
+ end
+
+ adapter = adapter.camelize
+ adapter = "PostgreSQL" if adapter == "Postgresql"
+ "ActionCable::SubscriptionAdapter::#{adapter}".constantize
+ end
+ end
+ end
+end
diff --git a/actioncable/lib/action_cable/server/connections.rb b/actioncable/lib/action_cable/server/connections.rb
new file mode 100644
index 0000000000..39557d63a7
--- /dev/null
+++ b/actioncable/lib/action_cable/server/connections.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+module ActionCable
+ module Server
+ # Collection class for all the connections that have been established on this specific server. Remember, usually you'll run many Action Cable servers, so
+ # you can't use this collection as a full list of all of the connections established against your application. Instead, use RemoteConnections for that.
+ module Connections # :nodoc:
+ BEAT_INTERVAL = 3
+
+ def connections
+ @connections ||= []
+ end
+
+ def add_connection(connection)
+ connections << connection
+ end
+
+ def remove_connection(connection)
+ connections.delete connection
+ end
+
+ # WebSocket connection implementations differ on when they'll mark a connection as stale. We basically never want a connection to go stale, as you
+ # then can't rely on being able to communicate with the connection. To solve this, a 3 second heartbeat runs on all connections. If the beat fails, we automatically
+ # disconnect.
+ def setup_heartbeat_timer
+ @heartbeat_timer ||= event_loop.timer(BEAT_INTERVAL) do
+ event_loop.post { connections.map(&:beat) }
+ end
+ end
+
+ def open_connections_statistics
+ connections.map(&:statistics)
+ end
+ end
+ end
+end
diff --git a/actioncable/lib/action_cable/server/worker.rb b/actioncable/lib/action_cable/server/worker.rb
new file mode 100644
index 0000000000..c69cc4ac31
--- /dev/null
+++ b/actioncable/lib/action_cable/server/worker.rb
@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+
+require "active_support/callbacks"
+require "active_support/core_ext/module/attribute_accessors_per_thread"
+require "concurrent"
+
+module ActionCable
+ module Server
+ # Worker used by Server.send_async to do connection work in threads.
+ class Worker # :nodoc:
+ include ActiveSupport::Callbacks
+
+ thread_mattr_accessor :connection
+ define_callbacks :work
+ include ActiveRecordConnectionManagement
+
+ attr_reader :executor
+
+ def initialize(max_size: 5)
+ @executor = Concurrent::ThreadPoolExecutor.new(
+ min_threads: 1,
+ max_threads: max_size,
+ max_queue: 0,
+ )
+ end
+
+ # Stop processing work: any work that has not already started
+ # running will be discarded from the queue
+ def halt
+ @executor.shutdown
+ end
+
+ def stopping?
+ @executor.shuttingdown?
+ end
+
+ def work(connection)
+ self.connection = connection
+
+ run_callbacks :work do
+ yield
+ end
+ ensure
+ self.connection = nil
+ end
+
+ def async_exec(receiver, *args, connection:, &block)
+ async_invoke receiver, :instance_exec, *args, connection: connection, &block
+ end
+
+ def async_invoke(receiver, method, *args, connection: receiver, &block)
+ @executor.post do
+ invoke(receiver, method, *args, connection: connection, &block)
+ end
+ end
+
+ def invoke(receiver, method, *args, connection:, &block)
+ work(connection) do
+ begin
+ receiver.send method, *args, &block
+ rescue Exception => e
+ logger.error "There was an exception - #{e.class}(#{e.message})"
+ logger.error e.backtrace.join("\n")
+
+ receiver.handle_exception if receiver.respond_to?(:handle_exception)
+ end
+ end
+ end
+
+ private
+
+ def logger
+ ActionCable.server.logger
+ end
+ end
+ end
+end
diff --git a/actioncable/lib/action_cable/server/worker/active_record_connection_management.rb b/actioncable/lib/action_cable/server/worker/active_record_connection_management.rb
new file mode 100644
index 0000000000..2e378d4bf3
--- /dev/null
+++ b/actioncable/lib/action_cable/server/worker/active_record_connection_management.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module ActionCable
+ module Server
+ class Worker
+ module ActiveRecordConnectionManagement
+ extend ActiveSupport::Concern
+
+ included do
+ if defined?(ActiveRecord::Base)
+ set_callback :work, :around, :with_database_connections
+ end
+ end
+
+ def with_database_connections
+ connection.logger.tag(ActiveRecord::Base.logger) { yield }
+ end
+ end
+ end
+ end
+end
diff --git a/actioncable/lib/action_cable/subscription_adapter.rb b/actioncable/lib/action_cable/subscription_adapter.rb
new file mode 100644
index 0000000000..6a9d5c2080
--- /dev/null
+++ b/actioncable/lib/action_cable/subscription_adapter.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+module ActionCable
+ module SubscriptionAdapter
+ extend ActiveSupport::Autoload
+
+ autoload :Base
+ autoload :Test
+ autoload :SubscriberMap
+ autoload :ChannelPrefix
+ end
+end
diff --git a/actioncable/lib/action_cable/subscription_adapter/async.rb b/actioncable/lib/action_cable/subscription_adapter/async.rb
new file mode 100644
index 0000000000..c9930299c7
--- /dev/null
+++ b/actioncable/lib/action_cable/subscription_adapter/async.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+require "action_cable/subscription_adapter/inline"
+
+module ActionCable
+ module SubscriptionAdapter
+ class Async < Inline # :nodoc:
+ private
+ def new_subscriber_map
+ AsyncSubscriberMap.new(server.event_loop)
+ end
+
+ class AsyncSubscriberMap < SubscriberMap
+ def initialize(event_loop)
+ @event_loop = event_loop
+ super()
+ end
+
+ def add_subscriber(*)
+ @event_loop.post { super }
+ end
+
+ def invoke_callback(*)
+ @event_loop.post { super }
+ end
+ end
+ end
+ end
+end
diff --git a/actioncable/lib/action_cable/subscription_adapter/base.rb b/actioncable/lib/action_cable/subscription_adapter/base.rb
new file mode 100644
index 0000000000..34077707fd
--- /dev/null
+++ b/actioncable/lib/action_cable/subscription_adapter/base.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+module ActionCable
+ module SubscriptionAdapter
+ class Base
+ attr_reader :logger, :server
+
+ def initialize(server)
+ @server = server
+ @logger = @server.logger
+ end
+
+ def broadcast(channel, payload)
+ raise NotImplementedError
+ end
+
+ def subscribe(channel, message_callback, success_callback = nil)
+ raise NotImplementedError
+ end
+
+ def unsubscribe(channel, message_callback)
+ raise NotImplementedError
+ end
+
+ def shutdown
+ raise NotImplementedError
+ end
+ end
+ end
+end
diff --git a/actioncable/lib/action_cable/subscription_adapter/channel_prefix.rb b/actioncable/lib/action_cable/subscription_adapter/channel_prefix.rb
new file mode 100644
index 0000000000..df0aa040f5
--- /dev/null
+++ b/actioncable/lib/action_cable/subscription_adapter/channel_prefix.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+module ActionCable
+ module SubscriptionAdapter
+ module ChannelPrefix # :nodoc:
+ def broadcast(channel, payload)
+ channel = channel_with_prefix(channel)
+ super
+ end
+
+ def subscribe(channel, callback, success_callback = nil)
+ channel = channel_with_prefix(channel)
+ super
+ end
+
+ def unsubscribe(channel, callback)
+ channel = channel_with_prefix(channel)
+ super
+ end
+
+ private
+ # Returns the channel name, including channel_prefix specified in cable.yml
+ def channel_with_prefix(channel)
+ [@server.config.cable[:channel_prefix], channel].compact.join(":")
+ end
+ end
+ end
+end
diff --git a/actioncable/lib/action_cable/subscription_adapter/inline.rb b/actioncable/lib/action_cable/subscription_adapter/inline.rb
new file mode 100644
index 0000000000..d2c85c1c8d
--- /dev/null
+++ b/actioncable/lib/action_cable/subscription_adapter/inline.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+module ActionCable
+ module SubscriptionAdapter
+ class Inline < Base # :nodoc:
+ def initialize(*)
+ super
+ @subscriber_map = nil
+ end
+
+ def broadcast(channel, payload)
+ subscriber_map.broadcast(channel, payload)
+ end
+
+ def subscribe(channel, callback, success_callback = nil)
+ subscriber_map.add_subscriber(channel, callback, success_callback)
+ end
+
+ def unsubscribe(channel, callback)
+ subscriber_map.remove_subscriber(channel, callback)
+ end
+
+ def shutdown
+ # nothing to do
+ end
+
+ private
+ def subscriber_map
+ @subscriber_map || @server.mutex.synchronize { @subscriber_map ||= new_subscriber_map }
+ end
+
+ def new_subscriber_map
+ SubscriberMap.new
+ end
+ end
+ end
+end
diff --git a/actioncable/lib/action_cable/subscription_adapter/postgresql.rb b/actioncable/lib/action_cable/subscription_adapter/postgresql.rb
new file mode 100644
index 0000000000..50ec438c3a
--- /dev/null
+++ b/actioncable/lib/action_cable/subscription_adapter/postgresql.rb
@@ -0,0 +1,130 @@
+# frozen_string_literal: true
+
+gem "pg", ">= 0.18", "< 2.0"
+require "pg"
+require "thread"
+require "digest/sha1"
+
+module ActionCable
+ module SubscriptionAdapter
+ class PostgreSQL < Base # :nodoc:
+ def initialize(*)
+ super
+ @listener = nil
+ end
+
+ def broadcast(channel, payload)
+ with_broadcast_connection do |pg_conn|
+ pg_conn.exec("NOTIFY #{pg_conn.escape_identifier(channel_identifier(channel))}, '#{pg_conn.escape_string(payload)}'")
+ end
+ end
+
+ def subscribe(channel, callback, success_callback = nil)
+ listener.add_subscriber(channel_identifier(channel), callback, success_callback)
+ end
+
+ def unsubscribe(channel, callback)
+ listener.remove_subscriber(channel_identifier(channel), callback)
+ end
+
+ def shutdown
+ listener.shutdown
+ end
+
+ def with_subscriptions_connection(&block) # :nodoc:
+ ar_conn = ActiveRecord::Base.connection_pool.checkout.tap do |conn|
+ # Action Cable is taking ownership over this database connection, and
+ # will perform the necessary cleanup tasks
+ ActiveRecord::Base.connection_pool.remove(conn)
+ end
+ pg_conn = ar_conn.raw_connection
+
+ verify!(pg_conn)
+ yield pg_conn
+ ensure
+ ar_conn.disconnect!
+ end
+
+ def with_broadcast_connection(&block) # :nodoc:
+ ActiveRecord::Base.connection_pool.with_connection do |ar_conn|
+ pg_conn = ar_conn.raw_connection
+ verify!(pg_conn)
+ yield pg_conn
+ end
+ end
+
+ private
+ def channel_identifier(channel)
+ channel.size > 63 ? Digest::SHA1.hexdigest(channel) : channel
+ end
+
+ def listener
+ @listener || @server.mutex.synchronize { @listener ||= Listener.new(self, @server.event_loop) }
+ end
+
+ def verify!(pg_conn)
+ unless pg_conn.is_a?(PG::Connection)
+ raise "The Active Record database must be PostgreSQL in order to use the PostgreSQL Action Cable storage adapter"
+ end
+ end
+
+ class Listener < SubscriberMap
+ def initialize(adapter, event_loop)
+ super()
+
+ @adapter = adapter
+ @event_loop = event_loop
+ @queue = Queue.new
+
+ @thread = Thread.new do
+ Thread.current.abort_on_exception = true
+ listen
+ end
+ end
+
+ def listen
+ @adapter.with_subscriptions_connection do |pg_conn|
+ catch :shutdown do
+ loop do
+ until @queue.empty?
+ action, channel, callback = @queue.pop(true)
+
+ case action
+ when :listen
+ pg_conn.exec("LISTEN #{pg_conn.escape_identifier channel}")
+ @event_loop.post(&callback) if callback
+ when :unlisten
+ pg_conn.exec("UNLISTEN #{pg_conn.escape_identifier channel}")
+ when :shutdown
+ throw :shutdown
+ end
+ end
+
+ pg_conn.wait_for_notify(1) do |chan, pid, message|
+ broadcast(chan, message)
+ end
+ end
+ end
+ end
+ end
+
+ def shutdown
+ @queue.push([:shutdown])
+ Thread.pass while @thread.alive?
+ end
+
+ def add_channel(channel, on_success)
+ @queue.push([:listen, channel, on_success])
+ end
+
+ def remove_channel(channel)
+ @queue.push([:unlisten, channel])
+ end
+
+ def invoke_callback(*)
+ @event_loop.post { super }
+ end
+ end
+ end
+ end
+end
diff --git a/actioncable/lib/action_cable/subscription_adapter/redis.rb b/actioncable/lib/action_cable/subscription_adapter/redis.rb
new file mode 100644
index 0000000000..ad8fa52760
--- /dev/null
+++ b/actioncable/lib/action_cable/subscription_adapter/redis.rb
@@ -0,0 +1,179 @@
+# frozen_string_literal: true
+
+require "thread"
+
+gem "redis", ">= 3", "< 5"
+require "redis"
+
+module ActionCable
+ module SubscriptionAdapter
+ class Redis < Base # :nodoc:
+ prepend ChannelPrefix
+
+ # Overwrite this factory method for Redis connections if you want to use a different Redis library than the redis gem.
+ # This is needed, for example, when using Makara proxies for distributed Redis.
+ cattr_accessor :redis_connector, default: ->(config) do
+ config[:id] ||= "ActionCable-PID-#{$$}"
+ ::Redis.new(config.slice(:url, :host, :port, :db, :password, :id))
+ end
+
+ def initialize(*)
+ super
+ @listener = nil
+ @redis_connection_for_broadcasts = nil
+ end
+
+ def broadcast(channel, payload)
+ redis_connection_for_broadcasts.publish(channel, payload)
+ end
+
+ def subscribe(channel, callback, success_callback = nil)
+ listener.add_subscriber(channel, callback, success_callback)
+ end
+
+ def unsubscribe(channel, callback)
+ listener.remove_subscriber(channel, callback)
+ end
+
+ def shutdown
+ @listener.shutdown if @listener
+ end
+
+ def redis_connection_for_subscriptions
+ redis_connection
+ end
+
+ private
+ def listener
+ @listener || @server.mutex.synchronize { @listener ||= Listener.new(self, @server.event_loop) }
+ end
+
+ def redis_connection_for_broadcasts
+ @redis_connection_for_broadcasts || @server.mutex.synchronize do
+ @redis_connection_for_broadcasts ||= redis_connection
+ end
+ end
+
+ def redis_connection
+ self.class.redis_connector.call(@server.config.cable)
+ end
+
+ class Listener < SubscriberMap
+ def initialize(adapter, event_loop)
+ super()
+
+ @adapter = adapter
+ @event_loop = event_loop
+
+ @subscribe_callbacks = Hash.new { |h, k| h[k] = [] }
+ @subscription_lock = Mutex.new
+
+ @raw_client = nil
+
+ @when_connected = []
+
+ @thread = nil
+ end
+
+ def listen(conn)
+ conn.without_reconnect do
+ original_client = conn.respond_to?(:_client) ? conn._client : conn.client
+
+ conn.subscribe("_action_cable_internal") do |on|
+ on.subscribe do |chan, count|
+ @subscription_lock.synchronize do
+ if count == 1
+ @raw_client = original_client
+
+ until @when_connected.empty?
+ @when_connected.shift.call
+ end
+ end
+
+ if callbacks = @subscribe_callbacks[chan]
+ next_callback = callbacks.shift
+ @event_loop.post(&next_callback) if next_callback
+ @subscribe_callbacks.delete(chan) if callbacks.empty?
+ end
+ end
+ end
+
+ on.message do |chan, message|
+ broadcast(chan, message)
+ end
+
+ on.unsubscribe do |chan, count|
+ if count == 0
+ @subscription_lock.synchronize do
+ @raw_client = nil
+ end
+ end
+ end
+ end
+ end
+ end
+
+ def shutdown
+ @subscription_lock.synchronize do
+ return if @thread.nil?
+
+ when_connected do
+ send_command("unsubscribe")
+ @raw_client = nil
+ end
+ end
+
+ Thread.pass while @thread.alive?
+ end
+
+ def add_channel(channel, on_success)
+ @subscription_lock.synchronize do
+ ensure_listener_running
+ @subscribe_callbacks[channel] << on_success
+ when_connected { send_command("subscribe", channel) }
+ end
+ end
+
+ def remove_channel(channel)
+ @subscription_lock.synchronize do
+ when_connected { send_command("unsubscribe", channel) }
+ end
+ end
+
+ def invoke_callback(*)
+ @event_loop.post { super }
+ end
+
+ private
+ def ensure_listener_running
+ @thread ||= Thread.new do
+ Thread.current.abort_on_exception = true
+
+ conn = @adapter.redis_connection_for_subscriptions
+ listen conn
+ end
+ end
+
+ def when_connected(&block)
+ if @raw_client
+ block.call
+ else
+ @when_connected << block
+ end
+ end
+
+ def send_command(*command)
+ @raw_client.write(command)
+
+ very_raw_connection =
+ @raw_client.connection.instance_variable_defined?(:@connection) &&
+ @raw_client.connection.instance_variable_get(:@connection)
+
+ if very_raw_connection && very_raw_connection.respond_to?(:flush)
+ very_raw_connection.flush
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/actioncable/lib/action_cable/subscription_adapter/subscriber_map.rb b/actioncable/lib/action_cable/subscription_adapter/subscriber_map.rb
new file mode 100644
index 0000000000..01cdc2dfa1
--- /dev/null
+++ b/actioncable/lib/action_cable/subscription_adapter/subscriber_map.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+module ActionCable
+ module SubscriptionAdapter
+ class SubscriberMap
+ def initialize
+ @subscribers = Hash.new { |h, k| h[k] = [] }
+ @sync = Mutex.new
+ end
+
+ def add_subscriber(channel, subscriber, on_success)
+ @sync.synchronize do
+ new_channel = !@subscribers.key?(channel)
+
+ @subscribers[channel] << subscriber
+
+ if new_channel
+ add_channel channel, on_success
+ elsif on_success
+ on_success.call
+ end
+ end
+ end
+
+ def remove_subscriber(channel, subscriber)
+ @sync.synchronize do
+ @subscribers[channel].delete(subscriber)
+
+ if @subscribers[channel].empty?
+ @subscribers.delete channel
+ remove_channel channel
+ end
+ end
+ end
+
+ def broadcast(channel, message)
+ list = @sync.synchronize do
+ return if !@subscribers.key?(channel)
+ @subscribers[channel].dup
+ end
+
+ list.each do |subscriber|
+ invoke_callback(subscriber, message)
+ end
+ end
+
+ def add_channel(channel, on_success)
+ on_success.call if on_success
+ end
+
+ def remove_channel(channel)
+ end
+
+ def invoke_callback(callback, message)
+ callback.call message
+ end
+ end
+ end
+end
diff --git a/actioncable/lib/action_cable/subscription_adapter/test.rb b/actioncable/lib/action_cable/subscription_adapter/test.rb
new file mode 100644
index 0000000000..ce604cc88e
--- /dev/null
+++ b/actioncable/lib/action_cable/subscription_adapter/test.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+require_relative "async"
+
+module ActionCable
+ module SubscriptionAdapter
+ # == Test adapter for Action Cable
+ #
+ # The test adapter should be used only in testing. Along with
+ # <tt>ActionCable::TestHelper</tt> it makes a great tool to test your Rails application.
+ #
+ # To use the test adapter set +adapter+ value to +test+ in your +config/cable.yml+ file.
+ #
+ # NOTE: Test adapter extends the <tt>ActionCable::SubscriptionsAdapter::Async</tt> adapter,
+ # so it could be used in system tests too.
+ class Test < Async
+ def broadcast(channel, payload)
+ broadcasts(channel) << payload
+ super
+ end
+
+ def broadcasts(channel)
+ channels_data[channel] ||= []
+ end
+
+ def clear_messages(channel)
+ channels_data[channel] = []
+ end
+
+ def clear
+ @channels_data = nil
+ end
+
+ private
+ def channels_data
+ @channels_data ||= {}
+ end
+ end
+ end
+end
diff --git a/actioncable/lib/action_cable/test_case.rb b/actioncable/lib/action_cable/test_case.rb
new file mode 100644
index 0000000000..d153259bf6
--- /dev/null
+++ b/actioncable/lib/action_cable/test_case.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+require "active_support/test_case"
+
+module ActionCable
+ class TestCase < ActiveSupport::TestCase
+ include ActionCable::TestHelper
+
+ ActiveSupport.run_load_hooks(:action_cable_test_case, self)
+ end
+end
diff --git a/actioncable/lib/action_cable/test_helper.rb b/actioncable/lib/action_cable/test_helper.rb
new file mode 100644
index 0000000000..7bc877663c
--- /dev/null
+++ b/actioncable/lib/action_cable/test_helper.rb
@@ -0,0 +1,133 @@
+# frozen_string_literal: true
+
+module ActionCable
+ # Provides helper methods for testing Action Cable broadcasting
+ module TestHelper
+ def before_setup # :nodoc:
+ server = ActionCable.server
+ test_adapter = ActionCable::SubscriptionAdapter::Test.new(server)
+
+ @old_pubsub_adapter = server.pubsub
+
+ server.instance_variable_set(:@pubsub, test_adapter)
+ super
+ end
+
+ def after_teardown # :nodoc:
+ super
+ ActionCable.server.instance_variable_set(:@pubsub, @old_pubsub_adapter)
+ end
+
+ # Asserts that the number of broadcasted messages to the stream matches the given number.
+ #
+ # def test_broadcasts
+ # assert_broadcasts 'messages', 0
+ # ActionCable.server.broadcast 'messages', { text: 'hello' }
+ # assert_broadcasts 'messages', 1
+ # ActionCable.server.broadcast 'messages', { text: 'world' }
+ # assert_broadcasts 'messages', 2
+ # end
+ #
+ # If a block is passed, that block should cause the specified number of
+ # messages to be broadcasted.
+ #
+ # def test_broadcasts_again
+ # assert_broadcasts('messages', 1) do
+ # ActionCable.server.broadcast 'messages', { text: 'hello' }
+ # end
+ #
+ # assert_broadcasts('messages', 2) do
+ # ActionCable.server.broadcast 'messages', { text: 'hi' }
+ # ActionCable.server.broadcast 'messages', { text: 'how are you?' }
+ # end
+ # end
+ #
+ def assert_broadcasts(stream, number)
+ if block_given?
+ original_count = broadcasts_size(stream)
+ yield
+ new_count = broadcasts_size(stream)
+ actual_count = new_count - original_count
+ else
+ actual_count = broadcasts_size(stream)
+ end
+
+ assert_equal number, actual_count, "#{number} broadcasts to #{stream} expected, but #{actual_count} were sent"
+ end
+
+ # Asserts that no messages have been sent to the stream.
+ #
+ # def test_no_broadcasts
+ # assert_no_broadcasts 'messages'
+ # ActionCable.server.broadcast 'messages', { text: 'hi' }
+ # assert_broadcasts 'messages', 1
+ # end
+ #
+ # If a block is passed, that block should not cause any message to be sent.
+ #
+ # def test_broadcasts_again
+ # assert_no_broadcasts 'messages' do
+ # # No job messages should be sent from this block
+ # end
+ # end
+ #
+ # Note: This assertion is simply a shortcut for:
+ #
+ # assert_broadcasts 'messages', 0, &block
+ #
+ def assert_no_broadcasts(stream, &block)
+ assert_broadcasts stream, 0, &block
+ end
+
+ # Asserts that the specified message has been sent to the stream.
+ #
+ # def test_assert_transmited_message
+ # ActionCable.server.broadcast 'messages', text: 'hello'
+ # assert_broadcast_on('messages', text: 'hello')
+ # end
+ #
+ # If a block is passed, that block should cause a message with the specified data to be sent.
+ #
+ # def test_assert_broadcast_on_again
+ # assert_broadcast_on('messages', text: 'hello') do
+ # ActionCable.server.broadcast 'messages', text: 'hello'
+ # end
+ # end
+ #
+ def assert_broadcast_on(stream, data)
+ # Encode to JSON and back–we want to use this value to compare
+ # with decoded JSON.
+ # Comparing JSON strings doesn't work due to the order if the keys.
+ serialized_msg =
+ ActiveSupport::JSON.decode(ActiveSupport::JSON.encode(data))
+
+ new_messages = broadcasts(stream)
+ if block_given?
+ old_messages = new_messages
+ clear_messages(stream)
+
+ yield
+ new_messages = broadcasts(stream)
+ clear_messages(stream)
+
+ # Restore all sent messages
+ (old_messages + new_messages).each { |m| pubsub_adapter.broadcast(stream, m) }
+ end
+
+ message = new_messages.find { |msg| ActiveSupport::JSON.decode(msg) == serialized_msg }
+
+ assert message, "No messages sent with #{data} to #{stream}"
+ end
+
+ def pubsub_adapter # :nodoc:
+ ActionCable.server.pubsub
+ end
+
+ delegate :broadcasts, :clear_messages, to: :pubsub_adapter
+
+ private
+ def broadcasts_size(channel)
+ broadcasts(channel).size
+ end
+ end
+end
diff --git a/actioncable/lib/action_cable/version.rb b/actioncable/lib/action_cable/version.rb
new file mode 100644
index 0000000000..86115c6065
--- /dev/null
+++ b/actioncable/lib/action_cable/version.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+require_relative "gem_version"
+
+module ActionCable
+ # Returns the version of the currently loaded Action Cable as a <tt>Gem::Version</tt>
+ def self.version
+ gem_version
+ end
+end
diff --git a/actioncable/lib/rails/generators/channel/USAGE b/actioncable/lib/rails/generators/channel/USAGE
new file mode 100644
index 0000000000..ea9662436c
--- /dev/null
+++ b/actioncable/lib/rails/generators/channel/USAGE
@@ -0,0 +1,12 @@
+Description:
+============
+ Stubs out a new cable channel for the server (in Ruby) and client (in JavaScript).
+ Pass the channel name, either CamelCased or under_scored, and an optional list of channel actions as arguments.
+
+Example:
+========
+ rails generate channel Chat speak
+
+ creates a Chat channel class and JavaScript asset:
+ Channel: app/channels/chat_channel.rb
+ Assets: app/javascript/channels/chat_channel.js
diff --git a/actioncable/lib/rails/generators/channel/channel_generator.rb b/actioncable/lib/rails/generators/channel/channel_generator.rb
new file mode 100644
index 0000000000..ef51981e89
--- /dev/null
+++ b/actioncable/lib/rails/generators/channel/channel_generator.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+module Rails
+ module Generators
+ class ChannelGenerator < NamedBase
+ source_root File.expand_path("templates", __dir__)
+
+ argument :actions, type: :array, default: [], banner: "method method"
+
+ class_option :assets, type: :boolean
+
+ check_class_collision suffix: "Channel"
+
+ def create_channel_file
+ template "channel.rb", File.join("app/channels", class_path, "#{file_name}_channel.rb")
+
+ if options[:assets]
+ if behavior == :invoke
+ template "javascript/index.js", "app/javascript/channels/index.js"
+ template "javascript/consumer.js", "app/javascript/channels/consumer.js"
+ end
+
+ js_template "javascript/channel", File.join("app/javascript/channels", class_path, "#{file_name}_channel")
+ end
+
+ generate_application_cable_files
+ end
+
+ private
+ def file_name
+ @_file_name ||= super.sub(/_channel\z/i, "")
+ end
+
+ # FIXME: Change these files to symlinks once RubyGems 2.5.0 is required.
+ def generate_application_cable_files
+ return if behavior != :invoke
+
+ files = [
+ "application_cable/channel.rb",
+ "application_cable/connection.rb"
+ ]
+
+ files.each do |name|
+ path = File.join("app/channels/", name)
+ template(name, path) if !File.exist?(path)
+ end
+ end
+ end
+ end
+end
diff --git a/actioncable/lib/rails/generators/channel/templates/application_cable/channel.rb.tt b/actioncable/lib/rails/generators/channel/templates/application_cable/channel.rb.tt
new file mode 100644
index 0000000000..d672697283
--- /dev/null
+++ b/actioncable/lib/rails/generators/channel/templates/application_cable/channel.rb.tt
@@ -0,0 +1,4 @@
+module ApplicationCable
+ class Channel < ActionCable::Channel::Base
+ end
+end
diff --git a/actioncable/lib/rails/generators/channel/templates/application_cable/connection.rb.tt b/actioncable/lib/rails/generators/channel/templates/application_cable/connection.rb.tt
new file mode 100644
index 0000000000..0ff5442f47
--- /dev/null
+++ b/actioncable/lib/rails/generators/channel/templates/application_cable/connection.rb.tt
@@ -0,0 +1,4 @@
+module ApplicationCable
+ class Connection < ActionCable::Connection::Base
+ end
+end
diff --git a/actioncable/lib/rails/generators/channel/templates/channel.rb.tt b/actioncable/lib/rails/generators/channel/templates/channel.rb.tt
new file mode 100644
index 0000000000..4bcfb2be4d
--- /dev/null
+++ b/actioncable/lib/rails/generators/channel/templates/channel.rb.tt
@@ -0,0 +1,16 @@
+<% module_namespacing do -%>
+class <%= class_name %>Channel < ApplicationCable::Channel
+ def subscribed
+ # stream_from "some_channel"
+ end
+
+ def unsubscribed
+ # Any cleanup needed when channel is unsubscribed
+ end
+<% actions.each do |action| -%>
+
+ def <%= action %>
+ end
+<% end -%>
+end
+<% end -%>
diff --git a/actioncable/lib/rails/generators/channel/templates/javascript/channel.js.tt b/actioncable/lib/rails/generators/channel/templates/javascript/channel.js.tt
new file mode 100644
index 0000000000..33baaa5a22
--- /dev/null
+++ b/actioncable/lib/rails/generators/channel/templates/javascript/channel.js.tt
@@ -0,0 +1,20 @@
+import consumer from "./consumer"
+
+consumer.subscriptions.create("<%= class_name %>Channel", {
+ connected: function() {
+ // Called when the subscription is ready for use on the server
+ },
+
+ disconnected: function() {
+ // Called when the subscription has been terminated by the server
+ },
+
+ received: function(data) {
+ // Called when there's incoming data on the websocket for this channel
+ }<%= actions.any? ? ",\n" : '' %>
+<% actions.each do |action| -%>
+ <%=action %>: function() {
+ return this.perform('<%= action %>');
+ }<%= action == actions[-1] ? '' : ",\n" %>
+<% end -%>
+});
diff --git a/actioncable/lib/rails/generators/channel/templates/javascript/consumer.js.tt b/actioncable/lib/rails/generators/channel/templates/javascript/consumer.js.tt
new file mode 100644
index 0000000000..76ca3d0f2f
--- /dev/null
+++ b/actioncable/lib/rails/generators/channel/templates/javascript/consumer.js.tt
@@ -0,0 +1,6 @@
+// Action Cable provides the framework to deal with WebSockets in Rails.
+// You can generate new channels where WebSocket features live using the `rails generate channel` command.
+
+import ActionCable from "actioncable"
+
+export default ActionCable.createConsumer()
diff --git a/actioncable/lib/rails/generators/channel/templates/javascript/index.js.tt b/actioncable/lib/rails/generators/channel/templates/javascript/index.js.tt
new file mode 100644
index 0000000000..0cfcf74919
--- /dev/null
+++ b/actioncable/lib/rails/generators/channel/templates/javascript/index.js.tt
@@ -0,0 +1,5 @@
+// Load all the channels within this directory and all subdirectories.
+// Channel files must be named *_channel.js.
+
+const channels = require.context('.', true, /_channel\.js$/)
+channels.keys().forEach(channels)