Transform actioncable documentation to Markdown

This commit is contained in:
Rafael Mendonça França 2024-02-10 00:04:49 +00:00
parent f0adde935e
commit 6fdd31d7db
No known key found for this signature in database
GPG Key ID: FC23B6D0F1EEE948
46 changed files with 736 additions and 582 deletions

@ -1,102 +1,112 @@
# frozen_string_literal: true
# :markup: markdown
require "set"
require "active_support/rescuable"
require "active_support/parameter_filter"
module ActionCable
module Channel
# = Action Cable \Channel \Base
# # Action Cable Channel Base
#
# 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.
# 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.
# 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.
# 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:
# 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]]
# class ChatChannel < ApplicationCable::Channel
# def subscribed
# @room = Chat::Room[params[:room_number]]
# end
#
# def speak(data)
# @room.speak data, user: current_user
# end
# 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.
#
# 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
# ## 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.
# remote-procedure call model. You can declare any public method on the channel
# (optionally taking a `data` 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)
# class AppearanceChannel < ApplicationCable::Channel
# def subscribed
# @connection_token = generate_connection_token
# 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.
# def unsubscribed
# current_user.disappear @connection_token
# end
#
# 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.
# def appear(data)
# current_user.appear @connection_token, on: data['appearing_on']
# end
#
# == Rejecting subscription requests
# 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
# `#appear` and `#away` are. `#generate_connection_token` 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. `#away` does not, since it's
# simply a trigger action.
#
# Also note that in this example, `current_user` 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)
# class ChatChannel < ApplicationCable::Channel
# def subscribed
# @room = Chat::Room[params[:room_number]]
# reject unless current_user.can_access?(@room)
# end
# 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.
# In this example, the subscription will be rejected if the `current_user` does
# not have access to the chat room. On the client-side, the `Channel#rejected`
# callback will get invoked when the server rejects the subscription request.
class Base
include Callbacks
include PeriodicTimers
@ -109,14 +119,13 @@ class Base
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.
# 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.
# #### Returns
# * `Set` - 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
@ -130,9 +139,9 @@ def action_methods
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.
# 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
@ -161,9 +170,9 @@ def initialize(connection, identifier, params = {})
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).
# 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)
@ -177,8 +186,8 @@ def perform_action(data)
end
end
# This method is called after subscription has been added to the connection
# and confirms or rejects the subscription.
# 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
@ -188,8 +197,9 @@ def subscribe_to_channel
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, override the #unsubscribed callback.
# 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, override the #unsubscribed callback.
def unsubscribe_from_channel # :nodoc:
run_callbacks :unsubscribe do
unsubscribed
@ -197,20 +207,22 @@ def unsubscribe_from_channel # :nodoc:
end
private
# Called once a consumer has become a subscriber of the channel. Usually the place to set up any streams
# you want this channel to be sending to the subscriber.
# Called once a consumer has become a subscriber of the channel. Usually the
# place to set up 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.
# 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.
# 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:
logger.debug do
status = "#{self.class.name} transmitting #{data.inspect.truncate(300)}"

@ -1,5 +1,7 @@
# frozen_string_literal: true
# :markup: markdown
require "active_support/core_ext/object/to_param"
module ActionCable
@ -8,17 +10,17 @@ module Broadcasting
extend ActiveSupport::Concern
module ClassMethods
# Broadcast a hash to a unique broadcasting for this <tt>model</tt> in this channel.
# Broadcast a hash to a unique broadcasting for this `model` in this channel.
def broadcast_to(model, message)
ActionCable.server.broadcast(broadcasting_for(model), message)
end
# Returns a unique broadcasting identifier for this <tt>model</tt> in this channel:
# Returns a unique broadcasting identifier for this `model` in this channel:
#
# CommentsChannel.broadcasting_for("all") # => "comments:all"
# CommentsChannel.broadcasting_for("all") # => "comments:all"
#
# You can pass any object as a target (e.g. Active Record model), and it
# would be serialized into a string under the hood.
# You can pass any object as a target (e.g. Active Record model), and it would
# be serialized into a string under the hood.
def broadcasting_for(model)
serialize_broadcasting([ channel_name, model ])
end

