actioncable 5.2.2.1 → 6.0.2

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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +150 -20
  3. data/MIT-LICENSE +1 -1
  4. data/README.md +2 -545
  5. data/app/assets/javascripts/action_cable.js +517 -0
  6. data/lib/action_cable.rb +15 -7
  7. data/lib/action_cable/channel.rb +1 -0
  8. data/lib/action_cable/channel/base.rb +7 -1
  9. data/lib/action_cable/channel/broadcasting.rb +18 -8
  10. data/lib/action_cable/channel/streams.rb +1 -1
  11. data/lib/action_cable/channel/test_case.rb +310 -0
  12. data/lib/action_cable/connection.rb +1 -0
  13. data/lib/action_cable/connection/authorization.rb +1 -1
  14. data/lib/action_cable/connection/base.rb +11 -7
  15. data/lib/action_cable/connection/message_buffer.rb +1 -4
  16. data/lib/action_cable/connection/stream.rb +4 -2
  17. data/lib/action_cable/connection/subscriptions.rb +1 -5
  18. data/lib/action_cable/connection/test_case.rb +234 -0
  19. data/lib/action_cable/connection/web_socket.rb +1 -3
  20. data/lib/action_cable/gem_version.rb +3 -3
  21. data/lib/action_cable/server/base.rb +8 -3
  22. data/lib/action_cable/server/worker.rb +5 -7
  23. data/lib/action_cable/subscription_adapter.rb +1 -0
  24. data/lib/action_cable/subscription_adapter/postgresql.rb +26 -8
  25. data/lib/action_cable/subscription_adapter/redis.rb +4 -1
  26. data/lib/action_cable/subscription_adapter/test.rb +40 -0
  27. data/lib/action_cable/test_case.rb +11 -0
  28. data/lib/action_cable/test_helper.rb +133 -0
  29. data/lib/rails/generators/channel/USAGE +4 -5
  30. data/lib/rails/generators/channel/channel_generator.rb +6 -3
  31. data/lib/rails/generators/channel/templates/{assets → javascript}/channel.js.tt +6 -4
  32. data/lib/rails/generators/channel/templates/{assets/cable.js.tt → javascript/consumer.js.tt} +2 -9
  33. data/lib/rails/generators/channel/templates/javascript/index.js.tt +5 -0
  34. data/lib/rails/generators/test_unit/channel_generator.rb +20 -0
  35. data/lib/rails/generators/test_unit/templates/channel_test.rb.tt +8 -0
  36. metadata +23 -13
  37. data/lib/assets/compiled/action_cable.js +0 -601
  38. data/lib/rails/generators/channel/templates/assets/channel.coffee.tt +0 -14
@@ -5,7 +5,7 @@ module ActionCable
5
5
  module Authorization
6
6
  class UnauthorizedError < StandardError; end
7
7
 
8
- # Closes the \WebSocket connection if it is open and returns a 404 "File not Found" response.
8
+ # Closes the WebSocket connection if it is open and returns a 404 "File not Found" response.
9
9
  def reject_unauthorized_connection
10
10
  logger.error "An unauthorized connection attempt was rejected"
11
11
  raise UnauthorizedError
@@ -95,7 +95,12 @@ module ActionCable
95
95
  end
96
96
 
97
97
  # Close the WebSocket connection.
98
- def close
98
+ def close(reason: nil, reconnect: true)
99
+ transmit(
100
+ type: ActionCable::INTERNAL[:message_types][:disconnect],
101
+ reason: reason,
102
+ reconnect: reconnect
103
+ )
99
104
  websocket.close
100
105
  end
101
106
 
@@ -136,13 +141,10 @@ module ActionCable
136
141
  send_async :handle_close
137
142
  end
138
143
 
139
- # TODO Change this to private once we've dropped Ruby 2.2 support.
140
- # Workaround for Ruby 2.2 "private attribute?" warning.
141
- protected
144
+ private
142
145
  attr_reader :websocket
143
146
  attr_reader :message_buffer
144
147
 
145
- private
146
148
  # The request that initiated the WebSocket connection is available here. This gives access to the environment, cookies, etc.
147
149
  def request # :doc:
148
150
  @request ||= begin
@@ -173,7 +175,7 @@ module ActionCable
173
175
  message_buffer.process!
