anycable-rails 0.6.5 → 1.0.0.rc3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +28 -110
  3. data/MIT-LICENSE +1 -1
  4. data/README.md +34 -37
  5. data/lib/action_cable/subscription_adapter/any_cable.rb +2 -1
  6. data/lib/anycable/rails.rb +37 -2
  7. data/lib/anycable/rails/actioncable/channel.rb +4 -0
  8. data/lib/anycable/rails/actioncable/connection.rb +72 -50
  9. data/lib/anycable/rails/actioncable/connection/persistent_session.rb +34 -0
  10. data/lib/anycable/rails/actioncable/connection/serializable_identification.rb +42 -0
  11. data/lib/anycable/rails/actioncable/remote_connections.rb +11 -0
  12. data/lib/anycable/rails/actioncable/testing.rb +35 -0
  13. data/lib/anycable/rails/channel_state.rb +46 -0
  14. data/lib/anycable/rails/compatibility.rb +7 -10
  15. data/lib/anycable/rails/compatibility/rubocop.rb +0 -1
  16. data/lib/anycable/rails/compatibility/rubocop/config/default.yml +3 -1
  17. data/lib/anycable/rails/compatibility/rubocop/cops/anycable/instance_vars.rb +1 -1
  18. data/lib/anycable/rails/compatibility/rubocop/cops/anycable/stream_from.rb +4 -4
  19. data/lib/anycable/rails/config.rb +8 -4
  20. data/lib/anycable/rails/rack.rb +56 -0
  21. data/lib/anycable/rails/railtie.rb +28 -13
  22. data/lib/anycable/rails/refinements/subscriptions.rb +1 -1
  23. data/lib/anycable/rails/session_proxy.rb +79 -0
  24. data/lib/anycable/rails/version.rb +1 -1
  25. data/lib/generators/anycable/download/USAGE +14 -0
  26. data/lib/generators/anycable/download/download_generator.rb +83 -0
  27. data/lib/generators/anycable/setup/USAGE +2 -0
  28. data/lib/generators/anycable/setup/setup_generator.rb +266 -0
  29. data/lib/generators/anycable/setup/templates/Procfile.dev +3 -0
  30. data/lib/generators/anycable/setup/templates/config/anycable.yml.tt +43 -0
  31. data/lib/generators/anycable/setup/templates/config/cable.yml.tt +11 -0
  32. data/lib/generators/anycable/setup/templates/config/initializers/anycable.rb.tt +9 -0
  33. data/lib/generators/anycable/with_os_helpers.rb +55 -0
  34. metadata +45 -30
  35. data/lib/anycable/rails/compatibility/rubocop/cops/anycable/remote_disconnect.rb +0 -31
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionCable
4
+ module Connection
5
+ module PersistentSession
6
+ def handle_open
7
+ super.tap { commit_session! }
8
+ end
9
+
10
+ def handle_channel_command(*)
11
+ super.tap { commit_session! }
12
+ end
13
+
14
+ def build_rack_request
15
+ return super unless socket.session
16
+
17
+ super.tap do |req|
18
+ req.env[Rack::RACK_SESSION] =
19
+ AnyCable::Rails::SessionProxy.new(req.env[Rack::RACK_SESSION], socket.session)
20
+ end
21
+ end
22
+
23
+ def commit_session!
24
+ return unless request_loaded? && request.session.respond_to?(:loaded?) && request.session.loaded?
25
+
26
+ socket.session = request.session.to_json
27
+ end
28
+ end
29
+ end
30
+ end
31
+
32
+ ::ActionCable::Connection::Base.prepend(
33
+ ::ActionCable::Connection::PersistentSession
34
+ )
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionCable
4
+ module Connection
5
+ module SerializableIdentification
6
+ extend ActiveSupport::Concern
7
+
8
+ class_methods do
9
+ def identified_by(*identifiers)
10
+ super
11
+ Array(identifiers).each do |identifier|
12
+ define_method(identifier) do
13
+ instance_variable_get(:"@#{identifier}") || fetch_identifier(identifier)
14
+ end
15
+ end
16
+ end
17
+ end
18
+
19
+ # Generate identifiers info.
20
+ # Converts GlobalID compatible vars to corresponding global IDs params.
21
+ def identifiers_hash
22
+ identifiers.each_with_object({}) do |id, acc|
23
+ obj = instance_variable_get("@#{id}")
24
+ next unless obj
25
+
26
+ acc[id] = AnyCable::Rails.serialize(obj)
27
+ end.compact
28
+ end
29
+
30
+ def identifiers_json
31
+ identifiers_hash.to_json
32
+ end
33
+
34
+ # Fetch identifier and deserialize if neccessary
35
+ def fetch_identifier(name)
36
+ @cached_ids[name] ||= @cached_ids.fetch(name) do
37
+ AnyCable::Rails.deserialize(ids[name.to_s])
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_cable/remote_connections"
4
+
5
+ ActionCable::RemoteConnections::RemoteConnection.include(ActionCable::Connection::SerializableIdentification)
6
+
7
+ ActionCable::RemoteConnections::RemoteConnection.prepend(Module.new do
8
+ def disconnect(reconnect: true)
9
+ ::AnyCable.broadcast_adapter.broadcast_command("disconnect", identifier: identifiers_json, reconnect: reconnect)
10
+ end
11
+ end)
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This file contains patches to Action Cable testing modules
4
+
5
+ # Trigger autoload (if constant is defined)
6
+ begin
7
+ ActionCable::Channel::TestCase # rubocop:disable Lint/Void
8
+ ActionCable::Connection::TestCase # rubocop:disable Lint/Void
9
+ rescue NameError
10
+ return
11
+ end
12
+
13
+ ActionCable::Channel::ChannelStub.prepend(Module.new do
14
+ def subscribe_to_channel
15
+ # allocate @streams
16
+ streams
17
+ handle_subscribe
18
+ end
19
+ end)
20
+
21
+ ActionCable::Channel::ConnectionStub.prepend(Module.new do
22
+ def socket
23
+ @socket ||= AnyCable::Socket.new(env: {})
24
+ end
25
+
26
+ alias_method :anycable_socket, :socket
27
+ end)
28
+
29
+ ActionCable::Connection::TestConnection.prepend(Module.new do
30
+ def initialize(request)
31
+ @request = request
32
+ @cached_ids = {}
33
+ super
34
+ end
35
+ end)
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnyCable
4
+ module Rails
5
+ module ChannelState
6
+ module ClassMethods
7
+ def state_attr_accessor(*names)
8
+ names.each do |name|
9
+ channel_state_attributes << name
10
+ class_eval <<~RUBY, __FILE__, __LINE__ + 1
11
+ def #{name}
12
+ return @#{name} if instance_variable_defined?(:@#{name})
13
+ @#{name} = AnyCable::Rails.deserialize(connection.socket.istate["#{name}"], json: true) if connection.anycable_socket
14
+ end
15
+
16
+ def #{name}=(val)
17
+ connection.socket.istate["#{name}"] = AnyCable::Rails.serialize(val, json: true) if connection.anycable_socket
18
+ instance_variable_set(:@#{name}, val)
19
+ end
20
+ RUBY
21
+ end
22
+ end
23
+
24
+ def channel_state_attributes
25
+ return @channel_state_attributes if instance_variable_defined?(:@channel_state_attributes)
26
+
27
+ @channel_state_attributes =
28
+ if superclass.respond_to?(:channel_state_attributes)
29
+ superclass.channel_state_attributes.dup
30
+ else
31
+ []
32
+ end
33
+ end
34
+ end
35
+
36
+ def self.included(base)
37
+ base.extend ClassMethods
38
+ end
39
+ end
40
+ end
41
+ end
42
+
43
+ ActiveSupport.on_load(:action_cable) do
44
+ # `state_attr_accessor` must be available in Action Cable
45
+ ::ActionCable::Channel::Base.include(AnyCable::Rails::ChannelState)
46
+ end
@@ -12,7 +12,7 @@ module AnyCable
12
12
 
13
13
  if callback.present? || block_given?
14
14
  raise AnyCable::CompatibilityError,
15
- "Custom stream callbacks are not supported by AnyCable"
15
+ "Custom stream callbacks are not supported by AnyCable"
16
16
  end
17
17
 
18
18
  super
@@ -33,10 +33,14 @@ module AnyCable
33
33
  res = yield
34
34
  diff = instance_variables - was_ivars
35
35
 
36
+ if self.class.respond_to?(:channel_state_attributes)
37
+ diff.delete_if { |ivar| self.class.channel_state_attributes.include?(:"#{ivar.to_s.sub(/^@/, "")}") }
38
+ end
39
+
36
40
  unless diff.empty?
37
41
  raise AnyCable::CompatibilityError,
38
- "Channel instance variables are not supported by AnyCable, " \
39
- "but were set: #{diff.join(', ')}"
42
+ "Channel instance variables are not supported by AnyCable, " \
43
+ "but were set: #{diff.join(", ")}"
40
44
  end
41
45
 
42
46
  res
@@ -48,12 +52,5 @@ module AnyCable
48
52
  raise AnyCable::CompatibilityError, "Periodical timers are not supported by AnyCable"
49
53
  end
50
54
  end)
