actioncable-next 0.1.0

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 (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