174
176
  server.add_connection(self)
175
177
  rescue ActionCable::Connection::Authorization::UnauthorizedError
176
- respond_to_invalid_request
178
+ close(reason: ActionCable::INTERNAL[:disconnect_reasons][:unauthorized], reconnect: false) if websocket.alive?
177
179
  end
178
180
 
179
181
  def handle_close
@@ -214,7 +216,7 @@ module ActionCable
214
216
  end
215
217
 
216
218
  def respond_to_invalid_request
217
- close if websocket.alive?
219
+ close(reason: ActionCable::INTERNAL[:disconnect_reasons][:invalid_request]) if websocket.alive?
218
220
 
219
221
  logger.error invalid_request_message
220
222
  logger.info finished_request_message
@@ -258,3 +260,5 @@ module ActionCable
258
260
  end
259
261
  end
260
262
  end
263
+
264
+ ActiveSupport.run_load_hooks(:action_cable_connection, ActionCable::Connection::Base)
@@ -30,13 +30,10 @@ module ActionCable
30
30
  receive_buffered_messages
31
31
  end
32
32
 
33
- # TODO Change this to private once we've dropped Ruby 2.2 support.
34
- # Workaround for Ruby 2.2 "private attribute?" warning.
35
- protected
33
+ private
36
34
  attr_reader :connection
37
35
  attr_reader :buffered_messages
38
36
 
39
- private
40
37
  def valid?(message)
41
38
  message.is_a?(String)
42
39
  end
@@ -98,8 +98,10 @@ module ActionCable
98
98
  def hijack_rack_socket
99
99
  return unless @socket_object.env["rack.hijack"]
100
100
 
101
- @socket_object.env["rack.hijack"].call
102
- @rack_hijack_io = @socket_object.env["rack.hijack_io"]
101
+ # This should return the underlying io according to the SPEC:
102
+ @rack_hijack_io = @socket_object.env["rack.hijack"].call
103
+ # Retain existing behaviour if required:
104
+ @rack_hijack_io ||= @socket_object.env["rack.hijack_io"]
103
105
 
104
106
  @event_loop.attach(@rack_hijack_io, self)
105
107
  end
@@ -63,12 +63,8 @@ module ActionCable
63
63
  subscriptions.each { |id, channel| remove_subscription(channel) }
64
64
  end
65
65
 
66
- # TODO Change this to private once we've dropped Ruby 2.2 support.
67
- # Workaround for Ruby 2.2 "private attribute?" warning.
68
- protected
69
- attr_reader :connection, :subscriptions
70
-
71
66
  private
67
+ attr_reader :connection, :subscriptions
72
68
  delegate :logger, to: :connection
73
69
 
74
70
  def find(data)
