anycable-rails-core 1.4.0

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 (39) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +203 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +106 -0
  5. data/lib/action_cable/subscription_adapter/any_cable.rb +40 -0
  6. data/lib/action_cable/subscription_adapter/anycable.rb +10 -0
  7. data/lib/anycable/rails/action_cable_ext/channel.rb +51 -0
  8. data/lib/anycable/rails/action_cable_ext/connection.rb +90 -0
  9. data/lib/anycable/rails/action_cable_ext/remote_connections.rb +13 -0
  10. data/lib/anycable/rails/channel_state.rb +108 -0
  11. data/lib/anycable/rails/compatibility/rubocop/config/default.yml +14 -0
  12. data/lib/anycable/rails/compatibility/rubocop/cops/anycable/instance_vars.rb +50 -0
  13. data/lib/anycable/rails/compatibility/rubocop/cops/anycable/periodical_timers.rb +29 -0
  14. data/lib/anycable/rails/compatibility/rubocop/cops/anycable/stream_from.rb +100 -0
  15. data/lib/anycable/rails/compatibility/rubocop.rb +27 -0
  16. data/lib/anycable/rails/compatibility.rb +63 -0
  17. data/lib/anycable/rails/config.rb +19 -0
  18. data/lib/anycable/rails/connection.rb +211 -0
  19. data/lib/anycable/rails/connection_factory.rb +44 -0
  20. data/lib/anycable/rails/connections/persistent_session.rb +40 -0
  21. data/lib/anycable/rails/connections/serializable_identification.rb +46 -0
  22. data/lib/anycable/rails/connections/session_proxy.rb +81 -0
  23. data/lib/anycable/rails/middlewares/executor.rb +31 -0
  24. data/lib/anycable/rails/middlewares/log_tagging.rb +21 -0
  25. data/lib/anycable/rails/rack.rb +56 -0
  26. data/lib/anycable/rails/railtie.rb +92 -0
  27. data/lib/anycable/rails/version.rb +7 -0
  28. data/lib/anycable/rails.rb +76 -0
  29. data/lib/anycable-rails.rb +3 -0
  30. data/lib/generators/anycable/download/USAGE +14 -0
  31. data/lib/generators/anycable/download/download_generator.rb +85 -0
  32. data/lib/generators/anycable/setup/USAGE +2 -0
  33. data/lib/generators/anycable/setup/setup_generator.rb +300 -0
  34. data/lib/generators/anycable/setup/templates/Procfile.dev.tt +6 -0
  35. data/lib/generators/anycable/setup/templates/config/anycable.yml.tt +48 -0
  36. data/lib/generators/anycable/setup/templates/config/cable.yml.tt +11 -0
  37. data/lib/generators/anycable/setup/templates/config/initializers/anycable.rb.tt +9 -0
  38. data/lib/generators/anycable/with_os_helpers.rb +55 -0
  39. metadata +128 -0
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubocop"
4
+
5
+ module RuboCop
6
+ module Cop
7
+ module AnyCable
8
+ # Checks for instance variable usage inside subscriptions.
9
+ #
10
+ # @example
11
+ # # bad
12
+ # class MyChannel < ApplicationCable::Channel
13
+ # def subscribed
14
+ # @post = Post.find(params[:id])
15
+ # stream_from @post
16
+ # end
17
+ # end
18
+ #
19
+ # # good
20
+ # class MyChannel < ApplicationCable::Channel
21
+ # def subscribed
22
+ # post = Post.find(params[:id])
23
+ # stream_from post
24
+ # end
25
+ # end
26
+ #
27
+ class InstanceVars < RuboCop::Cop::Cop
28
+ MSG = "Channel instance variables are not supported in AnyCable. Use `state_attr_accessor` instead"
29
+
30
+ def on_class(node)
31
+ find_nested_ivars(node) do |nested_ivar|
32
+ add_offense(nested_ivar)
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def find_nested_ivars(node, &block)
39
+ node.each_child_node do |child|
40
+ if child.ivasgn_type? || child.ivar_type?
41
+ yield(child)
42
+ elsif child.children.any?
43
+ find_nested_ivars(child, &block)
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubocop"
4
+
5
+ module RuboCop
6
+ module Cop
7
+ module AnyCable
8
+ # Checks for periodical timers usage.
9
+ #
10
+ # @example
11
+ # # bad
12
+ # class MyChannel < ApplicationCable::Channel
13
+ # periodically(:do_something, every: 2.seconds)
14
+ # end
15
+ #
16
+ class PeriodicalTimers < RuboCop::Cop::Cop
17
+ MSG = "Periodical Timers are not supported in AnyCable"
18
+
19
+ def_node_matcher :calls_periodically?, <<-PATTERN
20
+ (send _ :periodically ...)
21
+ PATTERN
22
+
23
+ def on_send(node)
24
+ add_offense(node) if calls_periodically?(node)
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubocop"
4
+
5
+ module RuboCop
6
+ module Cop
7
+ module AnyCable
8
+ # Checks for #stream_from calls with custom callbacks or coders.
9
+ #
10
+ # @example
11
+ # # bad
12
+ # class MyChannel < ApplicationCable::Channel
13
+ # def follow
14
+ # stream_for(room) {}
15
+ # end
16
+ # end
17
+ #
18
+ # class MyChannel < ApplicationCable::Channel
19
+ # def follow
20
+ # stream_from("all", -> {})
21
+ # end
22
+ # end
23
+ #
24
+ # class MyChannel < ApplicationCable::Channel
25
+ # def follow
26
+ # stream_from("all", coder: SomeCoder)
27
+ # end
28
+ # end
29
+ #
30
+ # # good
31
+ # class MyChannel < ApplicationCable::Channel
32
+ # def follow
33
+ # stream_from "all"
34
+ # end
35
+ # end
36
+ #
37
+ class StreamFrom < RuboCop::Cop::Cop
38
+ def_node_matcher :stream_from_with_block?, <<-PATTERN
39
+ (block {(send _ :stream_from ...) (send _ :stream_for ...)} ...)
40
+ PATTERN
41
+
42
+ def_node_matcher :stream_from_with_callback?, <<-PATTERN
43
+ {(send _ :stream_from str_type? (block (send nil? :lambda) ...)) (send _ :stream_for ... (block (send nil? :lambda) ...))}
44
+ PATTERN
45
+
46
+ def_node_matcher :args_of_stream_from, <<-PATTERN
47
+ {(send _ :stream_from str_type? $...) (send _ :stream_for $...)}
48
+ PATTERN
49
+
50
+ def_node_matcher :coder_symbol?, "(pair (sym :coder) ...)"
51
+
52
+ def_node_matcher :active_support_json?, <<-PATTERN
53
+ (pair _ (const (const nil? :ActiveSupport) :JSON))
54
+ PATTERN
55
+
56
+ def on_block(node)
57
+ add_callback_offense(node) if stream_from_with_block?(node)
58
+ end
59
+
60
+ def on_send(node)
61
+ if stream_from_with_callback?(node)
62
+ add_callback_offense(node)
63
+ return
64
+ end
65
+
66
+ args = args_of_stream_from(node)
67
+ find_coders(args) { |coder| add_custom_coder_offense(coder) }
68
+ end
69
+
70
+ private
71
+
72
+ def find_coders(args)
73
+ return if args.nil?
74
+
75
+ args.select(&:hash_type?).each do |arg|
76
+ arg.each_child_node do |pair|
77
+ yield(pair) if coder_symbol?(pair) && !active_support_json?(pair)
78
+ end
79
+ end
80
+ end
81
+
82
+ def add_callback_offense(node)
83
+ add_offense(
84
+ node,
85
+ location: :expression,
86
+ message: "Custom stream callbacks are not supported in AnyCable"
87
+ )
88
+ end
89
+
90
+ def add_custom_coder_offense(node)
91
+ add_offense(
92
+ node,
93
+ location: :expression,
94
+ message: "Custom coders are not supported in AnyCable"
95
+ )
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubocop"
4
+ require "pathname"
5
+
6
+ require_relative "rubocop/cops/anycable/stream_from"
7
+ require_relative "rubocop/cops/anycable/periodical_timers"
8
+ require_relative "rubocop/cops/anycable/instance_vars"
9
+
10
+ module RuboCop
11
+ module AnyCable # :nodoc:
12
+ CONFIG_DEFAULT = Pathname.new(__dir__).join("rubocop", "config", "default.yml").freeze
13
+
14
+ # Merge anycable config into default configuration
15
+ # See https://github.com/backus/rubocop-rspec/blob/master/lib/rubocop/rspec/inject.rb
16
+ def self.inject!
17
+ path = CONFIG_DEFAULT.to_s
18
+ puts "configuration from #{path}" if ConfigLoader.debug?
19
+ hash = ConfigLoader.send(:load_yaml_configuration, path)
20
+ config = Config.new(hash, path)
21
+ config = ConfigLoader.merge_with_default(config, path)
22
+ ConfigLoader.instance_variable_set(:@default_configuration, config)
23
+ end
24
+ end
25
+ end
26
+
27
+ RuboCop::AnyCable.inject!
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnyCable
4
+ class CompatibilityError < StandardError; end
5
+
6
+ module Compatibility # :nodoc:
7
+ IGNORE_INSTANCE_VARS = %i[
8
+ @active_periodic_timers
9
+ @_streams
10
+ @parameter_filter
11
+ ]
12
+
13
+ ActionCable::Channel::Base.prepend(Module.new do
14
+ def stream_from(broadcasting, callback = nil, coder: nil)
15
+ if coder.present? && coder != ActiveSupport::JSON
16
+ raise AnyCable::CompatibilityError, "Custom coders are not supported by AnyCable"
17
+ end
18
+
19
+ if callback.present? || block_given?
20
+ raise AnyCable::CompatibilityError,
21
+ "Custom stream callbacks are not supported by AnyCable"
22
+ end
23
+
24
+ super
25
+ end
26
+
27
+ # Do not prepend `subscribe_to_channel` 'cause we make it no-op
28
+ # when AnyCable is running (see anycable/rails/actioncable/channel.rb)
29
+ %w[run_callbacks perform_action].each do |mid|
30
+ module_eval <<~CODE, __FILE__, __LINE__ + 1
31
+ def #{mid}(*)
32
+ __anycable_check_ivars__ { super }
33
+ end
34
+ CODE
35
+ end
36
+
37
+ def __anycable_check_ivars__
38
+ was_ivars = instance_variables
39
+ res = yield
40
+ diff = instance_variables - was_ivars - IGNORE_INSTANCE_VARS
41
+
42
+ if self.class.respond_to?(:channel_state_attributes)
43
+ diff.delete(:@__istate__)
44
+ diff.delete_if { |ivar| self.class.channel_state_attributes.include?(:"#{ivar.to_s.sub(/^@/, "")}") }
45
+ end
46
+
47
+ unless diff.empty?
48
+ raise AnyCable::CompatibilityError,
49
+ "Channel instance variables are not supported by AnyCable, " \
50
+ "but were set: #{diff.join(", ")}"
51
+ end
52
+
53
+ res
54
+ end
55
+ end)
56
+
57
+ ActionCable::Channel::Base.singleton_class.prepend(Module.new do
58
+ def periodically(*)
59
+ raise AnyCable::CompatibilityError, "Periodical timers are not supported by AnyCable"
60
+ end
61
+ end)
62
+ end
63
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "anycable/config"
4
+ # Make sure Rails extensions for Anyway Config are loaded
5
+ # See https://github.com/anycable/anycable-rails/issues/63
6
+ require "anyway/rails"
7
+
8
+ # Extend AnyCable configuration with:
9
+ # - `access_logs_disabled` (defaults to true) — whether to print Started/Finished logs
10
+ # - `persistent_session_enabled` (defaults to false) — whether to store session changes in the connection state
11
+ # - `embedded` (defaults to false) — whether to run RPC server inside a Rails server process
12
+ # - `http_rpc_mount_path` (default to nil) — path to mount HTTP RPC server
13
+ AnyCable::Config.attr_config(
14
+ access_logs_disabled: true,
15
+ persistent_session_enabled: false,
16
+ embedded: false,
17
+ http_rpc_mount_path: nil
18
+ )
19
+ AnyCable::Config.ignore_options :access_logs_disabled, :persistent_session_enabled
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_cable"
4
+
5
+ module AnyCable
6
+ module Rails
7
+ # Enhance Action Cable connection
8
+ using(Module.new do
9
+ refine ActionCable::Connection::Base do
10
+ attr_writer :env, :websocket, :logger, :coder,
11
+ :subscriptions, :serialized_ids, :cached_ids, :server,
12
+ :anycable_socket
13
+
14
+ # Using public :send_welcome_message causes stack level too deep 🤷🏻‍♂️
15
+ def send_welcome_message
16
+ transmit({
17
+ type: ActionCable::INTERNAL[:message_types][:welcome],
18
+ sid: env["anycable.sid"]
19
+ }.compact)
20
+ end
21
+
22
+ def public_request
23
+ request
24
+ end
25
+ end
26
+
27
+ refine ActionCable::Channel::Base do
28
+ def rejected?
29
+ subscription_rejected?
30
+ end
31
+ end
32
+
33
+ refine ActionCable::Connection::Subscriptions do
34
+ # Find or add a subscription to the list
35
+ def fetch(identifier)
36
+ add("identifier" => identifier) unless subscriptions[identifier]
37
+
38
+ unless subscriptions[identifier]
39
+ raise "Channel not found: #{ActiveSupport::JSON.decode(identifier).fetch("channel")}"
40
+ end
41
+
42
+ subscriptions[identifier]
43
+ end
44
+ end
45
+ end)
46
+
47
+ class Connection
48
+ # We store logger tags in the connection state to be able
49
+ # to re-use them in the subsequent calls
50
+ LOG_TAGS_IDENTIFIER = "__ltags__"
51
+
52
+ delegate :identifiers_json, to: :conn
53
+
54
+ attr_reader :socket, :logger
55
+
56
+ def initialize(connection_class, socket, identifiers: nil, subscriptions: nil)
57
+ @socket = socket
58
+
59
+ logger_tags = fetch_logger_tags_from_state
60
+ @logger = ActionCable::Connection::TaggedLoggerProxy.new(AnyCable.logger, tags: logger_tags)
61
+
62
+ # Instead of calling #initialize,
63
+ # we allocate an instance and setup all the required components manually
64
+ @conn = connection_class.allocate
65
+ # Required to access config (for access origin checks)
66
+ conn.server = ActionCable.server
67
+ conn.logger = logger
68
+ conn.anycable_socket = conn.websocket = socket
69
+ conn.env = socket.env
70
+ conn.coder = ActiveSupport::JSON
71
+ conn.subscriptions = ActionCable::Connection::Subscriptions.new(conn)
72
+ conn.serialized_ids = {}
73
+ conn.serialized_ids = ActiveSupport::JSON.decode(identifiers) if identifiers
74
+ conn.cached_ids = {}
75
+ conn.anycable_request_builder = self
76
+
77
+ return unless subscriptions
78
+
79
+ # Pre-initialize channels (for disconnect)
80
+ subscriptions.each do |id|
81
+ channel = conn.subscriptions.fetch(id)
82
+ next unless socket.istate[id]
83
+
84
+ channel.__istate__ = ActiveSupport::JSON.decode(socket.istate[id])
85
+ end
86
+ end
87
+
88
+ def handle_open
89
+ logger.info started_request_message if access_logs?
90
+
91
+ verify_origin! || return
92
+
93
+ conn.connect if conn.respond_to?(:connect)
94
+
95
+ socket.cstate.write(LOG_TAGS_IDENTIFIER, logger.tags.to_json) unless logger.tags.empty?
96
+
97
+ conn.send_welcome_message
98
+ rescue ::ActionCable::Connection::Authorization::UnauthorizedError
99
+ reject_request(
100
+ ActionCable::INTERNAL[:disconnect_reasons]&.[](:unauthorized) || "unauthorized"
101
+ )
102
+ end
103
+
104
+ def handle_close
105
+ logger.info finished_request_message if access_logs?
106
+
107
+ conn.subscriptions.unsubscribe_from_all
108
+ conn.disconnect if conn.respond_to?(:disconnect)
109
+ true
110
+ end
111
+
112
+ def handle_channel_command(identifier, command, data)
113
+ conn.run_callbacks :command do
114
+ # We cannot use subscriptions#execute_command here,
115
+ # since we MUST return true of false, depending on the status
116
+ # of execution
117
+ channel = conn.subscriptions.fetch(identifier)
118
+ case command
119
+ when "subscribe"
120
+ channel.handle_subscribe
121
+ !channel.rejected?
122
+ when "unsubscribe"
123
+ conn.subscriptions.remove_subscription(channel)
124
+ true
125
+ when "message"
126
+ channel.perform_action ActiveSupport::JSON.decode(data)
127
+ true
128
+ else
129
+ false
130
+ end
131
+ end
132
+ # Support rescue_from
133
+ # https://github.com/rails/rails/commit/d2571e560c62116f60429c933d0c41a0e249b58b
134
+ rescue Exception => e # rubocop:disable Lint/RescueException
135
+ rescue_with_handler(e) || raise
136
+ false
137
+ end
138
+
139
+ def build_rack_request(env)
140
+ environment = ::Rails.application.env_config.merge(env) if defined?(::Rails.application) && ::Rails.application
141
+ AnyCable::Rails::Rack.app.call(environment) if environment
142
+
143
+ ActionDispatch::Request.new(environment || env)
144
+ end
145
+
146
+ def action_cable_connection
147
+ conn
148
+ end
149
+
150
+ private
151
+
152
+ attr_reader :conn
153
+
154
+ def reject_request(reason, reconnect = false)
155
+ logger.info finished_request_message("Rejected") if access_logs?
156
+ conn.close(
157
+ reason: reason,
158
+ reconnect: reconnect
159
+ )
160
+ end
161
+
162
+ def fetch_logger_tags_from_state
163
+ socket.cstate.read(LOG_TAGS_IDENTIFIER).yield_self do |raw_tags|
164
+ next [] unless raw_tags
165
+ ActiveSupport::JSON.decode(raw_tags)
166
+ end
167
+ end
168
+
169
+ def started_request_message
170
+ format(
171
+ 'Started "%s"%s for %s at %s',
172
+ request.filtered_path, " [AnyCable]", request.ip, Time.now.to_s
173
+ )
174
+ end
175
+
176
+ def finished_request_message(reason = "Closed")
177
+ format(
178
+ 'Finished "%s"%s for %s at %s (%s)',
179
+ request.filtered_path, " [AnyCable]", request.ip, Time.now.to_s, reason
180
+ )
181
+ end
182
+
183
+ def verify_origin!
184
+ return true unless socket.env.key?("HTTP_ORIGIN")
185
+
186
+ return true if conn.send(:allow_request_origin?)
187
+
188
+ reject_request(
189
+ ActionCable::INTERNAL[:disconnect_reasons]&.[](:invalid_request) || "invalid_request"
190
+ )
191
+ false
192
+ end
193
+
194
+ def access_logs?
195
+ AnyCable.config.access_logs_disabled == false
196
+ end
197
+
198
+ def request
199
+ conn.public_request
200
+ end
201
+
202
+ def request_loaded?
203
+ conn.instance_variable_defined?(:@request)
204
+ end
205
+
206
+ def rescue_with_handler(e)
207
+ conn.rescue_with_handler(e) if conn.respond_to?(:rescue_with_handler)
208
+ end
209
+ end
210
+ end
211
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "anycable/rails/connection"
4
+
5
+ module AnyCable
6
+ module Rails
7
+ class ConnectionFactory
8
+ def initialize(&block)
9
+ @mappings = []
10
+ @use_router = false
11
+ instance_eval(&block) if block
12
+ end
13
+
14
+ def call(socket, **options)
15
+ connection_class = use_router? ? resolve_connection_class(socket.env) :
16
+ ActionCable.server.config.connection_class.call
17
+
18
+ AnyCable::Rails::Connection.new(connection_class, socket, **options)
19
+ end
20
+
21
+ def map(route, &block)
22
+ raise ArgumentError, "Block is required" unless block
23
+
24
+ @use_router = true
25
+ mappings << [route, block]
26
+ end
27
+
28
+ private
29
+
30
+ attr_reader :mappings, :use_router
31
+ alias_method :use_router?, :use_router
32
+
33
+ def resolve_connection_class(env)
34
+ path = env["PATH_INFO"]
35
+
36
+ mappings.each do |(prefix, resolver)|
37
+ return resolver.call if path.starts_with?(prefix)
38
+ end
39
+
40
+ raise "No connection class found matching #{path}"
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "anycable/rails/connections/session_proxy"
4
+
5
+ module AnyCable
6
+ module Rails
7
+ module Connections
8
+ module PersistentSession
9
+ def handle_open
10
+ super.tap { commit_session! }
11
+ end
12
+
13
+ def handle_channel_command(*)
14
+ super.tap { commit_session! }
15
+ end
16
+
17
+ def build_rack_request(env)
18
+ return super unless socket.session
19
+
20
+ super.tap do |req|
21
+ req.env[::Rack::RACK_SESSION] =
22
+ SessionProxy.new(req.env[::Rack::RACK_SESSION], socket.session)
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def commit_session!
29
+ return unless request_loaded? && request.session.respond_to?(:loaded?) && request.session.loaded?
30
+
31
+ socket.session = request.session.to_json
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+
38
+ AnyCable::Rails::Connection.prepend(
39
+ AnyCable::Rails::Connections::PersistentSession
40
+ )
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnyCable
4
+ module Rails
5
+ module Connections
6
+ module SerializableIdentification
7
+ extend ActiveSupport::Concern
8
+
9
+ class_methods do
10
+ def identified_by(*identifiers)
11
+ super
12
+ Array(identifiers).each do |identifier|
13
+ define_method(identifier) do
14
+ instance_variable_get(:"@#{identifier}") || fetch_identifier(identifier)
15
+ end
16
+ end
17
+ end
18
+ end
19
+
20
+ # Generate identifiers info.
21
+ # Converts GlobalID compatible vars to corresponding global IDs params.
22
+ def identifiers_hash
23
+ identifiers.each_with_object({}) do |id, acc|
24
+ obj = instance_variable_get("@#{id}")
25
+ next unless obj
26
+
27
+ acc[id] = AnyCable::Rails.serialize(obj)
28
+ end.compact
29
+ end
30
+
31
+ def identifiers_json
32
+ identifiers_hash.to_json
33
+ end
34
+
35
+ # Fetch identifier and deserialize if neccessary
36
+ def fetch_identifier(name)
37
+ return unless @cached_ids
38
+
39
+ @cached_ids[name] ||= @cached_ids.fetch(name) do
40
+ AnyCable::Rails.deserialize(@serialized_ids[name.to_s])
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end