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,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :markup: markdown
4
+
5
+ require "monitor"
6
+
7
+ module ActionCable
8
+ module Server
9
+ # A wrapper over ConcurrentRuby::ThreadPoolExecutor and Concurrent::TimerTask
10
+ class ThreadedExecutor # :nodoc:
11
+ def initialize(max_size: 10)
12
+ @executor = Concurrent::ThreadPoolExecutor.new(
13
+ name: "ActionCable server",
14
+ min_threads: 1,
15
+ max_threads: max_size,
16
+ max_queue: 0,
17
+ )
18
+ end
19
+
20
+ def post(task = nil, &block)
21
+ task ||= block
22
+ @executor << task
23
+ end
24
+
25
+ def timer(interval, &block)
26
+ Concurrent::TimerTask.new(execution_interval: interval, &block).tap(&:execute)
27
+ end
28
+
29
+ def shutdown = @executor.shutdown
30
+ end
31
+
32
+ # # Action Cable Server Base
33
+ #
34
+ # A singleton ActionCable::Server instance is available via ActionCable.server.
35
+ # It's used by the Rack process that starts the Action Cable server, but is also
36
+ # used by the user to reach the RemoteConnections object, which is used for
37
+ # finding and disconnecting connections across all servers.
38
+ #
39
+ # Also, this is the server instance used for broadcasting. See Broadcasting for
40
+ # more information.
41
+ class Base
42
+ include ActionCable::Server::Broadcasting
43
+ include ActionCable::Server::Connections
44
+
45
+ cattr_accessor :config, instance_accessor: false, default: ActionCable::Server::Configuration.new
46
+
47
+ attr_reader :config
48
+
49
+ def self.logger; config.logger; end
50
+ delegate :logger, to: :config
51
+
52
+ attr_reader :mutex
53
+
54
+ def initialize(config: self.class.config)
55
+ @config = config
56
+ @mutex = Monitor.new
57
+ @remote_connections = @event_loop = @worker_pool = @executor = @pubsub = nil
58
+ end
59
+
60
+ # Called by Rack to set up the server.
61
+ def call(env)
62
+ return config.health_check_application.call(env) if env["PATH_INFO"] == config.health_check_path
63
+ setup_heartbeat_timer
64
+ Socket.new(self, env).process
65
+ end
66
+
67
+ # Disconnect all the connections identified by `identifiers` on this server or
68
+ # any others via RemoteConnections.
69
+ def disconnect(identifiers)
70
+ remote_connections.where(identifiers).disconnect
71
+ end
72
+
73
+ def restart
74
+ connections.each do |connection|
75
+ connection.close(reason: ActionCable::INTERNAL[:disconnect_reasons][:server_restart])
76
+ end
77
+
78
+ @mutex.synchronize do
79
+ # Shutdown the worker pool
80
+ @worker_pool.halt if @worker_pool
81
+ @worker_pool = nil
82
+
83
+ # Shutdown the executor
84
+ @executor.shutdown if @executor
85
+ @executor = nil
86
+
87
+ # Shutdown the pub/sub adapter
88
+ @pubsub.shutdown if @pubsub
89
+ @pubsub = nil
90
+ end
91
+ end
92
+
93
+ # Gateway to RemoteConnections. See that class for details.
94
+ def remote_connections
95
+ @remote_connections || @mutex.synchronize { @remote_connections ||= RemoteConnections.new(self) }
96
+ end
97
+
98
+ def event_loop
99
+ @event_loop || @mutex.synchronize { @event_loop ||= StreamEventLoop.new }
100
+ end
101
+
102
+ # The worker pool is where we run connection callbacks and channel actions. We
103
+ # do as little as possible on the server's main thread. The worker pool is an
104
+ # executor service that's backed by a pool of threads working from a task queue.
105
+ # The thread pool size maxes out at 4 worker threads by default. Tune the size
106
+ # yourself with `config.action_cable.worker_pool_size`.
107
+ #
108
+ # Using Active Record, Redis, etc within your channel actions means you'll get a
109
+ # separate connection from each thread in the worker pool. Plan your deployment
110
+ # accordingly: 5 servers each running 5 Puma workers each running an 8-thread
111
+ # worker pool means at least 200 database connections.
112
+ #
113
+ # Also, ensure that your database connection pool size is as least as large as
114
+ # your worker pool size. Otherwise, workers may oversubscribe the database
115
+ # connection pool and block while they wait for other workers to release their
116
+ # connections. Use a smaller worker pool or a larger database connection pool
117
+ # instead.
118
+ def worker_pool
119
+ @worker_pool || @mutex.synchronize { @worker_pool ||= ActionCable::Server::Worker.new(max_size: config.worker_pool_size) }
120
+ end
121
+
122
+ # Executor is used by various actions within Action Cable (e.g., pub/sub operations) to run code asynchronously.
123
+ def executor
124
+ @executor || @mutex.synchronize { @executor ||= ThreadedExecutor.new(max_size: config.executor_pool_size) }
125
+ end
126
+
127
+ # Adapter used for all streams/broadcasting.
128
+ def pubsub
129
+ @pubsub || (executor && @mutex.synchronize { @pubsub ||= config.pubsub_adapter.new(self) })
130
+ end
131
+
132
+ # All of the identifiers applied to the connection class associated with this
133
+ # server.
134
+ def connection_identifiers
135
+ config.connection_class.call.identifiers
136
+ end
137
+
138
+ # Tags are declared in the server but computed in the connection. This allows us per-connection tailored tags.
139
+ # You can pass request object either directly or via block to lazily evaluate it.
140
+ def new_tagged_logger(request = nil, &block)
141
+ TaggedLoggerProxy.new logger,
142
+ tags: config.log_tags.map { |tag| tag.respond_to?(:call) ? tag.call(request ||= block.call) : tag.to_s.camelize }
143
+ end
144
+
145
+ # Check if the request origin is allowed to connect to the Action Cable server.
146
+ def allow_request_origin?(env)
147
+ return true if config.disable_request_forgery_protection
148
+
149
+ proto = Rack::Request.new(env).ssl? ? "https" : "http"
150
+ if config.allow_same_origin_as_host && env["HTTP_ORIGIN"] == "#{proto}://#{env['HTTP_HOST']}"
151
+ true
152
+ elsif Array(config.allowed_request_origins).any? { |allowed_origin| allowed_origin === env["HTTP_ORIGIN"] }
153
+ true
154
+ else
155
+ logger.error("Request origin not allowed: #{env['HTTP_ORIGIN']}")
156
+ false
157
+ end
158
+ end
159
+ end
160
+
161
+ ActiveSupport.run_load_hooks(:action_cable, Base.config)
162
+ end
163
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :markup: markdown
4
+
5
+ module ActionCable
6
+ module Server
7
+ # # Action Cable Server Broadcasting
8
+ #
9
+ # Broadcasting is how other parts of your application can send messages to a
10
+ # channel's subscribers. As explained in Channel, most of the time, these
11
+ # broadcastings are streamed directly to the clients subscribed to the named
12
+ # broadcasting. Let's explain with a full-stack example:
13
+ #
14
+ # class WebNotificationsChannel < ApplicationCable::Channel
15
+ # def subscribed
16
+ # stream_from "web_notifications_#{current_user.id}"
17
+ # end
18
+ # end
19
+ #
20
+ # # Somewhere in your app this is called, perhaps from a NewCommentJob:
21
+ # ActionCable.server.broadcast \
22
+ # "web_notifications_1", { title: "New things!", body: "All that's fit for print" }
23
+ #
24
+ # # Client-side CoffeeScript, which assumes you've already requested the right to send web notifications:
25
+ # App.cable.subscriptions.create "WebNotificationsChannel",
26
+ # received: (data) ->
27
+ # new Notification data['title'], body: data['body']
28
+ module Broadcasting
29
+ # Broadcast a hash directly to a named `broadcasting`. This will later be JSON
30
+ # encoded.
31
+ def broadcast(broadcasting, message, coder: ActiveSupport::JSON)
32
+ broadcaster_for(broadcasting, coder: coder).broadcast(message)
33
+ end
34
+
35
+ # Returns a broadcaster for a named `broadcasting` that can be reused. Useful
36
+ # when you have an object that may need multiple spots to transmit to a specific
37
+ # broadcasting over and over.
38
+ def broadcaster_for(broadcasting, coder: ActiveSupport::JSON)
39
+ Broadcaster.new(self, String(broadcasting), coder: coder)
40
+ end
41
+
42
+ private
43
+ class Broadcaster
44
+ attr_reader :server, :broadcasting, :coder
45
+
46
+ def initialize(server, broadcasting, coder:)
47
+ @server, @broadcasting, @coder = server, broadcasting, coder
48
+ end
49
+
50
+ def broadcast(message)
51
+ server.logger.debug { "[ActionCable] Broadcasting to #{broadcasting}: #{message.inspect.truncate(300)}" }
52
+
53
+ payload = { broadcasting: broadcasting, message: message, coder: coder }
54
+ ActiveSupport::Notifications.instrument("broadcast.action_cable", payload) do
55
+ encoded = coder ? coder.encode(message) : message
56
+ server.pubsub.broadcast broadcasting, encoded
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :markup: markdown
4
+
5
+ require "rack"
6
+
7
+ module ActionCable
8
+ module Server
9
+ # # Action Cable Server Configuration
10
+ #
11
+ # An instance of this configuration object is available via
12
+ # ActionCable.server.config, which allows you to tweak Action Cable
13
+ # configuration in a Rails config initializer.
14
+ class Configuration
15
+ attr_accessor :logger, :log_tags
16
+ attr_accessor :connection_class, :worker_pool_size, :executor_pool_size
17
+ attr_accessor :disable_request_forgery_protection, :allowed_request_origins, :allow_same_origin_as_host, :filter_parameters
18
+ attr_accessor :cable, :url, :mount_path
19
+ attr_accessor :precompile_assets
20
+ attr_accessor :health_check_path, :health_check_application
21
+ attr_writer :pubsub_adapter
22
+
23
+ def initialize
24
+ @log_tags = []
25
+
26
+ @connection_class = -> { ActionCable::Connection::Base }
27
+ @worker_pool_size = 4
28
+ @executor_pool_size = 10
29
+
30
+ @disable_request_forgery_protection = false
31
+ @allow_same_origin_as_host = true
32
+ @filter_parameters = []
33
+
34
+ @health_check_application = ->(env) {
35
+ [200, { Rack::CONTENT_TYPE => "text/html", "date" => Time.now.httpdate }, []]
36
+ }
37
+ end
38
+
39
+ # Returns constant of subscription adapter specified in config/cable.yml or directly in the configuration.
40
+ # If the adapter cannot be found, this will default to the Redis adapter. Also makes
41
+ # sure proper dependencies are required.
42
+ def pubsub_adapter
43
+ # Provided explicitly in the configuration
44
+ return @pubsub_adapter.constantize if @pubsub_adapter
45
+
46
+ adapter = (cable.fetch("adapter") { "redis" })
47
+
48
+ # Require the adapter itself and give useful feedback about
49
+ # 1. Missing adapter gems and
50
+ # 2. Adapter gems' missing dependencies.
51
+ path_to_adapter = "action_cable/subscription_adapter/#{adapter}"
52
+ begin
53
+ require path_to_adapter
54
+ rescue LoadError => e
55
+ # We couldn't require the adapter itself. Raise an exception that points out
56
+ # config typos and missing gems.
57
+ if e.path == path_to_adapter
58
+ # We can assume that a non-builtin adapter was specified, so it's either
59
+ # misspelled or missing from Gemfile.
60
+ raise e.class, "Could not load the '#{adapter}' Action Cable pubsub adapter. Ensure that the adapter is spelled correctly in config/cable.yml and that you've added the necessary adapter gem to your Gemfile.", e.backtrace
61
+
62
+ # Bubbled up from the adapter require. Prefix the exception message with some
63
+ # guidance about how to address it and reraise.
64
+ else
65
+ raise e.class, "Error loading the '#{adapter}' Action Cable pubsub adapter. Missing a gem it depends on? #{e.message}", e.backtrace
66
+ end
67
+ end
68
+
69
+ adapter = adapter.camelize
70
+ adapter = "PostgreSQL" if adapter == "Postgresql"
71
+ "ActionCable::SubscriptionAdapter::#{adapter}".constantize
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :markup: markdown
4
+
5
+ module ActionCable
6
+ module Server
7
+ # # Action Cable Server Connections
8
+ #
9
+ # Collection class for all the connections that have been established on this
10
+ # specific server. Remember, usually you'll run many Action Cable servers, so
11
+ # you can't use this collection as a full list of all of the connections
12
+ # established against your application. Instead, use RemoteConnections for that.
13
+ module Connections # :nodoc:
14
+ BEAT_INTERVAL = 3
15
+
16
+ def connections
17
+ @connections ||= []
18
+ end
19
+
20
+ def add_connection(connection)
21
+ connections << connection
22
+ end
23
+
24
+ def remove_connection(connection)
25
+ connections.delete connection
26
+ end
27
+
28
+ # WebSocket connection implementations differ on when they'll mark a connection
29
+ # as stale. We basically never want a connection to go stale, as you then can't
30
+ # rely on being able to communicate with the connection. To solve this, a 3
31
+ # second heartbeat runs on all connections. If the beat fails, we automatically
32
+ # disconnect.
33
+ def setup_heartbeat_timer
34
+ @heartbeat_timer ||= executor.timer(BEAT_INTERVAL) do
35
+ executor.post { connections.each(&:beat) }
36
+ end
37
+ end
38
+
39
+ def open_connections_statistics
40
+ connections.map(&:statistics)
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "websocket/driver"
4
+
5
+ module ActionCable
6
+ module Server
7
+ class Socket
8
+ #--
9
+ # This class is heavily based on faye-websocket-ruby
10
+ #
11
+ # Copyright (c) 2010-2015 James Coglan
12
+ class ClientSocket # :nodoc:
13
+ def self.determine_url(env)
14
+ scheme = secure_request?(env) ? "wss:" : "ws:"
15
+ "#{ scheme }//#{ env['HTTP_HOST'] }#{ env['REQUEST_URI'] }"
16
+ end
17
+
18
+ def self.secure_request?(env)
19
+ return true if env["HTTPS"] == "on"
20
+ return true if env["HTTP_X_FORWARDED_SSL"] == "on"
21
+ return true if env["HTTP_X_FORWARDED_SCHEME"] == "https"
22
+ return true if env["HTTP_X_FORWARDED_PROTO"] == "https"
23
+ return true if env["rack.url_scheme"] == "https"
24
+
25
+ false
26
+ end
27
+
28
+ CONNECTING = 0
29
+ OPEN = 1
30
+ CLOSING = 2
31
+ CLOSED = 3
32
+
33
+ attr_reader :env, :url
34
+
35
+ def initialize(env, event_target, event_loop, protocols)
36
+ @env = env
37
+ @event_target = event_target
38
+ @event_loop = event_loop
39
+
40
+ @url = ClientSocket.determine_url(@env)
41
+
42
+ @driver = @driver_started = nil
43
+ @close_params = ["", 1006]
44
+
45
+ @ready_state = CONNECTING
46
+
47
+ # The driver calls +env+, +url+, and +write+
48
+ @driver = ::WebSocket::Driver.rack(self, protocols: protocols)
49
+
50
+ @driver.on(:open) { |e| open }
51
+ @driver.on(:message) { |e| receive_message(e.data) }
52
+ @driver.on(:close) { |e| begin_close(e.reason, e.code) }
53
+ @driver.on(:error) { |e| emit_error(e.message) }
54
+
55
+ @stream = Stream.new(@event_loop, self)
56
+ end
57
+
58
+ def start_driver
59
+ return if @driver.nil? || @driver_started
60
+ @stream.hijack_rack_socket
61
+
62
+ if callback = @env["async.callback"]
63
+ callback.call([101, {}, @stream])
64
+ end
65
+
66
+ @driver_started = true
67
+ @driver.start
68
+ end
69
+
70
+ def rack_response
71
+ start_driver
72
+ [ -1, {}, [] ]
73
+ end
74
+
75
+ def write(data)
76
+ @stream.write(data)
77
+ rescue => e
78
+ emit_error e.message
79
+ end
80
+
81
+ def transmit(message)
82
+ return false if @ready_state > OPEN
83
+ case message
84
+ when Numeric then @driver.text(message.to_s)
85
+ when String then @driver.text(message)
86
+ when Array then @driver.binary(message)
87
+ else false
88
+ end
89
+ end
90
+
91
+ def close(code = nil, reason = nil)
92
+ code ||= 1000
93
+ reason ||= ""
94
+
95
+ unless code == 1000 || (code >= 3000 && code <= 4999)
96
+ raise ArgumentError, "Failed to execute 'close' on WebSocket: " \
97
+ "The code must be either 1000, or between 3000 and 4999. " \
98
+ "#{code} is neither."
99
+ end
100
+
101
+ @ready_state = CLOSING unless @ready_state == CLOSED
102
+ @driver.close(reason, code)
103
+ end
104
+
105
+ def parse(data)
106
+ @driver.parse(data)
107
+ end
108
+
109
+ def client_gone
110
+ finalize_close
111
+ end
112
+
113
+ def alive?
114
+ @ready_state == OPEN
115
+ end
116
+
117
+ def protocol
118
+ @driver.protocol
119
+ end
120
+
121
+ private
122
+ def open
123
+ return unless @ready_state == CONNECTING
124
+ @ready_state = OPEN
125
+
126
+ @event_target.on_open
127
+ end
128
+
129
+ def receive_message(data)
130
+ return unless @ready_state == OPEN
131
+
132
+ @event_target.on_message(data)
133
+ end
134
+
135
+ def emit_error(message)
136
+ return if @ready_state >= CLOSING
137
+
138
+ @event_target.on_error(message)
139
+ end
140
+
141
+ def begin_close(reason, code)
142
+ return if @ready_state == CLOSED
143
+ @ready_state = CLOSING
144
+ @close_params = [reason, code]
145
+
146
+ @stream.shutdown if @stream
147
+ finalize_close
148
+ end
149
+
150
+ def finalize_close
151
+ return if @ready_state == CLOSED
152
+ @ready_state = CLOSED
153
+
154
+ @event_target.on_close(*@close_params)
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionCable
4
+ module Server
5
+ class Socket
6
+ # Allows us to buffer messages received from the WebSocket before the Connection has been fully initialized, and is ready to receive them.
7
+ class MessageBuffer # :nodoc:
8
+ def initialize(connection)
9
+ @connection = connection
10
+ @buffered_messages = []
11
+ end
12
+
13
+ def append(message)
14
+ if valid? message
15
+ if processing?
16
+ receive message
17
+ else
18
+ buffer message
19
+ end
20
+ else
21
+ connection.logger.error "Couldn't handle non-string message: #{message.class}"
22
+ end
23
+ end
24
+
25
+ def processing?
26
+ @processing
27
+ end
28
+
29
+ def process!
30
+ @processing = true
31
+ receive_buffered_messages
32
+ end
33
+
34
+ private
35
+ attr_reader :connection
36
+ attr_reader :buffered_messages
37
+
38
+ def valid?(message)
39
+ message.is_a?(String)
40
+ end
41
+
42
+ def receive(message)
43
+ connection.receive message
44
+ end
45
+
46
+ def buffer(message)
47
+ buffered_messages << message
48
+ end
49
+
50
+ def receive_buffered_messages
51
+ receive buffered_messages.shift until buffered_messages.empty?
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionCable
4
+ module Server
5
+ class Socket
6
+ #--
7
+ # This class is heavily based on faye-websocket-ruby
8
+ #
9
+ # Copyright (c) 2010-2015 James Coglan
10
+ class Stream # :nodoc:
11
+ def initialize(event_loop, socket)
12
+ @event_loop = event_loop
13
+ @socket_object = socket
14
+ @stream_send = socket.env["stream.send"]
15
+
16
+ @rack_hijack_io = nil
17
+ @write_lock = Mutex.new
18
+
19
+ @write_head = nil
20
+ @write_buffer = Queue.new
21
+ end
22
+
23
+ def each(&callback)
24
+ @stream_send ||= callback
25
+ end
26
+
27
+ def close
28
+ shutdown
29
+ @socket_object.client_gone
30
+ end
31
+
32
+ def shutdown
33
+ clean_rack_hijack
34
+ end
35
+
36
+ def write(data)
37
+ if @stream_send
38
+ return @stream_send.call(data)
39
+ end
40
+
41
+ if @write_lock.try_lock
42
+ begin
43
+ if @write_head.nil? && @write_buffer.empty?
44
+ written = @rack_hijack_io.write_nonblock(data, exception: false)
45
+
46
+ case written
47
+ when :wait_writable
48
+ # proceed below
49
+ when data.bytesize
50
+ return data.bytesize
51
+ else
52
+ @write_head = data.byteslice(written, data.bytesize)
53
+ @event_loop.writes_pending @rack_hijack_io
54
+
55
+ return data.bytesize
56
+ end
57
+ end
58
+ ensure
59
+ @write_lock.unlock
60
+ end
61
+ end
62
+
63
+ @write_buffer << data
64
+ @event_loop.writes_pending @rack_hijack_io
65
+
66
+ data.bytesize
67
+ rescue EOFError, Errno::ECONNRESET
68
+ @socket_object.client_gone
69
+ end
70
+
71
+ def flush_write_buffer
72
+ @write_lock.synchronize do
73
+ loop do
74
+ if @write_head.nil?
75
+ return true if @write_buffer.empty?
76
+ @write_head = @write_buffer.pop
77
+ end
78
+
79
+ written = @rack_hijack_io.write_nonblock(@write_head, exception: false)
80
+ case written
81
+ when :wait_writable
82
+ return false
83
+ when @write_head.bytesize
84
+ @write_head = nil
85
+ else
86
+ @write_head = @write_head.byteslice(written, @write_head.bytesize)
87
+ return false
88
+ end
89
+ end
90
+ end
91
+ end
92
+
93
+ def receive(data)
94
+ @socket_object.parse(data)
95
+ end
96
+
97
+ def hijack_rack_socket
98
+ return unless @socket_object.env["rack.hijack"]
99
+
100
+ # This should return the underlying io according to the SPEC:
101
+ @rack_hijack_io = @socket_object.env["rack.hijack"].call
102
+ # Retain existing behavior if required:
103
+ @rack_hijack_io ||= @socket_object.env["rack.hijack_io"]
104
+
105
+ @event_loop.attach(@rack_hijack_io, self)
106
+ end
107
+
108
+ private
109
+ def clean_rack_hijack
110
+ return unless @rack_hijack_io
111
+ @event_loop.detach(@rack_hijack_io, self)
112
+ @rack_hijack_io = nil
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end