anycable-rails 0.6.5 → 1.0.0.rc3

Sign up to get free protection for your applications and to get access to all the features.
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