@@ -0,0 +1,234 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+ require "active_support/test_case"
5
+ require "active_support/core_ext/hash/indifferent_access"
6
+ require "action_dispatch"
7
+ require "action_dispatch/http/headers"
8
+ require "action_dispatch/testing/test_request"
9
+
10
+ module ActionCable
11
+ module Connection
12
+ class NonInferrableConnectionError < ::StandardError
13
+ def initialize(name)
14
+ super "Unable to determine the connection to test from #{name}. " +
15
+ "You'll need to specify it using `tests YourConnection` in your " +
16
+ "test case definition."
17
+ end
18
+ end
19
+
20
+ module Assertions
21
+ # Asserts that the connection is rejected (via +reject_unauthorized_connection+).
22
+ #
23
+ # # Asserts that connection without user_id fails
24
+ # assert_reject_connection { connect params: { user_id: '' } }
25
+ def assert_reject_connection(&block)
26
+ assert_raises(Authorization::UnauthorizedError, "Expected to reject connection but no rejection was made", &block)
27
+ end
28
+ end
29
+
30
+ # We don't want to use the whole "encryption stack" for connection
31
+ # unit-tests, but we want to make sure that users test against the correct types
32
+ # of cookies (i.e. signed or encrypted or plain)
33
+ class TestCookieJar < ActiveSupport::HashWithIndifferentAccess
34
+ def signed
35
+ self[:signed] ||= {}.with_indifferent_access
36
+ end
37
+
38
+ def encrypted
39
+ self[:encrypted] ||= {}.with_indifferent_access
40
+ end
41
+ end
42
+
43
+ class TestRequest < ActionDispatch::TestRequest
44
+ attr_accessor :session, :cookie_jar
45
+ end
46
+
47
+ module TestConnection
48
+ attr_reader :logger, :request
49
+
50
+ def initialize(request)
51
+ inner_logger = ActiveSupport::Logger.new(StringIO.new)
52
+ tagged_logging = ActiveSupport::TaggedLogging.new(inner_logger)
53
+ @logger = ActionCable::Connection::TaggedLoggerProxy.new(tagged_logging, tags: [])
54
+ @request = request
55
+ @env = request.env
56
+ end
57
+ end
58
+
59
+ # Unit test Action Cable connections.
60
+ #
61
+ # Useful to check whether a connection's +identified_by+ gets assigned properly
62
+ # and that any improper connection requests are rejected.
63
+ #
64
+ # == Basic example
65
+ #
66
+ # Unit tests are written as follows:
67
+ #
68
+ # 1. Simulate a connection attempt by calling +connect+.
69
+ # 2. Assert state, e.g. identifiers, has been assigned.
70
+ #
71
+ #
72
+ # class ApplicationCable::ConnectionTest < ActionCable::Connection::TestCase
73
+ # def test_connects_with_proper_cookie
74
+ # # Simulate the connection request with a cookie.
75
+ # cookies["user_id"] = users(:john).id
76
+ #
77
+ # connect
78
+ #
79
+ # # Assert the connection identifier matches the fixture.
80
+ # assert_equal users(:john).id, connection.user.id
81
+ # end
82
+ #
83
+ # def test_rejects_connection_without_proper_cookie
84
+ # assert_reject_connection { connect }
85
+ # end
86
+ # end
87
+ #
88
+ # +connect+ accepts additional information the HTTP request with the
89
+ # +params+, +headers+, +session+ and Rack +env+ options.
90
+ #
91
+ # def test_connect_with_headers_and_query_string
92
+ # connect params: { user_id: 1 }, headers: { "X-API-TOKEN" => "secret-my" }
93
+ #
94
+ # assert_equal "1", connection.user.id
95
+ # assert_equal "secret-my", connection.token
96
+ # end
97
+ #
98
+ # def test_connect_with_params
99
+ # connect params: { user_id: 1 }
100
+ #
101
+ # assert_equal "1", connection.user.id
102
+ # end
103
+ #
104
+ # You can also setup the correct cookies before the connection request:
105
+ #
106
+ # def test_connect_with_cookies
107
+ # # Plain cookies:
108
+ # cookies["user_id"] = 1
109
+ #
110
+ # # Or signed/encrypted:
111
+ # # cookies.signed["user_id"] = 1
112
+ # # cookies.encrypted["user_id"] = 1
113
+ #
114
+ # connect
115
+ #
116
+ # assert_equal "1", connection.user_id
117
+ # end
118
+ #
119
+ # == Connection is automatically inferred
120
+ #
121
+ # ActionCable::Connection::TestCase will automatically infer the connection under test
122
+ # from the test class name. If the channel cannot be inferred from the test
123
+ # class name, you can explicitly set it with +tests+.
124
+ #
125
+ # class ConnectionTest < ActionCable::Connection::TestCase
126
+ # tests ApplicationCable::Connection
127
+ # end
128
+ #
129
+ class TestCase < ActiveSupport::TestCase
130
+ module Behavior
131
+ extend ActiveSupport::Concern
132
+
133
+ DEFAULT_PATH = "/cable"
134
+
135
+ include ActiveSupport::Testing::ConstantLookup
136
+ include Assertions
137
+
138
+ included do
139
+ class_attribute :_connection_class
140
+
141
+ attr_reader :connection
142
+
143
+ ActiveSupport.run_load_hooks(:action_cable_connection_test_case, self)
144
+ end
145
+
146
+ module ClassMethods
147
+ def tests(connection)
148
+ case connection
149
+ when String, Symbol
150
+ self._connection_class = connection.to_s.camelize.constantize
151
+ when Module
152
+ self._connection_class = connection
153
+ else
154
+ raise NonInferrableConnectionError.new(connection)
155
+ end
156
+ end
157
+
158
+ def connection_class
159
+ if connection = self._connection_class
160
+ connection
161
+ else
162
+ tests determine_default_connection(name)
163
+ end
164
+ end
165
+
166
+ def determine_default_connection(name)
167
+ connection = determine_constant_from_test_name(name) do |constant|
168
+ Class === constant && constant < ActionCable::Connection::Base
169
+ end
170
+ raise NonInferrableConnectionError.new(name) if connection.nil?
171
+ connection
172
+ end
173
+ end
174
+
175
+ # Performs connection attempt to exert #connect on the connection under test.
176
+ #
177
+ # Accepts request path as the first argument and the following request options:
178
+ #
179
+ # - params – URL parameters (Hash)
180
+ # - headers – request headers (Hash)
181
+ # - session – session data (Hash)
182
+ # - env – additional Rack env configuration (Hash)
183
+ def connect(path = ActionCable.server.config.mount_path, **request_params)
184
+ path ||= DEFAULT_PATH
185
+
186
+ connection = self.class.connection_class.allocate
187
+ connection.singleton_class.include(TestConnection)
188
+ connection.send(:initialize, build_test_request(path, request_params))
189
+ connection.connect if connection.respond_to?(:connect)
190
+
191
+ # Only set instance variable if connected successfully
192
+ @connection = connection
193
+ end
194
+
195
+ # Exert #disconnect on the connection under test.
196
+ def disconnect
197
+ raise "Must be connected!" if connection.nil?
198
+
199
+ connection.disconnect if connection.respond_to?(:disconnect)
200
+ @connection = nil
201
+ end
202
+
203
+ def cookies
204
+ @cookie_jar ||= TestCookieJar.new
205
+ end
206
+
207
+ private
208
+ def build_test_request(path, params: nil, headers: {}, session: {}, env: {})
209
+ wrapped_headers = ActionDispatch::Http::Headers.from_hash(headers)
210
+
211
+ uri = URI.parse(path)
212
+
213
+ query_string = params.nil? ? uri.query : params.to_query
214
+
215
+ request_env = {
216
+ "QUERY_STRING" => query_string,
217
+ "PATH_INFO" => uri.path
218
+ }.merge(env)
219
+
220
+ if wrapped_headers.present?
221
+ ActionDispatch::Http::Headers.from_hash(request_env).merge!(wrapped_headers)
222
+ end
223
+
224
+ TestRequest.create(request_env).tap do |request|
225
+ request.session = session.with_indifferent_access
226
+ request.cookie_jar = cookies
227
+ end
228
+ end
229
+ end
230
+
231
+ include Behavior
232
+ end
233
+ end
234
+ end
@@ -34,9 +34,7 @@ module ActionCable
34
34
  websocket.rack_response
