actioncable 0.0.0 → 5.0.0.beta1

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