51
-
52
- ActionCable::RemoteConnections::RemoteConnection.prepend(Module.new do
53
- def disconnect
54
- raise AnyCable::CompatibilityError,
55
- "Disconnecting remote clients is not supported by AnyCable yet"
56
- end
57
- end)
58
55
  end
59
56
  end
@@ -4,7 +4,6 @@ require "rubocop"
4
4
  require "pathname"
5
5
 
6
6
  require_relative "rubocop/cops/anycable/stream_from"
7
- require_relative "rubocop/cops/anycable/remote_disconnect"
8
7
  require_relative "rubocop/cops/anycable/periodical_timers"
9
8
  require_relative "rubocop/cops/anycable/instance_vars"
10
9
 
@@ -1,12 +1,14 @@
1
1
  AnyCable/InstanceVars:
2
+ Enabled: true
2
3
  Include:
3
4
  - "**/channels/**/*.rb"
4
5
 
5
6
  AnyCable/StreamFrom:
7
+ Enabled: true
6
8
  Include:
7
9
  - "**/channels/**/*.rb"
8
10
 
9
11
  AnyCable/PeriodicalTimers:
12
+ Enabled: true
10
13
  Include:
11
14
  - "**/channels/**/*.rb"
12
-
@@ -25,7 +25,7 @@ module RuboCop
25
25
  # end