@ -1,36 +1,39 @@
# frozen_string_literal: true
# :markup: markdown
require "active_support/callbacks"
module ActionCable
module Channel
# = Action Cable \Channel \Callbacks
# # Action Cable Channel Callbacks
#
# Action Cable Channel provides callback hooks that are invoked during the
# life cycle of a channel:
# Action Cable Channel provides callback hooks that are invoked during the life
# cycle of a channel:
#
# * {before_subscribe}[rdoc-ref:ClassMethods#before_subscribe]
# * {after_subscribe}[rdoc-ref:ClassMethods#after_subscribe] (aliased as
# {on_subscribe}[rdoc-ref:ClassMethods#on_subscribe])
# * {before_unsubscribe}[rdoc-ref:ClassMethods#before_unsubscribe]
# * {after_unsubscribe}[rdoc-ref:ClassMethods#after_unsubscribe] (aliased as
# {on_unsubscribe}[rdoc-ref:ClassMethods#on_unsubscribe])
# * [before_subscribe](rdoc-ref:ClassMethods#before_subscribe)
# * [after_subscribe](rdoc-ref:ClassMethods#after_subscribe) (aliased as
# [on_subscribe](rdoc-ref:ClassMethods#on_subscribe))
# * [before_unsubscribe](rdoc-ref:ClassMethods#before_unsubscribe)
# * [after_unsubscribe](rdoc-ref:ClassMethods#after_unsubscribe) (aliased as
# [on_unsubscribe](rdoc-ref:ClassMethods#on_unsubscribe))
#
# ==== Example
#
# class ChatChannel < ApplicationCable::Channel
# after_subscribe :send_welcome_message, unless: :subscription_rejected?
# after_subscribe :track_subscription
# #### Example
#
# private
# def send_welcome_message
# broadcast_to(...)
# end
# class ChatChannel < ApplicationCable::Channel
# after_subscribe :send_welcome_message, unless: :subscription_rejected?
# after_subscribe :track_subscription
#
# def track_subscription
# # ...
# end
# end
# private
# def send_welcome_message
# broadcast_to(...)
# end
#
# def track_subscription
# # ...
# end
# end
#
module Callbacks
extend ActiveSupport::Concern
@ -46,14 +49,13 @@ def before_subscribe(*methods, &block)
set_callback(:subscribe, :before, *methods, &block)
end
# This callback will be triggered after the Base#subscribed method is
# called, even if the subscription was rejected with the Base#reject
# method.
# This callback will be triggered after the Base#subscribed method is called,
# even if the subscription was rejected with the Base#reject method.
#
# To trigger the callback only on successful subscriptions, use the
# Base#subscription_rejected? method:
#
# after_subscribe :my_method, unless: :subscription_rejected?
# after_subscribe :my_method, unless: :subscription_rejected?
#
def after_subscribe(*methods, &block)
set_callback(:subscribe, :after, *methods, &block)

@ -1,18 +1,20 @@
# frozen_string_literal: true
# :markup: markdown
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
# Returns the name of the channel, underscored, without the `Channel` 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'
# ChatChannel.channel_name # => 'chat'
# Chats::AppearancesChannel.channel_name # => 'chats:appearances'
# FooChats::BarAppearancesChannel.channel_name # => 'foo_chats:bar_appearances'
def channel_name
@channel_name ||= name.delete_suffix("Channel").gsub("::", ":").underscore
end

@ -1,5 +1,7 @@
# frozen_string_literal: true
# :markup: markdown
module ActionCable
module Channel
module PeriodicTimers
@ -13,14 +15,12 @@ module PeriodicTimers
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.
# 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.
# Pass a method name or lambda argument or provide a block to call. Specify the
# calling period in seconds using the `every:` keyword argument.
#
# periodically :transmit_progress, every: 5.seconds
#

@ -1,67 +1,77 @@
# frozen_string_literal: true
# :markup: markdown
module ActionCable
module Channel
# = Action Cable \Channel \Streams
# # Action Cable Channel Streams
#
# 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.
# 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:
# 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']}"
# class CommentsChannel < ApplicationCable::Channel
# def follow(data)
# stream_from "comments_for_#{data['recording_id']}"
# end
#
# def unfollow
# stop_all_streams
# end
# 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.
# Based on the above example, the subscribers of this channel will get whatever
# data is put into the, let's say, `comments_for_45` 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' }
# 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>.
# 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 `comments:Z2lkOi8vVGVzdEFwcC9Qb3N0LzE`.
#
# class CommentsChannel < ApplicationCable::Channel
# def subscribed
# post = Post.find(params[:id])
# stream_for post
# class CommentsChannel < ApplicationCable::Channel
# def subscribed
# post = Post.find(params[:id])
# stream_for post
# end
# end
# end
#
# You can then broadcast to this channel using:
#
# CommentsChannel.broadcast_to(@post, @comment)
# 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:
# 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]]
# 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)
# 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"
# 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
#
# transmit message
# end
# end
# end
#
# You can stop streaming from all broadcasts by calling #stop_all_streams.
module Streams
@ -71,18 +81,20 @@ module Streams
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.
# Start streaming from the named `broadcasting` pubsub queue. Optionally, you
# can pass a `callback` that'll be used instead of the default of just
# transmitting the updates straight to the subscriber. Pass `coder:
# ActiveSupport::JSON` to decode messages as JSON before passing to the
# callback. Defaults to `coder: nil` 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.
# 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
@ -94,17 +106,18 @@ def stream_from(broadcasting, callback = nil, coder: nil, &block)
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.
# Start streaming the pubsub queue for the `model` in this channel. Optionally,
# you can pass a `callback` 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.
# Pass `coder: ActiveSupport::JSON` to decode messages as JSON before passing to
# the callback. Defaults to `coder: nil` which does no decoding, passes raw
# messages.
def stream_for(model, callback = nil, coder: nil, &block)
stream_from(broadcasting_for(model), callback || block, coder: coder)
end
# Unsubscribes streams from the named <tt>broadcasting</tt>.
# Unsubscribes streams from the named `broadcasting`.
def stop_stream_from(broadcasting)
callback = streams.delete(broadcasting)
if callback
@ -113,7 +126,7 @@ def stop_stream_from(broadcasting)
end
end
# Unsubscribes streams for the <tt>model</tt>.
# Unsubscribes streams for the `model`.
def stop_stream_for(model)
stop_stream_from(broadcasting_for(model))
end
@ -126,7 +139,7 @@ def stop_all_streams
end.clear
end
# Calls stream_for with the given <tt>model</tt> if it's present to start streaming,
# Calls stream_for with the given `model` if it's present to start streaming,
# otherwise rejects the subscription.
def stream_or_reject_for(model)
if model
@ -143,8 +156,8 @@ def streams
@_streams ||= {}
end
# Always wrap the outermost handler to invoke the user handler on the
# worker pool rather than blocking the event loop.
# 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)
@ -153,8 +166,8 @@ def worker_pool_stream_handler(broadcasting, user_handler, coder: nil)
end
end
# May be overridden to add instrumentation, logging, specialized error
# handling, or other forms of handler decoration.
# 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)
@ -165,14 +178,14 @@ def stream_handler(broadcasting, user_handler, coder: nil)
end
end
# May be overridden to change the default stream handling behavior
# which decodes JSON and transmits to the client.
# 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.
# 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

