actioncable-next 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (60) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +17 -0
  5. data/lib/action_cable/channel/base.rb +335 -0
  6. data/lib/action_cable/channel/broadcasting.rb +50 -0
  7. data/lib/action_cable/channel/callbacks.rb +76 -0
  8. data/lib/action_cable/channel/naming.rb +28 -0
  9. data/lib/action_cable/channel/periodic_timers.rb +81 -0
  10. data/lib/action_cable/channel/streams.rb +213 -0
  11. data/lib/action_cable/channel/test_case.rb +329 -0
  12. data/lib/action_cable/connection/authorization.rb +18 -0
  13. data/lib/action_cable/connection/base.rb +165 -0
  14. data/lib/action_cable/connection/callbacks.rb +57 -0
  15. data/lib/action_cable/connection/identification.rb +51 -0
  16. data/lib/action_cable/connection/internal_channel.rb +50 -0
  17. data/lib/action_cable/connection/subscriptions.rb +124 -0
  18. data/lib/action_cable/connection/test_case.rb +294 -0
  19. data/lib/action_cable/deprecator.rb +9 -0
  20. data/lib/action_cable/engine.rb +98 -0
  21. data/lib/action_cable/gem_version.rb +19 -0
  22. data/lib/action_cable/helpers/action_cable_helper.rb +45 -0
  23. data/lib/action_cable/remote_connections.rb +82 -0
  24. data/lib/action_cable/server/base.rb +163 -0
  25. data/lib/action_cable/server/broadcasting.rb +62 -0
  26. data/lib/action_cable/server/configuration.rb +75 -0
  27. data/lib/action_cable/server/connections.rb +44 -0
  28. data/lib/action_cable/server/socket/client_socket.rb +159 -0
  29. data/lib/action_cable/server/socket/message_buffer.rb +56 -0
  30. data/lib/action_cable/server/socket/stream.rb +117 -0
  31. data/lib/action_cable/server/socket/web_socket.rb +47 -0
  32. data/lib/action_cable/server/socket.rb +180 -0
  33. data/lib/action_cable/server/stream_event_loop.rb +119 -0
  34. data/lib/action_cable/server/tagged_logger_proxy.rb +46 -0
  35. data/lib/action_cable/server/worker/active_record_connection_management.rb +23 -0
  36. data/lib/action_cable/server/worker.rb +75 -0
  37. data/lib/action_cable/subscription_adapter/async.rb +14 -0
  38. data/lib/action_cable/subscription_adapter/base.rb +39 -0
  39. data/lib/action_cable/subscription_adapter/channel_prefix.rb +30 -0
  40. data/lib/action_cable/subscription_adapter/inline.rb +40 -0
  41. data/lib/action_cable/subscription_adapter/postgresql.rb +130 -0
  42. data/lib/action_cable/subscription_adapter/redis.rb +257 -0
  43. data/lib/action_cable/subscription_adapter/subscriber_map.rb +80 -0
  44. data/lib/action_cable/subscription_adapter/test.rb +41 -0
  45. data/lib/action_cable/test_case.rb +13 -0
  46. data/lib/action_cable/test_helper.rb +163 -0
  47. data/lib/action_cable/version.rb +12 -0
  48. data/lib/action_cable.rb +81 -0
  49. data/lib/actioncable-next.rb +5 -0
  50. data/lib/rails/generators/channel/USAGE +19 -0
  51. data/lib/rails/generators/channel/channel_generator.rb +127 -0
  52. data/lib/rails/generators/channel/templates/application_cable/channel.rb.tt +4 -0
  53. data/lib/rails/generators/channel/templates/application_cable/connection.rb.tt +4 -0
  54. data/lib/rails/generators/channel/templates/channel.rb.tt +16 -0
  55. data/lib/rails/generators/channel/templates/javascript/channel.js.tt +20 -0
  56. data/lib/rails/generators/channel/templates/javascript/consumer.js.tt +6 -0
  57. data/lib/rails/generators/channel/templates/javascript/index.js.tt +1 -0
  58. data/lib/rails/generators/test_unit/channel_generator.rb +22 -0
  59. data/lib/rails/generators/test_unit/templates/channel_test.rb.tt +8 -0
  60. metadata +191 -0
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :markup: markdown
4
+
5
+ require "websocket/driver"
6
+
7
+ module ActionCable
8
+ module Server
9
+ class Socket
10
+ # # Action Cable Connection WebSocket
11
+ #
12
+ # Wrap the real socket to minimize the externally-presented API
13
+ class WebSocket # :nodoc:
14
+ def initialize(env, event_target, event_loop, protocols: ActionCable::INTERNAL[:protocols])
15
+ @websocket = ::WebSocket::Driver.websocket?(env) ? ClientSocket.new(env, event_target, event_loop, protocols) : nil
16
+ end
17
+
18
+ def possible?
19
+ websocket
20
+ end
21
+
22
+ def alive?
23
+ websocket&.alive?
24
+ end
25
+
26
+ def transmit(...)
27
+ websocket&.transmit(...)
28
+ end
29
+
30
+ def close(...)
31
+ websocket&.close(...)
32
+ end
33
+
34
+ def protocol
35
+ websocket&.protocol
36
+ end
37
+
38
+ def rack_response
39
+ websocket&.rack_response
40
+ end
41
+
42
+ private
43
+ attr_reader :websocket
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_dispatch"
4
+
5
+ module ActionCable
6
+ module Server
7
+ # This class encapsulates all the low-level logic of working with the underlying WebSocket conenctions
8
+ # and delegate all the business-logic to the user-level connection object (e.g., ApplicationCable::Connection).
9
+ # This connection object is also responsible for handling encoding and decoding of messages, so the user-level
10
+ # connection object shouldn't know about such details.
11
+ class Socket
12
+ attr_reader :server, :env, :protocol, :logger, :connection
13
+ private attr_reader :worker_pool
14
+
15
+ delegate :event_loop, :pubsub, :config, to: :server
16
+
17
+ def initialize(server, env, coder: ActiveSupport::JSON)
18
+ @server, @env, @coder = server, env, coder
19
+
20
+ @worker_pool = server.worker_pool
21
+ @logger = server.new_tagged_logger { request }
22
+
23
+ @websocket = WebSocket.new(env, self, event_loop)
24
+ @message_buffer = MessageBuffer.new(self)
25
+
26
+ @protocol = nil
27
+ @connection = config.connection_class.call.new(server, self)
28
+ end
29
+
30
+ # Called by the server when a new WebSocket connection is established.
31
+ def process # :nodoc:
32
+ logger.info started_request_message
33
+
34
+ if websocket.possible? && server.allow_request_origin?(env)
35
+ respond_to_successful_request
36
+ else
37
+ respond_to_invalid_request
38
+ end
39
+ end
40
+
41
+ # Methods used by the delegate (i.e., an application connection)
42
+
43
+ # Send a non-serialized message over the WebSocket connection.
44
+ def transmit(cable_message)
45
+ return unless websocket.alive?
46
+
47
+ websocket.transmit encode(cable_message)
48
+ end
49
+
50
+ # Close the WebSocket connection.
51
+ def close(...)
52
+ websocket.close(...) if websocket.alive?
53
+ end
54
+
55
+ # Invoke a method on the connection asynchronously through the pool of thread workers.
56
+ def perform_work(receiver, method, *args)
57
+ worker_pool.async_invoke(receiver, method, *args, connection: self)
58
+ end
59
+
60
+ def send_async(method, *arguments)
61
+ worker_pool.async_invoke(self, method, *arguments)
62
+ end
63
+
64
+ # The request that initiated the WebSocket connection is available here. This gives access to the environment, cookies, etc.
65
+ def request
66
+ @request ||= begin
67
+ environment = Rails.application.env_config.merge(env) if defined?(Rails.application) && Rails.application
68
+ ActionDispatch::Request.new(environment || env)
69
+ end
70
+ end
71
+
72
+ # Decodes WebSocket messages and dispatches them to subscribed channels.
73
+ # WebSocket message transfer encoding is always JSON.
74
+ def receive(websocket_message) # :nodoc:
75
+ send_async :dispatch_websocket_message, websocket_message
76
+ end
77
+
78
+ def dispatch_websocket_message(websocket_message) # :nodoc:
79
+ if websocket.alive?
80
+ @connection.handle_incoming decode(websocket_message)
81
+ else
82
+ logger.error "Ignoring message processed after the WebSocket was closed: #{websocket_message.inspect})"
83
+ end
84
+ rescue Exception => e
85
+ logger.error "Could not handle incoming message: #{websocket_message.inspect} [#{e.class} - #{e.message}]: #{e.backtrace.first(5).join(" | ")}"
86
+ end
87
+
88
+ def on_open # :nodoc:
89
+ send_async :handle_open
90
+ end
91
+
92
+ def on_message(message) # :nodoc:
93
+ message_buffer.append message
94
+ end
95
+
96
+ def on_error(message) # :nodoc:
97
+ # log errors to make diagnosing socket errors easier
98
+ logger.error "WebSocket error occurred: #{message}"
99
+ end
100
+
101
+ def on_close(reason, code) # :nodoc:
102
+ send_async :handle_close
103
+ end
104
+
105
+ def inspect # :nodoc:
106
+ "#<#{self.class.name}:#{'%#016x' % (object_id << 1)}>"
107
+ end
108
+
109
+ private
110
+ attr_reader :websocket
111
+ attr_reader :message_buffer
112
+
113
+ def encode(cable_message)
114
+ @coder.encode cable_message
115
+ end
116
+
117
+ def decode(websocket_message)
118
+ @coder.decode websocket_message
119
+ end
120
+
121
+ def handle_open
122
+ @protocol = websocket.protocol
123
+
124
+ @connection.handle_open
125
+
126
+ message_buffer.process!
127
+ server.add_connection(@connection)
128
+ end
129
+
130
+ def handle_close
131
+ logger.info finished_request_message
132
+
133
+ server.remove_connection(@connection)
134
+ @connection.handle_close
135
+ end
136
+
137
+ def respond_to_successful_request
138
+ logger.info successful_request_message
139
+ websocket.rack_response
140
+ end
141
+
142
+ def respond_to_invalid_request
143
+ close if websocket.alive?
144
+
145
+ logger.error invalid_request_message
146
+ logger.info finished_request_message
147
+ [ 404, { Rack::CONTENT_TYPE => "text/plain; charset=utf-8" }, [ "Page not found" ] ]
148
+ end
149
+
150
+ def started_request_message
151
+ 'Started %s "%s"%s for %s at %s' % [
152
+ request.request_method,
153
+ request.filtered_path,
154
+ websocket.possible? ? " [WebSocket]" : "[non-WebSocket]",
155
+ request.ip,
156
+ Time.now.to_s ]
157
+ end
158
+
159
+ def finished_request_message
160
+ 'Finished "%s"%s for %s at %s' % [
161
+ request.filtered_path,
162
+ websocket.possible? ? " [WebSocket]" : "[non-WebSocket]",
163
+ request.ip,
164
+ Time.now.to_s ]
165
+ end
166
+
167
+ def invalid_request_message
168
+ "Failed to upgrade to WebSocket (REQUEST_METHOD: %s, HTTP_CONNECTION: %s, HTTP_UPGRADE: %s)" % [
169
+ env["REQUEST_METHOD"], env["HTTP_CONNECTION"], env["HTTP_UPGRADE"]
170
+ ]
171
+ end
172
+
173
+ def successful_request_message
174
+ "Successfully upgraded to WebSocket (REQUEST_METHOD: %s, HTTP_CONNECTION: %s, HTTP_UPGRADE: %s)" % [
175
+ env["REQUEST_METHOD"], env["HTTP_CONNECTION"], env["HTTP_UPGRADE"]
176
+ ]
177
+ end
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :markup: markdown
4
+
5
+ require "nio"
6
+
7
+ module ActionCable
8
+ module Server
9
+ class StreamEventLoop
10
+ def initialize
11
+ @nio = @thread = nil
12
+ @map = {}
13
+ @stopping = false
14
+ @todo = Queue.new
15
+
16
+ @spawn_mutex = Mutex.new
17
+ end
18
+
19
+ def attach(io, stream)
20
+ @todo << lambda do
21
+ @map[io] = @nio.register(io, :r)
22
+ @map[io].value = stream
23
+ end
24
+ wakeup
25
+ end
26
+
27
+ def detach(io, stream)
28
+ @todo << lambda do
29
+ @nio.deregister io
30
+ @map.delete io
31
+ io.close
32
+ end
33
+ wakeup
34
+ end
35
+
36
+ def writes_pending(io)
37
+ @todo << lambda do
38
+ if monitor = @map[io]
39
+ monitor.interests = :rw
40
+ end
41
+ end
42
+ wakeup
43
+ end
44
+
45
+ def stop
46
+ @stopping = true
47
+ wakeup if @nio
48
+ end
49
+
50
+ private
51
+ def spawn
52
+ return if @thread && @thread.status
53
+
54
+ @spawn_mutex.synchronize do
55
+ return if @thread && @thread.status
56
+
57
+ @nio ||= NIO::Selector.new
58
+
59
+ @thread = Thread.new { run }
60
+
61
+ return true
62
+ end
63
+ end
64
+
65
+ def wakeup
66
+ spawn || @nio.wakeup
67
+ end
68
+
69
+ def run
70
+ loop do
71
+ if @stopping
72
+ @nio.close
73
+ break
74
+ end
75
+
76
+ until @todo.empty?
77
+ @todo.pop(true).call
78
+ end
79
+
80
+ next unless monitors = @nio.select
81
+
82
+ monitors.each do |monitor|
83
+ io = monitor.io
84
+ stream = monitor.value
85
+
86
+ begin
87
+ if monitor.writable?
88
+ if stream.flush_write_buffer
89
+ monitor.interests = :r
90
+ end
91
+ next unless monitor.readable?
92
+ end
93
+
94
+ incoming = io.read_nonblock(4096, exception: false)
95
+ case incoming
96
+ when :wait_readable
97
+ next
98
+ when nil
99
+ stream.close
100
+ else
101
+ stream.receive incoming
102
+ end
103
+ rescue
104
+ # We expect one of EOFError or Errno::ECONNRESET in normal operation (when the
105
+ # client goes away). But if anything else goes wrong, this is still the best way
106
+ # to handle it.
107
+ begin
108
+ stream.close
109
+ rescue
110
+ @nio.deregister io
111
+ @map.delete io
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :markup: markdown
4
+
5
+ module ActionCable
6
+ module Server
7
+ # # Action Cable Connection TaggedLoggerProxy
8
+ #
9
+ # Allows the use of per-connection tags against the server logger. This wouldn't work using the traditional
10
+ # ActiveSupport::TaggedLogging enhanced Rails.logger, as that logger will reset the tags between requests.
11
+ # The connection is long-lived, so it needs its own set of tags for its independent duration.
12
+ class TaggedLoggerProxy
13
+ attr_reader :tags
14
+
15
+ def initialize(logger, tags:)
16
+ @logger = logger
17
+ @tags = tags.flatten
18
+ end
19
+
20
+ def add_tags(*tags)
21
+ @tags += tags.flatten
22
+ @tags = @tags.uniq
23
+ end
24
+
25
+ def tag(logger, &block)
26
+ if logger.respond_to?(:tagged)
27
+ current_tags = tags - logger.formatter.current_tags
28
+ logger.tagged(*current_tags, &block)
29
+ else
30
+ yield
31
+ end
32
+ end
33
+
34
+ %i( debug info warn error fatal unknown ).each do |severity|
35
+ define_method(severity) do |message = nil, &block|
36
+ log severity, message, &block
37
+ end
38
+ end
39
+
40
+ private
41
+ def log(type, message, &block) # :doc:
42
+ tag(@logger) { @logger.send type, message, &block }
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :markup: markdown
4
+
5
+ module ActionCable
6
+ module Server
7
+ class Worker
8
+ module ActiveRecordConnectionManagement
9
+ extend ActiveSupport::Concern
10
+
11
+ included do
12
+ if defined?(ActiveRecord::Base)
13
+ set_callback :work, :around, :with_database_connections
14
+ end
15
+ end
16
+
17
+ def with_database_connections(&block)
18
+ connection.logger.tag(ActiveRecord::Base.logger, &block)
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :markup: markdown
4
+
5
+ require "active_support/callbacks"
6
+ require "active_support/core_ext/module/attribute_accessors_per_thread"
7
+ require "concurrent"
8
+
9
+ module ActionCable
10
+ module Server
11
+ # Worker used by Server.send_async to do connection work in threads.
12
+ class Worker # :nodoc:
13
+ include ActiveSupport::Callbacks
14
+
15
+ thread_mattr_accessor :connection
16
+ define_callbacks :work
17
+ include ActiveRecordConnectionManagement
18
+
19
+ attr_reader :executor
20
+
21
+ def initialize(max_size: 5)
22
+ @executor = Concurrent::ThreadPoolExecutor.new(
23
+ name: "ActionCable worker",
24
+ min_threads: 1,
25
+ max_threads: max_size,
26
+ max_queue: 0,
27
+ )
28
+ end
29
+
30
+ # Stop processing work: any work that has not already started running will be
31
+ # discarded from the queue
32
+ def halt
33
+ @executor.shutdown
34
+ end
35
+
36
+ def stopping?
37
+ @executor.shuttingdown?
38
+ end
39
+
40
+ def work(connection, &block)
41
+ self.connection = connection
42
+
43
+ run_callbacks :work, &block
44
+ ensure
45
+ self.connection = nil
46
+ end
47
+
48
+ def async_exec(receiver, *args, connection:, &block)
49
+ async_invoke receiver, :instance_exec, *args, connection: connection, &block
50
+ end
51
+
52
+ def async_invoke(receiver, method, *args, connection: receiver, &block)
53
+ @executor.post do
54
+ invoke(receiver, method, *args, connection: connection, &block)
55
+ end
56
+ end
57
+
58
+ def invoke(receiver, method, *args, connection:, &block)
59
+ work(connection) do
60
+ receiver.send method, *args, &block
61
+ rescue Exception => e
62
+ logger.error "There was an exception - #{e.class}(#{e.message})"
63
+ logger.error e.backtrace.join("\n")
64
+
65
+ receiver.handle_exception if receiver.respond_to?(:handle_exception)
66
+ end
67
+ end
68
+
69
+ private
70
+ def logger
71
+ ActionCable.server.logger
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :markup: markdown
4
+
5
+ module ActionCable
6
+ module SubscriptionAdapter
7
+ class Async < Inline # :nodoc:
8
+ private
9
+ def new_subscriber_map
10
+ SubscriberMap::Async.new(executor)
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :markup: markdown
4
+
5
+ module ActionCable
6
+ module SubscriptionAdapter
7
+ class Base
8
+ private attr_reader :executor
9
+ private attr_reader :config
10
+
11
+ delegate :logger, to: :config
12
+
13
+ def initialize(server)
14
+ @executor = server.executor
15
+ @config = server.config
16
+ end
17
+
18
+ def broadcast(channel, payload)
19
+ raise NotImplementedError
20
+ end
21
+
22
+ def subscribe(channel, message_callback, success_callback = nil)
23
+ raise NotImplementedError
24
+ end
25
+
26
+ def unsubscribe(channel, message_callback)
27
+ raise NotImplementedError
28
+ end
29
+
30
+ def shutdown
31
+ raise NotImplementedError
32
+ end
33
+
34
+ def identifier
35
+ config.cable[:id] ||= "ActionCable-PID-#{$$}"
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :markup: markdown
4
+
5
+ module ActionCable
6
+ module SubscriptionAdapter
7
+ module ChannelPrefix # :nodoc:
8
+ def broadcast(channel, payload)
9
+ channel = channel_with_prefix(channel)
10
+ super
11
+ end
12
+
13
+ def subscribe(channel, callback, success_callback = nil)
14
+ channel = channel_with_prefix(channel)
15
+ super
16
+ end
17
+
18
+ def unsubscribe(channel, callback)
19
+ channel = channel_with_prefix(channel)
20
+ super
21
+ end
22
+
23
+ private
24
+ # Returns the channel name, including channel_prefix specified in cable.yml
25
+ def channel_with_prefix(channel)
26
+ [config.cable[:channel_prefix], channel].compact.join(":")
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :markup: markdown
4
+
5
+ module ActionCable
6
+ module SubscriptionAdapter
7
+ class Inline < Base # :nodoc:
8
+ def initialize(*)
9
+ super
10
+ @mutex = Mutex.new
11
+ @subscriber_map = nil
12
+ end
13
+
14
+ def broadcast(channel, payload)
15
+ subscriber_map.broadcast(channel, payload)
16
+ end
17
+
18
+ def subscribe(channel, callback, success_callback = nil)
19
+ subscriber_map.add_subscriber(channel, callback, success_callback)
20
+ end
21
+
22
+ def unsubscribe(channel, callback)
23
+ subscriber_map.remove_subscriber(channel, callback)
24
+ end
25
+
26
+ def shutdown
27
+ # nothing to do
28
+ end
29
+
30
+ private
31
+ def subscriber_map
32
+ @subscriber_map || @mutex.synchronize { @subscriber_map ||= new_subscriber_map }
33
+ end
34
+
35
+ def new_subscriber_map
36
+ SubscriberMap.new
37
+ end
38
+ end
39
+ end
40
+ end