actioncable 5.2.4.4 → 6.1.1

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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +27 -56
  3. data/MIT-LICENSE +1 -1
  4. data/README.md +3 -546
  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 +10 -4
  9. data/lib/action_cable/channel/broadcasting.rb +18 -8
  10. data/lib/action_cable/channel/naming.rb +1 -1
  11. data/lib/action_cable/channel/streams.rb +29 -3
  12. data/lib/action_cable/channel/test_case.rb +310 -0
  13. data/lib/action_cable/connection.rb +1 -0
  14. data/lib/action_cable/connection/authorization.rb +1 -1
  15. data/lib/action_cable/connection/base.rb +13 -7
  16. data/lib/action_cable/connection/message_buffer.rb +1 -4
  17. data/lib/action_cable/connection/stream.rb +4 -2
  18. data/lib/action_cable/connection/subscriptions.rb +2 -5
  19. data/lib/action_cable/connection/test_case.rb +234 -0
  20. data/lib/action_cable/connection/web_socket.rb +1 -3
  21. data/lib/action_cable/engine.rb +1 -1
  22. data/lib/action_cable/gem_version.rb +4 -4
  23. data/lib/action_cable/helpers/action_cable_helper.rb +3 -3
  24. data/lib/action_cable/server.rb +0 -1
  25. data/lib/action_cable/server/base.rb +9 -4
  26. data/lib/action_cable/server/broadcasting.rb +1 -1
  27. data/lib/action_cable/server/worker.rb +6 -8
  28. data/lib/action_cable/subscription_adapter.rb +1 -0
  29. data/lib/action_cable/subscription_adapter/base.rb +4 -0
  30. data/lib/action_cable/subscription_adapter/postgresql.rb +28 -9
  31. data/lib/action_cable/subscription_adapter/redis.rb +4 -2
  32. data/lib/action_cable/subscription_adapter/test.rb +40 -0
  33. data/lib/action_cable/test_case.rb +11 -0
  34. data/lib/action_cable/test_helper.rb +133 -0
  35. data/lib/rails/generators/channel/USAGE +5 -6
  36. data/lib/rails/generators/channel/channel_generator.rb +6 -3
  37. data/lib/rails/generators/channel/templates/{assets → javascript}/channel.js.tt +6 -4
  38. data/lib/rails/generators/channel/templates/javascript/consumer.js.tt +6 -0
  39. data/lib/rails/generators/channel/templates/javascript/index.js.tt +5 -0
  40. data/lib/rails/generators/test_unit/channel_generator.rb +20 -0
  41. data/lib/rails/generators/test_unit/templates/channel_test.rb.tt +8 -0
  42. metadata +40 -16
  43. data/lib/assets/compiled/action_cable.js +0 -601
  44. data/lib/rails/generators/channel/templates/assets/cable.js.tt +0 -13
  45. data/lib/rails/generators/channel/templates/assets/channel.coffee.tt +0 -14
data/lib/action_cable.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  #--
4
- # Copyright (c) 2015-2018 Basecamp, LLC
4
+ # Copyright (c) 2015-2020 Basecamp, LLC
5
5
  #
6
6
  # Permission is hereby granted, free of charge, to any person obtaining
7
7
  # a copy of this software and associated documentation files (the
@@ -32,13 +32,19 @@ module ActionCable
32
32
 
33
33
  INTERNAL = {
34
34
  message_types: {
35
- welcome: "welcome".freeze,
36
- ping: "ping".freeze,
37
- confirmation: "confirm_subscription".freeze,
38
- rejection: "reject_subscription".freeze
35
+ welcome: "welcome",
36
+ disconnect: "disconnect",
37
+ ping: "ping",
38
+ confirmation: "confirm_subscription",
39
+ rejection: "reject_subscription"
39
40
  },
40
- default_mount_path: "/cable".freeze,
41
- protocols: ["actioncable-v1-json".freeze, "actioncable-unsupported".freeze].freeze
41
+ disconnect_reasons: {
42
+ unauthorized: "unauthorized",
43
+ invalid_request: "invalid_request",
44
+ server_restart: "server_restart"
45
+ },
46
+ default_mount_path: "/cable",
47
+ protocols: ["actioncable-v1-json", "actioncable-unsupported"].freeze
42
48
  }
43
49
 
44
50
  # Singleton instance of the server
@@ -51,4 +57,6 @@ module ActionCable
51
57
  autoload :Channel
52
58
  autoload :RemoteConnections
53
59
  autoload :SubscriptionAdapter
60
+ autoload :TestHelper
61
+ autoload :TestCase
54
62
  end