@ -1,5 +1,7 @@
# frozen_string_literal: true
# :markup: markdown
require "active_support"
require "active_support/test_case"
require "active_support/core_ext/hash/indifferent_access"
@ -15,11 +17,10 @@ def initialize(name)
end
end
# = Action Cable \Channel Stub
# # Action Cable Channel Stub
#
# Stub +stream_from+ to track streams for the channel.
# Add public aliases for +subscription_confirmation_sent?+ and
# +subscription_rejected?+.
# 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?
@ -86,103 +87,104 @@ def connection_gid(ids)
# Superclass for Action Cable channel functional tests.
#
# == Basic example
# ## 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.
# 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
# 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 subscription was successfully created
# assert subscription.confirmed?
#
# # Asserts that the channel subscribes connection to a stream
# assert_has_stream "chat_1"
# # Asserts that the channel subscribes connection to a stream
# assert_has_stream "chat_1"
#
# # Asserts that the channel subscribes connection to a specific
# # stream created for a model
# assert_has_stream_for Room.find(1)
# # Asserts that the channel subscribes connection to a specific
# # stream created for a model
# assert_has_stream_for Room.find(1)
# end
#
# def test_does_not_stream_with_incorrect_room_number
# subscribe room_number: -1
#
# # Asserts that not streams was started
# assert_no_streams
# end
#
# def test_does_not_subscribe_without_room_number
# subscribe
#
# # Asserts that the subscription was rejected
# assert subscription.rejected?
# end
# end
#
# def test_does_not_stream_with_incorrect_room_number
# subscribe room_number: -1
#
# # Asserts that not streams was started
# assert_no_streams
# 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
# def test_perform_speak
# subscribe room_number: 1
#
# perform :speak, message: "Hello, Rails!"
# perform :speak, message: "Hello, Rails!"
#
# assert_equal "Hello, Rails!", transmissions.last["text"]
# end
# assert_equal "Hello, Rails!", transmissions.last["text"]
# end
#
# == Special methods
# ## Special methods
#
# ActionCable::Channel::TestCase will also automatically provide the following instance
# methods for use in the tests:
# 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.
# **connection**
# : An ActionCable::Channel::ConnectionStub, representing the current HTTP
# connection.
# **subscription**
# : An instance of the current channel, created when you call `subscribe`.
# **transmissions**
# : A list of all messages that have been transmitted into the channel.
#
#
# == Channel is automatically inferred
# ## 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 name, you can explicitly set it with `tests`.
#
# class SpecialEdgeCaseChannelTest < ActionCable::Channel::TestCase
# tests SpecialChannel
# end
# class SpecialEdgeCaseChannelTest < ActionCable::Channel::TestCase
# tests SpecialChannel
# end
#
# == Specifying connection identifiers
# ## Specifying connection identifiers
#
# You need to set up your connection manually to provide values for the identifiers.
# To do this just use:
# You need to set up your connection manually to provide values for the
# identifiers. To do this just use:
#
# stub_connection(user: users(:john))
# stub_connection(user: users(:john))
#
# == Testing broadcasting
# ## Testing broadcasting
#
# ActionCable::Channel::TestCase enhances ActionCable::TestHelper assertions (e.g.
# +assert_broadcasts+) to handle broadcasting to models:
# 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
#
# # in your channel
# def speak(data)
# broadcast_to room, text: data["message"]
# end
# def test_speak
# subscribe room_id: rooms(:chat).id
#
# def test_speak
# subscribe room_id: rooms(:chat).id
#
# assert_broadcast_on(rooms(:chat), text: "Hello, Rails!") do
# perform :speak, message: "Hello, Rails!"
# end
# end
# assert_broadcast_on(rooms(:chat), text: "Hello, Rails!") do
# perform :speak, message: "Hello, Rails!"
# end
# end
class TestCase < ActiveSupport::TestCase
module Behavior
extend ActiveSupport::Concern
@ -231,16 +233,17 @@ def determine_default_channel(name)
# Set up test connection with the specified identifiers:
#
# class ApplicationCable < ActionCable::Connection::Base
# identified_by :user, :token
# end
# class ApplicationCable < ActionCable::Connection::Base
# identified_by :user, :token
# end
#
# stub_connection(user: users[:john], token: 'my-secret-token')
# 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.
# 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)
@ -269,8 +272,7 @@ def transmissions
connection.transmissions.filter_map { |data| data["message"] }
end
# Enhance TestHelper assertions to handle non-String
# broadcastings
# Enhance TestHelper assertions to handle non-String broadcastings
def assert_broadcasts(stream_or_object, *args)
super(broadcasting_for(stream_or_object), *args)
end
@ -281,10 +283,10 @@ def assert_broadcast_on(stream_or_object, *args)
# Asserts that no streams have been started.
#
# def test_assert_no_started_stream
# subscribe
# assert_no_streams
# end
# def test_assert_no_started_stream
# subscribe
# assert_no_streams
# end
#
def assert_no_streams
assert subscription.streams.empty?, "No streams started was expected, but #{subscription.streams.count} found"
@ -292,10 +294,10 @@ def assert_no_streams
# Asserts that the specified stream has been started.
#
# def test_assert_started_stream
# subscribe
# assert_has_stream 'messages'
# end
# def test_assert_started_stream
# subscribe
# assert_has_stream 'messages'
# end
#
def assert_has_stream(stream)
assert subscription.streams.include?(stream), "Stream #{stream} has not been started"
@ -303,10 +305,10 @@ def assert_has_stream(stream)
# Asserts that the specified stream for a model has started.
#
# def test_assert_started_stream_for
# subscribe id: 42
# assert_has_stream_for User.find(42)
# end
# def test_assert_started_stream_for
# subscribe id: 42
# assert_has_stream_for User.find(42)
# end
#
def assert_has_stream_for(object)
assert_has_stream(broadcasting_for(object))
@ -314,10 +316,10 @@ def assert_has_stream_for(object)
# Asserts that the specified stream has not been started.
#
# def test_assert_no_started_stream
# subscribe
# assert_has_no_stream 'messages'
# end
# def test_assert_no_started_stream
# subscribe
# assert_has_no_stream 'messages'
# end
#
def assert_has_no_stream(stream)
assert subscription.streams.exclude?(stream), "Stream #{stream} has been started"
@ -325,10 +327,10 @@ def assert_has_no_stream(stream)
# Asserts that the specified stream for a model has not started.
#
# def test_assert_no_started_stream_for
# subscribe id: 41
# assert_has_no_stream_for User.find(42)
# end
# def test_assert_no_started_stream_for
# subscribe id: 41
# assert_has_no_stream_for User.find(42)
# end
#
def assert_has_no_stream_for(object)
assert_has_no_stream(broadcasting_for(object))

