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