actioncable 6.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 (61) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +169 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +24 -0
  5. data/app/assets/javascripts/action_cable.js +517 -0
  6. data/lib/action_cable.rb +62 -0
  7. data/lib/action_cable/channel.rb +17 -0
  8. data/lib/action_cable/channel/base.rb +311 -0
  9. data/lib/action_cable/channel/broadcasting.rb +41 -0
  10. data/lib/action_cable/channel/callbacks.rb +37 -0
  11. data/lib/action_cable/channel/naming.rb +25 -0
  12. data/lib/action_cable/channel/periodic_timers.rb +78 -0
  13. data/lib/action_cable/channel/streams.rb +176 -0
  14. data/lib/action_cable/channel/test_case.rb +310 -0
  15. data/lib/action_cable/connection.rb +22 -0
  16. data/lib/action_cable/connection/authorization.rb +15 -0
  17. data/lib/action_cable/connection/base.rb +264 -0
  18. data/lib/action_cable/connection/client_socket.rb +157 -0
  19. data/lib/action_cable/connection/identification.rb +47 -0
  20. data/lib/action_cable/connection/internal_channel.rb +45 -0
  21. data/lib/action_cable/connection/message_buffer.rb +54 -0
  22. data/lib/action_cable/connection/stream.rb +117 -0
  23. data/lib/action_cable/connection/stream_event_loop.rb +136 -0
  24. data/lib/action_cable/connection/subscriptions.rb +79 -0
  25. data/lib/action_cable/connection/tagged_logger_proxy.rb +42 -0
  26. data/lib/action_cable/connection/test_case.rb +234 -0
  27. data/lib/action_cable/connection/web_socket.rb +41 -0
  28. data/lib/action_cable/engine.rb +79 -0
  29. data/lib/action_cable/gem_version.rb +17 -0
  30. data/lib/action_cable/helpers/action_cable_helper.rb +42 -0
  31. data/lib/action_cable/remote_connections.rb +71 -0
  32. data/lib/action_cable/server.rb +17 -0
  33. data/lib/action_cable/server/base.rb +94 -0
  34. data/lib/action_cable/server/broadcasting.rb +54 -0
  35. data/lib/action_cable/server/configuration.rb +56 -0
  36. data/lib/action_cable/server/connections.rb +36 -0
  37. data/lib/action_cable/server/worker.rb +75 -0
  38. data/lib/action_cable/server/worker/active_record_connection_management.rb +21 -0
  39. data/lib/action_cable/subscription_adapter.rb +12 -0
  40. data/lib/action_cable/subscription_adapter/async.rb +29 -0
  41. data/lib/action_cable/subscription_adapter/base.rb +30 -0
  42. data/lib/action_cable/subscription_adapter/channel_prefix.rb +28 -0
  43. data/lib/action_cable/subscription_adapter/inline.rb +37 -0
  44. data/lib/action_cable/subscription_adapter/postgresql.rb +132 -0
  45. data/lib/action_cable/subscription_adapter/redis.rb +181 -0
  46. data/lib/action_cable/subscription_adapter/subscriber_map.rb +59 -0
  47. data/lib/action_cable/subscription_adapter/test.rb +40 -0
  48. data/lib/action_cable/test_case.rb +11 -0
  49. data/lib/action_cable/test_helper.rb +133 -0
  50. data/lib/action_cable/version.rb +10 -0
  51. data/lib/rails/generators/channel/USAGE +13 -0
  52. data/lib/rails/generators/channel/channel_generator.rb +52 -0
  53. data/lib/rails/generators/channel/templates/application_cable/channel.rb.tt +4 -0
  54. data/lib/rails/generators/channel/templates/application_cable/connection.rb.tt +4 -0
  55. data/lib/rails/generators/channel/templates/channel.rb.tt +16 -0
  56. data/lib/rails/generators/channel/templates/javascript/channel.js.tt +20 -0
  57. data/lib/rails/generators/channel/templates/javascript/consumer.js.tt +6 -0
  58. data/lib/rails/generators/channel/templates/javascript/index.js.tt +5 -0
  59. data/lib/rails/generators/test_unit/channel_generator.rb +20 -0
  60. data/lib/rails/generators/test_unit/templates/channel_test.rb.tt +8 -0
  61. metadata +149 -0
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/module/redefine_method"
4
+
5
+ module ActionCable
6
+ # If you need to disconnect a given connection, you can go through the
7
+ # RemoteConnections. You can find the connections you're looking for by
8
+ # searching for the identifier declared on the connection. For example:
9
+ #
10
+ # module ApplicationCable
11
+ # class Connection < ActionCable::Connection::Base
12
+ # identified_by :current_user
13
+ # ....
14
+ # end
15
+ # end
16
+ #
17
+ # ActionCable.server.remote_connections.where(current_user: User.find(1)).disconnect
18
+ #
19
+ # This will disconnect all the connections established for
20
+ # <tt>User.find(1)</tt>, across all servers running on all machines, because
21
+ # it uses the internal channel that all of these servers are subscribed to.
22
+ class RemoteConnections
23
+ attr_reader :server
24
+
25
+ def initialize(server)
26
+ @server = server
27
+ end
28
+
29
+ def where(identifier)
30
+ RemoteConnection.new(server, identifier)
31
+ end
32
+
33
+ private
34
+ # Represents a single remote connection found via <tt>ActionCable.server.remote_connections.where(*)</tt>.
35
+ # Exists solely for the purpose of calling #disconnect on that connection.
36
+ class RemoteConnection
37
+ class InvalidIdentifiersError < StandardError; end
38
+
39
+ include Connection::Identification, Connection::InternalChannel
40
+
41
+ def initialize(server, ids)
42
+ @server = server
43
+ set_identifier_instance_vars(ids)
44
+ end
45
+
46
+ # Uses the internal channel to disconnect the connection.
47
+ def disconnect
48
+ server.broadcast internal_channel, type: "disconnect"
49
+ end
50
+
51
+ # Returns all the identifiers that were applied to this connection.
52
+ redefine_method :identifiers do
53
+ server.connection_identifiers
54
+ end
55
+
56
+ protected
57
+ attr_reader :server
58
+
59
+ private
60
+ def set_identifier_instance_vars(ids)
61
+ raise InvalidIdentifiersError unless valid_identifiers?(ids)
62
+ ids.each { |k, v| instance_variable_set("@#{k}", v) }
63
+ end
64
+
65
+ def valid_identifiers?(ids)
66
+ keys = ids.keys
67
+ identifiers.all? { |id| keys.include?(id) }
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionCable
4
+ module Server
5
+ extend ActiveSupport::Autoload
6
+
7
+ eager_autoload do
8
+ autoload :Base
9
+ autoload :Broadcasting
10
+ autoload :Connections
11
+ autoload :Configuration
12
+
13
+ autoload :Worker
14
+ autoload :ActiveRecordConnectionManagement, "action_cable/server/worker/active_record_connection_management"
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+
5
+ module ActionCable
6
+ module Server
7
+ # A singleton ActionCable::Server instance is available via ActionCable.server. It's used by the Rack process that starts the Action Cable server, but
8
+ # is also used by the user to reach the RemoteConnections object, which is used for finding and disconnecting connections across all servers.
9
+ #
10
+ # Also, this is the server instance used for broadcasting. See Broadcasting for more information.
11
+ class Base
12
+ include ActionCable::Server::Broadcasting
13
+ include ActionCable::Server::Connections
14
+
15
+ cattr_accessor :config, instance_accessor: false, default: ActionCable::Server::Configuration.new
16
+
17
+ attr_reader :config
18
+
19
+ def self.logger; config.logger; end
20
+ delegate :logger, to: :config
21
+
22
+ attr_reader :mutex
23
+
24
+ def initialize(config: self.class.config)
25
+ @config = config
26
+ @mutex = Monitor.new
27
+ @remote_connections = @event_loop = @worker_pool = @pubsub = nil
28
+ end
29
+
30
+ # Called by Rack to setup the server.
31
+ def call(env)
32
+ setup_heartbeat_timer
33
+ config.connection_class.call.new(self, env).process
34
+ end
35
+
36
+ # Disconnect all the connections identified by +identifiers+ on this server or any others via RemoteConnections.
37
+ def disconnect(identifiers)
38
+ remote_connections.where(identifiers).disconnect
39
+ end
40
+
41
+ def restart
42
+ connections.each do |connection|
43
+ connection.close(reason: ActionCable::INTERNAL[:disconnect_reasons][:server_restart])
44
+ end
45
+
46
+ @mutex.synchronize do
47
+ # Shutdown the worker pool
48
+ @worker_pool.halt if @worker_pool
49
+ @worker_pool = nil
50
+
51
+ # Shutdown the pub/sub adapter
52
+ @pubsub.shutdown if @pubsub
53
+ @pubsub = nil
54
+ end
55
+ end
56
+
57
+ # Gateway to RemoteConnections. See that class for details.
58
+ def remote_connections
59
+ @remote_connections || @mutex.synchronize { @remote_connections ||= RemoteConnections.new(self) }
60
+ end
61
+
62
+ def event_loop
63
+ @event_loop || @mutex.synchronize { @event_loop ||= ActionCable::Connection::StreamEventLoop.new }
64
+ end
65
+
66
+ # The worker pool is where we run connection callbacks and channel actions. We do as little as possible on the server's main thread.
67
+ # The worker pool is an executor service that's backed by a pool of threads working from a task queue. The thread pool size maxes out
68
+ # at 4 worker threads by default. Tune the size yourself with <tt>config.action_cable.worker_pool_size</tt>.
69
+ #
70
+ # Using Active Record, Redis, etc within your channel actions means you'll get a separate connection from each thread in the worker pool.
71
+ # Plan your deployment accordingly: 5 servers each running 5 Puma workers each running an 8-thread worker pool means at least 200 database
72
+ # connections.
73
+ #
74
+ # Also, ensure that your database connection pool size is as least as large as your worker pool size. Otherwise, workers may oversubscribe
75
+ # the database connection pool and block while they wait for other workers to release their connections. Use a smaller worker pool or a larger
76
+ # database connection pool instead.
77
+ def worker_pool
78
+ @worker_pool || @mutex.synchronize { @worker_pool ||= ActionCable::Server::Worker.new(max_size: config.worker_pool_size) }
79
+ end
80
+
81
+ # Adapter used for all streams/broadcasting.
82
+ def pubsub
83
+ @pubsub || @mutex.synchronize { @pubsub ||= config.pubsub_adapter.new(self) }
84
+ end
85
+
86
+ # All of the identifiers applied to the connection class associated with this server.
87
+ def connection_identifiers
88
+ config.connection_class.call.identifiers
89
+ end
90
+ end
91
+
92
+ ActiveSupport.run_load_hooks(:action_cable, Base.config)
93
+ end
94
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionCable
4
+ module Server
5
+ # Broadcasting is how other parts of your application can send messages to a channel's subscribers. As explained in Channel, most of the time, these
6
+ # broadcastings are streamed directly to the clients subscribed to the named broadcasting. Let's explain with a full-stack example:
7
+ #
8
+ # class WebNotificationsChannel < ApplicationCable::Channel
9
+ # def subscribed
10
+ # stream_from "web_notifications_#{current_user.id}"
11
+ # end
12
+ # end
13
+ #
14
+ # # Somewhere in your app this is called, perhaps from a NewCommentJob:
15
+ # ActionCable.server.broadcast \
16
+ # "web_notifications_1", { title: "New things!", body: "All that's fit for print" }
17
+ #
18
+ # # Client-side CoffeeScript, which assumes you've already requested the right to send web notifications:
19
+ # App.cable.subscriptions.create "WebNotificationsChannel",
20
+ # received: (data) ->
21
+ # new Notification data['title'], body: data['body']
22
+ module Broadcasting
23
+ # Broadcast a hash directly to a named <tt>broadcasting</tt>. This will later be JSON encoded.
24
+ def broadcast(broadcasting, message, coder: ActiveSupport::JSON)
25
+ broadcaster_for(broadcasting, coder: coder).broadcast(message)
26
+ end
27
+
28
+ # Returns a broadcaster for a named <tt>broadcasting</tt> that can be reused. Useful when you have an object that
29
+ # may need multiple spots to transmit to a specific broadcasting over and over.
30
+ def broadcaster_for(broadcasting, coder: ActiveSupport::JSON)
31
+ Broadcaster.new(self, String(broadcasting), coder: coder)
32
+ end
33
+
34
+ private
35
+ class Broadcaster
36
+ attr_reader :server, :broadcasting, :coder
37
+
38
+ def initialize(server, broadcasting, coder:)
39
+ @server, @broadcasting, @coder = server, broadcasting, coder
40
+ end
41
+
42
+ def broadcast(message)
43
+ server.logger.debug "[ActionCable] Broadcasting to #{broadcasting}: #{message.inspect}"
44
+
45
+ payload = { broadcasting: broadcasting, message: message, coder: coder }
46
+ ActiveSupport::Notifications.instrument("broadcast.action_cable", payload) do
47
+ encoded = coder ? coder.encode(message) : message
48
+ server.pubsub.broadcast broadcasting, encoded
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionCable
4
+ module Server
5
+ # An instance of this configuration object is available via ActionCable.server.config, which allows you to tweak Action Cable configuration
6
+ # in a Rails config initializer.
7
+ class Configuration
8
+ attr_accessor :logger, :log_tags
9
+ attr_accessor :connection_class, :worker_pool_size
10
+ attr_accessor :disable_request_forgery_protection, :allowed_request_origins, :allow_same_origin_as_host
11
+ attr_accessor :cable, :url, :mount_path
12
+
13
+ def initialize
14
+ @log_tags = []
15
+
16
+ @connection_class = -> { ActionCable::Connection::Base }
17
+ @worker_pool_size = 4
18
+
19
+ @disable_request_forgery_protection = false
20
+ @allow_same_origin_as_host = true
21
+ end
22
+
23
+ # Returns constant of subscription adapter specified in config/cable.yml.
24
+ # If the adapter cannot be found, this will default to the Redis adapter.
25
+ # Also makes sure proper dependencies are required.
26
+ def pubsub_adapter
27
+ adapter = (cable.fetch("adapter") { "redis" })
28
+
29
+ # Require the adapter itself and give useful feedback about
30
+ # 1. Missing adapter gems and
31
+ # 2. Adapter gems' missing dependencies.
32
+ path_to_adapter = "action_cable/subscription_adapter/#{adapter}"
33
+ begin
34
+ require path_to_adapter
35
+ rescue LoadError => e
36
+ # We couldn't require the adapter itself. Raise an exception that
37
+ # points out config typos and missing gems.
38
+ if e.path == path_to_adapter
39
+ # We can assume that a non-builtin adapter was specified, so it's
40
+ # either misspelled or missing from Gemfile.
41
+ raise e.class, "Could not load the '#{adapter}' Action Cable pubsub adapter. Ensure that the adapter is spelled correctly in config/cable.yml and that you've added the necessary adapter gem to your Gemfile.", e.backtrace
42
+
43
+ # Bubbled up from the adapter require. Prefix the exception message
44
+ # with some guidance about how to address it and reraise.
45
+ else
46
+ raise e.class, "Error loading the '#{adapter}' Action Cable pubsub adapter. Missing a gem it depends on? #{e.message}", e.backtrace
47
+ end
48
+ end
49
+
50
+ adapter = adapter.camelize
51
+ adapter = "PostgreSQL" if adapter == "Postgresql"
52
+ "ActionCable::SubscriptionAdapter::#{adapter}".constantize
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionCable
4
+ module Server
5
+ # Collection class for all the connections that have been established on this specific server. Remember, usually you'll run many Action Cable servers, so
6
+ # you can't use this collection as a full list of all of the connections established against your application. Instead, use RemoteConnections for that.
7
+ module Connections # :nodoc:
8
+ BEAT_INTERVAL = 3
9
+
10
+ def connections
11
+ @connections ||= []
12
+ end
13
+
14
+ def add_connection(connection)
15
+ connections << connection
16
+ end
17
+
18
+ def remove_connection(connection)
19
+ connections.delete connection
20
+ end
21
+
22
+ # WebSocket connection implementations differ on when they'll mark a connection as stale. We basically never want a connection to go stale, as you
23
+ # then can't rely on being able to communicate with the connection. To solve this, a 3 second heartbeat runs on all connections. If the beat fails, we automatically
24
+ # disconnect.
25
+ def setup_heartbeat_timer
26
+ @heartbeat_timer ||= event_loop.timer(BEAT_INTERVAL) do
27
+ event_loop.post { connections.map(&:beat) }
28
+ end
29
+ end
30
+
31
+ def open_connections_statistics
32
+ connections.map(&:statistics)
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/callbacks"
4
+ require "active_support/core_ext/module/attribute_accessors_per_thread"
5
+ require "concurrent"
6
+
7
+ module ActionCable
8
+ module Server
9
+ # Worker used by Server.send_async to do connection work in threads.
10
+ class Worker # :nodoc:
11
+ include ActiveSupport::Callbacks
12
+
13
+ thread_mattr_accessor :connection
14
+ define_callbacks :work
15
+ include ActiveRecordConnectionManagement
16
+
17
+ attr_reader :executor
18
+
19
+ def initialize(max_size: 5)
20
+ @executor = Concurrent::ThreadPoolExecutor.new(
21
+ min_threads: 1,
22
+ max_threads: max_size,
23
+ max_queue: 0,
24
+ )
25
+ end
26
+
27
+ # Stop processing work: any work that has not already started
28
+ # running will be discarded from the queue
29
+ def halt
30
+ @executor.shutdown
31
+ end
32
+
33
+ def stopping?
34
+ @executor.shuttingdown?
35
+ end
36
+
37
+ def work(connection)
38
+ self.connection = connection
39
+
40
+ run_callbacks :work do
41
+ yield
42
+ end
43
+ ensure
44
+ self.connection = nil
45
+ end
46
+
47
+ def async_exec(receiver, *args, connection:, &block)
48
+ async_invoke receiver, :instance_exec, *args, connection: connection, &block
49
+ end
50
+
51
+ def async_invoke(receiver, method, *args, connection: receiver, &block)
52
+ @executor.post do
53
+ invoke(receiver, method, *args, connection: connection, &block)
54
+ end
55
+ end
56
+
57
+ def invoke(receiver, method, *args, connection:, &block)
58
+ work(connection) do
59
+ receiver.send method, *args, &block
60
+ rescue Exception => e
61
+ logger.error "There was an exception - #{e.class}(#{e.message})"
62
+ logger.error e.backtrace.join("\n")
63
+
64
+ receiver.handle_exception if receiver.respond_to?(:handle_exception)
65
+ end
66
+ end
67
+
68
+ private
69
+
70
+ def logger
71
+ ActionCable.server.logger
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionCable
4
+ module Server
5
+ class Worker
6
+ module ActiveRecordConnectionManagement
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ if defined?(ActiveRecord::Base)
11
+ set_callback :work, :around, :with_database_connections
12
+ end
13
+ end
14
+
15
+ def with_database_connections
16
+ connection.logger.tag(ActiveRecord::Base.logger) { yield }
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionCable
4
+ module SubscriptionAdapter
5
+ extend ActiveSupport::Autoload
6
+
7
+ autoload :Base
8
+ autoload :Test
9
+ autoload :SubscriberMap
10
+ autoload :ChannelPrefix
11
+ end
12
+ end