@ -1,11 +1,14 @@
# frozen_string_literal: true
# :markup: markdown
module ActionCable
module Connection
module Authorization
class UnauthorizedError < StandardError; end
# Closes the WebSocket connection if it is open and returns an "unauthorized" reason.
# Closes the WebSocket connection if it is open and returns an "unauthorized"
# reason.
def reject_unauthorized_connection
logger.error "An unauthorized connection attempt was rejected"
raise UnauthorizedError

@ -1,48 +1,57 @@
# frozen_string_literal: true
# :markup: markdown
require "action_dispatch"
require "active_support/rescuable"
module ActionCable
module Connection
# = Action Cable \Connection \Base
# # Action Cable Connection Base
#
# 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.
# 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
# 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
# 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
# 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.
# 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.
# 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.
# 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
@ -69,8 +78,10 @@ def initialize(server, env, coder: ActiveSupport::JSON)
@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.
# 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
@ -115,13 +126,15 @@ def close(reason: nil, reconnect: true)
websocket.close
end
# Invoke a method on the connection asynchronously through the pool of thread workers.
# 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.
# Return a basic hash of statistics for the connection keyed with `identifier`,
# `started_at`, `subscriptions`, and `request_id`. This can be returned by a
# health check against the connection.
def statistics
{
identifier: connection_identifier,
@ -160,7 +173,8 @@ def inspect # :nodoc:
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.
# 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
@ -168,7 +182,8 @@ def request # :doc:
end
end
# The cookies of the request that initiated the WebSocket connection. Useful for performing authorization checks.
# The cookies of the request that initiated the WebSocket connection. Useful for
# performing authorization checks.
def cookies # :doc:
request.cookie_jar
end
@ -205,9 +220,8 @@ def handle_close
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.
# 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
@ -238,7 +252,8 @@ def respond_to_invalid_request
[ 404, { Rack::CONTENT_TYPE => "text/plain; charset=utf-8" }, [ "Page not found" ] ]
end
# Tags are declared in the server but computed in the connection. This allows us per-connection tailored tags.
# 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 }

@ -1,33 +1,35 @@
# frozen_string_literal: true
# :markup: markdown
require "active_support/callbacks"
module ActionCable
module Connection
# = Action Cable \Connection \Callbacks
# # Action Cable Connection Callbacks
#
# The {before_command}[rdoc-ref:ClassMethods#before_command],
# {after_command}[rdoc-ref:ClassMethods#after_command], and
# {around_command}[rdoc-ref:ClassMethods#around_command] callbacks are
# invoked when sending commands to the client, such as when subscribing,
# unsubscribing, or performing an action.
# The [before_command](rdoc-ref:ClassMethods#before_command),
# [after_command](rdoc-ref:ClassMethods#after_command), and
# [around_command](rdoc-ref:ClassMethods#around_command) callbacks are invoked
# when sending commands to the client, such as when subscribing, unsubscribing,
# or performing an action.
#
# ==== Example
# #### Example
#
# module ApplicationCable
# class Connection < ActionCable::Connection::Base
# identified_by :user
# module ApplicationCable
# class Connection < ActionCable::Connection::Base
# identified_by :user
#
# around_command :set_current_account
# around_command :set_current_account
#
# private
# private
#
# def set_current_account
# # Now all channels could use Current.account
# Current.set(account: user.account) { yield }
# end
# end
# end
# def set_current_account
# # Now all channels could use Current.account
# Current.set(account: user.account) { yield }
# end
# end
# end
#
module Callbacks
extend ActiveSupport::Concern

@ -1,5 +1,7 @@
# frozen_string_literal: true
# :markup: markdown
require "websocket/driver"
module ActionCable
@ -43,7 +45,7 @@ def initialize(env, event_target, event_loop, protocols)
@ready_state = CONNECTING
# The driver calls +env+, +url+, and +write+
# The driver calls `env`, `url`, and `write`
@driver = ::WebSocket::Driver.rack(self, protocols: protocols)
@driver.on(:open) { |e| open }

@ -1,5 +1,7 @@
# frozen_string_literal: true
# :markup: markdown
require "set"
module ActionCable
@ -12,18 +14,20 @@ module Identification
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.
# 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.
# 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.
# 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.filter_map { |id| instance_variable_get("@#{id}") }

@ -1,10 +1,13 @@
# frozen_string_literal: true
# :markup: markdown
module ActionCable
module Connection
# = Action Cable \InternalChannel
# # Action Cable InternalChannel
#
# Makes it possible for the RemoteConnection to disconnect a specific connection.
# Makes it possible for the RemoteConnection to disconnect a specific
# connection.
module InternalChannel
extend ActiveSupport::Concern

@ -1,8 +1,11 @@
# frozen_string_literal: true
# :markup: markdown
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.
# 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

@ -1,5 +1,7 @@
# frozen_string_literal: true
# :markup: markdown
module ActionCable
module Connection
#--

@ -1,5 +1,7 @@
# frozen_string_literal: true
# :markup: markdown
require "nio"
module ActionCable
@ -116,9 +118,8 @@ def run
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
# 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

@ -1,13 +1,16 @@
# frozen_string_literal: true
# :markup: markdown
require "active_support/core_ext/hash/indifferent_access"
module ActionCable
module Connection
# = Action Cable \Connection \Subscriptions
# # Action Cable Connection Subscriptions
#
# 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.
# 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

@ -1,12 +1,15 @@
# frozen_string_literal: true
# :markup: markdown
module ActionCable
module Connection
# = Action Cable \Connection \TaggedLoggerProxy
# # Action Cable Connection TaggedLoggerProxy
#
# Allows the use of per-connection tags against the server logger. This wouldn't work using the traditional
# ActiveSupport::TaggedLogging 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.
# Allows the use of per-connection tags against the server logger. This wouldn't
# work using the traditional ActiveSupport::TaggedLogging 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

@ -1,5 +1,7 @@
# frozen_string_literal: true
# :markup: markdown
require "active_support"
require "active_support/test_case"
require "active_support/core_ext/hash/indifferent_access"
@ -18,18 +20,19 @@ def initialize(name)
end
module Assertions
# Asserts that the connection is rejected (via +reject_unauthorized_connection+).
# Asserts that the connection is rejected (via
# `reject_unauthorized_connection`).
#
# # Asserts that connection without user_id fails
# assert_reject_connection { connect params: { user_id: '' } }
# # Asserts that connection without user_id fails
# assert_reject_connection { connect params: { user_id: '' } }
def assert_reject_connection(&block)
assert_raises(Authorization::UnauthorizedError, "Expected to reject connection but no rejection was made", &block)
end
end
# We don't want to use the whole "encryption stack" for connection
# unit-tests, but we want to make sure that users test against the correct types
# of cookies (i.e. signed or encrypted or plain)
# We don't want to use the whole "encryption stack" for connection unit-tests,
# but we want to make sure that users test against the correct types of cookies
# (i.e. signed or encrypted or plain)
class TestCookieJar < ActiveSupport::HashWithIndifferentAccess
def signed
self[:signed] ||= {}.with_indifferent_access
@ -56,77 +59,77 @@ def initialize(request)
end
end
# = Action Cable \Connection \TestCase
# # Action Cable Connection TestCase
#
# Unit test Action Cable connections.
#
# Useful to check whether a connection's +identified_by+ gets assigned properly
# Useful to check whether a connection's `identified_by` gets assigned properly
# and that any improper connection requests are rejected.
#
# == Basic example
# ## Basic example
#
# Unit tests are written as follows:
#
# 1. Simulate a connection attempt by calling +connect+.
# 2. Assert state, e.g. identifiers, has been assigned.
# 1. Simulate a connection attempt by calling `connect`.
# 2. Assert state, e.g. identifiers, has been assigned.
#
#
# class ApplicationCable::ConnectionTest < ActionCable::Connection::TestCase
# def test_connects_with_proper_cookie
# # Simulate the connection request with a cookie.
# cookies["user_id"] = users(:john).id
# class ApplicationCable::ConnectionTest < ActionCable::Connection::TestCase
# def test_connects_with_proper_cookie
# # Simulate the connection request with a cookie.
# cookies["user_id"] = users(:john).id
#
# connect
# connect
#
# # Assert the connection identifier matches the fixture.
# assert_equal users(:john).id, connection.user.id
# # Assert the connection identifier matches the fixture.
# assert_equal users(:john).id, connection.user.id
# end
#
# def test_rejects_connection_without_proper_cookie
# assert_reject_connection { connect }
# end
# end
#
# def test_rejects_connection_without_proper_cookie
# assert_reject_connection { connect }
# `connect` accepts additional information about the HTTP request with the
# `params`, `headers`, `session`, and Rack `env` options.
#
# def test_connect_with_headers_and_query_string
# connect params: { user_id: 1 }, headers: { "X-API-TOKEN" => "secret-my" }
#
# assert_equal "1", connection.user.id
# assert_equal "secret-my", connection.token
# end
# end
#
# +connect+ accepts additional information about the HTTP request with the
# +params+, +headers+, +session+, and Rack +env+ options.
# def test_connect_with_params
# connect params: { user_id: 1 }
#
# def test_connect_with_headers_and_query_string
# connect params: { user_id: 1 }, headers: { "X-API-TOKEN" => "secret-my" }
#
# assert_equal "1", connection.user.id
# assert_equal "secret-my", connection.token
# end
#
# def test_connect_with_params
# connect params: { user_id: 1 }
#
# assert_equal "1", connection.user.id
# end
# assert_equal "1", connection.user.id
# end
#
# You can also set up the correct cookies before the connection request:
#
# def test_connect_with_cookies
# # Plain cookies:
# cookies["user_id"] = 1
# def test_connect_with_cookies
# # Plain cookies:
# cookies["user_id"] = 1
#
# # Or signed/encrypted:
# # cookies.signed["user_id"] = 1
# # cookies.encrypted["user_id"] = 1
# # Or signed/encrypted:
# # cookies.signed["user_id"] = 1
# # cookies.encrypted["user_id"] = 1
#
# connect
# connect
#
# assert_equal "1", connection.user_id
# end
# assert_equal "1", connection.user_id
# end
#
# == \Connection is automatically inferred
# ## Connection is automatically inferred
#
# ActionCable::Connection::TestCase will automatically infer the connection 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+.
# ActionCable::Connection::TestCase will automatically infer the connection
# 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 ConnectionTest < ActionCable::Connection::TestCase
# tests ApplicationCable::Connection
# end
# class ConnectionTest < ActionCable::Connection::TestCase
# tests ApplicationCable::Connection
# end
#
class TestCase < ActiveSupport::TestCase
module Behavior
@ -178,10 +181,10 @@ def determine_default_connection(name)
#
# Accepts request path as the first argument and the following request options:
#
# - params  URL parameters (Hash)
# - headers request headers (Hash)
# - session  session data (Hash)
# - env  additional Rack env configuration (Hash)
# * params  URL parameters (Hash)
# * headers request headers (Hash)
# * session  session data (Hash)
# * env  additional Rack env configuration (Hash)
def connect(path = ActionCable.server.config.mount_path, **request_params)
path ||= DEFAULT_PATH

@ -1,10 +1,12 @@
# frozen_string_literal: true
# :markup: markdown
require "websocket/driver"
module ActionCable
module Connection
# = Action Cable \Connection \WebSocket
# # Action Cable Connection WebSocket
#
# Wrap the real socket to minimize the externally-presented API
class WebSocket # :nodoc:

@ -1,5 +1,7 @@
# frozen_string_literal: true
# :markup: markdown
module ActionCable
def self.deprecator # :nodoc:
@deprecator ||= ActiveSupport::Deprecation.new

@ -1,5 +1,7 @@
# frozen_string_literal: true
# :markup: markdown
require "rails"
require "action_cable"
require "active_support/core_ext/hash/indifferent_access"
@ -72,9 +74,9 @@ class Engine < Rails::Engine # :nodoc:
ActiveSupport.on_load(:action_cable) do
ActionCable::Server::Worker.set_callback :work, :around, prepend: true do |_, inner|
app.executor.wrap(source: "application.action_cable") 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.
# 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

@ -1,7 +1,9 @@
# frozen_string_literal: true
# :markup: markdown
module ActionCable
# Returns the currently loaded version of Action Cable as a +Gem::Version+.
# Returns the currently loaded version of Action Cable as a `Gem::Version`.
def self.gem_version
Gem::Version.new VERSION::STRING
end

@ -1,35 +1,37 @@
# frozen_string_literal: true
# :markup: markdown
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:
# 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-turbo-track' => 'reload' %>
# </head>
# <head>
# <%= action_cable_meta_tag %>
# <%= javascript_include_tag 'application', 'data-turbo-track' => 'reload' %>
# </head>
#
# This is then used by Action Cable to determine the URL of your WebSocket server.
# Your JavaScript can then connect to the server without needing to specify the
# URL directly:
# This is then used by Action Cable to determine the URL of your WebSocket
# server. Your JavaScript can then connect to the server without needing to
# specify the URL directly:
#
# import Cable from "@rails/actioncable"
# window.Cable = Cable
# window.App = {}
# App.cable = Cable.createConsumer()
# import Cable from "@rails/actioncable"
# window.Cable = Cable
# window.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.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" />
# 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: (

@ -1,31 +1,33 @@
# frozen_string_literal: true
# :markup: markdown
require "active_support/core_ext/module/redefine_method"
module ActionCable
# = Action Cable Remote Connections
# # Action Cable Remote Connections
#
# 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
# ....
# module ApplicationCable
# class Connection < ActionCable::Connection::Base
# identified_by :current_user
# ....
# end
# end
# end
#
# ActionCable.server.remote_connections.where(current_user: User.find(1)).disconnect
# 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.
# This will disconnect all the connections established for `User.find(1)`,
# across all servers running on all machines, because it uses the internal
# channel that all of these servers are subscribed to.
#
# By default, server sends a "disconnect" message with "reconnect" flag set to true.
# You can override it by specifying the +reconnect+ option:
# By default, server sends a "disconnect" message with "reconnect" flag set to
# true. You can override it by specifying the `reconnect` option:
#
# ActionCable.server.remote_connections.where(current_user: User.find(1)).disconnect(reconnect: false)
# ActionCable.server.remote_connections.where(current_user: User.find(1)).disconnect(reconnect: false)
class RemoteConnections
attr_reader :server
@ -38,10 +40,11 @@ def where(identifier)
end
private
# = Action Cable Remote \Connection
# # Action Cable Remote Connection
#
# 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.
# Represents a single remote connection found via
# `ActionCable.server.remote_connections.where(*)`. Exists solely for the
# purpose of calling #disconnect on that connection.
class RemoteConnection
class InvalidIdentifiersError < StandardError; end

@ -1,15 +1,20 @@
# frozen_string_literal: true
# :markup: markdown
require "monitor"
module ActionCable
module Server
# = Action Cable \Server \Base
# # Action Cable Server Base
#
# 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.
# 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.
# Also, this is the server instance used for broadcasting. See Broadcasting for
# more information.
class Base
include ActionCable::Server::Broadcasting
include ActionCable::Server::Connections
@ -36,7 +41,8 @@ def call(env)
config.connection_class.call.new(self, env).process
end
# Disconnect all the connections identified by +identifiers+ on this server or any others via RemoteConnections.
# Disconnect all the connections identified by `identifiers` on this server or
# any others via RemoteConnections.
def disconnect(identifiers)
remote_connections.where(identifiers).disconnect
end
@ -66,17 +72,22 @@ 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>.
# 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 `config.action_cable.worker_pool_size`.
#
# 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.
# 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.
# 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
@ -86,7 +97,8 @@ 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.
# All of the identifiers applied to the connection class associated with this
# server.
def connection_identifiers
config.connection_class.call.identifiers
end

@ -1,34 +1,40 @@
# frozen_string_literal: true
# :markup: markdown
module ActionCable
module Server
# = Action Cable \Server \Broadcasting
# # Action Cable Server Broadcasting
#
# 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:
# 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}"
# class WebNotificationsChannel < ApplicationCable::Channel
# def subscribed
# stream_from "web_notifications_#{current_user.id}"
# end
# 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" }
# # 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']
# # 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.
# Broadcast a hash directly to a named `broadcasting`. 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.
# Returns a broadcaster for a named `broadcasting` 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

@ -1,13 +1,16 @@
# frozen_string_literal: true
# :markup: markdown
require "rack"
module ActionCable
module Server
# = Action Cable \Server \Configuration
# # Action Cable Server Configuration
#
# 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.
# 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
@ -31,28 +34,28 @@ def initialize
}
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.
# 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.
# 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.
# 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.
# 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.
# 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

