actioncable 6.0.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 +169 -0
- data/MIT-LICENSE +20 -0
- data/README.md +24 -0
- data/app/assets/javascripts/action_cable.js +517 -0
- data/lib/action_cable.rb +62 -0
- data/lib/action_cable/channel.rb +17 -0
- data/lib/action_cable/channel/base.rb +311 -0
- data/lib/action_cable/channel/broadcasting.rb +41 -0
- data/lib/action_cable/channel/callbacks.rb +37 -0
- data/lib/action_cable/channel/naming.rb +25 -0
- data/lib/action_cable/channel/periodic_timers.rb +78 -0
- data/lib/action_cable/channel/streams.rb +176 -0
- data/lib/action_cable/channel/test_case.rb +310 -0
- data/lib/action_cable/connection.rb +22 -0
- data/lib/action_cable/connection/authorization.rb +15 -0
- data/lib/action_cable/connection/base.rb +264 -0
- data/lib/action_cable/connection/client_socket.rb +157 -0
- data/lib/action_cable/connection/identification.rb +47 -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/stream.rb +117 -0
- data/lib/action_cable/connection/stream_event_loop.rb +136 -0
- data/lib/action_cable/connection/subscriptions.rb +79 -0
- data/lib/action_cable/connection/tagged_logger_proxy.rb +42 -0
- data/lib/action_cable/connection/test_case.rb +234 -0
- data/lib/action_cable/connection/web_socket.rb +41 -0
- data/lib/action_cable/engine.rb +79 -0
- data/lib/action_cable/gem_version.rb +17 -0
- data/lib/action_cable/helpers/action_cable_helper.rb +42 -0
- data/lib/action_cable/remote_connections.rb +71 -0
- data/lib/action_cable/server.rb +17 -0
- data/lib/action_cable/server/base.rb +94 -0
- data/lib/action_cable/server/broadcasting.rb +54 -0
- data/lib/action_cable/server/configuration.rb +56 -0
- data/lib/action_cable/server/connections.rb +36 -0
- data/lib/action_cable/server/worker.rb +75 -0
- data/lib/action_cable/server/worker/active_record_connection_management.rb +21 -0
- data/lib/action_cable/subscription_adapter.rb +12 -0
- data/lib/action_cable/subscription_adapter/async.rb +29 -0
- data/lib/action_cable/subscription_adapter/base.rb +30 -0
- data/lib/action_cable/subscription_adapter/channel_prefix.rb +28 -0
- data/lib/action_cable/subscription_adapter/inline.rb +37 -0
- data/lib/action_cable/subscription_adapter/postgresql.rb +132 -0
- data/lib/action_cable/subscription_adapter/redis.rb +181 -0
- data/lib/action_cable/subscription_adapter/subscriber_map.rb +59 -0
- data/lib/action_cable/subscription_adapter/test.rb +40 -0
- data/lib/action_cable/test_case.rb +11 -0
- data/lib/action_cable/test_helper.rb +133 -0
- data/lib/action_cable/version.rb +10 -0
- data/lib/rails/generators/channel/USAGE +13 -0
- data/lib/rails/generators/channel/channel_generator.rb +52 -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 +5 -0
- data/lib/rails/generators/test_unit/channel_generator.rb +20 -0
- data/lib/rails/generators/test_unit/templates/channel_test.rb.tt +8 -0
- metadata +149 -0
@@ -0,0 +1,47 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "set"
|
4
|
+
|
5
|
+
module ActionCable
|
6
|
+
module Connection
|
7
|
+
module Identification
|
8
|
+
extend ActiveSupport::Concern
|
9
|
+
|
10
|
+
included do
|
11
|
+
class_attribute :identifiers, default: Set.new
|
12
|
+
end
|
13
|
+
|
14
|
+
module ClassMethods
|
15
|
+
# Mark a key as being a connection identifier index that can then be used to find the specific connection again later.
|
16
|
+
# Common identifiers are current_user and current_account, but could be anything, really.
|
17
|
+
#
|
18
|
+
# Note that anything marked as an identifier will automatically create a delegate by the same name on any
|
19
|
+
# channel instances created off the connection.
|
20
|
+
def identified_by(*identifiers)
|
21
|
+
Array(identifiers).each { |identifier| attr_accessor identifier }
|
22
|
+
self.identifiers += identifiers
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
# Return a single connection identifier that combines the value of all the registered identifiers into a single gid.
|
27
|
+
def connection_identifier
|
28
|
+
unless defined? @connection_identifier
|
29
|
+
@connection_identifier = connection_gid identifiers.map { |id| instance_variable_get("@#{id}") }.compact
|
30
|
+
end
|
31
|
+
|
32
|
+
@connection_identifier
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
def connection_gid(ids)
|
37
|
+
ids.map do |o|
|
38
|
+
if o.respond_to? :to_gid_param
|
39
|
+
o.to_gid_param
|
40
|
+
else
|
41
|
+
o.to_s
|
42
|
+
end
|
43
|
+
end.sort.join(":")
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionCable
|
4
|
+
module Connection
|
5
|
+
# Makes it possible for the RemoteConnection to disconnect a specific connection.
|
6
|
+
module InternalChannel
|
7
|
+
extend ActiveSupport::Concern
|
8
|
+
|
9
|
+
private
|
10
|
+
def internal_channel
|
11
|
+
"action_cable/#{connection_identifier}"
|
12
|
+
end
|
13
|
+
|
14
|
+
def subscribe_to_internal_channel
|
15
|
+
if connection_identifier.present?
|
16
|
+
callback = -> (message) { process_internal_message decode(message) }
|
17
|
+
@_internal_subscriptions ||= []
|
18
|
+
@_internal_subscriptions << [ internal_channel, callback ]
|
19
|
+
|
20
|
+
server.event_loop.post { pubsub.subscribe(internal_channel, callback) }
|
21
|
+
logger.info "Registered connection (#{connection_identifier})"
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def unsubscribe_from_internal_channel
|
26
|
+
if @_internal_subscriptions.present?
|
27
|
+
@_internal_subscriptions.each { |channel, callback| server.event_loop.post { pubsub.unsubscribe(channel, callback) } }
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def process_internal_message(message)
|
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
|
@@ -0,0 +1,54 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionCable
|
4
|
+
module Connection
|
5
|
+
# Allows us to buffer messages received from the WebSocket before the Connection has been fully initialized, and is ready to receive them.
|
6
|
+
class MessageBuffer # :nodoc:
|
7
|
+
def initialize(connection)
|
8
|
+
@connection = connection
|
9
|
+
@buffered_messages = []
|
10
|
+
end
|
11
|
+
|
12
|
+
def append(message)
|
13
|
+
if valid? message
|
14
|
+
if processing?
|
15
|
+
receive message
|
16
|
+
else
|
17
|
+
buffer message
|
18
|
+
end
|
19
|
+
else
|
20
|
+
connection.logger.error "Couldn't handle non-string message: #{message.class}"
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def processing?
|
25
|
+
@processing
|
26
|
+
end
|
27
|
+
|
28
|
+
def process!
|
29
|
+
@processing = true
|
30
|
+
receive_buffered_messages
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
attr_reader :connection
|
35
|
+
attr_reader :buffered_messages
|
36
|
+
|
37
|
+
def valid?(message)
|
38
|
+
message.is_a?(String)
|
39
|
+
end
|
40
|
+
|
41
|
+
def receive(message)
|
42
|
+
connection.receive message
|
43
|
+
end
|
44
|
+
|
45
|
+
def buffer(message)
|
46
|
+
buffered_messages << message
|
47
|
+
end
|
48
|
+
|
49
|
+
def receive_buffered_messages
|
50
|
+
receive buffered_messages.shift until buffered_messages.empty?
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
@@ -0,0 +1,117 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "thread"
|
4
|
+
|
5
|
+
module ActionCable
|
6
|
+
module Connection
|
7
|
+
#--
|
8
|
+
# This class is heavily based on faye-websocket-ruby
|
9
|
+
#
|
10
|
+
# Copyright (c) 2010-2015 James Coglan
|
11
|
+
class Stream # :nodoc:
|
12
|
+
def initialize(event_loop, socket)
|
13
|
+
@event_loop = event_loop
|
14
|
+
@socket_object = socket
|
15
|
+
@stream_send = socket.env["stream.send"]
|
16
|
+
|
17
|
+
@rack_hijack_io = nil
|
18
|
+
@write_lock = Mutex.new
|
19
|
+
|
20
|
+
@write_head = nil
|
21
|
+
@write_buffer = Queue.new
|
22
|
+
end
|
23
|
+
|
24
|
+
def each(&callback)
|
25
|
+
@stream_send ||= callback
|
26
|
+
end
|
27
|
+
|
28
|
+
def close
|
29
|
+
shutdown
|
30
|
+
@socket_object.client_gone
|
31
|
+
end
|
32
|
+
|
33
|
+
def shutdown
|
34
|
+
clean_rack_hijack
|
35
|
+
end
|
36
|
+
|
37
|
+
def write(data)
|
38
|
+
if @stream_send
|
39
|
+
return @stream_send.call(data)
|
40
|
+
end
|
41
|
+
|
42
|
+
if @write_lock.try_lock
|
43
|
+
begin
|
44
|
+
if @write_head.nil? && @write_buffer.empty?
|
45
|
+
written = @rack_hijack_io.write_nonblock(data, exception: false)
|
46
|
+
|
47
|
+
case written
|
48
|
+
when :wait_writable
|
49
|
+
# proceed below
|
50
|
+
when data.bytesize
|
51
|
+
return data.bytesize
|
52
|
+
else
|
53
|
+
@write_head = data.byteslice(written, data.bytesize)
|
54
|
+
@event_loop.writes_pending @rack_hijack_io
|
55
|
+
|
56
|
+
return data.bytesize
|
57
|
+
end
|
58
|
+
end
|
59
|
+
ensure
|
60
|
+
@write_lock.unlock
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
@write_buffer << data
|
65
|
+
@event_loop.writes_pending @rack_hijack_io
|
66
|
+
|
67
|
+
data.bytesize
|
68
|
+
rescue EOFError, Errno::ECONNRESET
|
69
|
+
@socket_object.client_gone
|
70
|
+
end
|
71
|
+
|
72
|
+
def flush_write_buffer
|
73
|
+
@write_lock.synchronize do
|
74
|
+
loop do
|
75
|
+
if @write_head.nil?
|
76
|
+
return true if @write_buffer.empty?
|
77
|
+
@write_head = @write_buffer.pop
|
78
|
+
end
|
79
|
+
|
80
|
+
written = @rack_hijack_io.write_nonblock(@write_head, exception: false)
|
81
|
+
case written
|
82
|
+
when :wait_writable
|
83
|
+
return false
|
84
|
+
when @write_head.bytesize
|
85
|
+
@write_head = nil
|
86
|
+
else
|
87
|
+
@write_head = @write_head.byteslice(written, @write_head.bytesize)
|
88
|
+
return false
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
def receive(data)
|
95
|
+
@socket_object.parse(data)
|
96
|
+
end
|
97
|
+
|
98
|
+
def hijack_rack_socket
|
99
|
+
return unless @socket_object.env["rack.hijack"]
|
100
|
+
|
101
|
+
# This should return the underlying io according to the SPEC:
|
102
|
+
@rack_hijack_io = @socket_object.env["rack.hijack"].call
|
103
|
+
# Retain existing behaviour if required:
|
104
|
+
@rack_hijack_io ||= @socket_object.env["rack.hijack_io"]
|
105
|
+
|
106
|
+
@event_loop.attach(@rack_hijack_io, self)
|
107
|
+
end
|
108
|
+
|
109
|
+
private
|
110
|
+
def clean_rack_hijack
|
111
|
+
return unless @rack_hijack_io
|
112
|
+
@event_loop.detach(@rack_hijack_io, self)
|
113
|
+
@rack_hijack_io = nil
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|
@@ -0,0 +1,136 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "nio"
|
4
|
+
require "thread"
|
5
|
+
|
6
|
+
module ActionCable
|
7
|
+
module Connection
|
8
|
+
class StreamEventLoop
|
9
|
+
def initialize
|
10
|
+
@nio = @executor = @thread = nil
|
11
|
+
@map = {}
|
12
|
+
@stopping = false
|
13
|
+
@todo = Queue.new
|
14
|
+
|
15
|
+
@spawn_mutex = Mutex.new
|
16
|
+
end
|
17
|
+
|
18
|
+
def timer(interval, &block)
|
19
|
+
Concurrent::TimerTask.new(execution_interval: interval, &block).tap(&:execute)
|
20
|
+
end
|
21
|
+
|
22
|
+
def post(task = nil, &block)
|
23
|
+
task ||= block
|
24
|
+
|
25
|
+
spawn
|
26
|
+
@executor << task
|
27
|
+
end
|
28
|
+
|
29
|
+
def attach(io, stream)
|
30
|
+
@todo << lambda do
|
31
|
+
@map[io] = @nio.register(io, :r)
|
32
|
+
@map[io].value = stream
|
33
|
+
end
|
34
|
+
wakeup
|
35
|
+
end
|
36
|
+
|
37
|
+
def detach(io, stream)
|
38
|
+
@todo << lambda do
|
39
|
+
@nio.deregister io
|
40
|
+
@map.delete io
|
41
|
+
io.close
|
42
|
+
end
|
43
|
+
wakeup
|
44
|
+
end
|
45
|
+
|
46
|
+
def writes_pending(io)
|
47
|
+
@todo << lambda do
|
48
|
+
if monitor = @map[io]
|
49
|
+
monitor.interests = :rw
|
50
|
+
end
|
51
|
+
end
|
52
|
+
wakeup
|
53
|
+
end
|
54
|
+
|
55
|
+
def stop
|
56
|
+
@stopping = true
|
57
|
+
wakeup if @nio
|
58
|
+
end
|
59
|
+
|
60
|
+
private
|
61
|
+
def spawn
|
62
|
+
return if @thread && @thread.status
|
63
|
+
|
64
|
+
@spawn_mutex.synchronize do
|
65
|
+
return if @thread && @thread.status
|
66
|
+
|
67
|
+
@nio ||= NIO::Selector.new
|
68
|
+
|
69
|
+
@executor ||= Concurrent::ThreadPoolExecutor.new(
|
70
|
+
min_threads: 1,
|
71
|
+
max_threads: 10,
|
72
|
+
max_queue: 0,
|
73
|
+
)
|
74
|
+
|
75
|
+
@thread = Thread.new { run }
|
76
|
+
|
77
|
+
return true
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
def wakeup
|
82
|
+
spawn || @nio.wakeup
|
83
|
+
end
|
84
|
+
|
85
|
+
def run
|
86
|
+
loop do
|
87
|
+
if @stopping
|
88
|
+
@nio.close
|
89
|
+
break
|
90
|
+
end
|
91
|
+
|
92
|
+
until @todo.empty?
|
93
|
+
@todo.pop(true).call
|
94
|
+
end
|
95
|
+
|
96
|
+
next unless monitors = @nio.select
|
97
|
+
|
98
|
+
monitors.each do |monitor|
|
99
|
+
io = monitor.io
|
100
|
+
stream = monitor.value
|
101
|
+
|
102
|
+
begin
|
103
|
+
if monitor.writable?
|
104
|
+
if stream.flush_write_buffer
|
105
|
+
monitor.interests = :r
|
106
|
+
end
|
107
|
+
next unless monitor.readable?
|
108
|
+
end
|
109
|
+
|
110
|
+
incoming = io.read_nonblock(4096, exception: false)
|
111
|
+
case incoming
|
112
|
+
when :wait_readable
|
113
|
+
next
|
114
|
+
when nil
|
115
|
+
stream.close
|
116
|
+
else
|
117
|
+
stream.receive incoming
|
118
|
+
end
|
119
|
+
rescue
|
120
|
+
# We expect one of EOFError or Errno::ECONNRESET in
|
121
|
+
# normal operation (when the client goes away). But if
|
122
|
+
# anything else goes wrong, this is still the best way
|
123
|
+
# to handle it.
|
124
|
+
begin
|
125
|
+
stream.close
|
126
|
+
rescue
|
127
|
+
@nio.deregister io
|
128
|
+
@map.delete io
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|
@@ -0,0 +1,79 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_support/core_ext/hash/indifferent_access"
|
4
|
+
|
5
|
+
module ActionCable
|
6
|
+
module Connection
|
7
|
+
# Collection class for all the channel subscriptions established on a given connection. Responsible for routing incoming commands that arrive on
|
8
|
+
# the connection to the proper channel.
|
9
|
+
class Subscriptions # :nodoc:
|
10
|
+
def initialize(connection)
|
11
|
+
@connection = connection
|
12
|
+
@subscriptions = {}
|
13
|
+
end
|
14
|
+
|
15
|
+
def execute_command(data)
|
16
|
+
case data["command"]
|
17
|
+
when "subscribe" then add data
|
18
|
+
when "unsubscribe" then remove data
|
19
|
+
when "message" then perform_action data
|
20
|
+
else
|
21
|
+
logger.error "Received unrecognized command in #{data.inspect}"
|
22
|
+
end
|
23
|
+
rescue Exception => e
|
24
|
+
logger.error "Could not execute command from (#{data.inspect}) [#{e.class} - #{e.message}]: #{e.backtrace.first(5).join(" | ")}"
|
25
|
+
end
|
26
|
+
|
27
|
+
def add(data)
|
28
|
+
id_key = data["identifier"]
|
29
|
+
id_options = ActiveSupport::JSON.decode(id_key).with_indifferent_access
|
30
|
+
|
31
|
+
return if subscriptions.key?(id_key)
|
32
|
+
|
33
|
+
subscription_klass = id_options[:channel].safe_constantize
|
34
|
+
|
35
|
+
if subscription_klass && ActionCable::Channel::Base >= subscription_klass
|
36
|
+
subscription = subscription_klass.new(connection, id_key, id_options)
|
37
|
+
subscriptions[id_key] = subscription
|
38
|
+
subscription.subscribe_to_channel
|
39
|
+
else
|
40
|
+
logger.error "Subscription class not found: #{id_options[:channel].inspect}"
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def remove(data)
|
45
|
+
logger.info "Unsubscribing from channel: #{data['identifier']}"
|
46
|
+
remove_subscription find(data)
|
47
|
+
end
|
48
|
+
|
49
|
+
def remove_subscription(subscription)
|
50
|
+
subscription.unsubscribe_from_channel
|
51
|
+
subscriptions.delete(subscription.identifier)
|
52
|
+
end
|
53
|
+
|
54
|
+
def perform_action(data)
|
55
|
+
find(data).perform_action ActiveSupport::JSON.decode(data["data"])
|
56
|
+
end
|
57
|
+
|
58
|
+
def identifiers
|
59
|
+
subscriptions.keys
|
60
|
+
end
|
61
|
+
|
62
|
+
def unsubscribe_from_all
|
63
|
+
subscriptions.each { |id, channel| remove_subscription(channel) }
|
64
|
+
end
|
65
|
+
|
66
|
+
private
|
67
|
+
attr_reader :connection, :subscriptions
|
68
|
+
delegate :logger, to: :connection
|
69
|
+
|
70
|
+
def find(data)
|
71
|
+
if subscription = subscriptions[data["identifier"]]
|
72
|
+
subscription
|
73
|
+
else
|
74
|
+
raise "Unable to find subscription with identifier: #{data['identifier']}"
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|