@@ -11,6 +11,7 @@ module ActionCable
11
11
  autoload :Naming
12
12
  autoload :PeriodicTimers
13
13
  autoload :Streams
14
+ autoload :TestCase
14
15
  end
15
16
  end
16
17
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "set"
4
+ require "active_support/rescuable"
4
5
 
5
6
  module ActionCable
6
7
  module Channel
@@ -99,6 +100,7 @@ module ActionCable
99
100
  include Streams
100
101
  include Naming
101
102
  include Broadcasting
103
+ include ActiveSupport::Rescuable
102
104
 
103
105
  attr_reader :params, :connection, :identifier
104
106
  delegate :logger, to: :connection
@@ -192,7 +194,7 @@ module ActionCable
192
194
  end
193
195
 
194
196
  private
195
- # Called once a consumer has become a subscriber of the channel. Usually the place to setup any streams
197
+ # Called once a consumer has become a subscriber of the channel. Usually the place to set up any streams
196
198
  # you want this channel to be sending to the subscriber.
197
199
  def subscribed # :doc:
198
200
  # Override in subclasses
@@ -267,10 +269,12 @@ module ActionCable
267
269
  else
268
270
  public_send action
269
271
  end
272
+ rescue Exception => exception
273
+ rescue_with_handler(exception) || raise
270
274
  end
271
275
 
272
276
  def action_signature(action, data)
273
- "#{self.class.name}##{action}".dup.tap do |signature|
277
+ (+"#{self.class.name}##{action}").tap do |signature|
274
278
  if (arguments = data.except("action")).any?
275
279
  signature << "(#{arguments.inspect})"
276
280
  end
@@ -279,7 +283,7 @@ module ActionCable
279
283
 
280
284
  def transmit_subscription_confirmation
281
285
  unless subscription_confirmation_sent?
282
- logger.info "#{self.class.name} is transmitting the subscription confirmation"
286
+ logger.debug "#{self.class.name} is transmitting the subscription confirmation"
283
287
 
284
288
  ActiveSupport::Notifications.instrument("transmit_subscription_confirmation.action_cable", channel_class: self.class.name) do
285
289
  connection.transmit identifier: @identifier, type: ActionCable::INTERNAL[:message_types][:confirmation]
@@ -294,7 +298,7 @@ module ActionCable
294
298
  end
295
299
 
296
300
  def transmit_subscription_rejection
297
- logger.info "#{self.class.name} is transmitting the subscription rejection"
301
+ logger.debug "#{self.class.name} is transmitting the subscription rejection"
298
302
 
299
303
  ActiveSupport::Notifications.instrument("transmit_subscription_rejection.action_cable", channel_class: self.class.name) do
300
304
  connection.transmit identifier: @identifier, type: ActionCable::INTERNAL[:message_types][:rejection]
@@ -303,3 +307,5 @@ module ActionCable
303
307
  end
304
308
  end
305
309
  end
310
+
311
+ ActiveSupport.run_load_hooks(:action_cable_channel, ActionCable::Channel::Base)
@@ -7,22 +7,32 @@ module ActionCable
7
7
  module Broadcasting
8
8
  extend ActiveSupport::Concern
9
9
 
10
- delegate :broadcasting_for, to: :class
10
+ delegate :broadcasting_for, :broadcast_to, to: :class
11
11
 
12
12
  module ClassMethods
13
13
  # Broadcast a hash to a unique broadcasting for this <tt>model</tt> in this channel.
14
14
  def broadcast_to(model, message)
15
- ActionCable.server.broadcast(broadcasting_for([ channel_name, model ]), message)
15
+ ActionCable.server.broadcast(broadcasting_for(model), message)
16
16
  end
17
17
 
18
- def broadcasting_for(model) #:nodoc:
18
+ # Returns a unique broadcasting identifier for this <tt>model</tt> in this channel:
19
+ #
20
+ # CommentsChannel.broadcasting_for("all") # => "comments:all"
21
+ #
22
+ # You can pass any object as a target (e.g. Active Record model), and it
23
+ # would be serialized into a string under the hood.
24
+ def broadcasting_for(model)
25
+ serialize_broadcasting([ channel_name, model ])
26
+ end
27
+
28
+ def serialize_broadcasting(object) #:nodoc:
19
29
  case
20
- when model.is_a?(Array)
21
- model.map { |m| broadcasting_for(m) }.join(":")
22
- when model.respond_to?(:to_gid_param)
23
- model.to_gid_param
30
+ when object.is_a?(Array)
31
+ object.map { |m| serialize_broadcasting(m) }.join(":")
32
+ when object.respond_to?(:to_gid_param)
33
+ object.to_gid_param
24
34
  else