@ -1,11 +1,15 @@
# frozen_string_literal: true
# :markup: markdown
module ActionCable
module Server
# = Action Cable \Server \Connections
# # Action Cable Server Connections
#
# 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.
# 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
@ -21,8 +25,10 @@ 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
# 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

@ -1,5 +1,7 @@
# frozen_string_literal: true
# :markup: markdown
require "active_support/callbacks"
require "active_support/core_ext/module/attribute_accessors_per_thread"
require "concurrent"
@ -25,8 +27,8 @@ def initialize(max_size: 5)
)
end
# Stop processing work: any work that has not already started
# running will be discarded from the queue
# Stop processing work: any work that has not already started running will be
# discarded from the queue
def halt
@executor.shutdown
end

@ -1,5 +1,7 @@
# frozen_string_literal: true
# :markup: markdown
module ActionCable
module Server
class Worker

@ -1,5 +1,7 @@
# frozen_string_literal: true
# :markup: markdown
module ActionCable
module SubscriptionAdapter
class Async < Inline # :nodoc:

@ -1,5 +1,7 @@
# frozen_string_literal: true
# :markup: markdown
module ActionCable
module SubscriptionAdapter
class Base

@ -1,5 +1,7 @@
# frozen_string_literal: true
# :markup: markdown
module ActionCable
module SubscriptionAdapter
module ChannelPrefix # :nodoc:

