actioncable 6.0.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 (61) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +169 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +24 -0
  5. data/app/assets/javascripts/action_cable.js +517 -0
  6. data/lib/action_cable.rb +62 -0
  7. data/lib/action_cable/channel.rb +17 -0
  8. data/lib/action_cable/channel/base.rb +311 -0
  9. data/lib/action_cable/channel/broadcasting.rb +41 -0
  10. data/lib/action_cable/channel/callbacks.rb +37 -0
  11. data/lib/action_cable/channel/naming.rb +25 -0
  12. data/lib/action_cable/channel/periodic_timers.rb +78 -0
  13. data/lib/action_cable/channel/streams.rb +176 -0
  14. data/lib/action_cable/channel/test_case.rb +310 -0
  15. data/lib/action_cable/connection.rb +22 -0
  16. data/lib/action_cable/connection/authorization.rb +15 -0
  17. data/lib/action_cable/connection/base.rb +264 -0
  18. data/lib/action_cable/connection/client_socket.rb +157 -0
  19. data/lib/action_cable/connection/identification.rb +47 -0
  20. data/lib/action_cable/connection/internal_channel.rb +45 -0
  21. data/lib/action_cable/connection/message_buffer.rb +54 -0
  22. data/lib/action_cable/connection/stream.rb +117 -0
  23. data/lib/action_cable/connection/stream_event_loop.rb +136 -0
  24. data/lib/action_cable/connection/subscriptions.rb +79 -0
  25. data/lib/action_cable/connection/tagged_logger_proxy.rb +42 -0
  26. data/lib/action_cable/connection/test_case.rb +234 -0
  27. data/lib/action_cable/connection/web_socket.rb +41 -0
  28. data/lib/action_cable/engine.rb +79 -0
  29. data/lib/action_cable/gem_version.rb +17 -0
  30. data/lib/action_cable/helpers/action_cable_helper.rb +42 -0
  31. data/lib/action_cable/remote_connections.rb +71 -0
  32. data/lib/action_cable/server.rb +17 -0
  33. data/lib/action_cable/server/base.rb +94 -0
  34. data/lib/action_cable/server/broadcasting.rb +54 -0
  35. data/lib/action_cable/server/configuration.rb +56 -0
  36. data/lib/action_cable/server/connections.rb +36 -0
  37. data/lib/action_cable/server/worker.rb +75 -0
  38. data/lib/action_cable/server/worker/active_record_connection_management.rb +21 -0
  39. data/lib/action_cable/subscription_adapter.rb +12 -0
  40. data/lib/action_cable/subscription_adapter/async.rb +29 -0
  41. data/lib/action_cable/subscription_adapter/base.rb +30 -0
  42. data/lib/action_cable/subscription_adapter/channel_prefix.rb +28 -0
  43. data/lib/action_cable/subscription_adapter/inline.rb +37 -0
  44. data/lib/action_cable/subscription_adapter/postgresql.rb +132 -0
  45. data/lib/action_cable/subscription_adapter/redis.rb +181 -0
  46. data/lib/action_cable/subscription_adapter/subscriber_map.rb +59 -0
  47. data/lib/action_cable/subscription_adapter/test.rb +40 -0
  48. data/lib/action_cable/test_case.rb +11 -0
  49. data/lib/action_cable/test_helper.rb +133 -0
  50. data/lib/action_cable/version.rb +10 -0
  51. data/lib/rails/generators/channel/USAGE +13 -0
  52. data/lib/rails/generators/channel/channel_generator.rb +52 -0
  53. data/lib/rails/generators/channel/templates/application_cable/channel.rb.tt +4 -0
  54. data/lib/rails/generators/channel/templates/application_cable/connection.rb.tt +4 -0
  55. data/lib/rails/generators/channel/templates/channel.rb.tt +16 -0
  56. data/lib/rails/generators/channel/templates/javascript/channel.js.tt +20 -0
  57. data/lib/rails/generators/channel/templates/javascript/consumer.js.tt +6 -0
  58. data/lib/rails/generators/channel/templates/javascript/index.js.tt +5 -0
  59. data/lib/rails/generators/test_unit/channel_generator.rb +20 -0
  60. data/lib/rails/generators/test_unit/templates/channel_test.rb.tt +8 -0
  61. metadata +149 -0
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionCable
4
+ module Connection
5
+ extend ActiveSupport::Autoload
6
+
7
+ eager_autoload do
8
+ autoload :Authorization
9
+ autoload :Base
10
+ autoload :ClientSocket
11
+ autoload :Identification
12
+ autoload :InternalChannel
13
+ autoload :MessageBuffer
14
+ autoload :Stream
15
+ autoload :StreamEventLoop
16
+ autoload :Subscriptions
17
+ autoload :TaggedLoggerProxy
18
+ autoload :TestCase
19
+ autoload :WebSocket
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionCable
4
+ module Connection
5
+ module Authorization
6
+ class UnauthorizedError < StandardError; end
7
+
8
+ # Closes the WebSocket connection if it is open and returns a 404 "File not Found" response.
9
+ def reject_unauthorized_connection
10
+ logger.error "An unauthorized connection attempt was rejected"
11
+ raise UnauthorizedError
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,264 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_dispatch"
4
+
5
+ module ActionCable
6
+ module Connection
7
+ # For every WebSocket connection the Action Cable server accepts, a Connection object will be instantiated. This instance becomes the parent
8
+ # of all of the channel subscriptions that are created from there on. Incoming messages are then routed to these channel subscriptions
9
+ # based on an identifier sent by the Action Cable consumer. The Connection itself does not deal with any specific application logic beyond
10
+ # authentication and authorization.
11
+ #
12
+ # Here's a basic example:
13
+ #
14
+ # module ApplicationCable
15
+ # class Connection < ActionCable::Connection::Base
16
+ # identified_by :current_user
17
+ #
18
+ # def connect
19
+ # self.current_user = find_verified_user
20
+ # logger.add_tags current_user.name
21
+ # end
22
+ #
23
+ # def disconnect
24
+ # # Any cleanup work needed when the cable connection is cut.
25
+ # end
26
+ #
27
+ # private
28
+ # def find_verified_user
29
+ # User.find_by_identity(cookies.encrypted[:identity_id]) ||
30
+ # reject_unauthorized_connection
31
+ # end
32
+ # end
33
+ # end
34
+ #
35
+ # First, we declare that this connection can be identified by its current_user. This allows us to later be able to find all connections
36
+ # established for that current_user (and potentially disconnect them). You can declare as many
37
+ # identification indexes as you like. Declaring an identification means that an attr_accessor is automatically set for that key.
38
+ #
39
+ # Second, we rely on the fact that the WebSocket connection is established with the cookies from the domain being sent along. This makes
40
+ # it easy to use signed cookies that were set when logging in via a web interface to authorize the WebSocket connection.
41
+ #
42
+ # Finally, we add a tag to the connection-specific logger with the name of the current user to easily distinguish their messages in the log.
43
+ #
44
+ # Pretty simple, eh?
45
+ class Base
46
+ include Identification
47
+ include InternalChannel
48
+ include Authorization
49
+
50
+ attr_reader :server, :env, :subscriptions, :logger, :worker_pool, :protocol
51
+ delegate :event_loop, :pubsub, to: :server
52
+
53
+ def initialize(server, env, coder: ActiveSupport::JSON)
54
+ @server, @env, @coder = server, env, coder
55
+
56
+ @worker_pool = server.worker_pool
57
+ @logger = new_tagged_logger
58
+
59
+ @websocket = ActionCable::Connection::WebSocket.new(env, self, event_loop)
60
+ @subscriptions = ActionCable::Connection::Subscriptions.new(self)
61
+ @message_buffer = ActionCable::Connection::MessageBuffer.new(self)
62
+
63
+ @_internal_subscriptions = nil
64
+ @started_at = Time.now
65
+ end
66
+
67
+ # Called by the server when a new WebSocket connection is established. This configures the callbacks intended for overwriting by the user.
68
+ # This method should not be called directly -- instead rely upon on the #connect (and #disconnect) callbacks.
69
+ def process #:nodoc:
70
+ logger.info started_request_message
71
+
72
+ if websocket.possible? && allow_request_origin?
73
+ respond_to_successful_request
74
+ else
75
+ respond_to_invalid_request
76
+ end
77
+ end
78
+
79
+ # Decodes WebSocket messages and dispatches them to subscribed channels.
80
+ # WebSocket message transfer encoding is always JSON.
81
+ def receive(websocket_message) #:nodoc:
82
+ send_async :dispatch_websocket_message, websocket_message
83
+ end
84
+
85
+ def dispatch_websocket_message(websocket_message) #:nodoc:
86
+ if websocket.alive?
87
+ subscriptions.execute_command decode(websocket_message)
88
+ else
89
+ logger.error "Ignoring message processed after the WebSocket was closed: #{websocket_message.inspect})"
90
+ end
91
+ end
92
+
93
+ def transmit(cable_message) # :nodoc:
94
+ websocket.transmit encode(cable_message)
95
+ end
96
+
97
+ # Close the WebSocket connection.
98
+ def close(reason: nil, reconnect: true)
99
+ transmit(
100
+ type: ActionCable::INTERNAL[:message_types][:disconnect],
101
+ reason: reason,
102
+ reconnect: reconnect
103
+ )
104
+ websocket.close
105
+ end
106
+
107
+ # Invoke a method on the connection asynchronously through the pool of thread workers.
108
+ def send_async(method, *arguments)
109
+ worker_pool.async_invoke(self, method, *arguments)
110
+ end
111
+
112
+ # Return a basic hash of statistics for the connection keyed with <tt>identifier</tt>, <tt>started_at</tt>, <tt>subscriptions</tt>, and <tt>request_id</tt>.
113
+ # This can be returned by a health check against the connection.
114
+ def statistics
115
+ {
116
+ identifier: connection_identifier,
117
+ started_at: @started_at,
118
+ subscriptions: subscriptions.identifiers,
119
+ request_id: @env["action_dispatch.request_id"]
120
+ }
121
+ end
122
+
123
+ def beat
124
+ transmit type: ActionCable::INTERNAL[:message_types][:ping], message: Time.now.to_i
125
+ end
126
+
127
+ def on_open # :nodoc:
128
+ send_async :handle_open
129
+ end
130
+
131
+ def on_message(message) # :nodoc:
132
+ message_buffer.append message
133
+ end
134
+
135
+ def on_error(message) # :nodoc:
136
+ # log errors to make diagnosing socket errors easier
137
+ logger.error "WebSocket error occurred: #{message}"
138
+ end
139
+
140
+ def on_close(reason, code) # :nodoc:
141
+ send_async :handle_close
142
+ end
143
+
144
+ private
145
+ attr_reader :websocket
146
+ attr_reader :message_buffer
147
+
148
+ # The request that initiated the WebSocket connection is available here. This gives access to the environment, cookies, etc.
149
+ def request # :doc:
150
+ @request ||= begin
151
+ environment = Rails.application.env_config.merge(env) if defined?(Rails.application) && Rails.application
152
+ ActionDispatch::Request.new(environment || env)
153
+ end
154
+ end
155
+
156
+ # The cookies of the request that initiated the WebSocket connection. Useful for performing authorization checks.
157
+ def cookies # :doc:
158
+ request.cookie_jar
159
+ end
160
+
161
+ def encode(cable_message)
162
+ @coder.encode cable_message
163
+ end
164
+
165
+ def decode(websocket_message)
166
+ @coder.decode websocket_message
167
+ end
168
+
169
+ def handle_open
170
+ @protocol = websocket.protocol
171
+ connect if respond_to?(:connect)
172
+ subscribe_to_internal_channel
173
+ send_welcome_message
174
+
175
+ message_buffer.process!
176
+ server.add_connection(self)
177
+ rescue ActionCable::Connection::Authorization::UnauthorizedError
178
+ close(reason: ActionCable::INTERNAL[:disconnect_reasons][:unauthorized], reconnect: false) if websocket.alive?
179
+ end
180
+
181
+ def handle_close
182
+ logger.info finished_request_message
183
+
184
+ server.remove_connection(self)
185
+
186
+ subscriptions.unsubscribe_from_all
187
+ unsubscribe_from_internal_channel
188
+
189
+ disconnect if respond_to?(:disconnect)
190
+ end
191
+
192
+ def send_welcome_message
193
+ # Send welcome message to the internal connection monitor channel.
194
+ # This ensures the connection monitor state is reset after a successful
195
+ # websocket connection.
196
+ transmit type: ActionCable::INTERNAL[:message_types][:welcome]
197
+ end
198
+
199
+ def allow_request_origin?
200
+ return true if server.config.disable_request_forgery_protection
201
+
202
+ proto = Rack::Request.new(env).ssl? ? "https" : "http"
203
+ if server.config.allow_same_origin_as_host && env["HTTP_ORIGIN"] == "#{proto}://#{env['HTTP_HOST']}"
204
+ true
205
+ elsif Array(server.config.allowed_request_origins).any? { |allowed_origin| allowed_origin === env["HTTP_ORIGIN"] }
206
+ true
207
+ else
208
+ logger.error("Request origin not allowed: #{env['HTTP_ORIGIN']}")
209
+ false
210
+ end
211
+ end
212
+
213
+ def respond_to_successful_request
214
+ logger.info successful_request_message
215
+ websocket.rack_response
216
+ end
217
+
218
+ def respond_to_invalid_request
219
+ close(reason: ActionCable::INTERNAL[:disconnect_reasons][:invalid_request]) if websocket.alive?
220
+
221
+ logger.error invalid_request_message
222
+ logger.info finished_request_message
223
+ [ 404, { "Content-Type" => "text/plain" }, [ "Page not found" ] ]
224
+ end
225
+
226
+ # Tags are declared in the server but computed in the connection. This allows us per-connection tailored tags.
227
+ def new_tagged_logger
228
+ TaggedLoggerProxy.new server.logger,
229
+ tags: server.config.log_tags.map { |tag| tag.respond_to?(:call) ? tag.call(request) : tag.to_s.camelize }
230
+ end
231
+
232
+ def started_request_message
233
+ 'Started %s "%s"%s for %s at %s' % [
234
+ request.request_method,
235
+ request.filtered_path,
236
+ websocket.possible? ? " [WebSocket]" : "[non-WebSocket]",
237
+ request.ip,
238
+ Time.now.to_s ]
239
+ end
240
+
241
+ def finished_request_message
242
+ 'Finished "%s"%s for %s at %s' % [
243
+ request.filtered_path,
244
+ websocket.possible? ? " [WebSocket]" : "[non-WebSocket]",
245
+ request.ip,
246
+ Time.now.to_s ]
247
+ end
248
+
249
+ def invalid_request_message
250
+ "Failed to upgrade to WebSocket (REQUEST_METHOD: %s, HTTP_CONNECTION: %s, HTTP_UPGRADE: %s)" % [
251
+ env["REQUEST_METHOD"], env["HTTP_CONNECTION"], env["HTTP_UPGRADE"]
252
+ ]
253
+ end
254
+
255
+ def successful_request_message
256
+ "Successfully upgraded to WebSocket (REQUEST_METHOD: %s, HTTP_CONNECTION: %s, HTTP_UPGRADE: %s)" % [
257
+ env["REQUEST_METHOD"], env["HTTP_CONNECTION"], env["HTTP_UPGRADE"]
258
+ ]
259
+ end
260
+ end
261
+ end
262
+ end
263
+
264
+ ActiveSupport.run_load_hooks(:action_cable_connection, ActionCable::Connection::Base)
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "websocket/driver"
4
+
5
+ module ActionCable
6
+ module Connection
7
+ #--
8
+ # This class is heavily based on faye-websocket-ruby
9
+ #
10
+ # Copyright (c) 2010-2015 James Coglan
11
+ class ClientSocket # :nodoc:
12
+ def self.determine_url(env)
13
+ scheme = secure_request?(env) ? "wss:" : "ws:"
14
+ "#{ scheme }//#{ env['HTTP_HOST'] }#{ env['REQUEST_URI'] }"
15
+ end
16
+
17
+ def self.secure_request?(env)
18
+ return true if env["HTTPS"] == "on"
19
+ return true if env["HTTP_X_FORWARDED_SSL"] == "on"
20
+ return true if env["HTTP_X_FORWARDED_SCHEME"] == "https"
21
+ return true if env["HTTP_X_FORWARDED_PROTO"] == "https"
22
+ return true if env["rack.url_scheme"] == "https"
23
+
24
+ false
25
+ end
26
+
27
+ CONNECTING = 0
28
+ OPEN = 1
29
+ CLOSING = 2
30
+ CLOSED = 3
31
+
32
+ attr_reader :env, :url
33
+
34
+ def initialize(env, event_target, event_loop, protocols)
35
+ @env = env
36
+ @event_target = event_target
37
+ @event_loop = event_loop
38
+
39
+ @url = ClientSocket.determine_url(@env)
40
+
41
+ @driver = @driver_started = nil
42
+ @close_params = ["", 1006]
43
+
44
+ @ready_state = CONNECTING
45
+
46
+ # The driver calls +env+, +url+, and +write+
47
+ @driver = ::WebSocket::Driver.rack(self, protocols: protocols)
48
+
49
+ @driver.on(:open) { |e| open }
50
+ @driver.on(:message) { |e| receive_message(e.data) }
51
+ @driver.on(:close) { |e| begin_close(e.reason, e.code) }
52
+ @driver.on(:error) { |e| emit_error(e.message) }
53
+
54
+ @stream = ActionCable::Connection::Stream.new(@event_loop, self)
55
+ end
56
+
57
+ def start_driver
58
+ return if @driver.nil? || @driver_started
59
+ @stream.hijack_rack_socket
60
+
61
+ if callback = @env["async.callback"]
62
+ callback.call([101, {}, @stream])
63
+ end
64
+
65
+ @driver_started = true
66
+ @driver.start
67
+ end
68
+
69
+ def rack_response
70
+ start_driver
71
+ [ -1, {}, [] ]
72
+ end
73
+
74
+ def write(data)
75
+ @stream.write(data)
76
+ rescue => e
77
+ emit_error e.message
78
+ end
79
+
80
+ def transmit(message)
81
+ return false if @ready_state > OPEN
82
+ case message
83
+ when Numeric then @driver.text(message.to_s)
84
+ when String then @driver.text(message)
85
+ when Array then @driver.binary(message)
86
+ else false
87
+ end
88
+ end
89
+
90
+ def close(code = nil, reason = nil)
91
+ code ||= 1000
92
+ reason ||= ""
93
+
94
+ unless code == 1000 || (code >= 3000 && code <= 4999)
95
+ raise ArgumentError, "Failed to execute 'close' on WebSocket: " \
96
+ "The code must be either 1000, or between 3000 and 4999. " \
97
+ "#{code} is neither."
98
+ end
99
+
100
+ @ready_state = CLOSING unless @ready_state == CLOSED
101
+ @driver.close(reason, code)
102
+ end
103
+
104
+ def parse(data)
105
+ @driver.parse(data)
106
+ end
107
+
108
+ def client_gone
109
+ finalize_close
110
+ end
111
+
112
+ def alive?
113
+ @ready_state == OPEN
114
+ end
115
+
116
+ def protocol
117
+ @driver.protocol
118
+ end
119
+
120
+ private
121
+ def open
122
+ return unless @ready_state == CONNECTING
123
+ @ready_state = OPEN
124
+
125
+ @event_target.on_open
126
+ end
127
+
128
+ def receive_message(data)
129
+ return unless @ready_state == OPEN
130
+
131
+ @event_target.on_message(data)
132
+ end
133
+
134
+ def emit_error(message)
135
+ return if @ready_state >= CLOSING
136
+
137
+ @event_target.on_error(message)
138
+ end
139
+
140
+ def begin_close(reason, code)
141
+ return if @ready_state == CLOSED
142
+ @ready_state = CLOSING
143
+ @close_params = [reason, code]
144
+
145
+ @stream.shutdown if @stream
146
+ finalize_close
147
+ end
148
+
149
+ def finalize_close
150
+ return if @ready_state == CLOSED
151
+ @ready_state = CLOSED
152
+
153
+ @event_target.on_close(*@close_params)
154
+ end
155
+ end
156
+ end
157
+ end