actioncable-next 0.1.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/MIT-LICENSE +20 -0
- data/README.md +17 -0
- data/lib/action_cable/channel/base.rb +335 -0
- data/lib/action_cable/channel/broadcasting.rb +50 -0
- data/lib/action_cable/channel/callbacks.rb +76 -0
- data/lib/action_cable/channel/naming.rb +28 -0
- data/lib/action_cable/channel/periodic_timers.rb +81 -0
- data/lib/action_cable/channel/streams.rb +213 -0
- data/lib/action_cable/channel/test_case.rb +329 -0
- data/lib/action_cable/connection/authorization.rb +18 -0
- data/lib/action_cable/connection/base.rb +165 -0
- data/lib/action_cable/connection/callbacks.rb +57 -0
- data/lib/action_cable/connection/identification.rb +51 -0
- data/lib/action_cable/connection/internal_channel.rb +50 -0
- data/lib/action_cable/connection/subscriptions.rb +124 -0
- data/lib/action_cable/connection/test_case.rb +294 -0
- data/lib/action_cable/deprecator.rb +9 -0
- data/lib/action_cable/engine.rb +98 -0
- data/lib/action_cable/gem_version.rb +19 -0
- data/lib/action_cable/helpers/action_cable_helper.rb +45 -0
- data/lib/action_cable/remote_connections.rb +82 -0
- data/lib/action_cable/server/base.rb +163 -0
- data/lib/action_cable/server/broadcasting.rb +62 -0
- data/lib/action_cable/server/configuration.rb +75 -0
- data/lib/action_cable/server/connections.rb +44 -0
- data/lib/action_cable/server/socket/client_socket.rb +159 -0
- data/lib/action_cable/server/socket/message_buffer.rb +56 -0
- data/lib/action_cable/server/socket/stream.rb +117 -0
- data/lib/action_cable/server/socket/web_socket.rb +47 -0
- data/lib/action_cable/server/socket.rb +180 -0
- data/lib/action_cable/server/stream_event_loop.rb +119 -0
- data/lib/action_cable/server/tagged_logger_proxy.rb +46 -0
- data/lib/action_cable/server/worker/active_record_connection_management.rb +23 -0
- data/lib/action_cable/server/worker.rb +75 -0
- data/lib/action_cable/subscription_adapter/async.rb +14 -0
- data/lib/action_cable/subscription_adapter/base.rb +39 -0
- data/lib/action_cable/subscription_adapter/channel_prefix.rb +30 -0
- data/lib/action_cable/subscription_adapter/inline.rb +40 -0
- data/lib/action_cable/subscription_adapter/postgresql.rb +130 -0
- data/lib/action_cable/subscription_adapter/redis.rb +257 -0
- data/lib/action_cable/subscription_adapter/subscriber_map.rb +80 -0
- data/lib/action_cable/subscription_adapter/test.rb +41 -0
- data/lib/action_cable/test_case.rb +13 -0
- data/lib/action_cable/test_helper.rb +163 -0
- data/lib/action_cable/version.rb +12 -0
- data/lib/action_cable.rb +81 -0
- data/lib/actioncable-next.rb +5 -0
- data/lib/rails/generators/channel/USAGE +19 -0
- data/lib/rails/generators/channel/channel_generator.rb +127 -0
- data/lib/rails/generators/channel/templates/application_cable/channel.rb.tt +4 -0
- data/lib/rails/generators/channel/templates/application_cable/connection.rb.tt +4 -0
- data/lib/rails/generators/channel/templates/channel.rb.tt +16 -0
- data/lib/rails/generators/channel/templates/javascript/channel.js.tt +20 -0
- data/lib/rails/generators/channel/templates/javascript/consumer.js.tt +6 -0
- data/lib/rails/generators/channel/templates/javascript/index.js.tt +1 -0
- data/lib/rails/generators/test_unit/channel_generator.rb +22 -0
- data/lib/rails/generators/test_unit/templates/channel_test.rb.tt +8 -0
- metadata +191 -0
@@ -0,0 +1,165 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# :markup: markdown
|
4
|
+
|
5
|
+
require "active_support/rescuable"
|
6
|
+
|
7
|
+
module ActionCable
|
8
|
+
module Connection
|
9
|
+
# # Action Cable Connection Base
|
10
|
+
#
|
11
|
+
# For every WebSocket connection the Action Cable server accepts, a Connection
|
12
|
+
# object will be instantiated. This instance becomes the parent of all of the
|
13
|
+
# channel subscriptions that are created from there on. Incoming messages are
|
14
|
+
# then routed to these channel subscriptions based on an identifier sent by the
|
15
|
+
# Action Cable consumer. The Connection itself does not deal with any specific
|
16
|
+
# application logic beyond authentication and authorization.
|
17
|
+
#
|
18
|
+
# Here's a basic example:
|
19
|
+
#
|
20
|
+
# module ApplicationCable
|
21
|
+
# class Connection < ActionCable::Connection::Base
|
22
|
+
# identified_by :current_user
|
23
|
+
#
|
24
|
+
# def connect
|
25
|
+
# self.current_user = find_verified_user
|
26
|
+
# logger.add_tags current_user.name
|
27
|
+
# end
|
28
|
+
#
|
29
|
+
# def disconnect
|
30
|
+
# # Any cleanup work needed when the cable connection is cut.
|
31
|
+
# end
|
32
|
+
#
|
33
|
+
# private
|
34
|
+
# def find_verified_user
|
35
|
+
# User.find_by_identity(cookies.encrypted[:identity_id]) ||
|
36
|
+
# reject_unauthorized_connection
|
37
|
+
# end
|
38
|
+
# end
|
39
|
+
# end
|
40
|
+
#
|
41
|
+
# First, we declare that this connection can be identified by its current_user.
|
42
|
+
# This allows us to later be able to find all connections established for that
|
43
|
+
# current_user (and potentially disconnect them). You can declare as many
|
44
|
+
# identification indexes as you like. Declaring an identification means that an
|
45
|
+
# attr_accessor is automatically set for that key.
|
46
|
+
#
|
47
|
+
# Second, we rely on the fact that the WebSocket connection is established with
|
48
|
+
# the cookies from the domain being sent along. This makes it easy to use signed
|
49
|
+
# cookies that were set when logging in via a web interface to authorize the
|
50
|
+
# WebSocket connection.
|
51
|
+
#
|
52
|
+
# Finally, we add a tag to the connection-specific logger with the name of the
|
53
|
+
# current user to easily distinguish their messages in the log.
|
54
|
+
#
|
55
|
+
class Base
|
56
|
+
include Identification
|
57
|
+
include InternalChannel
|
58
|
+
include Authorization
|
59
|
+
include Callbacks
|
60
|
+
include ActiveSupport::Rescuable
|
61
|
+
|
62
|
+
attr_reader :subscriptions, :logger
|
63
|
+
private attr_reader :server, :socket
|
64
|
+
|
65
|
+
delegate :pubsub, :executor, :config, to: :server
|
66
|
+
delegate :env, :request, :protocol, :perform_work, to: :socket, allow_nil: true
|
67
|
+
|
68
|
+
def initialize(server, socket)
|
69
|
+
@server = server
|
70
|
+
@socket = socket
|
71
|
+
|
72
|
+
@logger = socket.logger
|
73
|
+
@subscriptions = Subscriptions.new(self)
|
74
|
+
|
75
|
+
@_internal_subscriptions = nil
|
76
|
+
|
77
|
+
@started_at = Time.now
|
78
|
+
end
|
79
|
+
|
80
|
+
# This method is called every time an Action Cable client establishes an underlying connection.
|
81
|
+
# Override it in your class to define authentication logic and
|
82
|
+
# populate connection identifiers.
|
83
|
+
def connect
|
84
|
+
end
|
85
|
+
|
86
|
+
# This method is called every time an Action Cable client disconnects.
|
87
|
+
# Override it in your class to cleanup the relevant application state (e.g., presence, online counts, etc.)
|
88
|
+
def disconnect
|
89
|
+
end
|
90
|
+
|
91
|
+
def handle_open
|
92
|
+
connect
|
93
|
+
subscribe_to_internal_channel
|
94
|
+
send_welcome_message
|
95
|
+
rescue ActionCable::Connection::Authorization::UnauthorizedError
|
96
|
+
close(reason: ActionCable::INTERNAL[:disconnect_reasons][:unauthorized], reconnect: false)
|
97
|
+
end
|
98
|
+
|
99
|
+
def handle_close
|
100
|
+
subscriptions.unsubscribe_from_all
|
101
|
+
unsubscribe_from_internal_channel
|
102
|
+
|
103
|
+
disconnect
|
104
|
+
end
|
105
|
+
|
106
|
+
def handle_channel_command(payload)
|
107
|
+
run_callbacks :command do
|
108
|
+
subscriptions.execute_command payload
|
109
|
+
end
|
110
|
+
rescue Exception => e
|
111
|
+
rescue_with_handler(e) || raise
|
112
|
+
end
|
113
|
+
|
114
|
+
alias_method :handle_incoming, :handle_channel_command
|
115
|
+
|
116
|
+
def transmit(data) # :nodoc:
|
117
|
+
socket.transmit(data)
|
118
|
+
end
|
119
|
+
|
120
|
+
# Close the connection.
|
121
|
+
def close(reason: nil, reconnect: true)
|
122
|
+
transmit(
|
123
|
+
type: ActionCable::INTERNAL[:message_types][:disconnect],
|
124
|
+
reason: reason,
|
125
|
+
reconnect: reconnect
|
126
|
+
)
|
127
|
+
socket.close
|
128
|
+
end
|
129
|
+
|
130
|
+
# Return a basic hash of statistics for the connection keyed with `identifier`,
|
131
|
+
# `started_at`, `subscriptions`, and `request_id`. This can be returned by a
|
132
|
+
# health check against the connection.
|
133
|
+
def statistics
|
134
|
+
{
|
135
|
+
identifier: connection_identifier,
|
136
|
+
started_at: @started_at,
|
137
|
+
subscriptions: subscriptions.identifiers,
|
138
|
+
request_id: env["action_dispatch.request_id"]
|
139
|
+
}
|
140
|
+
end
|
141
|
+
|
142
|
+
def beat
|
143
|
+
transmit type: ActionCable::INTERNAL[:message_types][:ping], message: Time.now.to_i
|
144
|
+
end
|
145
|
+
|
146
|
+
def inspect # :nodoc:
|
147
|
+
"#<#{self.class.name}:#{'%#016x' % (object_id << 1)}>"
|
148
|
+
end
|
149
|
+
|
150
|
+
private
|
151
|
+
# The cookies of the request that initiated the WebSocket connection. Useful for performing authorization checks.
|
152
|
+
def cookies # :doc:
|
153
|
+
request.cookie_jar
|
154
|
+
end
|
155
|
+
|
156
|
+
def send_welcome_message
|
157
|
+
# Send welcome message to the internal connection monitor channel. This ensures
|
158
|
+
# the connection monitor state is reset after a successful websocket connection.
|
159
|
+
transmit type: ActionCable::INTERNAL[:message_types][:welcome]
|
160
|
+
end
|
161
|
+
end
|
162
|
+
end
|
163
|
+
end
|
164
|
+
|
165
|
+
ActiveSupport.run_load_hooks(:action_cable_connection, ActionCable::Connection::Base)
|
@@ -0,0 +1,57 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# :markup: markdown
|
4
|
+
|
5
|
+
require "active_support/callbacks"
|
6
|
+
|
7
|
+
module ActionCable
|
8
|
+
module Connection
|
9
|
+
# # Action Cable Connection Callbacks
|
10
|
+
#
|
11
|
+
# The [before_command](rdoc-ref:ClassMethods#before_command),
|
12
|
+
# [after_command](rdoc-ref:ClassMethods#after_command), and
|
13
|
+
# [around_command](rdoc-ref:ClassMethods#around_command) callbacks are invoked
|
14
|
+
# when sending commands to the client, such as when subscribing, unsubscribing,
|
15
|
+
# or performing an action.
|
16
|
+
#
|
17
|
+
# #### Example
|
18
|
+
#
|
19
|
+
# module ApplicationCable
|
20
|
+
# class Connection < ActionCable::Connection::Base
|
21
|
+
# identified_by :user
|
22
|
+
#
|
23
|
+
# around_command :set_current_account
|
24
|
+
#
|
25
|
+
# private
|
26
|
+
#
|
27
|
+
# def set_current_account
|
28
|
+
# # Now all channels could use Current.account
|
29
|
+
# Current.set(account: user.account) { yield }
|
30
|
+
# end
|
31
|
+
# end
|
32
|
+
# end
|
33
|
+
#
|
34
|
+
module Callbacks
|
35
|
+
extend ActiveSupport::Concern
|
36
|
+
include ActiveSupport::Callbacks
|
37
|
+
|
38
|
+
included do
|
39
|
+
define_callbacks :command
|
40
|
+
end
|
41
|
+
|
42
|
+
module ClassMethods
|
43
|
+
def before_command(*methods, &block)
|
44
|
+
set_callback(:command, :before, *methods, &block)
|
45
|
+
end
|
46
|
+
|
47
|
+
def after_command(*methods, &block)
|
48
|
+
set_callback(:command, :after, *methods, &block)
|
49
|
+
end
|
50
|
+
|
51
|
+
def around_command(*methods, &block)
|
52
|
+
set_callback(:command, :around, *methods, &block)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# :markup: markdown
|
4
|
+
|
5
|
+
require "set"
|
6
|
+
|
7
|
+
module ActionCable
|
8
|
+
module Connection
|
9
|
+
module Identification
|
10
|
+
extend ActiveSupport::Concern
|
11
|
+
|
12
|
+
included do
|
13
|
+
class_attribute :identifiers, default: Set.new
|
14
|
+
end
|
15
|
+
|
16
|
+
module ClassMethods
|
17
|
+
# Mark a key as being a connection identifier index that can then be used to
|
18
|
+
# find the specific connection again later. Common identifiers are current_user
|
19
|
+
# and current_account, but could be anything, really.
|
20
|
+
#
|
21
|
+
# Note that anything marked as an identifier will automatically create a
|
22
|
+
# delegate by the same name on any channel instances created off the connection.
|
23
|
+
def identified_by(*identifiers)
|
24
|
+
Array(identifiers).each { |identifier| attr_accessor identifier }
|
25
|
+
self.identifiers += identifiers
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
# Return a single connection identifier that combines the value of all the
|
30
|
+
# registered identifiers into a single gid.
|
31
|
+
def connection_identifier
|
32
|
+
unless defined? @connection_identifier
|
33
|
+
@connection_identifier = connection_gid identifiers.filter_map { |id| instance_variable_get("@#{id}") }
|
34
|
+
end
|
35
|
+
|
36
|
+
@connection_identifier
|
37
|
+
end
|
38
|
+
|
39
|
+
private
|
40
|
+
def connection_gid(ids)
|
41
|
+
ids.map do |o|
|
42
|
+
if o.respond_to? :to_gid_param
|
43
|
+
o.to_gid_param
|
44
|
+
else
|
45
|
+
o.to_s
|
46
|
+
end
|
47
|
+
end.sort.join(":")
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# :markup: markdown
|
4
|
+
|
5
|
+
module ActionCable
|
6
|
+
module Connection
|
7
|
+
# # Action Cable InternalChannel
|
8
|
+
#
|
9
|
+
# Makes it possible for the RemoteConnection to disconnect a specific
|
10
|
+
# connection.
|
11
|
+
module InternalChannel
|
12
|
+
extend ActiveSupport::Concern
|
13
|
+
|
14
|
+
private
|
15
|
+
def internal_channel
|
16
|
+
"action_cable/#{connection_identifier}"
|
17
|
+
end
|
18
|
+
|
19
|
+
def subscribe_to_internal_channel
|
20
|
+
if connection_identifier.present?
|
21
|
+
callback = -> (message) { process_internal_message ActiveSupport::JSON.decode(message) }
|
22
|
+
@_internal_subscriptions ||= []
|
23
|
+
@_internal_subscriptions << [ internal_channel, callback ]
|
24
|
+
|
25
|
+
pubsub.subscribe(internal_channel, callback)
|
26
|
+
logger.info "Registered connection (#{connection_identifier})"
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def unsubscribe_from_internal_channel
|
31
|
+
if @_internal_subscriptions.present?
|
32
|
+
@_internal_subscriptions.each { |channel, callback| pubsub.unsubscribe(channel, callback) }
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def process_internal_message(message)
|
37
|
+
case message["type"]
|
38
|
+
when "disconnect"
|
39
|
+
logger.info "Removing connection (#{connection_identifier})"
|
40
|
+
close(reason: ActionCable::INTERNAL[:disconnect_reasons][:remote], reconnect: message.fetch("reconnect", true))
|
41
|
+
end
|
42
|
+
rescue Exception => e
|
43
|
+
logger.error "There was an exception - #{e.class}(#{e.message})"
|
44
|
+
logger.error e.backtrace.join("\n")
|
45
|
+
|
46
|
+
close
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,124 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# :markup: markdown
|
4
|
+
|
5
|
+
require "active_support/core_ext/hash/indifferent_access"
|
6
|
+
|
7
|
+
module ActionCable
|
8
|
+
module Connection
|
9
|
+
# # Action Cable Connection Subscriptions
|
10
|
+
#
|
11
|
+
# Collection class for all the channel subscriptions established on a given
|
12
|
+
# connection. Responsible for routing incoming commands that arrive on the
|
13
|
+
# connection to the proper channel.
|
14
|
+
class Subscriptions # :nodoc:
|
15
|
+
class Error < StandardError; end
|
16
|
+
|
17
|
+
class AlreadySubscribedError < Error
|
18
|
+
def initialize(identifier)
|
19
|
+
super "Already subscribed to #{identifier}"
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
class ChannelNotFound < Error
|
24
|
+
def initialize(channel_id)
|
25
|
+
super "Channel not found: #{channel_id}"
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
class MalformedCommandError < Error
|
30
|
+
def initialize(data)
|
31
|
+
super "Malformed command: #{data.inspect}"
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
class UnknownCommandError < Error
|
36
|
+
def initialize(command)
|
37
|
+
super "Received unrecognized command: #{command}"
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
class UnknownSubscription < Error
|
42
|
+
def initialize(identifier)
|
43
|
+
"Unable to find subscription with identifier: #{identifier}"
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def initialize(connection)
|
48
|
+
@connection = connection
|
49
|
+
@subscriptions = {}
|
50
|
+
end
|
51
|
+
|
52
|
+
def execute_command(data)
|
53
|
+
case data["command"]
|
54
|
+
when "subscribe" then add data
|
55
|
+
when "unsubscribe" then remove data
|
56
|
+
when "message" then perform_action data
|
57
|
+
else
|
58
|
+
raise UnknownCommandError, data["command"]
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
def add(data)
|
63
|
+
id_key = data["identifier"]
|
64
|
+
|
65
|
+
raise MalformedCommandError, data unless id_key.present?
|
66
|
+
|
67
|
+
raise AlreadySubscribedError, id_key if subscriptions.key?(id_key)
|
68
|
+
|
69
|
+
subscription = subscription_from_identifier(id_key)
|
70
|
+
|
71
|
+
if subscription
|
72
|
+
subscriptions[id_key] = subscription
|
73
|
+
subscription.subscribe_to_channel
|
74
|
+
else
|
75
|
+
id_options = ActiveSupport::JSON.decode(id_key).with_indifferent_access
|
76
|
+
raise ChannelNotFound, id_options[:channel]
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
def remove(data)
|
81
|
+
logger.info "Unsubscribing from channel: #{data['identifier']}"
|
82
|
+
remove_subscription find(data)
|
83
|
+
end
|
84
|
+
|
85
|
+
def remove_subscription(subscription)
|
86
|
+
subscription.unsubscribe_from_channel
|
87
|
+
subscriptions.delete(subscription.identifier)
|
88
|
+
end
|
89
|
+
|
90
|
+
def perform_action(data)
|
91
|
+
find(data).perform_action ActiveSupport::JSON.decode(data["data"])
|
92
|
+
end
|
93
|
+
|
94
|
+
def identifiers
|
95
|
+
subscriptions.keys
|
96
|
+
end
|
97
|
+
|
98
|
+
def unsubscribe_from_all
|
99
|
+
subscriptions.each { |id, channel| remove_subscription(channel) }
|
100
|
+
end
|
101
|
+
|
102
|
+
private
|
103
|
+
attr_reader :connection, :subscriptions
|
104
|
+
delegate :logger, to: :connection
|
105
|
+
|
106
|
+
def find(data)
|
107
|
+
if subscription = subscriptions[data["identifier"]]
|
108
|
+
subscription
|
109
|
+
else
|
110
|
+
raise UnknownSubscription, data["identifier"]
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
def subscription_from_identifier(id_key)
|
115
|
+
id_options = ActiveSupport::JSON.decode(id_key).with_indifferent_access
|
116
|
+
subscription_klass = id_options[:channel].safe_constantize
|
117
|
+
|
118
|
+
if subscription_klass && ActionCable::Channel::Base > subscription_klass
|
119
|
+
subscription_klass.new(connection, id_key, id_options)
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|