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,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
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnyCable
4
+ module Rails
5
+ module Middlewares
6
+ # Executor runs Rails executor for each call
7
+ # See https://guides.rubyonrails.org/v5.2.0/threading_and_code_execution.html#framework-behavior
8
+ class Executor < AnyCable::Middleware
9
+ attr_reader :executor
10
+
11
+ def initialize(executor)
12
+ @executor = executor
13
+ end
14
+
15
+ def call(method, message, metadata)
16
+ if ::Rails.respond_to?(:error)
17
+ executor.wrap do
18
+ sid = metadata["sid"]
19
+
20
+ ::Rails.error.record(context: {method: method, payload: message.to_h, sid: sid}) do
21
+ yield
22
+ end
23
+ end
24
+ else
25
+ executor.wrap { yield }
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnyCable
4
+ module Rails
5
+ module Middlewares
6
+ # Middleware to add `sid` (session ID) tag to logs.
7
+ #
8
+ # Session ID could be provided through gRPC metadata `sid` key.
9
+ #
10
+ # See https://github.com/grpc/grpc-go/blob/master/Documentation/grpc-metadata.md
11
+ class LogTagging < AnyCable::Middleware
12
+ def call(_method, _request, metadata)
13
+ sid = metadata["sid"]
14
+ return yield unless sid
15
+
16
+ AnyCable.logger.tagged("AnyCable sid=#{sid}") { yield }
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/configuration"
4
+ require "action_dispatch/middleware/stack"
5
+
6
+ module AnyCable
7
+ module Rails
8
+ # Rack middleware stack to modify the HTTP request object.
9
+ #
10
+ # AnyCable Websocket server does not use Rack middleware processing mechanism (which Rails uses
11
+ # when Action Cable is mounted into the main app).
12
+ #
13
+ # Some middlewares could enhance request env with useful information.
14
+ #
15
+ # For instance, consider the Rails session middleware: it's responsible for restoring the
16
+ # session data from cookies.
17
+ #
18
+ # AnyCable adds session middelware by default to its own stack.
19
+ #
20
+ # You can also use any Rack/Rails middleware you want. For example, to enable Devise/Warden
21
+ # you can add the following code to an initializer or any other configuration file:
22
+ #
23
+ # AnyCable::Rails::Rack.middleware.use Warden::Manager do |config|
24
+ # Devise.warden_config = config
25
+ # end
26
+ module Rack
27
+ def self.app_build_lock
28
+ @app_build_lock
29
+ end
30
+
31
+ @app_build_lock = Mutex.new
32
+
33
+ def self.middleware
34
+ @middleware ||= ::Rails::Configuration::MiddlewareStackProxy.new
35
+ end
36
+
37
+ def self.default_middleware_stack
38
+ config = ::Rails.application.config
39
+
40
+ ActionDispatch::MiddlewareStack.new do |middleware|
41
+ middleware.use(config.session_store, config.session_options) if config.session_store
42
+ end
43
+ end
44
+
45
+ def self.app
46
+ @rack_app || app_build_lock.synchronize do
47
+ @rack_app ||= default_middleware_stack.yield_self do |stack|
48
+ middleware.merge_into(stack)
49
+ end.yield_self do |stack|
50
+ stack.build { [-1, {}, []] }
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
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
+
7
+ require "anycable/rails/channel_state"
8
+ require "anycable/rails/connection_factory"
9
+
10
+ module AnyCable
11
+ module Rails
12
+ class Railtie < ::Rails::Railtie # :nodoc:
13
+ initializer "anycable.disable_action_cable_mount", after: "action_cable.set_configs" do |app|
14
+ next unless AnyCable::Rails.enabled?
15
+
16
+ app.config.action_cable.mount_path = nil
17
+ end
18
+
19
+ initializer "anycable.logger", after: "action_cable.logger" do |_app|
20
+ AnyCable.logger = ::ActionCable.server.config.logger
21
+
22
+ AnyCable.configure_server do
23
+ server_logger = AnyCable.logger = ::ActionCable.server.config.logger
24
+ AnyCable.logger = ActiveSupport::TaggedLogging.new(server_logger) if server_logger.is_a?(::Logger)
25
+ # Broadcast server logs to STDOUT in development
26
+ if ::Rails.env.development? &&
27
+ !ActiveSupport::Logger.logger_outputs_to?(::Rails.logger, $stdout)
28
+ console = ActiveSupport::Logger.new($stdout)
29
+ console.formatter = ::Rails.logger.formatter if ::Rails.logger.respond_to?(:formatter)
30
+ console.level = ::Rails.logger.level if ::Rails.logger.respond_to?(:level)
31
+ AnyCable.logger.extend(ActiveSupport::Logger.broadcast(console))
32
+ end
33
+ end
34
+
35
+ # Add tagging middleware
36
+ if AnyCable.logger.respond_to?(:tagged)
37
+ require "anycable/rails/middlewares/log_tagging"
38
+
39
+ AnyCable.middleware.use(AnyCable::Rails::Middlewares::LogTagging)
40
+ end
41
+ end
42
+
43
+ initializer "anycable.executor" do |app|
44
+ require "anycable/rails/middlewares/executor"
45
+ # see https://github.com/rails/rails/pull/33469/files
46
+ executor = app.config.reload_classes_only_on_change ? app.reloader : app.executor
47
+ AnyCable.middleware.use(AnyCable::Rails::Middlewares::Executor.new(executor))
48
+
49
+ if app.executor.respond_to?(:error_reporter)
50
+ AnyCable.capture_exception do |ex, method, message|
51
+ ::Rails.error.report(ex, handled: false, context: {method: method.to_sym, payload: message})
52
+ end
53
+ end
54
+ end
55
+
56
+ initializer "anycable.connection_factory", after: "action_cable.set_configs" do |app|
57
+ ActiveSupport.on_load(:action_cable) do
58
+ app.config.to_prepare do
59
+ AnyCable.connection_factory = AnyCable::Rails::ConnectionFactory.new
60
+ end
61
+
62
+ if AnyCable.config.persistent_session_enabled?
63
+ require "anycable/rails/connections/persistent_session"
64
+ end
65
+ end
66
+ end
67
+
68
+ initializer "anycable.routes" do
69
+ next unless AnyCable::Rails.enabled?
70
+
71
+ config.after_initialize do |app|
72
+ config = AnyCable.config
73
+ unless config.http_rpc_mount_path.nil?
74
+ app.routes.prepend do
75
+ mount AnyCable::HTTRPC::Server.new => config.http_rpc_mount_path, :internal => true
76
+ end
77
+ end
78
+ end
79
+ end
80
+
81
+ # Since Rails 6.1
82
+ if respond_to?(:server)
83
+ server do
84
+ next unless AnyCable.config.embedded? && AnyCable::Rails.enabled?
85
+
86
+ require "anycable/cli"
87
+ AnyCable::CLI.embed!
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnyCable
4
+ module Rails
5
+ VERSION = "1.4.0"
6
+ end
7
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "anycable"
4
+ require "anycable/rails/version"
5
+ require "anycable/rails/config"
6
+ require "anycable/rails/rack"
7
+
8
+ require "globalid"
9
+
10
+ module AnyCable
11
+ # Rails handler for AnyCable
12
+ module Rails
13
+ require "anycable/rails/railtie"
14
+
15
+ ADAPTER_ALIASES = %w[any_cable anycable].freeze
16
+
17
+ class << self
18
+ def enabled?
19
+ adapter = ::ActionCable.server.config.cable&.fetch("adapter", nil)
20
+ compatible_adapter?(adapter)
21
+ end
22
+
23
+ def compatible_adapter?(adapter)
24
+ ADAPTER_ALIASES.include?(adapter)
25
+ end
26
+
27
+ # Serialize connection/channel state variable to string
28
+ # using GlobalID where possible or JSON (if json: true)
29
+ def serialize(obj, json: false)
30
+ obj.try(:to_gid_param) || (json ? obj.to_json : obj)
31
+ end
32
+
33
+ # Deserialize previously serialized value from string to
34
+ # Ruby object.
35
+ # If the resulting object is a Hash, make it indifferent
36
+ def deserialize(str, json: false)
37
+ str.yield_self do |val|
38
+ next val unless val.is_a?(String)
39
+
40
+ gval = GlobalID::Locator.locate(val)
41
+ return gval if gval
42
+
43
+ next val unless json
44
+
45
+ JSON.parse(val)
46
+ end.yield_self do |val|
47
+ next val.with_indifferent_access if val.is_a?(Hash)
48
+ val
49
+ end
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
62
+ end
63
+ end
64
+ end
65
+
66
+ # Warn if application has been already initialized.
67
+ # AnyCable should be loaded before initialization in order to work correctly.
68
+ if defined?(::Rails) && ::Rails.application && ::Rails.application.initialized?
69
+ puts("\n**************************************************")
70
+ puts(
71
+ "⛔️ WARNING: AnyCable loaded after application initialization. Might not work correctly.\n" \
72
+ "Please, make sure to remove `require: false` in your Gemfile or " \
73
+ "require manually in `environment.rb` before `Rails.application.initialize!`"
74
+ )
75
+ puts("**************************************************\n\n")
76
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "anycable/rails"
@@ -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,85 @@
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
+ FileUtils.mkdir_p(to) unless File.directory?(to)
65
+
66
+ run "#{sudo(to)}curl -L #{url} -o #{file_path}", abort_on_failure: true
67
+ run "#{sudo(to)}chmod +x #{file_path}", abort_on_failure: true
68
+ run "#{file_path} -v", abort_on_failure: true
69
+ end
70
+
71
+ def sudo(path)
72
+ sudo = ""
73
+ unless File.writable?(path)
74
+ if yes? "Path is not writable 😕. Do you have sudo privileges?"
75
+ sudo = "sudo "
76
+ else
77
+ say_status :error, "❌ Failed to install AnyCable-Go WebSocket server", :red
78
+ raise StandardError, "Path #{path} is not writable!"
79
+ end
80
+ end
81
+
82
+ sudo
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,2 @@
1
+ Description:
2
+ Configures your application to work with AnyCable interactively.