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