25
- model.to_param
35
+ object.to_param
26
36
  end
27
37
  end
28
38
  end
@@ -14,7 +14,7 @@ module ActionCable
14
14
  # Chats::AppearancesChannel.channel_name # => 'chats:appearances'
15
15
  # FooChats::BarAppearancesChannel.channel_name # => 'foo_chats:bar_appearances'
16
16
  def channel_name
17
- @channel_name ||= name.sub(/Channel$/, "").gsub("::", ":").underscore
17
+ @channel_name ||= name.delete_suffix("Channel").gsub("::", ":").underscore
18
18
  end
19
19
  end
20
20
 
@@ -82,7 +82,7 @@ module ActionCable
82
82
  # Build a stream handler by wrapping the user-provided callback with
83
83
  # a decoder or defaulting to a JSON-decoding retransmitter.
84
84
  handler = worker_pool_stream_handler(broadcasting, callback || block, coder: coder)
85
- streams << [ broadcasting, handler ]
85
+ streams[broadcasting] = handler
86
86
 
87
87
  connection.server.event_loop.post do
88
88
  pubsub.subscribe(broadcasting, handler, lambda do
@@ -99,7 +99,21 @@ module ActionCable
99
99
  # Pass <tt>coder: ActiveSupport::JSON</tt> to decode messages as JSON before passing to the callback.
100
100
  # Defaults to <tt>coder: nil</tt> which does no decoding, passes raw messages.
101
101
  def stream_for(model, callback = nil, coder: nil, &block)
102
- stream_from(broadcasting_for([ channel_name, model ]), callback || block, coder: coder)
102
+ stream_from(broadcasting_for(model), callback || block, coder: coder)
103
+ end
104
+
105
+ # Unsubscribes streams from the named <tt>broadcasting</tt>.
106
+ def stop_stream_from(broadcasting)
107
+ callback = streams.delete(broadcasting)
108
+ if callback
109
+ pubsub.unsubscribe(broadcasting, callback)
110
+ logger.info "#{self.class.name} stopped streaming from #{broadcasting}"
111
+ end
112
+ end
113
+
114
+ # Unsubscribes streams for the <tt>model</tt>.
115
+ def stop_stream_for(model)
116
+ stop_stream_from(broadcasting_for(model))
103
117
  end
104
118
 
105
119
  # Unsubscribes all streams associated with this channel from the pubsub queue.
@@ -110,11 +124,23 @@ module ActionCable
110
124
  end.clear
111
125
  end
112
126
 
127
+ # Calls stream_for if record is present, otherwise calls reject.
128
+ # This method is intended to be called when you're looking
129
+ # for a record based on a parameter, if its found it will start
130
+ # streaming. If the record is nil then it will reject the connection.
131
+ def stream_or_reject_for(record)
132
+ if record
133
+ stream_for record
134
+ else
135
+ reject
136
+ end
137
+ end
138
+
113
139
  private
114
140
  delegate :pubsub, to: :connection
115
141
 
116
142
  def streams
117
- @_streams ||= []
143
+ @_streams ||= {}
118
144
  end
119
145
 
120
146
  # Always wrap the outermost handler to invoke the user handler on the
@@ -0,0 +1,310 @@
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 "json"
7
+
8
+ module ActionCable
9
+ module Channel
10
+ class NonInferrableChannelError < ::StandardError
11
+ def initialize(name)
12
+ super "Unable to determine the channel to test from #{name}. " +
13
+ "You'll need to specify it using `tests YourChannel` in your " +
14
+ "test case definition."
15
+ end
16
+ end
17
+
18
+ # Stub `stream_from` to track streams for the channel.
19
+ # Add public aliases for `subscription_confirmation_sent?` and
20
+ # `subscription_rejected?`.
21
+ module ChannelStub
22
+ def confirmed?
23
+ subscription_confirmation_sent?
24
+ end
25
+
26
+ def rejected?
27
+ subscription_rejected?
28
+ end
29
+
30
+ def stream_from(broadcasting, *)
31
+ streams << broadcasting
32
+ end
33
+
34
+ def stop_all_streams
35
+ @_streams = []
36
+ end
37
+
38
+ def streams
39
+ @_streams ||= []
40
+ end
41
+
42
+ # Make periodic timers no-op
43
+ def start_periodic_timers; end
44
+ alias stop_periodic_timers start_periodic_timers
45
+ end
46
+
47
+ class ConnectionStub
48
+ attr_reader :transmissions, :identifiers, :subscriptions, :logger
49
+
50
+ def initialize(identifiers = {})
51
+ @transmissions = []
52
+
53
+ identifiers.each do |identifier, val|
54
+ define_singleton_method(identifier) { val }
55
+ end
56
+
57
+ @subscriptions = ActionCable::Connection::Subscriptions.new(self)
58
+ @identifiers = identifiers.keys
59
+ @logger = ActiveSupport::TaggedLogging.new ActiveSupport::Logger.new(StringIO.new)
60
+ end
61
+
62
+ def transmit(cable_message)
63
+ transmissions << cable_message.with_indifferent_access
64
+ end
65
+ end
66
+
67
+ # Superclass for Action Cable channel functional tests.
68
+ #
69
+ # == Basic example
70
+ #
71
+ # Functional tests are written as follows:
72
+ # 1. First, one uses the +subscribe+ method to simulate subscription creation.
73
+ # 2. Then, one asserts whether the current state is as expected. "State" can be anything:
74
+ # transmitted messages, subscribed streams, etc.
75
+ #
76
+ # For example:
77
+ #
78
+ # class ChatChannelTest < ActionCable::Channel::TestCase
79
+ # def test_subscribed_with_room_number
80
+ # # Simulate a subscription creation
81
+ # subscribe room_number: 1
82
+ #
83
+ # # Asserts that the subscription was successfully created
84
+ # assert subscription.confirmed?
85
+ #
86
+ # # Asserts that the channel subscribes connection to a stream
87
+ # assert_has_stream "chat_1"
88
+ #
89
+ # # Asserts that the channel subscribes connection to a specific
90
+ # # stream created for a model
91
+ # assert_has_stream_for Room.find(1)
92
+ # end
93
+ #
94
+ # def test_does_not_stream_with_incorrect_room_number
95
+ # subscribe room_number: -1
96
+ #
97
+ # # Asserts that not streams was started
98
+ # assert_no_streams
99
+ # end
100
+ #
101
+ # def test_does_not_subscribe_without_room_number
102
+ # subscribe
103
+ #
104
+ # # Asserts that the subscription was rejected
105
+ # assert subscription.rejected?
106
+ # end
107
+ # end
108
+ #
109
+ # You can also perform actions:
110
+ # def test_perform_speak
111
+ # subscribe room_number: 1
112
+ #
113
+ # perform :speak, message: "Hello, Rails!"
114
+ #
115
+ # assert_equal "Hello, Rails!", transmissions.last["text"]
116
+ # end
117
+ #
118
+ # == Special methods
119
+ #
120
+ # ActionCable::Channel::TestCase will also automatically provide the following instance
121
+ # methods for use in the tests:
122
+ #
123
+ # <b>connection</b>::
124
+ # An ActionCable::Channel::ConnectionStub, representing the current HTTP connection.
125
+ # <b>subscription</b>::
126
+ # An instance of the current channel, created when you call `subscribe`.
127
+ # <b>transmissions</b>::
128
+ # A list of all messages that have been transmitted into the channel.
129
+ #
130
+ #
131
+ # == Channel is automatically inferred
132
+ #
133
+ # ActionCable::Channel::TestCase will automatically infer the channel under test
134
+ # from the test class name. If the channel cannot be inferred from the test
135
+ # class name, you can explicitly set it with +tests+.
136
+ #
137
+ # class SpecialEdgeCaseChannelTest < ActionCable::Channel::TestCase
138
+ # tests SpecialChannel
139
+ # end
140
+ #
141
+ # == Specifying connection identifiers
142
+ #
143
+ # You need to set up your connection manually to provide values for the identifiers.
144
+ # To do this just use:
145
+ #
146
+ # stub_connection(user: users(:john))
147
+ #
148
+ # == Testing broadcasting
149
+ #
150
+ # ActionCable::Channel::TestCase enhances ActionCable::TestHelper assertions (e.g.
151
+ # +assert_broadcasts+) to handle broadcasting to models:
152
+ #
153
+ #
154
+ # # in your channel
155
+ # def speak(data)
156
+ # broadcast_to room, text: data["message"]
157
+ # end
158
+ #
159
+ # def test_speak
160
+ # subscribe room_id: rooms(:chat).id
161
+ #
162
+ # assert_broadcast_on(rooms(:chat), text: "Hello, Rails!") do
163
+ # perform :speak, message: "Hello, Rails!"
164
+ # end
165
+ # end
166
+ class TestCase < ActiveSupport::TestCase
167
+ module Behavior
168
+ extend ActiveSupport::Concern
169
+
170
+ include ActiveSupport::Testing::ConstantLookup
171
+ include ActionCable::TestHelper
172
+
173
+ CHANNEL_IDENTIFIER = "test_stub"
174
+
175
+ included do
176
+ class_attribute :_channel_class
177
+
178
+ attr_reader :connection, :subscription
179
+
180
+ ActiveSupport.run_load_hooks(:action_cable_channel_test_case, self)
181
+ end
182
+
183
+ module ClassMethods
184
+ def tests(channel)
185
+ case channel
186
+ when String, Symbol
187
+ self._channel_class = channel.to_s.camelize.constantize
188
+ when Module
189
+ self._channel_class = channel
190
+ else
191
+ raise NonInferrableChannelError.new(channel)
192
+ end
193
+ end
194
+
195
+ def channel_class
196
+ if channel = self._channel_class
197
+ channel
198
+ else
199
+ tests determine_default_channel(name)
200
+ end
201
+ end
202
+
203
+ def determine_default_channel(name)
204
+ channel = determine_constant_from_test_name(name) do |constant|
205
+ Class === constant && constant < ActionCable::Channel::Base
206
+ end
207
+ raise NonInferrableChannelError.new(name) if channel.nil?
208
+ channel
209
+ end
210
+ end
211
+
212
+ # Set up test connection with the specified identifiers:
213
+ #
214
+ # class ApplicationCable < ActionCable::Connection::Base
215
+ # identified_by :user, :token
216
+ # end
217
+ #
218
+ # stub_connection(user: users[:john], token: 'my-secret-token')
219
+ def stub_connection(identifiers = {})
220
+ @connection = ConnectionStub.new(identifiers)
221
+ end
222
+
223
+ # Subscribe to the channel under test. Optionally pass subscription parameters as a Hash.
224
+ def subscribe(params = {})
225
+ @connection ||= stub_connection
226
+ @subscription = self.class.channel_class.new(connection, CHANNEL_IDENTIFIER, params.with_indifferent_access)
227
+ @subscription.singleton_class.include(ChannelStub)
228
+ @subscription.subscribe_to_channel
229
+ @subscription
230
+ end
231
+
232
+ # Unsubscribe the subscription under test.
233
+ def unsubscribe
234
+ check_subscribed!
235
+ subscription.unsubscribe_from_channel
236
+ end
237
+
238
+ # Perform action on a channel.
239
+ #
240
+ # NOTE: Must be subscribed.
241
+ def perform(action, data = {})
242
+ check_subscribed!
243
+ subscription.perform_action(data.stringify_keys.merge("action" => action.to_s))
244
+ end
245
+
246
+ # Returns messages transmitted into channel
247
+ def transmissions
248
+ # Return only directly sent message (via #transmit)
249
+ connection.transmissions.map { |data| data["message"] }.compact
250
+ end
251
+
252
+ # Enhance TestHelper assertions to handle non-String
253
+ # broadcastings
254
+ def assert_broadcasts(stream_or_object, *args)
255
+ super(broadcasting_for(stream_or_object), *args)
256
+ end
257
+
258
+ def assert_broadcast_on(stream_or_object, *args)
259
+ super(broadcasting_for(stream_or_object), *args)
260
+ end
261
+
262
+ # Asserts that no streams have been started.
263
+ #
264
+ # def test_assert_no_started_stream
265
+ # subscribe
266
+ # assert_no_streams
267
+ # end
268
+ #
269
+ def assert_no_streams
270
+ assert subscription.streams.empty?, "No streams started was expected, but #{subscription.streams.count} found"
271
+ end
272
+
273
+ # Asserts that the specified stream has been started.
274
+ #
275
+ # def test_assert_started_stream
276
+ # subscribe
277
+ # assert_has_stream 'messages'
278
+ # end
279
+ #
280
+ def assert_has_stream(stream)
281
+ assert subscription.streams.include?(stream), "Stream #{stream} has not been started"
282
+ end
283
+
284
+ # Asserts that the specified stream for a model has started.
285
+ #
286
+ # def test_assert_started_stream_for
287
+ # subscribe id: 42
288
+ # assert_has_stream_for User.find(42)
289
+ # end
290
+ #
291
+ def assert_has_stream_for(object)
292
+ assert_has_stream(broadcasting_for(object))
293
+ end
294
+
295
+ private
296
+ def check_subscribed!
297
+ raise "Must be subscribed!" if subscription.nil? || subscription.rejected?
298
+ end
299
+
300
+ def broadcasting_for(stream_or_object)
301
+ return stream_or_object if stream_or_object.is_a?(String)
302
+
303
+ self.class.channel_class.broadcasting_for(stream_or_object)
304
+ end
305
+ end
306
+
307
+ include Behavior
308
+ end
309
+ end
310
+ end