anycable-rails 1.0.0.preview2 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +28 -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