35
35
  end
36
36
 
37
- # TODO Change this to private once we've dropped Ruby 2.2 support.
38
- # Workaround for Ruby 2.2 "private attribute?" warning.
39
- protected
37
+ private
40
38
  attr_reader :websocket
41
39
  end
42
40
  end
@@ -7,10 +7,10 @@ module ActionCable
7
7
  end
8
8
 
9
9
  module VERSION
10
- MAJOR = 5
11
- MINOR = 2
10
+ MAJOR = 6
11
+ MINOR = 0
12
12
  TINY = 2
13
- PRE = "1"
13
+ PRE = nil
14
14
 
15
15
  STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".")
16
16
  end
@@ -12,14 +12,17 @@ module ActionCable
12
12
  include ActionCable::Server::Broadcasting
13
13
  include ActionCable::Server::Connections
14
14
 
15
- cattr_accessor :config, instance_accessor: true, default: ActionCable::Server::Configuration.new
15
+ cattr_accessor :config, instance_accessor: false, default: ActionCable::Server::Configuration.new
16
+
17
+ attr_reader :config
16
18
 
17
19
  def self.logger; config.logger; end
18
20
  delegate :logger, to: :config
19
21
 
20
22
  attr_reader :mutex
21
23
 
22
- def initialize
24
+ def initialize(config: self.class.config)
25
+ @config = config
23
26
  @mutex = Monitor.new
24
27
  @remote_connections = @event_loop = @worker_pool = @pubsub = nil
25
28
  end
@@ -36,7 +39,9 @@ module ActionCable
36
39
  end
