actioncable 5.2.2.1 → 6.0.2

Sign up to get free protection for your applications and to get access to all the features.
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?