26
26
  #
27
27
  class InstanceVars < RuboCop::Cop::Cop
28
- MSG = "Channel instance variables are not supported in AnyCable"
28
+ MSG = "Channel instance variables are not supported in AnyCable. Use `state_attr_accessor` instead"
29
29
 
30
30
  def on_class(node)
31
31
  find_nested_ivars(node) do |nested_ivar|
@@ -11,7 +11,7 @@ module RuboCop
11
11
  # # bad
12
12
  # class MyChannel < ApplicationCable::Channel
13
13
  # def follow
14
- # stream_from("all") {}
14
+ # stream_for(room) {}
15
15
  # end
16
16
  # end
17
17
  #
@@ -36,15 +36,15 @@ module RuboCop
36
36
  #
37
37
  class StreamFrom < RuboCop::Cop::Cop
38
38
  def_node_matcher :stream_from_with_block?, <<-PATTERN
39
- (block (send _ :stream_from ...) ...)
39
+ (block {(send _ :stream_from ...) (send _ :stream_for ...)} ...)
40
40
  PATTERN
41
41
 
42
42
  def_node_matcher :stream_from_with_callback?, <<-PATTERN
43
- (send _ :stream_from str_type? (block (send nil? :lambda) ...))
43
+ {(send _ :stream_from str_type? (block (send nil? :lambda) ...)) (send _ :stream_for ... (block (send nil? :lambda) ...))}
44
44
  PATTERN
45
45
 
46
46
  def_node_matcher :args_of_stream_from, <<-PATTERN
47
- (send _ :stream_from str_type? $...)
47
+ {(send _ :stream_from str_type? $...) (send _ :stream_for $...)}
48
48
  PATTERN
49
49
 
50
50
  def_node_matcher :coder_symbol?, "(pair (sym :coder) ...)"
@@ -2,7 +2,11 @@
2
2
 
3
3
  require "anycable/config"
4
4
 
