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