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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +150 -20
- data/MIT-LICENSE +1 -1
- data/README.md +2 -545
- data/app/assets/javascripts/action_cable.js +517 -0
- data/lib/action_cable.rb +15 -7
- data/lib/action_cable/channel.rb +1 -0
- data/lib/action_cable/channel/base.rb +7 -1
- data/lib/action_cable/channel/broadcasting.rb +18 -8
- data/lib/action_cable/channel/streams.rb +1 -1
- data/lib/action_cable/channel/test_case.rb +310 -0
- data/lib/action_cable/connection.rb +1 -0
- data/lib/action_cable/connection/authorization.rb +1 -1
- data/lib/action_cable/connection/base.rb +11 -7
- data/lib/action_cable/connection/message_buffer.rb +1 -4
- data/lib/action_cable/connection/stream.rb +4 -2
- data/lib/action_cable/connection/subscriptions.rb +1 -5
- data/lib/action_cable/connection/test_case.rb +234 -0
- data/lib/action_cable/connection/web_socket.rb +1 -3
- data/lib/action_cable/gem_version.rb +3 -3
- data/lib/action_cable/server/base.rb +8 -3
- data/lib/action_cable/server/worker.rb +5 -7
- data/lib/action_cable/subscription_adapter.rb +1 -0
- data/lib/action_cable/subscription_adapter/postgresql.rb +26 -8
- data/lib/action_cable/subscription_adapter/redis.rb +4 -1
- data/lib/action_cable/subscription_adapter/test.rb +40 -0
- data/lib/action_cable/test_case.rb +11 -0
- data/lib/action_cable/test_helper.rb +133 -0
- data/lib/rails/generators/channel/USAGE +4 -5
- data/lib/rails/generators/channel/channel_generator.rb +6 -3
- data/lib/rails/generators/channel/templates/{assets → javascript}/channel.js.tt +6 -4
- data/lib/rails/generators/channel/templates/{assets/cable.js.tt → javascript/consumer.js.tt} +2 -9
- data/lib/rails/generators/channel/templates/javascript/index.js.tt +5 -0
- data/lib/rails/generators/test_unit/channel_generator.rb +20 -0
- data/lib/rails/generators/test_unit/templates/channel_test.rb.tt +8 -0
- metadata +23 -13
- data/lib/assets/compiled/action_cable.js +0 -601
- 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
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
102
|
-
@rack_hijack_io = @socket_object.env["rack.
|
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
|
@@ -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:
|
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
|
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
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
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
|
-
|
66
|
-
end
|
64
|
+
receiver.handle_exception if receiver.respond_to?(:handle_exception)
|
67
65
|
end
|
68
66
|
end
|
69
67
|
|
@@ -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
|
-
|
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
|
35
|
-
ActiveRecord::Base.connection_pool.
|
36
|
-
|
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
|
-
|
39
|
-
|
40
|
-
|
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.
|
88
|
+
@adapter.with_subscriptions_connection do |pg_conn|
|
71
89
|
catch :shutdown do
|
72
90
|
loop do
|
73
91
|
until @queue.empty?
|