anycable-rails-core 1.4.0

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