anycable-rails 1.0.0.preview2 → 1.0.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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +28 -122
  3. data/README.md +14 -34
  4. data/lib/anycable/rails.rb +36 -2
  5. data/lib/anycable/rails/actioncable/channel.rb +4 -0
  6. data/lib/anycable/rails/actioncable/connection.rb +39 -35
  7. data/lib/anycable/rails/actioncable/connection/persistent_session.rb +7 -3
  8. data/lib/anycable/rails/actioncable/connection/serializable_identification.rb +42 -0
  9. data/lib/anycable/rails/actioncable/remote_connections.rb +11 -0
  10. data/lib/anycable/rails/actioncable/testing.rb +35 -0
  11. data/lib/anycable/rails/channel_state.rb +46 -0
  12. data/lib/anycable/rails/compatibility.rb +4 -7
  13. data/lib/anycable/rails/compatibility/rubocop.rb +0 -1
  14. data/lib/anycable/rails/compatibility/rubocop/config/default.yml +3 -0
  15. data/lib/anycable/rails/compatibility/rubocop/cops/anycable/instance_vars.rb +1 -1
  16. data/lib/anycable/rails/compatibility/rubocop/cops/anycable/stream_from.rb +4 -4
  17. data/lib/anycable/rails/railtie.rb +26 -18
  18. data/lib/anycable/rails/refinements/subscriptions.rb +5 -0
  19. data/lib/anycable/rails/session_proxy.rb +19 -2
  20. data/lib/anycable/rails/version.rb +1 -1
  21. data/lib/generators/anycable/download/USAGE +14 -0
  22. data/lib/generators/anycable/download/download_generator.rb +83 -0
  23. data/lib/generators/anycable/setup/USAGE +2 -0
  24. data/lib/{rails/generators → generators}/anycable/setup/setup_generator.rb +100 -88
  25. data/lib/generators/anycable/setup/templates/Procfile.dev.tt +6 -0
  26. data/lib/{rails/generators → generators}/anycable/setup/templates/config/anycable.yml.tt +22 -4
  27. data/lib/generators/anycable/setup/templates/config/cable.yml.tt +11 -0
  28. data/lib/{rails/generators/anycable/setup/templates/config/initializers/anycable.rb → generators/anycable/setup/templates/config/initializers/anycable.rb.tt} +1 -1
  29. data/lib/generators/anycable/with_os_helpers.rb +55 -0
  30. metadata +29 -52
  31. data/lib/anycable/rails/compatibility/rubocop/cops/anycable/remote_disconnect.rb +0 -31
  32. data/lib/rails/generators/anycable/setup/templates/Procfile +0 -2
  33. data/lib/rails/generators/anycable/setup/templates/Procfile.dev +0 -3
  34. data/lib/rails/generators/anycable/setup/templates/bin/heroku-web +0 -7
  35. data/lib/rails/generators/anycable/setup/templates/config/cable.yml.tt +0 -11
@@ -15,16 +15,20 @@ module ActionCable
15
15
  return super unless socket.session
16
16
 
17
17
  super.tap do |req|
18
- req.env[Rack::RACK_SESSION] =
19
- AnyCable::Rails::SessionProxy.new(req.env[Rack::RACK_SESSION], socket.session)
18
+ req.env[::Rack::RACK_SESSION] =
19
+ AnyCable::Rails::SessionProxy.new(req.env[::Rack::RACK_SESSION], socket.session)
20
20
  end
21
21
  end
22
22
 
23
23
  def commit_session!
24
- return unless request_loaded? && request.session.loaded?
24
+ return unless request_loaded? && request.session.respond_to?(:loaded?) && request.session.loaded?
25
25
 
26
26
  socket.session = request.session.to_json
27
27
  end
28
28
  end
29
29
  end
30
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
@@ -33,6 +33,10 @@ 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
42
  "Channel instance variables are not supported by AnyCable, " \
@@ -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,11 +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"
@@ -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) ...)"
@@ -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,23 +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)
49
+
50
+ app.config.to_prepare do
51
+ AnyCable.connection_factory = ActionCable.server.config.connection_class.call
52
+ end
48
53
 
54
+ if AnyCable::Rails.enabled?
55
+ require "anycable/rails/actioncable/connection"
49
56
  if AnyCable.config.persistent_session_enabled
50
57
  require "anycable/rails/actioncable/connection/persistent_session"
51
- ::ActionCable::Connection::Base.prepend(
52
- ::ActionCable::Connection::PersistentSession
53
- )
54
- end
55
-
56
- app.config.to_prepare do
57
- AnyCable.connection_factory = ActionCable.server.config.connection_class.call
58
58
  end
