actioncable 0.0.0 → 5.0.0.beta1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +8 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +439 -21
  5. data/lib/action_cable.rb +47 -2
  6. data/lib/action_cable/channel.rb +14 -0
  7. data/lib/action_cable/channel/base.rb +277 -0
  8. data/lib/action_cable/channel/broadcasting.rb +29 -0
  9. data/lib/action_cable/channel/callbacks.rb +35 -0
  10. data/lib/action_cable/channel/naming.rb +22 -0
  11. data/lib/action_cable/channel/periodic_timers.rb +41 -0
  12. data/lib/action_cable/channel/streams.rb +114 -0
  13. data/lib/action_cable/connection.rb +16 -0
  14. data/lib/action_cable/connection/authorization.rb +13 -0
  15. data/lib/action_cable/connection/base.rb +221 -0
  16. data/lib/action_cable/connection/identification.rb +46 -0
  17. data/lib/action_cable/connection/internal_channel.rb +45 -0
  18. data/lib/action_cable/connection/message_buffer.rb +54 -0
  19. data/lib/action_cable/connection/subscriptions.rb +76 -0
  20. data/lib/action_cable/connection/tagged_logger_proxy.rb +40 -0
  21. data/lib/action_cable/connection/web_socket.rb +29 -0
  22. data/lib/action_cable/engine.rb +38 -0
  23. data/lib/action_cable/gem_version.rb +15 -0
  24. data/lib/action_cable/helpers/action_cable_helper.rb +29 -0
  25. data/lib/action_cable/process/logging.rb +10 -0
  26. data/lib/action_cable/remote_connections.rb +64 -0
  27. data/lib/action_cable/server.rb +19 -0
  28. data/lib/action_cable/server/base.rb +77 -0
  29. data/lib/action_cable/server/broadcasting.rb +54 -0
  30. data/lib/action_cable/server/configuration.rb +35 -0
  31. data/lib/action_cable/server/connections.rb +37 -0
  32. data/lib/action_cable/server/worker.rb +42 -0
  33. data/lib/action_cable/server/worker/active_record_connection_management.rb +22 -0
  34. data/lib/action_cable/version.rb +6 -1
  35. data/lib/assets/javascripts/action_cable.coffee.erb +23 -0
  36. data/lib/assets/javascripts/action_cable/connection.coffee +84 -0
  37. data/lib/assets/javascripts/action_cable/connection_monitor.coffee +84 -0
  38. data/lib/assets/javascripts/action_cable/consumer.coffee +31 -0
  39. data/lib/assets/javascripts/action_cable/subscription.coffee +68 -0
  40. data/lib/assets/javascripts/action_cable/subscriptions.coffee +78 -0
  41. data/lib/rails/generators/channel/USAGE +14 -0
  42. data/lib/rails/generators/channel/channel_generator.rb +21 -0
  43. data/lib/rails/generators/channel/templates/assets/channel.coffee +14 -0
  44. data/lib/rails/generators/channel/templates/channel.rb +17 -0
  45. metadata +161 -26
  46. data/.gitignore +0 -9
  47. data/Gemfile +0 -4
  48. data/LICENSE.txt +0 -21
  49. data/Rakefile +0 -2
  50. data/actioncable.gemspec +0 -22
  51. data/bin/console +0 -14
  52. data/bin/setup +0 -7