@ -1,5 +1,7 @@
# frozen_string_literal: true
# :markup: markdown
module ActionCable
module SubscriptionAdapter
class Inline < Base # :nodoc:

@ -1,5 +1,7 @@
# frozen_string_literal: true
# :markup: markdown
gem "pg", "~> 1.1"
require "pg"
require "openssl"
@ -34,8 +36,8 @@ def shutdown
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
# 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

@ -1,5 +1,7 @@
# frozen_string_literal: true
# :markup: markdown
gem "redis", ">= 4", "< 6"
require "redis"
@ -10,8 +12,9 @@ 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.
# 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
::Redis.new(config.except(:adapter, :channel_prefix))
end

@ -1,5 +1,7 @@
# frozen_string_literal: true
# :markup: markdown
module ActionCable
module SubscriptionAdapter
class SubscriberMap

@ -1,16 +1,19 @@
# frozen_string_literal: true
# :markup: markdown
module ActionCable
module SubscriptionAdapter
# == \Test adapter for Action Cable
# ## Test adapter for Action Cable
#
# The test adapter should be used only in testing. Along with
# ActionCable::TestHelper it makes a great tool to test your \Rails application.
# ActionCable::TestHelper 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.
# To use the test adapter set `adapter` value to `test` in your
# `config/cable.yml` file.
#
# NOTE: +Test+ adapter extends the +ActionCable::SubscriptionAdapter::Async+ adapter,
# so it could be used in system tests too.
# NOTE: `Test` adapter extends the `ActionCable::SubscriptionAdapter::Async`
# adapter, so it could be used in system tests too.
class Test < Async
def broadcast(channel, payload)
broadcasts(channel) << payload

