actioncable-next 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (60) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +17 -0
  5. data/lib/action_cable/channel/base.rb +335 -0
  6. data/lib/action_cable/channel/broadcasting.rb +50 -0
  7. data/lib/action_cable/channel/callbacks.rb +76 -0
  8. data/lib/action_cable/channel/naming.rb +28 -0
  9. data/lib/action_cable/channel/periodic_timers.rb +81 -0
  10. data/lib/action_cable/channel/streams.rb +213 -0
  11. data/lib/action_cable/channel/test_case.rb +329 -0
  12. data/lib/action_cable/connection/authorization.rb +18 -0
  13. data/lib/action_cable/connection/base.rb +165 -0
  14. data/lib/action_cable/connection/callbacks.rb +57 -0
  15. data/lib/action_cable/connection/identification.rb +51 -0
  16. data/lib/action_cable/connection/internal_channel.rb +50 -0
  17. data/lib/action_cable/connection/subscriptions.rb +124 -0
  18. data/lib/action_cable/connection/test_case.rb +294 -0
  19. data/lib/action_cable/deprecator.rb +9 -0
  20. data/lib/action_cable/engine.rb +98 -0
  21. data/lib/action_cable/gem_version.rb +19 -0
  22. data/lib/action_cable/helpers/action_cable_helper.rb +45 -0
  23. data/lib/action_cable/remote_connections.rb +82 -0
  24. data/lib/action_cable/server/base.rb +163 -0
  25. data/lib/action_cable/server/broadcasting.rb +62 -0
  26. data/lib/action_cable/server/configuration.rb +75 -0
  27. data/lib/action_cable/server/connections.rb +44 -0
  28. data/lib/action_cable/server/socket/client_socket.rb +159 -0
  29. data/lib/action_cable/server/socket/message_buffer.rb +56 -0
  30. data/lib/action_cable/server/socket/stream.rb +117 -0
  31. data/lib/action_cable/server/socket/web_socket.rb +47 -0
  32. data/lib/action_cable/server/socket.rb +180 -0
  33. data/lib/action_cable/server/stream_event_loop.rb +119 -0
  34. data/lib/action_cable/server/tagged_logger_proxy.rb +46 -0
  35. data/lib/action_cable/server/worker/active_record_connection_management.rb +23 -0
  36. data/lib/action_cable/server/worker.rb +75 -0
  37. data/lib/action_cable/subscription_adapter/async.rb +14 -0
  38. data/lib/action_cable/subscription_adapter/base.rb +39 -0
  39. data/lib/action_cable/subscription_adapter/channel_prefix.rb +30 -0
  40. data/lib/action_cable/subscription_adapter/inline.rb +40 -0
  41. data/lib/action_cable/subscription_adapter/postgresql.rb +130 -0
  42. data/lib/action_cable/subscription_adapter/redis.rb +257 -0
  43. data/lib/action_cable/subscription_adapter/subscriber_map.rb +80 -0
  44. data/lib/action_cable/subscription_adapter/test.rb +41 -0
  45. data/lib/action_cable/test_case.rb +13 -0
  46. data/lib/action_cable/test_helper.rb +163 -0
  47. data/lib/action_cable/version.rb +12 -0
  48. data/lib/action_cable.rb +81 -0
  49. data/lib/actioncable-next.rb +5 -0
  50. data/lib/rails/generators/channel/USAGE +19 -0
  51. data/lib/rails/generators/channel/channel_generator.rb +127 -0
  52. data/lib/rails/generators/channel/templates/application_cable/channel.rb.tt +4 -0
  53. data/lib/rails/generators/channel/templates/application_cable/connection.rb.tt +4 -0
  54. data/lib/rails/generators/channel/templates/channel.rb.tt +16 -0
  55. data/lib/rails/generators/channel/templates/javascript/channel.js.tt +20 -0
  56. data/lib/rails/generators/channel/templates/javascript/consumer.js.tt +6 -0
  57. data/lib/rails/generators/channel/templates/javascript/index.js.tt +1 -0
  58. data/lib/rails/generators/test_unit/channel_generator.rb +22 -0
  59. data/lib/rails/generators/test_unit/templates/channel_test.rb.tt +8 -0
  60. metadata +191 -0
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :markup: markdown
4
+
5
+ module ActionCable
6
+ module Channel
7
+ # # Action Cable Channel Streams
8
+ #
9
+ # Streams allow channels to route broadcastings to the subscriber. A
10
+ # broadcasting is, as discussed elsewhere, a pubsub queue where any data placed
11
+ # into it is automatically sent to the clients that are connected at that time.
12
+ # It's purely an online queue, though. If you're not streaming a broadcasting at
13
+ # the very moment it sends out an update, you will not get that update, even if
14
+ # you connect after it has been sent.
15
+ #
16
+ # Most commonly, the streamed broadcast is sent straight to the subscriber on
17
+ # the client-side. The channel just acts as a connector between the two parties
18
+ # (the broadcaster and the channel subscriber). Here's an example of a channel
19
+ # that allows subscribers to get all new comments on a given page:
20
+ #
21
+ # class CommentsChannel < ApplicationCable::Channel
22
+ # def follow(data)
23
+ # stream_from "comments_for_#{data['recording_id']}"
24
+ # end
25
+ #
26
+ # def unfollow
27
+ # stop_all_streams
28
+ # end
29
+ # end
30
+ #
31
+ # Based on the above example, the subscribers of this channel will get whatever
32
+ # data is put into the, let's say, `comments_for_45` broadcasting as soon as
33
+ # it's put there.
34
+ #
35
+ # An example broadcasting for this channel looks like so:
36
+ #
37
+ # ActionCable.server.broadcast "comments_for_45", { author: 'DHH', content: 'Rails is just swell' }
38
+ #
39
+ # If you have a stream that is related to a model, then the broadcasting used
40
+ # can be generated from the model and channel. The following example would
41
+ # subscribe to a broadcasting like `comments:Z2lkOi8vVGVzdEFwcC9Qb3N0LzE`.
42
+ #
43
+ # class CommentsChannel < ApplicationCable::Channel
44
+ # def subscribed
45
+ # post = Post.find(params[:id])
46
+ # stream_for post
47
+ # end
48
+ # end
49
+ #
50
+ # You can then broadcast to this channel using:
51
+ #
52
+ # CommentsChannel.broadcast_to(@post, @comment)
53
+ #
54
+ # If you don't just want to parlay the broadcast unfiltered to the subscriber,
55
+ # you can also supply a callback that lets you alter what is sent out. The below
56
+ # example shows how you can use this to provide performance introspection in the
57
+ # process:
58
+ #
59
+ # class ChatChannel < ApplicationCable::Channel
60
+ # def subscribed
61
+ # @room = Chat::Room[params[:room_number]]
62
+ #
63
+ # stream_for @room, coder: ActiveSupport::JSON do |message|
64
+ # if message['originated_at'].present?
65
+ # elapsed_time = (Time.now.to_f - message['originated_at']).round(2)
66
+ #
67
+ # ActiveSupport::Notifications.instrument :performance, measurement: 'Chat.message_delay', value: elapsed_time, action: :timing
68
+ # logger.info "Message took #{elapsed_time}s to arrive"
69
+ # end
70
+ #
71
+ # transmit message
72
+ # end
73
+ # end
74
+ # end
75
+ #
76
+ # You can stop streaming from all broadcasts by calling #stop_all_streams.
77
+ module Streams
78
+ extend ActiveSupport::Concern
79
+
80
+ included do
81
+ on_unsubscribe :stop_all_streams
82
+ end
83
+
84
+ # Start streaming from the named `broadcasting` pubsub queue. Optionally, you
85
+ # can pass a `callback` that'll be used instead of the default of just
86
+ # transmitting the updates straight to the subscriber. Pass `coder:
87
+ # ActiveSupport::JSON` to decode messages as JSON before passing to the
88
+ # callback. Defaults to `coder: nil` which does no decoding, passes raw
89
+ # messages.
90
+ def stream_from(broadcasting, callback = nil, coder: nil, &block)
91
+ broadcasting = String(broadcasting)
92
+
93
+ # Don't send the confirmation until pubsub#subscribe is successful
94
+ defer_subscription_confirmation!
95
+
96
+ # Build a stream handler by wrapping the user-provided callback with a decoder
97
+ # or defaulting to a JSON-decoding retransmitter.
98
+ handler = worker_pool_stream_handler(broadcasting, callback || block, coder: coder)
99
+ streams[broadcasting] = handler
100
+
101
+ pubsub.subscribe(broadcasting, handler, lambda do
102
+ ensure_confirmation_sent
103
+ logger.info "#{self.class.name} is streaming from #{broadcasting}"
104
+ end)
105
+ end
106
+
107
+ # Start streaming the pubsub queue for the `model` in this channel. Optionally,
108
+ # you can pass a `callback` that'll be used instead of the default of just
109
+ # transmitting the updates straight to the subscriber.
110
+ #
111
+ # Pass `coder: ActiveSupport::JSON` to decode messages as JSON before passing to
112
+ # the callback. Defaults to `coder: nil` which does no decoding, passes raw
113
+ # messages.
114
+ def stream_for(model, ...)
115
+ stream_from(broadcasting_for(model), ...)
116
+ end
117
+
118
+ # Unsubscribes streams from the named `broadcasting`.
119
+ def stop_stream_from(broadcasting)
120
+ callback = streams.delete(broadcasting)
121
+ if callback
122
+ pubsub.unsubscribe(broadcasting, callback)
123
+ logger.info "#{self.class.name} stopped streaming from #{broadcasting}"
124
+ end
125
+ end
126
+
127
+ # Unsubscribes streams for the `model`.
128
+ def stop_stream_for(model)
129
+ stop_stream_from(broadcasting_for(model))
130
+ end
131
+
132
+ # Unsubscribes all streams associated with this channel from the pubsub queue.
133
+ def stop_all_streams
134
+ streams.each do |broadcasting, callback|
135
+ pubsub.unsubscribe broadcasting, callback
136
+ logger.info "#{self.class.name} stopped streaming from #{broadcasting}"
137
+ end.clear
138
+ end
139
+
140
+ # Calls stream_for with the given `model` if it's present to start streaming,
141
+ # otherwise rejects the subscription.
142
+ def stream_or_reject_for(model)
143
+ if model
144
+ stream_for model
145
+ else
146
+ reject
147
+ end
148
+ end
149
+
150
+ private
151
+ delegate :pubsub, to: :connection
152
+
153
+ def streams
154
+ @_streams ||= {}
155
+ end
156
+
157
+ # Always wrap the outermost handler to invoke the user handler on the worker
158
+ # pool rather than blocking the event loop.
159
+ def worker_pool_stream_handler(broadcasting, user_handler, coder: nil)
160
+ handler = stream_handler(broadcasting, user_handler, coder: coder)
161
+
162
+ -> message do
163
+ connection.perform_work handler, :call, message
164
+ end
165
+ end
166
+
167
+ # May be overridden to add instrumentation, logging, specialized error handling,
168
+ # or other forms of handler decoration.
169
+ #
170
+ # TODO: Tests demonstrating this.
171
+ def stream_handler(broadcasting, user_handler, coder: nil)
172
+ if user_handler
173
+ stream_decoder user_handler, coder: coder
174
+ else
175
+ default_stream_handler broadcasting, coder: coder
176
+ end
177
+ end
178
+
179
+ # May be overridden to change the default stream handling behavior which decodes
180
+ # JSON and transmits to the client.
181
+ #
182
+ # TODO: Tests demonstrating this.
183
+ #
184
+ # TODO: Room for optimization. Update transmit API to be coder-aware so we can
185
+ # no-op when pubsub and connection are both JSON-encoded. Then we can skip
186
+ # decode+encode if we're just proxying messages.
187
+ def default_stream_handler(broadcasting, coder:)
188
+ coder ||= ActiveSupport::JSON
189
+ stream_transmitter stream_decoder(coder: coder), broadcasting: broadcasting
190
+ end
191
+
192
+ def stream_decoder(handler = identity_handler, coder:)
193
+ if coder
194
+ -> message { handler.(coder.decode(message)) }
195
+ else
196
+ handler
197
+ end
198
+ end
199
+
200
+ def stream_transmitter(handler = identity_handler, broadcasting:)
201
+ via = "streamed from #{broadcasting}"
202
+
203
+ -> (message) do
204
+ transmit handler.(message), via: via
205
+ end
206
+ end
207
+
208
+ def identity_handler
209
+ -> message { message }
210
+ end
211
+ end
212
+ end
213
+ end
@@ -0,0 +1,329 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :markup: markdown
4
+
5
+ require "active_support"
6
+ require "active_support/test_case"
7
+ require "active_support/core_ext/hash/indifferent_access"
8
+ require "json"
9
+
10
+ module ActionCable
11
+ module Channel
12
+ class NonInferrableChannelError < ::StandardError
13
+ def initialize(name)
14
+ super "Unable to determine the channel to test from #{name}. " +
15
+ "You'll need to specify it using `tests YourChannel` in your " +
16
+ "test case definition."
17
+ end
18
+ end
19
+
20
+ # # Action Cable Channel extensions for testing
21
+ #
22
+ # Add public aliases for +subscription_confirmation_sent?+ and
23
+ # +subscription_rejected?+ and +stream_names+ to access the list of subscribed streams.
24
+ module ChannelExt
25
+ def confirmed? = subscription_confirmation_sent?
26
+
27
+ def rejected? = subscription_rejected?
28
+
29
+ def stream_names = streams.keys
30
+ end
31
+
32
+ # Superclass for Action Cable channel functional tests.
33
+ #
34
+ # ## Basic example
35
+ #
36
+ # Functional tests are written as follows:
37
+ # 1. First, one uses the `subscribe` method to simulate subscription creation.
38
+ # 2. Then, one asserts whether the current state is as expected. "State" can be
39
+ # anything: transmitted messages, subscribed streams, etc.
40
+ #
41
+ #
42
+ # For example:
43
+ #
44
+ # class ChatChannelTest < ActionCable::Channel::TestCase
45
+ # def test_subscribed_with_room_number
46
+ # # Simulate a subscription creation
47
+ # subscribe room_number: 1
48
+ #
49
+ # # Asserts that the subscription was successfully created
50
+ # assert subscription.confirmed?
51
+ #
52
+ # # Asserts that the channel subscribes connection to a stream
53
+ # assert_has_stream "chat_1"
54
+ #
55
+ # # Asserts that the channel subscribes connection to a specific
56
+ # # stream created for a model
57
+ # assert_has_stream_for Room.find(1)
58
+ # end
59
+ #
60
+ # def test_does_not_stream_with_incorrect_room_number
61
+ # subscribe room_number: -1
62
+ #
63
+ # # Asserts that not streams was started
64
+ # assert_no_streams
65
+ # end
66
+ #
67
+ # def test_does_not_subscribe_without_room_number
68
+ # subscribe
69
+ #
70
+ # # Asserts that the subscription was rejected
71
+ # assert subscription.rejected?
72
+ # end
73
+ # end
74
+ #
75
+ # You can also perform actions:
76
+ # def test_perform_speak
77
+ # subscribe room_number: 1
78
+ #
79
+ # perform :speak, message: "Hello, Rails!"
80
+ #
81
+ # assert_equal "Hello, Rails!", transmissions.last["text"]
82
+ # end
83
+ #
84
+ # ## Special methods
85
+ #
86
+ # ActionCable::Channel::TestCase will also automatically provide the following
87
+ # instance methods for use in the tests:
88
+ #
89
+ # connection
90
+ # : An ActionCable::Channel::ConnectionStub, representing the current HTTP
91
+ # connection.
92
+ #
93
+ # subscription
94
+ # : An instance of the current channel, created when you call `subscribe`.
95
+ #
96
+ # transmissions
97
+ # : A list of all messages that have been transmitted into the channel.
98
+ #
99
+ #
100
+ # ## Channel is automatically inferred
101
+ #
102
+ # ActionCable::Channel::TestCase will automatically infer the channel under test
103
+ # from the test class name. If the channel cannot be inferred from the test
104
+ # class name, you can explicitly set it with `tests`.
105
+ #
106
+ # class SpecialEdgeCaseChannelTest < ActionCable::Channel::TestCase
107
+ # tests SpecialChannel
108
+ # end
109
+ #
110
+ # ## Specifying connection identifiers
111
+ #
112
+ # You need to set up your connection manually to provide values for the
113
+ # identifiers. To do this just use:
114
+ #
115
+ # stub_connection(user: users(:john))
116
+ #
117
+ # ## Testing broadcasting
118
+ #
119
+ # ActionCable::Channel::TestCase enhances ActionCable::TestHelper assertions
120
+ # (e.g. `assert_broadcasts`) to handle broadcasting to models:
121
+ #
122
+ # # in your channel
123
+ # def speak(data)
124
+ # broadcast_to room, text: data["message"]
125
+ # end
126
+ #
127
+ # def test_speak
128
+ # subscribe room_id: rooms(:chat).id
129
+ #
130
+ # assert_broadcast_on(rooms(:chat), text: "Hello, Rails!") do
131
+ # perform :speak, message: "Hello, Rails!"
132
+ # end
133
+ # end
134
+ class TestCase < ActionCable::Connection::TestCase
135
+ module Behavior
136
+ extend ActiveSupport::Concern
137
+
138
+ include ActiveSupport::Testing::ConstantLookup
139
+ include ActionCable::TestHelper
140
+
141
+ CHANNEL_IDENTIFIER = "test_stub"
142
+
143
+ included do
144
+ class_attribute :_channel_class
145
+
146
+ ActiveSupport.run_load_hooks(:action_cable_channel_test_case, self)
147
+ end
148
+
149
+ module ClassMethods
150
+ def tests(channel)
151
+ case channel
152
+ when String, Symbol
153
+ self._channel_class = channel.to_s.camelize.constantize
154
+ when Module
155
+ self._channel_class = channel
156
+ else
157
+ raise NonInferrableChannelError.new(channel)
158
+ end
159
+ end
160
+
161
+ def channel_class
162
+ if channel = self._channel_class
163
+ channel
164
+ else
165
+ tests determine_default_channel(name)
166
+ end
167
+ end
168
+
169
+ def tests_connection(connection)
170
+ case connection
171
+ when String, Symbol
172
+ self._connection_class = connection.to_s.camelize.constantize
173
+ when Module
174
+ self._connection_class = connection
175
+ else
176
+ raise Connection::NonInferrableConnectionError.new(connection)
177
+ end
178
+ end
179
+
180
+ def connection_class
181
+ if connection = self._connection_class
182
+ connection
183
+ else
184
+ tests_connection ActionCable.server.config.connection_class.call
185
+ end
186
+ end
187
+
188
+ def determine_default_channel(name)
189
+ channel = determine_constant_from_test_name(name) do |constant|
190
+ Class === constant && constant < ActionCable::Channel::Base
191
+ end
192
+ raise NonInferrableChannelError.new(name) if channel.nil?
193
+ channel
194
+ end
195
+ end
196
+
197
+ # Use testserver (not test_server) to silence "Test is missing assertions: `test_server`" warnings
198
+ attr_reader :subscription, :testserver
199
+
200
+ # Set up test connection with the specified identifiers:
201
+ #
202
+ # class ApplicationCable < ActionCable::Connection::Base
203
+ # identified_by :user, :token
204
+ # end
205
+ #
206
+ # stub_connection(user: users[:john], token: 'my-secret-token')
207
+ def stub_connection(server: ActionCable.server, **identifiers)
208
+ @socket = Connection::TestSocket.new(Connection::TestSocket.build_request(ActionCable.server.config.mount_path || "/cable"))
209
+ @testserver = Connection::TestServer.new(server)
210
+ @connection = self.class.connection_class.new(testserver, socket).tap do |conn|
211
+ identifiers.each do |identifier, val|
212
+ conn.public_send("#{identifier}=", val)
213
+ end
214
+ end
215
+ end
216
+
217
+ # Subscribe to the channel under test. Optionally pass subscription parameters
218
+ # as a Hash.
219
+ def subscribe(params = {})
220
+ @connection ||= stub_connection
221
+ @subscription = self.class.channel_class.new(connection, CHANNEL_IDENTIFIER, params.with_indifferent_access)
222
+ @subscription.singleton_class.include(ChannelExt)
223
+ @subscription.subscribe_to_channel
224
+ @subscription
225
+ end
226
+
227
+ # Unsubscribe the subscription under test.
228
+ def unsubscribe
229
+ check_subscribed!
230
+ subscription.unsubscribe_from_channel
231
+ end
232
+
233
+ # Perform action on a channel.
234
+ #
235
+ # NOTE: Must be subscribed.
236
+ def perform(action, data = {})
237
+ check_subscribed!
238
+ subscription.perform_action(data.stringify_keys.merge("action" => action.to_s))
239
+ end
240
+
241
+ # Returns messages transmitted into channel
242
+ def transmissions
243
+ # Return only directly sent message (via #transmit)
244
+ socket.transmissions.filter_map { |data| data["message"] }
245
+ end
246
+
247
+ # Enhance TestHelper assertions to handle non-String broadcastings
248
+ def assert_broadcasts(stream_or_object, *args)
249
+ super(broadcasting_for(stream_or_object), *args)
250
+ end
251
+
252
+ def assert_broadcast_on(stream_or_object, *args)
253
+ super(broadcasting_for(stream_or_object), *args)
254
+ end
255
+
256
+ # Asserts that no streams have been started.
257
+ #
258
+ # def test_assert_no_started_stream
259
+ # subscribe
260
+ # assert_no_streams
261
+ # end
262
+ #
263
+ def assert_no_streams
264
+ check_subscribed!
265
+ assert subscription.stream_names.empty?, "No streams started was expected, but #{subscription.stream_names.count} found"
266
+ end
267
+
268
+ # Asserts that the specified stream has been started.
269
+ #
270
+ # def test_assert_started_stream
271
+ # subscribe
272
+ # assert_has_stream 'messages'
273
+ # end
274
+ #
275
+ def assert_has_stream(stream)
276
+ check_subscribed!
277
+ assert subscription.stream_names.include?(stream), "Stream #{stream} has not been started"
278
+ end
279
+
280
+ # Asserts that the specified stream for a model has started.
281
+ #
282
+ # def test_assert_started_stream_for
283
+ # subscribe id: 42
284
+ # assert_has_stream_for User.find(42)
285
+ # end
286
+ #
287
+ def assert_has_stream_for(object)
288
+ assert_has_stream(broadcasting_for(object))
289
+ end
290
+
291
+ # Asserts that the specified stream has not been started.
292
+ #
293
+ # def test_assert_no_started_stream
294
+ # subscribe
295
+ # assert_has_no_stream 'messages'
296
+ # end
297
+ #
298
+ def assert_has_no_stream(stream)
299
+ check_subscribed!
300
+ assert subscription.stream_names.exclude?(stream), "Stream #{stream} has been started"
301
+ end
302
+
303
+ # Asserts that the specified stream for a model has not started.
304
+ #
305
+ # def test_assert_no_started_stream_for
306
+ # subscribe id: 41
307
+ # assert_has_no_stream_for User.find(42)
308
+ # end
309
+ #
310
+ def assert_has_no_stream_for(object)
311
+ assert_has_no_stream(broadcasting_for(object))
312
+ end
313
+
314
+ private
315
+ def check_subscribed!
316
+ raise "Must be subscribed!" if subscription.nil? || subscription.rejected?
317
+ end
318
+
319
+ def broadcasting_for(stream_or_object)
320
+ return stream_or_object if stream_or_object.is_a?(String)
321
+
322
+ self.class.channel_class.broadcasting_for(stream_or_object)
323
+ end
324
+ end
325
+
326
+ include Behavior
327
+ end
328
+ end
329
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :markup: markdown
4
+
5
+ module ActionCable
6
+ module Connection
7
+ module Authorization
8
+ class UnauthorizedError < StandardError; end
9
+
10
+ # Closes the WebSocket connection if it is open and returns an "unauthorized"
11
+ # reason.
12
+ def reject_unauthorized_connection
13
+ logger.error "An unauthorized connection attempt was rejected"
14
+ raise UnauthorizedError
15
+ end
16
+ end
17
+ end
18
+ end