5
- # Extend AnyCable configuration with
6
- # `access_logs_disabled` options (defaults to true)
7
- AnyCable::Config.attr_config access_logs_disabled: true
8
- AnyCable::Config.ignore_options :access_logs_disabled
5
+ # Extend AnyCable configuration with:
6
+ # - `access_logs_disabled` (defaults to true) — whether to print Started/Finished logs
7
+ # - `persistent_session_enabled` (defaults to false) — whether to store session changes in the connection state
8
+ AnyCable::Config.attr_config(
9
+ access_logs_disabled: true,
10
+ persistent_session_enabled: false
11
+ )
12
+ AnyCable::Config.ignore_options :access_logs_disabled, :persistent_session_enabled
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/configuration"
4
+ require "action_dispatch/middleware/stack"
5
+
6
+ module AnyCable
7
+ module Rails
8
+ # Rack middleware stack to modify the HTTP request object.
9
+ #
10
+ # AnyCable Websocket server does not use Rack middleware processing mechanism (which Rails uses
11
+ # when Action Cable is mounted into the main app).
12
+ #
13
+ # Some middlewares could enhance request env with useful information.
14
+ #
15
+ # For instance, consider the Rails session middleware: it's responsible for restoring the
16
+ # session data from cookies.
17
+ #
18
+ # AnyCable adds session middelware by default to its own stack.
19
+ #
20
+ # You can also use any Rack/Rails middleware you want. For example, to enable Devise/Warden
21
+ # you can add the following code to an initializer or any other configuration file:
22
+ #
23
+ # AnyCable::Rails::Rack.middleware.use Warden::Manager do |config|
24
+ # Devise.warden_config = config
25
+ # end
26
+ module Rack
27
+ def self.app_build_lock
28
+ @app_build_lock
29
+ end
30
+
31
+ @app_build_lock = Mutex.new
32
+
33
+ def self.middleware
34
+ @middleware ||= ::Rails::Configuration::MiddlewareStackProxy.new
35
+ end
36
+
37
+ def self.default_middleware_stack
38
+ config = ::Rails.application.config
39
+
40
+ ActionDispatch::MiddlewareStack.new do |middleware|
41
+ middleware.use(config.session_store, config.session_options)
42
+ end
43
+ end
44
+
45
+ def self.app
46
+ @rack_app || app_build_lock.synchronize do
47
+ @rack_app ||= begin
48
+ stack = default_middleware_stack
49
+ @middleware = middleware.merge_into(stack)
50
+ middleware.build { [-1, {}, []] }
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -1,23 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "anycable/rails/channel_state"
4
+
3
5
  module AnyCable
4
6
  module Rails
5
7
  class Railtie < ::Rails::Railtie # :nodoc:
6
8
  initializer "anycable.disable_action_cable_mount", after: "action_cable.set_configs" do |app|
7
- # Disable Action Cable default route when AnyCable adapter is used
8
- adapter = ::ActionCable.server.config.cable&.fetch("adapter", nil)
9
- next unless AnyCable::Rails.compatible_adapter?(adapter)
9
+ next unless AnyCable::Rails.enabled?
10
10
 
11
11
  app.config.action_cable.mount_path = nil
12
12
  end
13
13
 
14
14
  initializer "anycable.logger", after: "action_cable.logger" do |_app|
15
- AnyCable.logger = ActiveSupport::TaggedLogging.new(::ActionCable.server.config.logger)
15
+ AnyCable.logger = ::ActionCable.server.config.logger
16
16
 
17
- # Broadcast server logs to STDOUT in development
18
- if ::Rails.env.development? &&
19
- !ActiveSupport::Logger.logger_outputs_to?(::Rails.logger, STDOUT)
20
- AnyCable.configure_server do
17
+ AnyCable.configure_server do
18
+ AnyCable.logger = ActiveSupport::TaggedLogging.new(::ActionCable.server.config.logger)
19
+ # Broadcast server logs to STDOUT in development
20
+ if ::Rails.env.development? &&
21
+ !ActiveSupport::Logger.logger_outputs_to?(::Rails.logger, STDOUT)
21
22
  console = ActiveSupport::Logger.new(STDOUT)
22
23
  console.formatter = ::Rails.logger.formatter
23
24
  console.level = ::Rails.logger.level
@@ -42,16 +43,30 @@ module AnyCable
42
43
 
43
44
  initializer "anycable.connection_factory", after: "action_cable.set_configs" do |app|
44
45
  ActiveSupport.on_load(:action_cable) do
