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.
Files changed (61) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +169 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +24 -0
  5. data/app/assets/javascripts/action_cable.js +517 -0
  6. data/lib/action_cable.rb +62 -0
  7. data/lib/action_cable/channel.rb +17 -0
  8. data/lib/action_cable/channel/base.rb +311 -0
  9. data/lib/action_cable/channel/broadcasting.rb +41 -0
  10. data/lib/action_cable/channel/callbacks.rb +37 -0
  11. data/lib/action_cable/channel/naming.rb +25 -0
  12. data/lib/action_cable/channel/periodic_timers.rb +78 -0
  13. data/lib/action_cable/channel/streams.rb +176 -0
  14. data/lib/action_cable/channel/test_case.rb +310 -0
  15. data/lib/action_cable/connection.rb +22 -0
  16. data/lib/action_cable/connection/authorization.rb +15 -0
  17. data/lib/action_cable/connection/base.rb +264 -0
  18. data/lib/action_cable/connection/client_socket.rb +157 -0
  19. data/lib/action_cable/connection/identification.rb +47 -0
  20. data/lib/action_cable/connection/internal_channel.rb +45 -0
  21. data/lib/action_cable/connection/message_buffer.rb +54 -0
  22. data/lib/action_cable/connection/stream.rb +117 -0
  23. data/lib/action_cable/connection/stream_event_loop.rb +136 -0
  24. data/lib/action_cable/connection/subscriptions.rb +79 -0
  25. data/lib/action_cable/connection/tagged_logger_proxy.rb +42 -0
  26. data/lib/action_cable/connection/test_case.rb +234 -0
  27. data/lib/action_cable/connection/web_socket.rb +41 -0
  28. data/lib/action_cable/engine.rb +79 -0
  29. data/lib/action_cable/gem_version.rb +17 -0
  30. data/lib/action_cable/helpers/action_cable_helper.rb +42 -0
  31. data/lib/action_cable/remote_connections.rb +71 -0
  32. data/lib/action_cable/server.rb +17 -0
  33. data/lib/action_cable/server/base.rb +94 -0
  34. data/lib/action_cable/server/broadcasting.rb +54 -0
  35. data/lib/action_cable/server/configuration.rb +56 -0
  36. data/lib/action_cable/server/connections.rb +36 -0
  37. data/lib/action_cable/server/worker.rb +75 -0
  38. data/lib/action_cable/server/worker/active_record_connection_management.rb +21 -0
  39. data/lib/action_cable/subscription_adapter.rb +12 -0
  40. data/lib/action_cable/subscription_adapter/async.rb +29 -0
  41. data/lib/action_cable/subscription_adapter/base.rb +30 -0
  42. data/lib/action_cable/subscription_adapter/channel_prefix.rb +28 -0
  43. data/lib/action_cable/subscription_adapter/inline.rb +37 -0
  44. data/lib/action_cable/subscription_adapter/postgresql.rb +132 -0
  45. data/lib/action_cable/subscription_adapter/redis.rb +181 -0
  46. data/lib/action_cable/subscription_adapter/subscriber_map.rb +59 -0
  47. data/lib/action_cable/subscription_adapter/test.rb +40 -0
  48. data/lib/action_cable/test_case.rb +11 -0
  49. data/lib/action_cable/test_helper.rb +133 -0
  50. data/lib/action_cable/version.rb +10 -0
  51. data/lib/rails/generators/channel/USAGE +13 -0
  52. data/lib/rails/generators/channel/channel_generator.rb +52 -0
  53. data/lib/rails/generators/channel/templates/application_cable/channel.rb.tt +4 -0
  54. data/lib/rails/generators/channel/templates/application_cable/connection.rb.tt +4 -0
  55. data/lib/rails/generators/channel/templates/channel.rb.tt +16 -0
  56. data/lib/rails/generators/channel/templates/javascript/channel.js.tt +20 -0
  57. data/lib/rails/generators/channel/templates/javascript/consumer.js.tt +6 -0
  58. data/lib/rails/generators/channel/templates/javascript/index.js.tt +5 -0
  59. data/lib/rails/generators/test_unit/channel_generator.rb +20 -0
  60. data/lib/rails/generators/test_unit/templates/channel_test.rb.tt +8 -0
  61. 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