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,294 @@
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 "action_dispatch"
9
+ require "action_dispatch/http/headers"
10
+ require "action_dispatch/testing/test_request"
11
+
12
+ module ActionCable
13
+ module Connection
14
+ class NonInferrableConnectionError < ::StandardError
15
+ def initialize(name)
16
+ super "Unable to determine the connection to test from #{name}. " +
17
+ "You'll need to specify it using `tests YourConnection` in your " +
18
+ "test case definition."
19
+ end
20
+ end
21
+
22
+ module Assertions
23
+ # Asserts that the connection is rejected (via
24
+ # `reject_unauthorized_connection`).
25
+ #
26
+ # # Asserts that connection without user_id fails
27
+ # assert_reject_connection { connect params: { user_id: '' } }
28
+ def assert_reject_connection(&block)
29
+ assert_raises(Authorization::UnauthorizedError, "Expected to reject connection but no rejection was made", &block)
30
+ end
31
+ end
32
+
33
+ class TestCookies < ActiveSupport::HashWithIndifferentAccess # :nodoc:
34
+ def []=(name, options)
35
+ value = options.is_a?(Hash) ? options.symbolize_keys[:value] : options
36
+ super(name, value)
37
+ end
38
+ end
39
+
40
+ # We don't want to use the whole "encryption stack" for connection unit-tests,
41
+ # but we want to make sure that users test against the correct types of cookies
42
+ # (i.e. signed or encrypted or plain)
43
+ class TestCookieJar < TestCookies
44
+ def signed
45
+ @signed ||= TestCookies.new
46
+ end
47
+
48
+ def encrypted
49
+ @encrypted ||= TestCookies.new
50
+ end
51
+ end
52
+
53
+ class TestSocket
54
+ # Make session and cookies available to the connection
55
+ class Request < ActionDispatch::TestRequest
56
+ attr_accessor :session, :cookie_jar
57
+ end
58
+
59
+ attr_reader :logger, :request, :transmissions, :closed, :env
60
+
61
+ class << self
62
+ def build_request(path, params: nil, headers: {}, session: {}, env: {}, cookies: nil)
63
+ wrapped_headers = ActionDispatch::Http::Headers.from_hash(headers)
64
+
65
+ uri = URI.parse(path)
66
+
67
+ query_string = params.nil? ? uri.query : params.to_query
68
+
69
+ request_env = {
70
+ "QUERY_STRING" => query_string,
71
+ "PATH_INFO" => uri.path
72
+ }.merge(env)
73
+
74
+ if wrapped_headers.present?
75
+ ActionDispatch::Http::Headers.from_hash(request_env).merge!(wrapped_headers)
76
+ end
77
+
78
+ Request.create(request_env).tap do |request|
79
+ request.session = session.with_indifferent_access
80
+ request.cookie_jar = cookies
81
+ end
82
+ end
83
+ end
84
+
85
+ def initialize(request)
86
+ inner_logger = ActiveSupport::Logger.new(StringIO.new)
87
+ tagged_logging = ActiveSupport::TaggedLogging.new(inner_logger)
88
+ @logger = ActionCable::Server::TaggedLoggerProxy.new(tagged_logging, tags: [])
89
+ @request = request
90
+ @env = request.env
91
+ @connection = nil
92
+ @closed = false
93
+ @transmissions = []
94
+ end
95
+
96
+ def transmit(data)
97
+ @transmissions << data.with_indifferent_access
98
+ end
99
+
100
+ def close
101
+ @closed = true
102
+ end
103
+ end
104
+
105
+ # TestServer provides test pub/sub and executor implementations
106
+ class TestServer
107
+ attr_reader :streams, :config
108
+
109
+ def initialize(server)
110
+ @streams = Hash.new { |h, k| h[k] = [] }
111
+ @config = server.config
112
+ end
113
+
114
+ alias_method :pubsub, :itself
115
+ alias_method :executor, :itself
116
+
117
+ #== Executor interface ==
118
+
119
+ # Inline async calls
120
+ def post(&work) = work.call
121
+ # We don't support timers in unit tests yet
122
+ def timer(_every) = nil
123
+
124
+ #== Pub/sub interface ==
125
+ def subscribe(stream, callback, success_callback = nil)
126
+ @streams[stream] << callback
127
+ success_callback&.call
128
+ end
129
+
130
+ def unsubscribe(stream, callback)
131
+ @streams[stream].delete(callback)
132
+ @streams.delete(stream) if @streams[stream].empty?
133
+ end
134
+ end
135
+
136
+ # # Action Cable Connection TestCase
137
+ #
138
+ # Unit test Action Cable connections.
139
+ #
140
+ # Useful to check whether a connection's `identified_by` gets assigned properly
141
+ # and that any improper connection requests are rejected.
142
+ #
143
+ # ## Basic example
144
+ #
145
+ # Unit tests are written as follows:
146
+ #
147
+ # 1. Simulate a connection attempt by calling `connect`.
148
+ # 2. Assert state, e.g. identifiers, has been assigned.
149
+ #
150
+ #
151
+ # class ApplicationCable::ConnectionTest < ActionCable::Connection::TestCase
152
+ # def test_connects_with_proper_cookie
153
+ # # Simulate the connection request with a cookie.
154
+ # cookies["user_id"] = users(:john).id
155
+ #
156
+ # connect
157
+ #
158
+ # # Assert the connection identifier matches the fixture.
159
+ # assert_equal users(:john).id, connection.user.id
160
+ # end
161
+ #
162
+ # def test_rejects_connection_without_proper_cookie
163
+ # assert_reject_connection { connect }
164
+ # end
165
+ # end
166
+ #
167
+ # `connect` accepts additional information about the HTTP request with the
168
+ # `params`, `headers`, `session`, and Rack `env` options.
169
+ #
170
+ # def test_connect_with_headers_and_query_string
171
+ # connect params: { user_id: 1 }, headers: { "X-API-TOKEN" => "secret-my" }
172
+ #
173
+ # assert_equal "1", connection.user.id
174
+ # assert_equal "secret-my", connection.token
175
+ # end
176
+ #
177
+ # def test_connect_with_params
178
+ # connect params: { user_id: 1 }
179
+ #
180
+ # assert_equal "1", connection.user.id
181
+ # end
182
+ #
183
+ # You can also set up the correct cookies before the connection request:
184
+ #
185
+ # def test_connect_with_cookies
186
+ # # Plain cookies:
187
+ # cookies["user_id"] = 1
188
+ #
189
+ # # Or signed/encrypted:
190
+ # # cookies.signed["user_id"] = 1
191
+ # # cookies.encrypted["user_id"] = 1
192
+ #
193
+ # connect
194
+ #
195
+ # assert_equal "1", connection.user_id
196
+ # end
197
+ #
198
+ # ## Connection is automatically inferred
199
+ #
200
+ # ActionCable::Connection::TestCase will automatically infer the connection
201
+ # under test from the test class name. If the channel cannot be inferred from
202
+ # the test class name, you can explicitly set it with `tests`.
203
+ #
204
+ # class ConnectionTest < ActionCable::Connection::TestCase
205
+ # tests ApplicationCable::Connection
206
+ # end
207
+ #
208
+ class TestCase < ActiveSupport::TestCase
209
+ module Behavior
210
+ extend ActiveSupport::Concern
211
+
212
+ DEFAULT_PATH = "/cable"
213
+
214
+ include ActiveSupport::Testing::ConstantLookup
215
+ include Assertions
216
+
217
+ included do
218
+ class_attribute :_connection_class
219
+
220
+ ActiveSupport.run_load_hooks(:action_cable_connection_test_case, self)
221
+ end
222
+
223
+ module ClassMethods
224
+ def tests(connection)
225
+ case connection
226
+ when String, Symbol
227
+ self._connection_class = connection.to_s.camelize.constantize
228
+ when Module
229
+ self._connection_class = connection
230
+ else
231
+ raise NonInferrableConnectionError.new(connection)
232
+ end
233
+ end
234
+
235
+ def connection_class
236
+ if connection = self._connection_class
237
+ connection
238
+ else
239
+ tests determine_default_connection(name)
240
+ end
241
+ end
242
+
243
+ def determine_default_connection(name)
244
+ connection = determine_constant_from_test_name(name) do |constant|
245
+ Class === constant && constant < ActionCable::Connection::Base
246
+ end
247
+ raise NonInferrableConnectionError.new(name) if connection.nil?
248
+ connection
249
+ end
250
+ end
251
+
252
+ attr_reader :connection, :socket, :testserver
253
+
254
+ # Performs connection attempt to exert #connect on the connection under test.
255
+ #
256
+ # Accepts request path as the first argument and the following request options:
257
+ #
258
+ # * params – URL parameters (Hash)
259
+ # * headers – request headers (Hash)
260
+ # * session – session data (Hash)
261
+ # * env – additional Rack env configuration (Hash)
262
+ def connect(path = ActionCable.server.config.mount_path, server: ActionCable.server, **request_params)
263
+ path ||= DEFAULT_PATH
264
+
265
+ @socket = TestSocket.new(TestSocket.build_request(path, **request_params, cookies: cookies))
266
+ @testserver = Connection::TestServer.new(server)
267
+ connection = self.class.connection_class.new(@testserver, socket)
268
+ connection.connect if connection.respond_to?(:connect)
269
+
270
+ # Only set instance variable if connected successfully
271
+ @connection = connection
272
+ end
273
+
274
+ # Exert #disconnect on the connection under test.
275
+ def disconnect
276
+ raise "Must be connected!" if connection.nil?
277
+
278
+ connection.disconnect if connection.respond_to?(:disconnect)
279
+ @connection = nil
280
+ end
281
+
282
+ def cookies
283
+ @cookie_jar ||= TestCookieJar.new
284
+ end
285
+
286
+ def transmissions
287
+ socket&.transmissions || []
288
+ end
289
+ end
290
+
291
+ include Behavior
292
+ end
293
+ end
294
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :markup: markdown
4
+
5
+ module ActionCable
6
+ def self.deprecator # :nodoc:
7
+ @deprecator ||= ActiveSupport::Deprecation.new
8
+ end
9
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :markup: markdown
4
+
5
+ require "rails"
6
+ require "action_cable"
7
+ require "active_support/core_ext/hash/indifferent_access"
8
+
9
+ module ActionCable
10
+ class Engine < Rails::Engine # :nodoc:
11
+ config.action_cable = ActiveSupport::OrderedOptions.new
12
+ config.action_cable.mount_path = ActionCable::INTERNAL[:default_mount_path]
13
+ config.action_cable.precompile_assets = true
14
+
15
+ initializer "action_cable.deprecator", before: :load_environment_config do |app|
16
+ app.deprecators[:action_cable] = ActionCable.deprecator
17
+ end
18
+
19
+ initializer "action_cable.helpers" do
20
+ ActiveSupport.on_load(:action_view) do
21
+ include ActionCable::Helpers::ActionCableHelper
22
+ end
23
+ end
24
+
25
+ initializer "action_cable.logger" do
26
+ ActiveSupport.on_load(:action_cable) { self.logger ||= ::Rails.logger }
27
+ end
28
+
29
+ initializer "action_cable.health_check_application" do
30
+ ActiveSupport.on_load(:action_cable) {
31
+ self.health_check_application = ->(env) { Rails::HealthController.action(:show).call(env) }
32
+ }
33
+ end
34
+
35
+ initializer "action_cable.asset" do
36
+ config.after_initialize do |app|
37
+ if app.config.respond_to?(:assets) && app.config.action_cable.precompile_assets
38
+ app.config.assets.precompile += %w( actioncable.js actioncable.esm.js )
39
+ end
40
+ end
41
+ end
42
+
43
+ initializer "action_cable.set_configs" do |app|
44
+ options = app.config.action_cable
45
+ options.allowed_request_origins ||= /https?:\/\/localhost:\d+/ if ::Rails.env.development?
46
+
47
+ app.paths.add "config/cable", with: "config/cable.yml"
48
+
49
+ ActiveSupport.on_load(:action_cable) do
50
+ if (config_path = Pathname.new(app.config.paths["config/cable"].first)).exist?
51
+ self.cable = app.config_for(config_path).to_h.with_indifferent_access
52
+ end
53
+
54
+ previous_connection_class = connection_class
55
+ self.connection_class = -> { "ApplicationCable::Connection".safe_constantize || previous_connection_class.call }
56
+ self.filter_parameters += app.config.filter_parameters
57
+
58
+ options.each { |k, v| send("#{k}=", v) }
59
+ end
60
+ end
61
+
62
+ initializer "action_cable.routes" do
63
+ config.after_initialize do |app|
64
+ config = app.config
65
+ unless config.action_cable.mount_path.nil?
66
+ app.routes.prepend do
67
+ mount ActionCable.server => config.action_cable.mount_path, internal: true, anchor: true
68
+ end
69
+ end
70
+ end
71
+ end
72
+
73
+ initializer "action_cable.set_work_hooks" do |app|
74
+ ActiveSupport.on_load(:action_cable) do
75
+ ActionCable::Server::Worker.set_callback :work, :around, prepend: true do |_, inner|
76
+ app.executor.wrap(source: "application.action_cable") do
77
+ # If we took a while to get the lock, we may have been halted in the meantime.
78
+ # As we haven't started doing any real work yet, we should pretend that we never
79
+ # made it off the queue.
80
+ unless stopping?
81
+ inner.call
82
+ end
83
+ end
84
+ end
85
+
86
+ wrap = lambda do |_, inner|
87
+ app.executor.wrap(source: "application.action_cable", &inner)
88
+ end
89
+ ActionCable::Channel::Base.set_callback :subscribe, :around, prepend: true, &wrap
90
+ ActionCable::Channel::Base.set_callback :unsubscribe, :around, prepend: true, &wrap
91
+
92
+ app.reloader.before_class_unload do
93
+ ActionCable.server.restart
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :markup: markdown
4
+
5
+ module ActionCable
6
+ # Returns the currently loaded version of Action Cable as a `Gem::Version`.
7
+ def self.gem_version
8
+ Gem::Version.new VERSION::STRING
9
+ end
10
+
11
+ module VERSION
12
+ MAJOR = 8
13
+ MINOR = 0
14
+ TINY = 0
15
+ PRE = "alpha"
16
+
17
+ STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".")
18
+ end
19
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :markup: markdown
4
+
5
+ module ActionCable
6
+ module Helpers
7
+ module ActionCableHelper
8
+ # Returns an "action-cable-url" meta tag with the value of the URL specified in
9
+ # your configuration. Ensure this is above your JavaScript tag:
10
+ #
11
+ # <head>
12
+ # <%= action_cable_meta_tag %>
13
+ # <%= javascript_include_tag 'application', 'data-turbo-track' => 'reload' %>
14
+ # </head>
15
+ #
16
+ # This is then used by Action Cable to determine the URL of your WebSocket
17
+ # server. Your JavaScript can then connect to the server without needing to
18
+ # specify the URL directly:
19
+ #
20
+ # import Cable from "@rails/actioncable"
21
+ # window.Cable = Cable
22
+ # window.App = {}
23
+ # App.cable = Cable.createConsumer()
24
+ #
25
+ # Make sure to specify the correct server location in each of your environment
26
+ # config files:
27
+ #
28
+ # config.action_cable.mount_path = "/cable123"
29
+ # <%= action_cable_meta_tag %> would render:
30
+ # => <meta name="action-cable-url" content="/cable123" />
31
+ #
32
+ # config.action_cable.url = "ws://actioncable.com"
33
+ # <%= action_cable_meta_tag %> would render:
34
+ # => <meta name="action-cable-url" content="ws://actioncable.com" />
35
+ #
36
+ def action_cable_meta_tag
37
+ tag "meta", name: "action-cable-url", content: (
38
+ ActionCable.server.config.url ||
39
+ ActionCable.server.config.mount_path ||
40
+ raise("No Action Cable URL configured -- please configure this at config.action_cable.url")
41
+ )
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :markup: markdown
4
+
5
+ require "active_support/core_ext/module/redefine_method"
6
+
7
+ module ActionCable
8
+ # # Action Cable Remote Connections
9
+ #
10
+ # If you need to disconnect a given connection, you can go through the
11
+ # RemoteConnections. You can find the connections you're looking for by
12
+ # searching for the identifier declared on the connection. For example:
13
+ #
14
+ # module ApplicationCable
15
+ # class Connection < ActionCable::Connection::Base
16
+ # identified_by :current_user
17
+ # ....
18
+ # end
19
+ # end
20
+ #
21
+ # ActionCable.server.remote_connections.where(current_user: User.find(1)).disconnect
22
+ #
23
+ # This will disconnect all the connections established for `User.find(1)`,
24
+ # across all servers running on all machines, because it uses the internal
25
+ # channel that all of these servers are subscribed to.
26
+ #
27
+ # By default, server sends a "disconnect" message with "reconnect" flag set to
28
+ # true. You can override it by specifying the `reconnect` option:
29
+ #
30
+ # ActionCable.server.remote_connections.where(current_user: User.find(1)).disconnect(reconnect: false)
31
+ class RemoteConnections
32
+ attr_reader :server
33
+
34
+ def initialize(server)
35
+ @server = server
36
+ end
37
+
38
+ def where(identifier)
39
+ RemoteConnection.new(server, identifier)
40
+ end
41
+
42
+ # # Action Cable Remote Connection
43
+ #
44
+ # Represents a single remote connection found via
45
+ # `ActionCable.server.remote_connections.where(*)`. Exists solely for the
46
+ # purpose of calling #disconnect on that connection.
47
+ class RemoteConnection
48
+ class InvalidIdentifiersError < StandardError; end
49
+
50
+ include Connection::Identification, Connection::InternalChannel
51
+
52
+ def initialize(server, ids)
53
+ @server = server
54
+ set_identifier_instance_vars(ids)
55
+ end
56
+
57
+ # Uses the internal channel to disconnect the connection.
58
+ def disconnect(reconnect: true)
59
+ server.broadcast internal_channel, { type: "disconnect", reconnect: reconnect }
60
+ end
61
+
62
+ # Returns all the identifiers that were applied to this connection.
63
+ redefine_method :identifiers do
64
+ server.connection_identifiers
65
+ end
66
+
67
+ protected
68
+ attr_reader :server
69
+
70
+ private
71
+ def set_identifier_instance_vars(ids)
72
+ raise InvalidIdentifiersError unless valid_identifiers?(ids)
73
+ ids.each { |k, v| instance_variable_set("@#{k}", v) }
74
+ end
75
+
76
+ def valid_identifiers?(ids)
77
+ keys = ids.keys
78
+ identifiers.all? { |id| keys.include?(id) }
79
+ end
80
+ end
81
+ end
82
+ end