anycable-rails 1.1.3 → 1.3.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 382f9a246580ced6d4142cb584ac2da9dffd519c32eccc6f407294f95159eaee
4
- data.tar.gz: a3ee884685392322889613d19ea5625d68bfb7a4c16de849f43175ca0dace7ad
3
+ metadata.gz: 37be6474db2d3efc3493ec6fdf5b3826fbdfd84f9c4bc5f4574f1e0497457ec1
4
+ data.tar.gz: bafe87b642e1aaf15c5b09650ed271081d0ba53738507077ed1075b2de5e0ef2
5
5
  SHA512:
6
- metadata.gz: 1de65d96f4496919d2da4761e43707493a851aeb75173693a85c9e38af1bfbd884ded0d9545c655dcd4d1d8b17d2dcc69711c1bd075d42d77f774e22507b36f1
7
- data.tar.gz: ee2584eaaa25cd406a57fe7c1df9336ebfbf4777c6c9ba806c9aa018a0c0e7bd2c0ffd3f484db4bc99138ad830e206777992b51adc70b04b907ef3af5fe1c332
6
+ metadata.gz: b656f5a17009a19aacef7c1aac587299b5bdca24079e3e6a1ce2c9e652e26a7d65f08e34f0022e1ed4a9e42d8783af9d7d9e85a9059577ec224a523b7c12378e
7
+ data.tar.gz: 383c128d5404a7ed42bba43a426cb68bd10be23607557bce7a8ef46cd1ea1cf2777c918e44a4c71f307a0f973ff863aef36f547290ce821d65251e7f9a62a3fd
data/CHANGELOG.md CHANGED
@@ -2,6 +2,30 @@
2
2
 
3
3
  ## master
4
4
 