37
40
 
38
41
  def restart
39
- connections.each(&:close)
42
+ connections.each do |connection|
43
+ connection.close(reason: ActionCable::INTERNAL[:disconnect_reasons][:server_restart])
44
+ end
40
45
 
41
46
  @mutex.synchronize do
42
47
  # Shutdown the worker pool
@@ -56,14 +56,12 @@ module ActionCable
56
56
 
57
57
  def invoke(receiver, method, *args, connection:, &block)
58
58
  work(connection) do
59
- begin
60
- receiver.send method, *args, &block
61
- rescue Exception => e
62
- logger.error "There was an exception - #{e.class}(#{e.message})"
63
- logger.error e.backtrace.join("\n")
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")
64
63
 
65
- receiver.handle_exception if receiver.respond_to?(:handle_exception)
66
- end
64
+ receiver.handle_exception if receiver.respond_to?(:handle_exception)
67
65
  end
68
66
  end
69
67
 
@@ -5,6 +5,7 @@ module ActionCable
5
5
  extend ActiveSupport::Autoload
6
6
 
7
7
  autoload :Base
8
+ autoload :Test
8
9
  autoload :SubscriberMap
9
10
  autoload :ChannelPrefix
10
11
  end
@@ -8,13 +8,15 @@ require "digest/sha1"
8
8
  module ActionCable
9
9
  module SubscriptionAdapter
10
10
  class PostgreSQL < Base # :nodoc:
11
+ prepend ChannelPrefix
12
+
11
13
  def initialize(*)
12
14
  super
13
15
  @listener = nil
14
16
  end
15
17
 
16
18
  def broadcast(channel, payload)
17
- with_connection do |pg_conn|
19
+ with_broadcast_connection do |pg_conn|
18
20
  pg_conn.exec("NOTIFY #{pg_conn.escape_identifier(channel_identifier(channel))}, '#{pg_conn.escape_string(payload)}'")
19
21
  end
20
22
  end
@@ -31,14 +33,24 @@ module ActionCable
31
33
  listener.shutdown
32
34
  end
33
35
 
34
- def with_connection(&block) # :nodoc:
35
- ActiveRecord::Base.connection_pool.with_connection do |ar_conn|
36
- pg_conn = ar_conn.raw_connection
36
+ def with_subscriptions_connection(&block) # :nodoc:
37
+ ar_conn = ActiveRecord::Base.connection_pool.checkout.tap do |conn|
38
+ # Action Cable is taking ownership over this database connection, and
39
+ # will perform the necessary cleanup tasks
40
+ ActiveRecord::Base.connection_pool.remove(conn)
41
+ end
42
+ pg_conn = ar_conn.raw_connection
37
43
 
38
- unless pg_conn.is_a?(PG::Connection)
39
- raise "The Active Record database must be PostgreSQL in order to use the PostgreSQL Action Cable storage adapter"
40
- end
44
+ verify!(pg_conn)
45
+ yield pg_conn
46
+ ensure
47
+ ar_conn.disconnect!
48
+ end
41
49
 
50
+ def with_broadcast_connection(&block) # :nodoc:
51
+ ActiveRecord::Base.connection_pool.with_connection do |ar_conn|
52
+ pg_conn = ar_conn.raw_connection
53
+ verify!(pg_conn)
42
54
  yield pg_conn
43
55
  end
44
56
  end
@@ -52,6 +64,12 @@ module ActionCable
52
64
  @listener || @server.mutex.synchronize { @listener ||= Listener.new(self, @server.event_loop) }
53
65
  end
54
66
 
67
+ def verify!(pg_conn)
68
+ unless pg_conn.is_a?(PG::Connection)
69
+ raise "The Active Record database must be PostgreSQL in order to use the PostgreSQL Action Cable storage adapter"
70
+ end
71
+ end
72
+
55
73
  class Listener < SubscriberMap
56
74
  def initialize(adapter, event_loop)
57
75
  super()
@@ -67,7 +85,7 @@ module ActionCable
67
85
  end
68
86
 
69
87
  def listen
70
- @adapter.with_connection do |pg_conn|
88
+ @adapter.with_subscriptions_connection do |pg_conn|
71
89
  catch :shutdown do
72
90
  loop do
73
91
  until @queue.empty?