@@ -0,0 +1,114 @@
1
+ module ActionCable
2
+ module Channel
3
+ # Streams allow channels to route broadcastings to the subscriber. A broadcasting is, as discussed elsewhere, a pub/sub queue where any data
4
+ # put into it is automatically sent to the clients that are connected at that time. It's purely an online queue, though. If you're not
5
+ # streaming a broadcasting at the very moment it sends out an update, you'll not get that update when connecting later.
6
+ #
7
+ # Most commonly, the streamed broadcast is sent straight to the subscriber on the client-side. The channel just acts as a connector between
8
+ # the two parties (the broadcaster and the channel subscriber). Here's an example of a channel that allows subscribers to get all new
9
+ # comments on a given page:
10
+ #
11
+ # class CommentsChannel < ApplicationCable::Channel
12
+ # def follow(data)
13
+ # stream_from "comments_for_#{data['recording_id']}"
14
+ # end
15
+ #
16
+ # def unfollow
17
+ # stop_all_streams
18
+ # end
19
+ # end
20
+ #
21
+ # So the subscribers of this channel will get whatever data is put into the, let's say, `comments_for_45` broadcasting as soon as it's put there.
22
+ # That looks like so from that side of things:
23
+ #
24
+ # ActionCable.server.broadcast "comments_for_45", author: 'DHH', content: 'Rails is just swell'
25
+ #
26
+ # If you have a stream that is related to a model, then the broadcasting used can be generated from the model and channel.
27
+ # The following example would subscribe to a broadcasting like `comments:Z2lkOi8vVGVzdEFwcC9Qb3N0LzE`
28
+ #
29
+ # class CommentsChannel < ApplicationCable::Channel
30
+ # def subscribed
31
+ # post = Post.find(params[:id])
32
+ # stream_for post
33
+ # end
34
+ # end
35
+ #
36
+ # You can then broadcast to this channel using:
37
+ #
38
+ # CommentsChannel.broadcast_to(@post, @comment)
39
+ #
40
+ # If you don't just want to parlay the broadcast unfiltered to the subscriber, you can supply a callback that lets you alter what goes out.
41
+ # Example below shows how you can use this to provide performance introspection in the process:
42
+ #
43
+ # class ChatChannel < ApplicationCable::Channel
44
+ # def subscribed
45
+ # @room = Chat::Room[params[:room_number]]
46
+ #
47
+ # stream_for @room, -> (encoded_message) do
48
+ # message = ActiveSupport::JSON.decode(encoded_message)
49
+ #
50
+ # if message['originated_at'].present?
51
+ # elapsed_time = (Time.now.to_f - message['originated_at']).round(2)
52
+ #
53
+ # ActiveSupport::Notifications.instrument :performance, measurement: 'Chat.message_delay', value: elapsed_time, action: :timing
54
+ # logger.info "Message took #{elapsed_time}s to arrive"
55
+ # end
56
+ #
57
+ # transmit message
58
+ # end
59
+ # end
60
+ #
61
+ # You can stop streaming from all broadcasts by calling #stop_all_streams.
62
+ module Streams
63
+ extend ActiveSupport::Concern
64
+
65
+ included do
66
+ on_unsubscribe :stop_all_streams
67
+ end
68
+
69
+ # Start streaming from the named <tt>broadcasting</tt> pubsub queue. Optionally, you can pass a <tt>callback</tt> that'll be used
70
+ # instead of the default of just transmitting the updates straight to the subscriber.
71
+ def stream_from(broadcasting, callback = nil)
72
+ # Hold off the confirmation until pubsub#subscribe is successful
73
+ defer_subscription_confirmation!
74
+
75
+ callback ||= default_stream_callback(broadcasting)
76
+ streams << [ broadcasting, callback ]
77
+
78
+ EM.next_tick do
79
+ pubsub.subscribe(broadcasting, &callback).callback do |reply|
80
+ transmit_subscription_confirmation
81
+ logger.info "#{self.class.name} is streaming from #{broadcasting}"
82
+ end
83
+ end
84
+ end
85
+
86
+ # Start streaming the pubsub queue for the <tt>model</tt> in this channel. Optionally, you can pass a
87
+ # <tt>callback</tt> that'll be used instead of the default of just transmitting the updates straight
88
+ # to the subscriber.
89
+ def stream_for(model, callback = nil)
90
+ stream_from(broadcasting_for([ channel_name, model ]), callback)
91
+ end
92
+
93
+ def stop_all_streams
94
+ streams.each do |broadcasting, callback|
95
+ pubsub.unsubscribe_proc broadcasting, callback
96
+ logger.info "#{self.class.name} stopped streaming from #{broadcasting}"
97
+ end.clear
98
+ end
99
+
100
+ private
101
+ delegate :pubsub, to: :connection
102
+
103
+ def streams
104
+ @_streams ||= []
105
+ end
106
+
107
+ def default_stream_callback(broadcasting)
108
+ -> (message) do
109
+ transmit ActiveSupport::JSON.decode(message), via: "streamed from #{broadcasting}"
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,16 @@
1
+ module ActionCable
2
+ module Connection
3
+ extend ActiveSupport::Autoload
4
+
5
+ eager_autoload do
6
+ autoload :Authorization
7
+ autoload :Base
8
+ autoload :Identification
9
+ autoload :InternalChannel
10
+ autoload :MessageBuffer
11
+ autoload :WebSocket
12
+ autoload :Subscriptions
13
+ autoload :TaggedLoggerProxy
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,13 @@
1
+ module ActionCable
2
+ module Connection
3
+ module Authorization
4
+ class UnauthorizedError < StandardError; end
5
+
6
+ private
7
+ def reject_unauthorized_connection
8
+ logger.error "An unauthorized connection attempt was rejected"
9
+ raise UnauthorizedError
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,221 @@
1
+ require 'action_dispatch'
2
+
3
+ module ActionCable
4
+ module Connection
5
+ # For every WebSocket the cable server is accepting, a Connection object will be instantiated. This instance becomes the parent
6
+ # of all the channel subscriptions that are created from there on. Incoming messages are then routed to these channel subscriptions
7
+ # based on an identifier sent by the cable consumer. The Connection itself does not deal with any specific application logic beyond
8
+ # authentication and authorization.
9
+ #
10
+ # Here's a basic example:
11
+ #
12
+ # module ApplicationCable
13
+ # class Connection < ActionCable::Connection::Base
14
+ # identified_by :current_user
15
+ #
16
+ # def connect
17
+ # self.current_user = find_verified_user
18
+ # logger.add_tags current_user.name
19
+ # end
20
+ #
21
+ # def disconnect
22
+ # # Any cleanup work needed when the cable connection is cut.
23
+ # end
24
+ #
25
+ # protected
26
+ # def find_verified_user
27
+ # if current_user = User.find_by_identity cookies.signed[:identity_id]
28
+ # current_user
29
+ # else
30
+ # reject_unauthorized_connection
31
+ # end
32
+ # end
33
+ # end
34
+ # end
35
+ #
36
+ # First, we declare that this connection can be identified by its current_user. This allows us later to be able to find all connections
37
+ # established for that current_user (and potentially disconnect them if the user was removed from an account). You can declare as many
38
+ # identification indexes as you like. Declaring an identification means that a attr_accessor is automatically set for that key.
39
+ #
40
+ # Second, we rely on the fact that the WebSocket connection is established with the cookies from the domain being sent along. This makes
41
+ # it easy to use signed cookies that were set when logging in via a web interface to authorize the WebSocket connection.
42
+ #
43
+ # Finally, we add a tag to the connection-specific logger with name of the current user to easily distinguish their messages in the log.
44
+ #
45
+ # Pretty simple, eh?
46
+ class Base
47
+ include Identification
48
+ include InternalChannel
49
+ include Authorization
50
+
51
+ attr_reader :server, :env, :subscriptions
52
+ delegate :worker_pool, :pubsub, to: :server
53
+
54
+ attr_reader :logger
55
+
56
+ def initialize(server, env)
57
+ @server, @env = server, env
58
+
59
+ @logger = new_tagged_logger
60
+
61
+ @websocket = ActionCable::Connection::WebSocket.new(env)
62
+ @subscriptions = ActionCable::Connection::Subscriptions.new(self)
63
+ @message_buffer = ActionCable::Connection::MessageBuffer.new(self)
64
+
65
+ @_internal_redis_subscriptions = nil
66
+ @started_at = Time.now
67
+ end
68
+
69
+ # Called by the server when a new WebSocket connection is established. This configures the callbacks intended for overwriting by the user.
70
+ # This method should not be called directly. Rely on the #connect (and #disconnect) callback instead.
71
+ def process
72
+ logger.info started_request_message
73
+
74
+ if websocket.possible? && allow_request_origin?
75
+ websocket.on(:open) { |event| send_async :on_open }
76
+ websocket.on(:message) { |event| on_message event.data }
77
+ websocket.on(:close) { |event| send_async :on_close }
78
+
79
+ respond_to_successful_request
80
+ else
81
+ respond_to_invalid_request
82
+ end
83
+ end
84
+
85
+ # Data received over the cable is handled by this method. It's expected that everything inbound is JSON encoded.
86
+ # The data is routed to the proper channel that the connection has subscribed to.
87
+ def receive(data_in_json)
88
+ if websocket.alive?
89
+ subscriptions.execute_command ActiveSupport::JSON.decode(data_in_json)
90
+ else
91
+ logger.error "Received data without a live WebSocket (#{data_in_json.inspect})"
92
+ end
93
+ end
94
+
95
+ # Send raw data straight back down the WebSocket. This is not intended to be called directly. Use the #transmit available on the
96
+ # Channel instead, as that'll automatically address the correct subscriber and wrap the message in JSON.
97
+ def transmit(data)
98
+ websocket.transmit data
99
+ end
100
+
101
+ # Close the WebSocket connection.
102
+ def close
103
+ websocket.close
104
+ end
105
+
106
+ # Invoke a method on the connection asynchronously through the pool of thread workers.
107
+ def send_async(method, *arguments)
108
+ worker_pool.async.invoke(self, method, *arguments)
109
+ end
110
+
111
+ # Return a basic hash of statistics for the connection keyed with `identifier`, `started_at`, and `subscriptions`.
112
+ # This can be returned by a health check against the connection.
113
+ def statistics
114
+ {
115
+ identifier: connection_identifier,
116
+ started_at: @started_at,
117
+ subscriptions: subscriptions.identifiers,
118
+ request_id: @env['action_dispatch.request_id']
119
+ }
120
+ end
121
+
122
+ def beat
123
+ transmit ActiveSupport::JSON.encode(identifier: ActionCable::INTERNAL[:identifiers][:ping], message: Time.now.to_i)
124
+ end
125
+
126
+
127
+ protected
128
+ # The request that initiated the WebSocket connection is available here. This gives access to the environment, cookies, etc.
129
+ def request
130
+ @request ||= begin
131
+ environment = Rails.application.env_config.merge(env) if defined?(Rails.application) && Rails.application
132
+ ActionDispatch::Request.new(environment || env)
133
+ end
134
+ end
135
+
136
+ # The cookies of the request that initiated the WebSocket connection. Useful for performing authorization checks.
137
+ def cookies
138
+ request.cookie_jar
139
+ end
140
+
141
+
142
+ protected
143
+ attr_reader :websocket
144
+ attr_reader :message_buffer
145
+
146
+ private
147
+ def on_open
148
+ connect if respond_to?(:connect)
149
+ subscribe_to_internal_channel
150
+ beat
151
+
152
+ message_buffer.process!
153
+ server.add_connection(self)
154
+ rescue ActionCable::Connection::Authorization::UnauthorizedError
155
+ respond_to_invalid_request
156
+ end
157
+
158
+ def on_message(message)
159
+ message_buffer.append message
160
+ end
161
+
162
+ def on_close
163
+ logger.info finished_request_message
164
+
165
+ server.remove_connection(self)
166
+
167
+ subscriptions.unsubscribe_from_all
168
+ unsubscribe_from_internal_channel
169
+
170
+ disconnect if respond_to?(:disconnect)
171
+ end
172
+
173
+
174
+ def allow_request_origin?
175
+ return true if server.config.disable_request_forgery_protection
176
+
177
+ if Array(server.config.allowed_request_origins).any? { |allowed_origin| allowed_origin === env['HTTP_ORIGIN'] }
178
+ true
179
+ else
180
+ logger.error("Request origin not allowed: #{env['HTTP_ORIGIN']}")
181
+ false
182
+ end
183
+ end
184
+
185
+ def respond_to_successful_request
186
+ websocket.rack_response
187
+ end
188
+
189
+ def respond_to_invalid_request
190
+ close if websocket.alive?
191
+
192
+ logger.info finished_request_message
193
+ [ 404, { 'Content-Type' => 'text/plain' }, [ 'Page not found' ] ]
194
+ end
195
+
196
+
197
+ # Tags are declared in the server but computed in the connection. This allows us per-connection tailored tags.
198
+ def new_tagged_logger
199
+ TaggedLoggerProxy.new server.logger,
200
+ tags: server.config.log_tags.map { |tag| tag.respond_to?(:call) ? tag.call(request) : tag.to_s.camelize }
201
+ end
202
+
203
+ def started_request_message
204
+ 'Started %s "%s"%s for %s at %s' % [
205
+ request.request_method,
206
+ request.filtered_path,
207
+ websocket.possible? ? ' [WebSocket]' : '',
208
+ request.ip,
209
+ Time.now.to_s ]
210
+ end
211
+
212
+ def finished_request_message
213
+ 'Finished "%s"%s for %s at %s' % [
214
+ request.filtered_path,
215
+ websocket.possible? ? ' [WebSocket]' : '',
216
+ request.ip,
217
+ Time.now.to_s ]
218
+ end
219
+ end
220
+ end
221
+ end
@@ -0,0 +1,46 @@
1
+ require 'set'
2
+
3
+ module ActionCable
4
+ module Connection
5
+ module Identification
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ class_attribute :identifiers
10
+ self.identifiers = Set.new
11
+ end
12
+
13
+ class_methods do
14
+ # Mark a key as being a connection identifier index that can then used to find the specific connection again later.
15
+ # Common identifiers are current_user and current_account, but could be anything really.
16
+ #
17
+ # Note that anything marked as an identifier will automatically create a delegate by the same name on any
18
+ # channel instances created off the connection.
19
+ def identified_by(*identifiers)
20
+ Array(identifiers).each { |identifier| attr_accessor identifier }
21
+ self.identifiers += identifiers
22
+ end
23
+ end
24
+
25
+ # Return a single connection identifier that combines the value of all the registered identifiers into a single gid.
26
+ def connection_identifier
27
+ unless defined? @connection_identifier
28
+ @connection_identifier = connection_gid identifiers.map { |id| instance_variable_get("@#{id}") }.compact
29
+ end
30
+
31
+ @connection_identifier
32
+ end
33
+
34
+ private
35
+ def connection_gid(ids)
36
+ ids.map do |o|
37
+ if o.respond_to? :to_gid_param
38
+ o.to_gid_param
39
+ else
40
+ o.to_s
41
+ end
42
+ end.sort.join(":")
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,45 @@
1
+ module ActionCable
2
+ module Connection
3
+ # Makes it possible for the RemoteConnection to disconnect a specific connection.
4
+ module InternalChannel
5
+ extend ActiveSupport::Concern
6
+
7
+ private
8
+ def internal_redis_channel
9
+ "action_cable/#{connection_identifier}"
10
+ end
11
+
12
+ def subscribe_to_internal_channel
13
+ if connection_identifier.present?
14
+ callback = -> (message) { process_internal_message(message) }
15
+ @_internal_redis_subscriptions ||= []
16
+ @_internal_redis_subscriptions << [ internal_redis_channel, callback ]
17
+
18
+ EM.next_tick { pubsub.subscribe(internal_redis_channel, &callback) }
19
+ logger.info "Registered connection (#{connection_identifier})"
20
+ end
21
+ end
22
+
23
+ def unsubscribe_from_internal_channel
24
+ if @_internal_redis_subscriptions.present?
25
+ @_internal_redis_subscriptions.each { |channel, callback| EM.next_tick { pubsub.unsubscribe_proc(channel, callback) } }
26
+ end
27
+ end
28
+
29
+ def process_internal_message(message)
30
+ message = ActiveSupport::JSON.decode(message)
31
+
32
+ case message['type']
33
+ when 'disconnect'
34
+ logger.info "Removing connection (#{connection_identifier})"
35
+ websocket.close
36
+ end
37
+ rescue Exception => e
38
+ logger.error "There was an exception - #{e.class}(#{e.message})"
39
+ logger.error e.backtrace.join("\n")
40
+
41
+ close
42
+ end
43
+ end
44
+ end
45
+ end