5
+ ## 1.3.0 (2021-02-21)
6
+
7
+ - Introduce `AnyCable::Rails.extend_adapter!` to make any pubsub adapter AnyCable-compatible. ([@palkan][])
8
+
9
+ - Refactored Action Cable patching to preserve original functionality and avoid monkey-patching collisions. ([@palkan][])
10
+
11
+ ## 1.2.1 (2021-01-31)
12
+
13
+ - Add a temporary fix to be compatible with `sentry-rails`. ([@palkan][])
14
+
15
+ See [#165](https://github.com/anycable/anycable-rails/issues/165).
16
+
17
+ - Run embedded RPC server only if `any_cable` adapter is used for Action Cable. ([@palkan][])
18
+
19
+ ## 1.2.0 (2021-12-21) 🎄
20
+
21
+ - Drop Rails 5 support.
22
+
23
+ - Drop Ruby 2.6 support.
24
+
25
+ ## 1.1.4 (2021-11-11)
26
+
27
+ - Added `Connection#state_attr_accessor`. ([@palkan][])
28
+
5
29
  ## 1.1.3 (2021-10-11)
6
30
 
7
31
  - Relax Action Cable dependency. ([@palkan][])
data/MIT-LICENSE CHANGED
@@ -1,4 +1,4 @@
1
- Copyright 2017-2021 palkan
1
+ Copyright 2017-2022 palkan
2
2
 
3
3
  Permission is hereby granted, free of charge, to any person obtaining
4
4
  a copy of this software and associated documentation files (the
data/README.md CHANGED
@@ -1,6 +1,5 @@
1
1
  [![Gem Version](https://badge.fury.io/rb/anycable-rails.svg)](https://rubygems.org/gems/anycable-rails)
2
2
  [![Build](https://github.com/anycable/anycable-rails/workflows/Build/badge.svg)](https://github.com/anycable/anycable-rails/actions)
3
- [![Gitter](https://img.shields.io/badge/gitter-join%20chat%20%E2%86%92-brightgreen.svg)](https://gitter.im/anycable/Lobby)
4
3
  [![Documentation](https://img.shields.io/badge/docs-link-brightgreen.svg)](https://docs.anycable.io/rails/getting_started)
5
4
 
6
5
  # AnyCable Rails
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_cable/channel"
4
+
5
+ ActionCable::Channel::Base.prepend(Module.new do
6
+ def subscribe_to_channel(force: false)
7
+ return if anycabled? && !force
8
+ super()
9
+ end
10
+
11
+ def handle_subscribe
12
+ subscribe_to_channel(force: true)
13
+ end
14
+
15
+ def start_periodic_timers
16
+ super unless anycabled?
17
+ end
18
+
19
+ def stop_periodic_timers
20
+ super unless anycabled?
21
+ end
22
+
23
+ def stream_from(broadcasting, _callback = nil, **)
24
+ return super unless anycabled?
25
+
26
+ connection.anycable_socket.subscribe identifier, broadcasting
27
+ end
28
+
29
+ def stop_stream_from(broadcasting)
30
+ return super unless anycabled?
31
+
32
+ connection.anycable_socket.unsubscribe identifier, broadcasting
33
+ end
34
+
35
+ def stop_all_streams
36
+ return super unless anycabled?
37
+
38
+ connection.anycable_socket.unsubscribe_from_all identifier
39
+ end
40
+
41
+ private
42
+
43
+ def anycabled?
44
+ # Use instance variable check here for testing compatibility
45
+ connection.instance_variable_defined?(:@anycable_socket)
46
+ end
47
+ end)
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_cable/connection"
4
+ require "anycable/rails/connections/serializable_identification"
5
+
6
+ ActionCable::Connection::Base.include(AnyCable::Rails::Connections::SerializableIdentification)
7
+ ActionCable::Connection::Base.prepend(Module.new do
8
+ attr_reader :anycable_socket
9
+ attr_accessor :anycable_request_builder
10
+
11
+ # In AnyCable, we lazily populate env by passing it through the middleware chain,
12
+ # so we access it via #request
13
+ def env
14
+ return super unless anycabled?
15
+
16
+ request.env
17
+ end
18
+
19
+ def anycabled?
20
+ @anycable_socket
21
+ end
22
+
23
+ private
24
+
25
+ def request
26
+ return super unless anycabled?
27
+
28
+ @request ||= anycable_request_builder.build_rack_request(@env)
29
+ end
30
+ end)
@@ -2,10 +2,12 @@
2
2
 
3
3
  require "action_cable/remote_connections"
4
4
 
5
- ActionCable::RemoteConnections::RemoteConnection.include(ActionCable::Connection::SerializableIdentification)
5
+ ActionCable::RemoteConnections::RemoteConnection.include(AnyCable::Rails::Connections::SerializableIdentification)
6
6
 
7
7
  ActionCable::RemoteConnections::RemoteConnection.prepend(Module.new do
8
8
  def disconnect(reconnect: true)
9
+ # Legacy Action Cable functionality if case we're not fully migrated yet
10
+ super() unless AnyCable::Rails.enabled?
9
11
  ::AnyCable.broadcast_adapter.broadcast_command("disconnect", identifier: identifiers_json, reconnect: reconnect)
10
12
  end
11
13
  end)
@@ -10,11 +10,11 @@ module AnyCable
10
10
  class_eval <<~RUBY, __FILE__, __LINE__ + 1
11
11
  def #{name}
12
12
  return @#{name} if instance_variable_defined?(:@#{name})
13
- @#{name} = AnyCable::Rails.deserialize(__istate__["#{name}"], json: true) if connection.anycable_socket
13
+ @#{name} = AnyCable::Rails.deserialize(__istate__["#{name}"], json: true) if anycabled?
14
14
  end
15
15
 
16
16
  def #{name}=(val)
17
- __istate__["#{name}"] = AnyCable::Rails.serialize(val, json: true) if connection.anycable_socket
17
+ __istate__["#{name}"] = AnyCable::Rails.serialize(val, json: true) if anycabled?
18
18
  instance_variable_set(:@#{name}, val)
19
19
  end
20
20
  RUBY
@@ -41,13 +41,68 @@ module AnyCable
41
41
  attr_writer :__istate__
42
42
 
43
43
  def __istate__
44
- @__istate__ ||= connection.socket.istate
44
+ @__istate__ ||= connection.anycable_socket.istate
45
+ end
46
+ end
47
+
48
+ module ConnectionState
49
+ module ClassMethods
50
+ def state_attr_accessor(*names)
51
+ names.each do |name|
52
+ connection_state_attributes << name
53
+ class_eval <<~RUBY, __FILE__, __LINE__ + 1
54
+ def #{name}
55
+ return @#{name} if instance_variable_defined?(:@#{name})
56
+ @#{name} = AnyCable::Rails.deserialize(__cstate__["#{name}"], json: true) if anycabled?
57
+ end
58
+
59
+ def #{name}=(val)
60
+ __cstate__["#{name}"] = AnyCable::Rails.serialize(val, json: true) if anycabled?
61
+ instance_variable_set(:@#{name}, val)
62
+ end
63
+ RUBY
64
+ end
65
+ end
66
+
67
+ def connection_state_attributes
68
+ return @connection_state_attributes if instance_variable_defined?(:@connection_state_attributes)
69
+
70
+ @connection_state_attributes =
71
+ if superclass.respond_to?(:connection_state_attributes)
72
+ superclass.connection_state_attributes.dup
73
+ else
74
+ []
75
+ end
76
+ end
77
+ end
78
+
79
+ def self.included(base)
80
+ base.extend ClassMethods
81
+ end
82
+
83
+ # Make it possible to provide istate explicitly for a connection instance
84
+ attr_writer :__cstate__
85
+
86
+ def __cstate__
87
+ @__cstate__ ||= anycable_socket.cstate
45
88
  end
46
89
  end
47
90
  end
48
91
  end
49
92
 
50
- ActiveSupport.on_load(:action_cable) do
93
+ if ActiveSupport::VERSION::MAJOR < 6
94
+ # `state_attr_accessor` must be available in Action Cable
95
+ ActiveSupport.on_load(:action_cable) do
96
+ ::ActionCable::Connection::Base.include(AnyCable::Rails::ConnectionState)
97
+ ::ActionCable::Channel::Base.include(AnyCable::Rails::ChannelState)
98
+ end
99
+ else
51
100
  # `state_attr_accessor` must be available in Action Cable
52
- ::ActionCable::Channel::Base.include(AnyCable::Rails::ChannelState)
101
+ ActiveSupport.on_load(:action_cable_connection) do
102
+ ::ActionCable::Connection::Base.include(AnyCable::Rails::ConnectionState)
103
+ end
104
+
105
+ ActiveSupport.on_load(:action_cable_channel) do
106
+ ::ActionCable::Channel::Base.include(AnyCable::Rails::ChannelState)
107
+ end
53
108
  end
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_cable/connection"
4
+ require "action_cable/channel"
5
+
6
+ module AnyCable
7
+ module Rails
8
+ # Enhance Action Cable connection
9
+ using(Module.new do
10
+ refine ActionCable::Connection::Base do
11
+ attr_writer :env, :websocket, :logger, :coder,
12
+ :subscriptions, :serialized_ids, :cached_ids, :server,
13
+ :anycable_socket
14
+
15
+ # Using public :send_welcome_message causes stack level too deep 🤷🏻‍♂️
16
+ def public_send_welcome_message
17
+ send_welcome_message
18
+ end
19
+
20
+ def public_request
21
+ request
22
+ end
23
+ end
24
+
25
+ refine ActionCable::Channel::Base do
26
+ def rejected?
27
+ subscription_rejected?
28
+ end
29
+ end
30
+
31
+ refine ActionCable::Connection::Subscriptions do
32
+ # Find or add a subscription to the list
33
+ def fetch(identifier)
34
+ add("identifier" => identifier) unless subscriptions[identifier]
35
+
36
+ unless subscriptions[identifier]
37
+ raise "Channel not found: #{ActiveSupport::JSON.decode(identifier).fetch("channel")}"
38
+ end
39
+
40
+ subscriptions[identifier]
41
+ end
42
+ end
43
+ end)
44
+
45
+ class Connection
46
+ # We store logger tags in the connection state to be able
47
+ # to re-use them in the subsequent calls
48
+ LOG_TAGS_IDENTIFIER = "__ltags__"
49
+
50
+ delegate :identifiers_json, to: :conn
51
+
52
+ attr_reader :socket, :logger
53
+
54
+ def initialize(connection_class, socket, identifiers: nil, subscriptions: nil)
55
+ @socket = socket
56
+
57
+ logger_tags = fetch_logger_tags_from_state
58
+ @logger = ActionCable::Connection::TaggedLoggerProxy.new(AnyCable.logger, tags: logger_tags)
59
+
60
+ # Instead of calling #initialize,
61
+ # we allocate an instance and setup all the required components manually
62
+ @conn = connection_class.allocate
63
+ # Required to access config (for access origin checks)
64
+ conn.server = ActionCable.server
65
+ conn.logger = logger
66
+ conn.anycable_socket = conn.websocket = socket
67
+ conn.env = socket.env
68
+ conn.coder = ActiveSupport::JSON
69
+ conn.subscriptions = ActionCable::Connection::Subscriptions.new(conn)
70
+ conn.serialized_ids = {}
71
+ conn.serialized_ids = ActiveSupport::JSON.decode(identifiers) if identifiers
72
+ conn.cached_ids = {}
73
+ conn.anycable_request_builder = self
74
+
75
+ return unless subscriptions
76
+
77
+ # Pre-initialize channels (for disconnect)
78
+ subscriptions.each do |id|
79
+ channel = conn.subscriptions.fetch(id)
80
+ next unless socket.istate[id]
81
+
82
+ channel.__istate__ = ActiveSupport::JSON.decode(socket.istate[id])
83
+ end
84
+ end
85
+
86
+ def handle_open
87
+ logger.info started_request_message if access_logs?
88
+
89
+ verify_origin! || return
90
+
91
+ conn.connect if conn.respond_to?(:connect)
92
+
93
+ socket.cstate.write(LOG_TAGS_IDENTIFIER, logger.tags.to_json) unless logger.tags.empty?
94
+
95
+ conn.public_send_welcome_message
96
+ rescue ::ActionCable::Connection::Authorization::UnauthorizedError
97
+ reject_request(
98
+ ActionCable::INTERNAL[:disconnect_reasons]&.[](:unauthorized) || "unauthorized"
99
+ )
100
+ end
101
+
102
+ def handle_close
103
+ logger.info finished_request_message if access_logs?
104
+
105
+ conn.subscriptions.unsubscribe_from_all
106
+ conn.disconnect if conn.respond_to?(:disconnect)
107
+ true
108
+ end
109
+
110
+ def handle_channel_command(identifier, command, data)
111
+ # We cannot use subscriptions#execute_command here,
112
+ # since we MUST return true of false, depending on the status
113
+ # of execution
114
+ channel = conn.subscriptions.fetch(identifier)
115
+ case command
116
+ when "subscribe"
117
+ channel.handle_subscribe
118
+ !channel.rejected?
119
+ when "unsubscribe"
120
+ conn.subscriptions.remove_subscription(channel)
121
+ true
122
+ when "message"
123
+ channel.perform_action ActiveSupport::JSON.decode(data)
124
+ true
125
+ else
126
+ false
127
+ end
128
+ # Support rescue_from
129
+ # https://github.com/rails/rails/commit/d2571e560c62116f60429c933d0c41a0e249b58b
130
+ rescue Exception => e # rubocop:disable Lint/RescueException
131
+ rescue_with_handler(e) || raise
132
+ false
133
+ end
134
+
135
+ def build_rack_request(env)
136
+ environment = ::Rails.application.env_config.merge(env) if defined?(::Rails.application) && ::Rails.application
137
+ AnyCable::Rails::Rack.app.call(environment) if environment
138
+
139
+ ActionDispatch::Request.new(environment || env)
140
+ end
141
+
142
+ def action_cable_connection
143
+ conn
144
+ end
145
+
146
+ private
147
+
148
+ attr_reader :conn
149
+
150
+ def reject_request(reason, reconnect = false)
151
+ logger.info finished_request_message("Rejected") if access_logs?
152
+ conn.close(
153
+ reason: reason,
154
+ reconnect: reconnect
155
+ )
156
+ end
157
+
158
+ def fetch_logger_tags_from_state
159
+ socket.cstate.read(LOG_TAGS_IDENTIFIER).yield_self do |raw_tags|
160
+ next [] unless raw_tags
161
+ ActiveSupport::JSON.decode(raw_tags)
162
+ end
163
+ end
164
+
165
+ def started_request_message
166
+ format(
167
+ 'Started "%s"%s for %s at %s',
168
+ request.filtered_path, " [AnyCable]", request.ip, Time.now.to_s
169
+ )
170
+ end
171
+
172
+ def finished_request_message(reason = "Closed")
173
+ format(
174
+ 'Finished "%s"%s for %s at %s (%s)',
175
+ request.filtered_path, " [AnyCable]", request.ip, Time.now.to_s, reason
176
+ )
177
+ end
178
+
179
+ def verify_origin!
180
+ return true unless socket.env.key?("HTTP_ORIGIN")
181
+
182
+ return true if conn.send(:allow_request_origin?)
183
+
184
+ reject_request(
185
+ ActionCable::INTERNAL[:disconnect_reasons]&.[](:invalid_request) || "invalid_request"
186
+ )
187
+ false
188
+ end
189
+
190
+ def access_logs?
191
+ AnyCable.config.access_logs_disabled == false
192
+ end
193
+
194
+ def request
195
+ conn.public_request
196
+ end
197
+
198
+ def request_loaded?
199
+ conn.instance_variable_defined?(:@request)
200
+ end
201
+
202
+ def rescue_with_handler(e)
203
+ conn.rescue_with_handler(e) if conn.respond_to?(:rescue_with_handler)
204
+ end
205
+ end
206
+ end
207
+ 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
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnyCable
4
+ module Rails
5
+ module Connections
6
+ # Wrap `request.session` to lazily load values provided
7
+ # in the RPC call (set by the previous calls)
8
+ class SessionProxy
9
+ attr_reader :rack_session, :socket_session
10
+
11
+ def initialize(rack_session, socket_session)
12
+ @rack_session = rack_session
13
+ @socket_session = JSON.parse(socket_session).with_indifferent_access
14
+ end
15
+
16
+ %i[has_key? [] []= fetch delete dig].each do |mid|
17
+ class_eval <<~CODE, __FILE__, __LINE__ + 1
18
+ def #{mid}(*args, **kwargs, &block)
19
+ restore_key! args.first
20
+ rack_session.#{mid}(*args, **kwargs, &block)
21
+ end
22
+ CODE
23
+ end
24
+
25
+ alias_method :include?, :has_key?
26
+ alias_method :key?, :has_key?
27
+
28
+ %i[update merge! to_hash].each do |mid|
29
+ class_eval <<~CODE, __FILE__, __LINE__ + 1
30
+ def #{mid}(*args, **kwargs, &block)
31
+ restore!
32
+ rack_session.#{mid}(*args, **kwargs, &block)
33
+ end
34
+ CODE
35
+ end
36
+
37
+ alias_method :to_h, :to_hash
38
+
39
+ def keys
40
+ rack_session.keys + socket_session.keys
41
+ end
42
+
43
+ # Delegate both publuc and private methods to rack_session
44
+ def respond_to_missing?(name, include_private = false)
45
+ return false if name == :marshal_dump || name == :_dump
46
+ rack_session.respond_to?(name, include_private) || super
47
+ end
48
+
49
+ def method_missing(method, *args, &block)
50
+ if rack_session.respond_to?(method, true)
51
+ rack_session.send(method, *args, &block)
52
+ else
53
+ super
54
+ end
55
+ end
56
+
57
+ # This method is used by StimulusReflex to obtain `@by`
58
+ def instance_variable_get(name)
59
+ super || rack_session.instance_variable_get(name)
60
+ end
61
+
62
+ private
63
+
64
+ def restore!
65
+ socket_session.keys.each(&method(:restore_key!))
66
+ end
67
+
68
+ def restore_key!(key)
69
+ return unless socket_session.key?(key)
70
+ val = socket_session.delete(key)
71
+ rack_session[key] =
72
+ if val.is_a?(String)
73
+ GlobalID::Locator.locate(val) || val
74
+ else
75
+ val
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -1,6 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "anycable/rails/action_cable_ext/connection"
4
+ require "anycable/rails/action_cable_ext/channel"
5
+ require "anycable/rails/action_cable_ext/remote_connections"
6
+
3
7
  require "anycable/rails/channel_state"
8
+ require "anycable/rails/connection_factory"
4
9
 
5
10
  module AnyCable
6
11
  module Rails
@@ -43,19 +48,12 @@ module AnyCable
43
48
 
44
49
  initializer "anycable.connection_factory", after: "action_cable.set_configs" do |app|
45
50
  ActiveSupport.on_load(:action_cable) do
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)
49
-
50
51
  app.config.to_prepare do
51
- AnyCable.connection_factory = ActionCable.server.config.connection_class.call
52
+ AnyCable.connection_factory = AnyCable::Rails::ConnectionFactory.new
52
53
  end
53
54
 
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"
58
- end
55
+ if AnyCable.config.persistent_session_enabled?
56
+ require "anycable/rails/connections/persistent_session"
59
57
  end
60
58
  end
61
59
  end
@@ -63,7 +61,7 @@ module AnyCable
63
61
  # Since Rails 6.1
64
62
  if respond_to?(:server)
65
63
  server do
66
- next unless AnyCable.config.embedded?
64
+ next unless AnyCable.config.embedded? && AnyCable::Rails.enabled?
67
65
 
68
66
  require "anycable/cli"
69
67
  AnyCable::CLI.embed!
@@ -2,6 +2,6 @@
2
2
 
3
3
  module AnyCable
4
4
  module Rails
5
- VERSION = "1.1.3"
5
+ VERSION = "1.3.0"
6
6
  end
7
7
  end
@@ -48,6 +48,17 @@ module AnyCable
48
48
  val
49
49
  end
50
50
  end
51
+
52
+ module Extension
53
+ def broadcast(channel, payload)
54
+ super
55
+ ::AnyCable.broadcast(channel, payload)
56
+ end
57
+ end
58
+
59
+ def extend_adapter!(adapter)
60
+ adapter.extend(Extension)
61
+ end
51
62
  end
52
63
  end
53
64
  end
@@ -24,6 +24,12 @@ module AnyCableRailsGenerators
24
24
  class_option :skip_procfile_dev,
25
25
  type: :boolean,
26
26
  desc: "Do not create Procfile.dev"
27
+ class_option :skip_jwt,
28
+ type: :boolean,
29
+ desc: "Do not install anycable-rails-jwt"
30
+ class_option :skip_install,
31
+ type: :boolean,
32
+ desc: "Do not run bundle install when adding new gems"
27
33
 
28
34
  include WithOSHelpers
29
35
 
@@ -59,7 +65,7 @@ module AnyCableRailsGenerators
59
65
  <<~SNIPPET
60
66
  # Specify AnyCable WebSocket server URL to use by JS client
61
67
  config.after_initialize do
62
- config.action_cable.url = ActionCable.server.config.url = ENV.fetch("CABLE_URL") if AnyCable::Rails.enabled?
68
+ config.action_cable.url = ActionCable.server.config.url = ENV.fetch("CABLE_URL", "/cable") if AnyCable::Rails.enabled?
63
69
  end
64
70
  SNIPPET
65
71
  end
@@ -129,6 +135,16 @@ module AnyCableRailsGenerators
129
135
  say_status :help, "⚠️ Please, take a look at the icompatibilities above and fix them. See #{DOCS_ROOT}/rails/compatibility" unless res
130
136
  end
131
137
 
138
+ def jwt
139
+ return if options[:skip_jwt]
140
+
141
+ return unless options[:skip_jwt] == false || yes?("Do you want to use JWT for authentication? [Yn]")
142
+
143
+ opts = " --skip-install" if options[:skip_install]
144
+
145
+ run "bundle add anycable-rails-jwt#{opts}"
146
+ end
147
+
132
148
  def finish
133
149
  say_status :info, "✅ AnyCable has been configured successfully!"
134
150
  end
@@ -172,7 +188,7 @@ module AnyCableRailsGenerators
172
188
  say <<~YML
173
189
  ─────────────────────────────────────────
174
190
  ws:
175
- image: anycable/anycable-go:1.0
191
+ image: anycable/anycable-go:1.2
176
192
  ports:
177
193
  - '8080:8080'
178
194
  environment:
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: anycable-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.3
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - palkan
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-10-11 00:00:00.000000000 Z
11
+ date: 2022-02-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: anycable
@@ -16,28 +16,28 @@ dependencies:
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: '1.1'
19
+ version: 1.2.0
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: '1.1'
26
+ version: 1.2.0
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: actioncable
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
31
  - - ">="
32
32
  - !ruby/object:Gem::Version
33
- version: '5.1'
33
+ version: '6.0'
34
34
  type: :runtime
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
38
  - - ">="
39
39
  - !ruby/object:Gem::Version
40
- version: '5.1'
40
+ version: '6.0'
41
41
  - !ruby/object:Gem::Dependency
42
42
  name: globalid
43
43
  requirement: !ruby/object:Gem::Requirement
@@ -150,12 +150,9 @@ files:
150
150
  - lib/action_cable/subscription_adapter/anycable.rb
151
151
  - lib/anycable-rails.rb
152
152
  - lib/anycable/rails.rb
153
- - lib/anycable/rails/actioncable/channel.rb
154
- - lib/anycable/rails/actioncable/connection.rb
155
- - lib/anycable/rails/actioncable/connection/persistent_session.rb
156
- - lib/anycable/rails/actioncable/connection/serializable_identification.rb
157
- - lib/anycable/rails/actioncable/remote_connections.rb
158
- - lib/anycable/rails/actioncable/testing.rb
153
+ - lib/anycable/rails/action_cable_ext/channel.rb
154
+ - lib/anycable/rails/action_cable_ext/connection.rb
155
+ - lib/anycable/rails/action_cable_ext/remote_connections.rb
159
156
  - lib/anycable/rails/channel_state.rb
160
157
  - lib/anycable/rails/compatibility.rb
161
158
  - lib/anycable/rails/compatibility/rubocop.rb
@@ -164,12 +161,15 @@ files:
164
161
  - lib/anycable/rails/compatibility/rubocop/cops/anycable/periodical_timers.rb
165
162
  - lib/anycable/rails/compatibility/rubocop/cops/anycable/stream_from.rb
166
163
  - lib/anycable/rails/config.rb
164
+ - lib/anycable/rails/connection.rb
165
+ - lib/anycable/rails/connection_factory.rb
166
+ - lib/anycable/rails/connections/persistent_session.rb
167
+ - lib/anycable/rails/connections/serializable_identification.rb
168
+ - lib/anycable/rails/connections/session_proxy.rb
167
169
  - lib/anycable/rails/middlewares/executor.rb
168
170
  - lib/anycable/rails/middlewares/log_tagging.rb
169
171
  - lib/anycable/rails/rack.rb
170
172
  - lib/anycable/rails/railtie.rb
171
- - lib/anycable/rails/refinements/subscriptions.rb
172
- - lib/anycable/rails/session_proxy.rb
173
173
  - lib/anycable/rails/version.rb
174
174
  - lib/generators/anycable/download/USAGE
175
175
  - lib/generators/anycable/download/download_generator.rb
@@ -198,7 +198,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
198
198
  requirements:
199
199
  - - ">="
200
200
  - !ruby/object:Gem::Version
201
- version: '2.6'
201
+ version: '2.7'
202
202
  required_rubygems_version: !ruby/object:Gem::Requirement
203
203
  requirements:
204
204
  - - ">="
@@ -1,40 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "action_cable/channel"
4
-
5
- module ActionCable
6
- module Channel
7
- class Base # :nodoc:
8
- alias_method :handle_subscribe, :subscribe_to_channel
9
-
10
- public :handle_subscribe, :subscription_rejected?
11
-
12
- # Action Cable calls this method from inside the Subscriptions#add
13
- # method, which we use to initialize the channel instance.
14
- # We don't want to invoke `subscribed` callbacks every time we do that.
15
- def subscribe_to_channel
16
- # noop
17
- end
18
-
19
- def start_periodic_timers
20
- # noop
21
- end
22
-
23
- def stop_periodic_timers
24
- # noop
25
- end
26
-
27
- def stream_from(broadcasting, _callback = nil, _options = {})
28
- connection.socket.subscribe identifier, broadcasting
29
- end
30
-
31
- def stop_stream_from(broadcasting)
32
- connection.socket.unsubscribe identifier, broadcasting
33
- end
34
-
35
- def stop_all_streams
36
- connection.socket.unsubscribe_from_all identifier
37
- end
38
- end
39
- end
40
- end
@@ -1,34 +0,0 @@
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
- )
@@ -1,42 +0,0 @@
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
@@ -1,222 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "action_cable/connection"
4
- require "anycable/rails/actioncable/connection/serializable_identification"
5
- require "anycable/rails/refinements/subscriptions"
6
- require "anycable/rails/actioncable/channel"
7
- require "anycable/rails/actioncable/remote_connections"
8
- require "anycable/rails/session_proxy"
9
-
10
- module ActionCable
11
- module Connection
12
- # rubocop: disable Metrics/ClassLength
13
- class Base # :nodoc:
14
- # We store logger tags in the connection state to be able
15
- # to re-use them in the subsequent calls
16
- LOG_TAGS_IDENTIFIER = "__ltags__"
17
-
18
- using AnyCable::Refinements::Subscriptions
19
-
20
- include SerializableIdentification
21
-
22
- attr_reader :socket
23
-
24
- alias_method :anycable_socket, :socket
25
-
26
- delegate :env, :session, to: :request
27
-
28
- class << self
29
- def call(socket, **options)
30
- new(socket, nil, **options)
31
- end
32
- end
33
-
34
- def initialize(socket, env, identifiers: "{}", subscriptions: nil)
35
- if env
36
- # If env is set, then somehow we're in the context of Action Cable
37
- # Return and print a warning in #process
38
- @request = ActionDispatch::Request.new(env)
39
- return
40
- end
41
-
42
- @ids = ActiveSupport::JSON.decode(identifiers)
43
-
44
- @ltags = socket.cstate.read(LOG_TAGS_IDENTIFIER).yield_self do |raw_tags|
45
- next unless raw_tags
46
- ActiveSupport::JSON.decode(raw_tags)
47
- end
48
-
49
- @cached_ids = {}
50
- @coder = ActiveSupport::JSON
51
- @socket = socket
52
- @subscriptions = ActionCable::Connection::Subscriptions.new(self)
53
-
54
- return unless subscriptions
55
-
56
- # Initialize channels (for disconnect)
57
- subscriptions.each do |id|
58
- channel = @subscriptions.fetch(id)
59
- next unless socket.istate[id]
60
-
61
- channel.__istate__ = ActiveSupport::JSON.decode(socket.istate[id])
62
- end
63
- end
64
-
65
- def process
66
- # Use Rails logger here to print to stdout in development
67
- logger.error invalid_request_message
68
- logger.info finished_request_message
69
- [404, {"Content-Type" => "text/plain"}, ["Page not found"]]
70
- end
71
-
72
- def invalid_request_message
73
- "You're trying to connect to Action Cable server while using AnyCable. " \
74
- "See https://docs.anycable.io/troubleshooting?id=server-raises-an-argumenterror-exception-when-client-tries-to-connect"
75
- end
76
-
77
- def handle_open
78
- logger.info started_request_message if access_logs?
79
-
80
- verify_origin! || return
81
-
82
- connect if respond_to?(:connect)
83
-
84
- socket.cstate.write(LOG_TAGS_IDENTIFIER, fetch_ltags.to_json)
85
-
86
- send_welcome_message
87
- rescue ActionCable::Connection::Authorization::UnauthorizedError
88
- reject_request(
89
- ActionCable::INTERNAL[:disconnect_reasons]&.[](:unauthorized) || "unauthorized"
90
- )
91
- end
92
-
93
- def handle_close
94
- logger.info finished_request_message if access_logs?
95
-
96
- subscriptions.unsubscribe_from_all
97
- disconnect if respond_to?(:disconnect)
98
- true
99
- end
100
-
101
- # rubocop:disable Metrics/MethodLength
102
- def handle_channel_command(identifier, command, data)
103
- channel = subscriptions.fetch(identifier)
104
- case command
105
- when "subscribe"
106
- channel.handle_subscribe
107
- !channel.subscription_rejected?
108
- when "unsubscribe"
109
- subscriptions.remove_subscription(channel)
110
- true
111
- when "message"
112
- channel.perform_action ActiveSupport::JSON.decode(data)
113
- true
114
- else
115
- false
116
- end
117
- end
118
- # rubocop:enable Metrics/MethodLength
119
-
120
- def close(reason: nil, reconnect: nil)
121
- transmit(
122
- type: ActionCable::INTERNAL[:message_types].fetch(:disconnect, "disconnect"),
123
- reason: reason,
124
- reconnect: reconnect
125
- )
126
- socket.close
127
- end
128
-
129
- def transmit(cable_message)
130
- socket.transmit encode(cable_message)
131
- end
132
-
133
- def logger
134
- @logger ||= TaggedLoggerProxy.new(AnyCable.logger, tags: ltags || [])
135
- end
136
-
137
- def request
138
- @request ||= build_rack_request
139
- end
140
-
141
- private
142
-
143
- attr_reader :ids, :ltags
144
-
145
- def started_request_message
146
- format(
147
- 'Started "%s"%s for %s at %s',
148
- request.filtered_path, " [AnyCable]", request.ip, Time.now.to_s
149
- )
150
- end
151
-
152
- def finished_request_message(reason = "Closed")
153
- format(
154
- 'Finished "%s"%s for %s at %s (%s)',
155
- request.filtered_path, " [AnyCable]", request.ip, Time.now.to_s, reason
156
- )
157
- end
158
-
159
- def access_logs?
160
- AnyCable.config.access_logs_disabled == false
161
- end
162
-
163
- def fetch_ltags
164
- if instance_variable_defined?(:@logger)
165
- logger.tags
166
- else
167
- ltags
168
- end
169
- end
170
-
171
- def server
172
- ActionCable.server
173
- end
174
-
175
- def verify_origin!
176
- return true unless socket.env.key?("HTTP_ORIGIN")
177
-
178
- return true if allow_request_origin?
179
-
180
- reject_request(
181
- ActionCable::INTERNAL[:disconnect_reasons]&.[](:invalid_request) || "invalid_request"
182
- )
183
- false
184
- end
185
-
186
- def reject_request(reason, reconnect = false)
187
- logger.info finished_request_message("Rejected") if access_logs?
188
- close(
189
- reason: reason,
190
- reconnect: reconnect
191
- )
192
- end
193
-
194
- def build_rack_request
195
- environment = Rails.application.env_config.merge(socket.env)
196
- AnyCable::Rails::Rack.app.call(environment)
197
-
198
- ActionDispatch::Request.new(environment)
199
- end
200
-
201
- def request_loaded?
202
- instance_variable_defined?(:@request)
203
- end
204
- end
205
- # rubocop:enable Metrics/ClassLength
206
- end
207
- end
208
-
209
- # Support rescue_from
210
- # https://github.com/rails/rails/commit/d2571e560c62116f60429c933d0c41a0e249b58b
211
- if ActionCable::Connection::Base.respond_to?(:rescue_from)
212
- ActionCable::Connection::Base.prepend(Module.new do
213
- def handle_channel_command(*)
214
- super
215
- rescue Exception => e # rubocop:disable Lint/RescueException
216
- rescue_with_handler(e) || raise
217
- false
218
- end
219
- end)
220
- end
221
-
222
- require "anycable/rails/actioncable/testing" if ::Rails.env.test?
@@ -1,33 +0,0 @@
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
- handle_subscribe
16
- end
17
- end)
18
-
19
- ActionCable::Channel::ConnectionStub.prepend(Module.new do
20
- def socket
21
- @socket ||= AnyCable::Socket.new(env: AnyCable::Env.new(url: "http://test.host", headers: {}))
22
- end
23
-
24
- alias_method :anycable_socket, :socket
25
- end)
26
-
27
- ActionCable::Connection::TestConnection.prepend(Module.new do
28
- def initialize(request)
29
- @request = request
30
- @cached_ids = {}
31
- super
32
- end
33
- end)
@@ -1,20 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module AnyCable
4
- module Refinements
5
- module Subscriptions # :nodoc:
6
- refine ActionCable::Connection::Subscriptions do
7
- # Find or add a subscription to the list
8
- def fetch(identifier)
9
- add("identifier" => identifier) unless subscriptions[identifier]
10
-
11
- unless subscriptions[identifier]
12
- raise "Channel not found: #{ActiveSupport::JSON.decode(identifier).fetch("channel")}"
13
- end
14
-
15
- subscriptions[identifier]
16
- end
17
- end
18
- end
19
- end
20
- end
@@ -1,79 +0,0 @@
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_method :include?, :has_key?
25
- alias_method :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_method :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