45
- adapter = ::ActionCable.server.config.cable&.fetch("adapter", nil)
46
- if AnyCable::Rails.compatible_adapter?(adapter)
47
- require "anycable/rails/actioncable/connection"
46
+ # Add AnyCable patch method stub (we use it in ChannelState to distinguish between Action Cable and AnyCable)
47
+ # NOTE: Method could be already defined if patch was loaded manually
48
+ ActionCable::Connection::Base.attr_reader(:anycable_socket) unless ActionCable::Connection::Base.method_defined?(:anycable_socket)
48
49
 
49
- app.config.to_prepare do
50
- AnyCable.connection_factory = ActionCable.server.config.connection_class.call
50
+ app.config.to_prepare do
51
+ AnyCable.connection_factory = ActionCable.server.config.connection_class.call
52
+ end
53
+
54
+ if AnyCable::Rails.enabled?
55
+ require "anycable/rails/actioncable/connection"
56
+ if AnyCable.config.persistent_session_enabled
57
+ require "anycable/rails/actioncable/connection/persistent_session"
51
58
  end
52
59
  end
53
60
  end
54
61
  end
62
+
63
+ initializer "anycable.testing" do |app|
64
+ next unless ::Rails.env.test?
65
+
66
+ ActiveSupport.on_load(:action_cable) do
67
+ require "anycable/rails/actioncable/testing"
68
+ end
69
+ end
55
70
  end
56
71
  end
57
72
  end
@@ -9,7 +9,7 @@ module AnyCable
9
9
  add("identifier" => identifier) unless subscriptions[identifier]
10
10
 
11
11
  unless subscriptions[identifier]
12
- raise "Channel not found: #{ActiveSupport::JSON.decode(identifier).fetch('channel')}"
12
+ raise "Channel not found: #{ActiveSupport::JSON.decode(identifier).fetch("channel")}"
13
13
  end
14
14
 
15
15
  subscriptions[identifier]
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnyCable
4
+ module Rails
5
+ # Wrap `request.session` to lazily load values provided
6
+ # in the RPC call (set by the previous calls)
7
+ class SessionProxy
8
+ attr_reader :rack_session, :socket_session
9
+
10
+ def initialize(rack_session, socket_session)
11
+ @rack_session = rack_session
12
+ @socket_session = JSON.parse(socket_session).with_indifferent_access
13
+ end
14
+
15
+ %i[has_key? [] []= fetch delete dig].each do |mid|
16
+ class_eval <<~CODE, __FILE__, __LINE__ + 1
17
+ def #{mid}(*args, **kwargs, &block)
18
+ restore_key! args.first
19
+ rack_session.#{mid}(*args, **kwargs, &block)
20
+ end
21
+ CODE
22
+ end
23
+
24
+ alias include? has_key?
25
+ alias key? has_key?
26
+
27
+ %i[update merge! to_hash].each do |mid|
28
+ class_eval <<~CODE, __FILE__, __LINE__ + 1
29
+ def #{mid}(*args, **kwargs, &block)
30
+ restore!
31
+ rack_session.#{mid}(*args, **kwargs, &block)
32
+ end
33
+ CODE
34
+ end
35
+
36
+ alias to_h to_hash
37
+
38
+ def keys
39
+ rack_session.keys + socket_session.keys
40
+ end
41
+
42
+ # Delegate both publuc and private methods to rack_session
43
+ def respond_to_missing?(name, include_private = false)
44
+ return false if name == :marshal_dump || name == :_dump
45
+ rack_session.respond_to?(name, include_private) || super
46
+ end
47
+
48
+ def method_missing(method, *args, &block)
49
+ if rack_session.respond_to?(method, true)
50
+ rack_session.send(method, *args, &block)
51
+ else
52
+ super
53
+ end
54
+ end
55
+
56
+ # This method is used by StimulusReflex to obtain `@by`
57
+ def instance_variable_get(name)
58
+ super || rack_session.instance_variable_get(name)
59
+ end
60
+
61
+ private
62
+
63
+ def restore!
64
+ socket_session.keys.each(&method(:restore_key!))
65
+ end
66
+
67
+ def restore_key!(key)
68
+ return unless socket_session.key?(key)
69
+ val = socket_session.delete(key)
70
+ rack_session[key] =
71
+ if val.is_a?(String)
72
+ GlobalID::Locator.locate(val) || val
73
+ else
74
+ val
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end