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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +8 -0
- data/MIT-LICENSE +20 -0
- data/README.md +439 -21
- data/lib/action_cable.rb +47 -2
- data/lib/action_cable/channel.rb +14 -0
- data/lib/action_cable/channel/base.rb +277 -0
- data/lib/action_cable/channel/broadcasting.rb +29 -0
- data/lib/action_cable/channel/callbacks.rb +35 -0
- data/lib/action_cable/channel/naming.rb +22 -0
- data/lib/action_cable/channel/periodic_timers.rb +41 -0
- data/lib/action_cable/channel/streams.rb +114 -0
- data/lib/action_cable/connection.rb +16 -0
- data/lib/action_cable/connection/authorization.rb +13 -0
- data/lib/action_cable/connection/base.rb +221 -0
- data/lib/action_cable/connection/identification.rb +46 -0
- data/lib/action_cable/connection/internal_channel.rb +45 -0
- data/lib/action_cable/connection/message_buffer.rb +54 -0
- data/lib/action_cable/connection/subscriptions.rb +76 -0
- data/lib/action_cable/connection/tagged_logger_proxy.rb +40 -0
- data/lib/action_cable/connection/web_socket.rb +29 -0
- data/lib/action_cable/engine.rb +38 -0
- data/lib/action_cable/gem_version.rb +15 -0
- data/lib/action_cable/helpers/action_cable_helper.rb +29 -0
- data/lib/action_cable/process/logging.rb +10 -0
- data/lib/action_cable/remote_connections.rb +64 -0
- data/lib/action_cable/server.rb +19 -0
- data/lib/action_cable/server/base.rb +77 -0
- data/lib/action_cable/server/broadcasting.rb +54 -0
- data/lib/action_cable/server/configuration.rb +35 -0
- data/lib/action_cable/server/connections.rb +37 -0
- data/lib/action_cable/server/worker.rb +42 -0
- data/lib/action_cable/server/worker/active_record_connection_management.rb +22 -0
- data/lib/action_cable/version.rb +6 -1
- data/lib/assets/javascripts/action_cable.coffee.erb +23 -0
- data/lib/assets/javascripts/action_cable/connection.coffee +84 -0
- data/lib/assets/javascripts/action_cable/connection_monitor.coffee +84 -0
- data/lib/assets/javascripts/action_cable/consumer.coffee +31 -0
- data/lib/assets/javascripts/action_cable/subscription.coffee +68 -0
- data/lib/assets/javascripts/action_cable/subscriptions.coffee +78 -0
- data/lib/rails/generators/channel/USAGE +14 -0
- data/lib/rails/generators/channel/channel_generator.rb +21 -0
- data/lib/rails/generators/channel/templates/assets/channel.coffee +14 -0
- data/lib/rails/generators/channel/templates/channel.rb +17 -0
- metadata +161 -26
- data/.gitignore +0 -9
- data/Gemfile +0 -4
- data/LICENSE.txt +0 -21
- data/Rakefile +0 -2
- data/actioncable.gemspec +0 -22
- data/bin/console +0 -14
- 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
|