59
59
  end
60
60
  end
61
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
62
70
  end
63
71
  end
64
72
  end
@@ -7,6 +7,11 @@ module AnyCable
7
7
  # Find or add a subscription to the list
8
8
  def fetch(identifier)
9
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
+
10
15
  subscriptions[identifier]
11
16
  end
12
17
  end
@@ -5,8 +5,6 @@ module AnyCable
5
5
  # Wrap `request.session` to lazily load values provided
6
6
  # in the RPC call (set by the previous calls)
7
7
  class SessionProxy
8
- delegate_missing_to :@rack_session
9
-
10
8
  attr_reader :rack_session, :socket_session
11
9
 
12
10
  def initialize(rack_session, socket_session)
@@ -41,6 +39,25 @@ module AnyCable
41
39
  rack_session.keys + socket_session.keys
42
40
  end
43
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
+
44
61
  private
45
62
 
46
63
  def restore!
@@ -2,6 +2,6 @@
2
2
 
3
3
  module AnyCable
4
4
  module Rails
5
- VERSION = "1.0.0.preview2"
5
+ VERSION = "1.0.0"
6
6
  end
7
7
  end
@@ -0,0 +1,14 @@
1
+ Description:
2
+ Install AnyCable-Go web server locally (the latest version by default).
3
+
4
+ Example:
5
+ rails generate anycable:download
6
+
7
+ This will ask:
8
+ Where to store a binary file.
9
+ This will create:
10
+ `<bin_path>/anycable-go`.
11
+
12
+ rails generate anycable:download --bin-path=/usr/local/bin
13
+
14
+ rails generate anycable:download --version=1.0.0
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "generators/anycable/with_os_helpers"
4
+
5
+ module AnyCableRailsGenerators
6
+ # Downloads anycable-go binary
7
+ class DownloadGenerator < ::Rails::Generators::Base
8
+ namespace "anycable:download"
9
+
10
+ include WithOSHelpers
11
+
12
+ VERSION = "latest"
13
+
14
+ class_option :bin_path,
15
+ type: :string,
16
+ desc: "Where to download AnyCable-Go server binary (default: #{DEFAULT_BIN_PATH})"
17
+ class_option :version,
18
+ type: :string,
19
+ desc: "Specify the AnyCable-Go version (defaults to latest release)"
20
+
21
+ def download_bin
22
+ out = options[:bin_path] || DEFAULT_BIN_PATH
23
+ version = options[:version] || VERSION
24
+
25
+ download_exe(
26
+ release_url(version),
27
+ to: out,
28
+ file_name: "anycable-go"
29
+ )
30
+
31
+ true
32
+ end
33
+
34
+ private
35
+
36
+ def release_url(version)
37
+ return latest_release_url(version) if version == "latest"
38
+
39
+ if Gem::Version.new(version).segments.first >= 1
40
+ new_release_url("v#{version}")
41
+ else
42
+ legacy_release_url("v#{version}")
43
+ end
44
+ end
45
+
46
+ def legacy_release_url(version)
47
+ "https://github.com/anycable/anycable-go/releases/download/#{version}/" \
48
+ "anycable-go-v#{version}-#{os_name}-#{cpu_name}"
49
+ end
50
+
51
+ def new_release_url(version)
52
+ "https://github.com/anycable/anycable-go/releases/download/#{version}/" \
53
+ "anycable-go-#{os_name}-#{cpu_name}"
54
+ end
55
+
56
+ def latest_release_url(version)
57
+ "https://github.com/anycable/anycable-go/releases/latest/download/" \
58
+ "anycable-go-#{os_name}-#{cpu_name}"
59
+ end
60
+
61
+ def download_exe(url, to:, file_name:)
62
+ file_path = File.join(to, file_name)
63
+
64
+ run "#{sudo(to)}curl -L #{url} -o #{file_path}", abort_on_failure: true
65
+ run "#{sudo(to)}chmod +x #{file_path}", abort_on_failure: true
66
+ run "#{file_path} -v", abort_on_failure: true
67
+ end
68
+
69
+ def sudo(path)
70
+ sudo = ""
71
+ unless File.writable?(path)
72
+ if yes? "Path is not writable 😕. Do you have sudo privileges?"
73
+ sudo = "sudo "
74
+ else
75
+ say_status :error, "❌ Failed to install AnyCable-Go WebSocket server", :red
76
+ raise StandardError, "Path #{path} is not writable!"
77
+ end
78
+ end
79
+
80
+ sudo
81
+ end
82
+ end
83
+ end