@ -1,5 +1,7 @@
# frozen_string_literal: true
# :markup: markdown
require "active_support/test_case"
module ActionCable

@ -1,5 +1,7 @@
# frozen_string_literal: true
# :markup: markdown
module ActionCable
# Provides helper methods for testing Action Cable broadcasting
module TestHelper
@ -18,33 +20,34 @@ def after_teardown # :nodoc:
ActionCable.server.instance_variable_set(:@pubsub, @old_pubsub_adapter)
end
# Asserts that the number of broadcasted messages to the stream matches the given number.
# 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. It returns the messages that were broadcasted.
#
# def test_broadcasts_again
# message = assert_broadcasts('messages', 1) do
# 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
# assert_equal({ text: 'hello' }, message)
#
# messages = assert_broadcasts('messages', 2) do
# ActionCable.server.broadcast 'messages', { text: 'hi' }
# ActionCable.server.broadcast 'messages', { text: 'how are you?' }
# If a block is passed, that block should cause the specified number of messages
# to be broadcasted. It returns the messages that were broadcasted.
#
# def test_broadcasts_again
# message = assert_broadcasts('messages', 1) do
# ActionCable.server.broadcast 'messages', { text: 'hello' }
# end
# assert_equal({ text: 'hello' }, message)
#
# messages = assert_broadcasts('messages', 2) do
# ActionCable.server.broadcast 'messages', { text: 'hi' }
# ActionCable.server.broadcast 'messages', { text: 'how are you?' }
# end
# assert_equal 2, messages.length
# assert_equal({ text: 'hi' }, messages.first)
# assert_equal({ text: 'how are you?' }, messages.last)
# end
# assert_equal 2, messages.length
# assert_equal({ text: 'hi' }, messages.first)
# assert_equal({ text: 'how are you?' }, messages.last)
# end
#
def assert_broadcasts(stream, number, &block)
if block_given?
@ -60,23 +63,23 @@ def assert_broadcasts(stream, number, &block)
# 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
# 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
# def test_broadcasts_again
# assert_no_broadcasts 'messages' do
# # No job messages should be sent from this block
# end
# end
# end
#
# Note: This assertion is simply a shortcut for:
#
# assert_broadcasts 'messages', 0, &block
# assert_broadcasts 'messages', 0, &block
#
def assert_no_broadcasts(stream, &block)
assert_broadcasts stream, 0, &block
@ -84,15 +87,15 @@ def assert_no_broadcasts(stream, &block)
# Returns the messages that are broadcasted in the block.
#
# def test_broadcasts
# messages = capture_broadcasts('messages') do
# ActionCable.server.broadcast 'messages', { text: 'hi' }
# ActionCable.server.broadcast 'messages', { text: 'how are you?' }
# def test_broadcasts
# messages = capture_broadcasts('messages') do
# ActionCable.server.broadcast 'messages', { text: 'hi' }
# ActionCable.server.broadcast 'messages', { text: 'how are you?' }
# end
# assert_equal 2, messages.length
# assert_equal({ text: 'hi' }, messages.first)
# assert_equal({ text: 'how are you?' }, messages.last)
# end
# assert_equal 2, messages.length
# assert_equal({ text: 'hi' }, messages.first)
# assert_equal({ text: 'how are you?' }, messages.last)
# end
#
def capture_broadcasts(stream, &block)
new_broadcasts_from(broadcasts(stream), stream, "capture_broadcasts", &block).map { |m| ActiveSupport::JSON.decode(m) }
@ -100,23 +103,23 @@ def capture_broadcasts(stream, &block)
# Asserts that the specified message has been sent to the stream.
#
# def test_assert_transmitted_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
# def test_assert_transmitted_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
# end
#
def assert_broadcast_on(stream, data, &block)
# Encode to JSON and backwe want to use this value to compare
# with decoded JSON.
# Comparing JSON strings doesn't work due to the order if the keys.
# Encode to JSON and backwe 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))

