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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/MIT-LICENSE +20 -0
- data/README.md +17 -0
- data/lib/action_cable/channel/base.rb +335 -0
- data/lib/action_cable/channel/broadcasting.rb +50 -0
- data/lib/action_cable/channel/callbacks.rb +76 -0
- data/lib/action_cable/channel/naming.rb +28 -0
- data/lib/action_cable/channel/periodic_timers.rb +81 -0
- data/lib/action_cable/channel/streams.rb +213 -0
- data/lib/action_cable/channel/test_case.rb +329 -0
- data/lib/action_cable/connection/authorization.rb +18 -0
- data/lib/action_cable/connection/base.rb +165 -0
- data/lib/action_cable/connection/callbacks.rb +57 -0
- data/lib/action_cable/connection/identification.rb +51 -0
- data/lib/action_cable/connection/internal_channel.rb +50 -0
- data/lib/action_cable/connection/subscriptions.rb +124 -0
- data/lib/action_cable/connection/test_case.rb +294 -0
- data/lib/action_cable/deprecator.rb +9 -0
- data/lib/action_cable/engine.rb +98 -0
- data/lib/action_cable/gem_version.rb +19 -0
- data/lib/action_cable/helpers/action_cable_helper.rb +45 -0
- data/lib/action_cable/remote_connections.rb +82 -0
- data/lib/action_cable/server/base.rb +163 -0
- data/lib/action_cable/server/broadcasting.rb +62 -0
- data/lib/action_cable/server/configuration.rb +75 -0
- data/lib/action_cable/server/connections.rb +44 -0
- data/lib/action_cable/server/socket/client_socket.rb +159 -0
- data/lib/action_cable/server/socket/message_buffer.rb +56 -0
- data/lib/action_cable/server/socket/stream.rb +117 -0
- data/lib/action_cable/server/socket/web_socket.rb +47 -0
- data/lib/action_cable/server/socket.rb +180 -0
- data/lib/action_cable/server/stream_event_loop.rb +119 -0
- data/lib/action_cable/server/tagged_logger_proxy.rb +46 -0
- data/lib/action_cable/server/worker/active_record_connection_management.rb +23 -0
- data/lib/action_cable/server/worker.rb +75 -0
- data/lib/action_cable/subscription_adapter/async.rb +14 -0
- data/lib/action_cable/subscription_adapter/base.rb +39 -0
- data/lib/action_cable/subscription_adapter/channel_prefix.rb +30 -0
- data/lib/action_cable/subscription_adapter/inline.rb +40 -0
- data/lib/action_cable/subscription_adapter/postgresql.rb +130 -0
- data/lib/action_cable/subscription_adapter/redis.rb +257 -0
- data/lib/action_cable/subscription_adapter/subscriber_map.rb +80 -0
- data/lib/action_cable/subscription_adapter/test.rb +41 -0
- data/lib/action_cable/test_case.rb +13 -0
- data/lib/action_cable/test_helper.rb +163 -0
- data/lib/action_cable/version.rb +12 -0
- data/lib/action_cable.rb +81 -0
- data/lib/actioncable-next.rb +5 -0
- data/lib/rails/generators/channel/USAGE +19 -0
- data/lib/rails/generators/channel/channel_generator.rb +127 -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 +1 -0
- data/lib/rails/generators/test_unit/channel_generator.rb +22 -0
- data/lib/rails/generators/test_unit/templates/channel_test.rb.tt +8 -0
- 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
|