@ -1,9 +1,11 @@
# frozen_string_literal: true
# :markup: markdown
require_relative "gem_version"
module ActionCable
# Returns the currently loaded version of Action Cable as a +Gem::Version+.
# Returns the currently loaded version of Action Cable as a `Gem::Version`.
def self.version
gem_version
end

@ -1,5 +1,7 @@
# frozen_string_literal: true
# :markup: markdown
module Rails
module Generators
class ChannelGenerator < NamedBase
@ -103,8 +105,8 @@ def using_js_runtime?
end
def using_bun?
# Cannot assume bun.lockb has been generated yet so we look for
# a file known to be generated by the jsbundling-rails gem
# Cannot assume bun.lockb has been generated yet so we look for a file known to
# be generated by the jsbundling-rails gem
@using_bun ||= using_js_runtime? && root.join("bun.config.js").exist?
end

@ -1,3 +1,5 @@
# :markup: markdown
module ApplicationCable
class Channel < ActionCable::Channel::Base
end

@ -1,3 +1,5 @@
# :markup: markdown
module ApplicationCable
class Connection < ActionCable::Connection::Base
end

@ -1,5 +1,7 @@
# frozen_string_literal: true
# :markup: markdown
module TestUnit
module Generators
class ChannelGenerator < ::Rails::Generators::NamedBase