anycable-core 1.1.0.pre1
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 +84 -0
- data/MIT-LICENSE +20 -0
- data/README.md +78 -0
- data/bin/anycable +13 -0
- data/bin/anycabled +30 -0
- data/bin/console +7 -0
- data/bin/setup +6 -0
- data/lib/anycable.rb +114 -0
- data/lib/anycable/broadcast_adapters.rb +34 -0
- data/lib/anycable/broadcast_adapters/base.rb +29 -0
- data/lib/anycable/broadcast_adapters/http.rb +131 -0
- data/lib/anycable/broadcast_adapters/redis.rb +46 -0
- data/lib/anycable/cli.rb +319 -0
- data/lib/anycable/config.rb +127 -0
- data/lib/anycable/exceptions_handling.rb +35 -0
- data/lib/anycable/grpc.rb +30 -0
- data/lib/anycable/grpc/check_version.rb +33 -0
- data/lib/anycable/grpc/config.rb +53 -0
- data/lib/anycable/grpc/handler.rb +25 -0
- data/lib/anycable/grpc/rpc_services_pb.rb +24 -0
- data/lib/anycable/grpc/server.rb +103 -0
- data/lib/anycable/health_server.rb +73 -0
- data/lib/anycable/middleware.rb +10 -0
- data/lib/anycable/middleware_chain.rb +74 -0
- data/lib/anycable/middlewares/exceptions.rb +35 -0
- data/lib/anycable/protos/rpc_pb.rb +74 -0
- data/lib/anycable/rpc.rb +91 -0
- data/lib/anycable/rpc/handler.rb +50 -0
- data/lib/anycable/rpc/handlers/command.rb +36 -0
- data/lib/anycable/rpc/handlers/connect.rb +33 -0
- data/lib/anycable/rpc/handlers/disconnect.rb +27 -0
- data/lib/anycable/rspec.rb +4 -0
- data/lib/anycable/rspec/rpc_command_context.rb +21 -0
- data/lib/anycable/socket.rb +169 -0
- data/lib/anycable/version.rb +5 -0
- data/sig/anycable.rbs +37 -0
- data/sig/anycable/broadcast_adapters.rbs +5 -0
- data/sig/anycable/cli.rbs +40 -0
- data/sig/anycable/config.rbs +46 -0
- data/sig/anycable/exceptions_handling.rbs +14 -0
- data/sig/anycable/health_server.rbs +21 -0
- data/sig/anycable/middleware.rbs +5 -0
- data/sig/anycable/middleware_chain.rbs +22 -0
- data/sig/anycable/rpc.rbs +143 -0
- data/sig/anycable/socket.rbs +40 -0
- data/sig/anycable/version.rbs +3 -0
- metadata +237 -0
@@ -0,0 +1,46 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
begin
|
4
|
+
require "redis"
|
5
|
+
rescue LoadError
|
6
|
+
raise "Please, install redis gem to use Redis broadcast adapter"
|
7
|
+
end
|
8
|
+
|
9
|
+
require "json"
|
10
|
+
|
11
|
+
module AnyCable
|
12
|
+
module BroadcastAdapters
|
13
|
+
# Redis adapter for broadcasting.
|
14
|
+
#
|
15
|
+
# Example:
|
16
|
+
#
|
17
|
+
# AnyCable.broadast_adapter = :redis
|
18
|
+
#
|
19
|
+
# It uses Redis configuration from global AnyCable config
|
20
|
+
# by default.
|
21
|
+
#
|
22
|
+
# You can override these params:
|
23
|
+
#
|
24
|
+
# AnyCable.broadcast_adapter = :redis, url: "redis://my_redis", channel: "_any_cable_"
|
25
|
+
class Redis < Base
|
26
|
+
attr_reader :redis_conn, :channel
|
27
|
+
|
28
|
+
def initialize(
|
29
|
+
channel: AnyCable.config.redis_channel,
|
30
|
+
**options
|
31
|
+
)
|
32
|
+
options = AnyCable.config.to_redis_params.merge(options)
|
33
|
+
@redis_conn = ::Redis.new(options)
|
34
|
+
@channel = channel
|
35
|
+
end
|
36
|
+
|
37
|
+
def raw_broadcast(payload)
|
38
|
+
redis_conn.publish(channel, payload)
|
39
|
+
end
|
40
|
+
|
41
|
+
def announce!
|
42
|
+
logger.info "Broadcasting Redis channel: #{channel}"
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
data/lib/anycable/cli.rb
ADDED
@@ -0,0 +1,319 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "optparse"
|
4
|
+
|
5
|
+
require "anycable"
|
6
|
+
|
7
|
+
$stdout.sync = true
|
8
|
+
|
9
|
+
module AnyCable
|
10
|
+
# Command-line interface for running AnyCable RPC server
|
11
|
+
class CLI
|
12
|
+
# (not-so-big) List of common boot files for
|
13
|
+
# different applications
|
14
|
+
APP_CANDIDATES = %w[
|
15
|
+
./config/anycable.rb
|
16
|
+
./config/environment.rb
|
17
|
+
].freeze
|
18
|
+
|
19
|
+
# Wait for external process termination (s)
|
20
|
+
WAIT_PROCESS = 2
|
21
|
+
|
22
|
+
attr_reader :server, :health_server, :embedded
|
23
|
+
alias_method :embedded?, :embedded
|
24
|
+
|
25
|
+
def initialize(embedded: false)
|
26
|
+
@embedded = embedded
|
27
|
+
end
|
28
|
+
|
29
|
+
def run(args = [])
|
30
|
+
@at_stop = []
|
31
|
+
|
32
|
+
extra_options = parse_cli_options!(args)
|
33
|
+
|
34
|
+
# Boot app first, 'cause it might change
|
35
|
+
# configuration, loggin settings, etc.
|
36
|
+
boot_app! unless embedded?
|
37
|
+
|
38
|
+
# Make sure Rails extensions for Anyway Config are loaded
|
39
|
+
# See https://github.com/anycable/anycable-rails/issues/63
|
40
|
+
require "anyway/rails" if defined?(::Rails::VERSION)
|
41
|
+
|
42
|
+
parse_gem_options!(extra_options)
|
43
|
+
|
44
|
+
configure_server!
|
45
|
+
|
46
|
+
logger.info "Starting AnyCable RPC server (pid: #{Process.pid})"
|
47
|
+
|
48
|
+
print_version!
|
49
|
+
|
50
|
+
logger.info "Serving #{defined?(::Rails) ? "Rails " : ""}application from #{boot_file}" unless embedded?
|
51
|
+
|
52
|
+
verify_connection_factory!
|
53
|
+
|
54
|
+
log_errors!
|
55
|
+
|
56
|
+
verify_server_builder!
|
57
|
+
|
58
|
+
@server = AnyCable.server_builder.call(config)
|
59
|
+
|
60
|
+
# Make sure middlewares are not adding after server has started
|
61
|
+
AnyCable.middleware.freeze
|
62
|
+
|
63
|
+
start_health_server! if config.http_health_port_provided?
|
64
|
+
start_pubsub!
|
65
|
+
|
66
|
+
server.start
|
67
|
+
|
68
|
+
run_custom_server_command! unless server_command.nil?
|
69
|
+
|
70
|
+
return if embedded?
|
71
|
+
|
72
|
+
begin
|
73
|
+
wait_till_terminated
|
74
|
+
rescue Interrupt => e
|
75
|
+
logger.info "Stopping... #{e.message}"
|
76
|
+
|
77
|
+
shutdown
|
78
|
+
|
79
|
+
logger.info "Stopped. Good-bye!"
|
80
|
+
exit(0)
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
def shutdown
|
85
|
+
at_stop.each(&:call)
|
86
|
+
server&.stop
|
87
|
+
end
|
88
|
+
|
89
|
+
private
|
90
|
+
|
91
|
+
attr_reader :boot_file, :server_command
|
92
|
+
|
93
|
+
def config
|
94
|
+
AnyCable.config
|
95
|
+
end
|
96
|
+
|
97
|
+
def logger
|
98
|
+
AnyCable.logger
|
99
|
+
end
|
100
|
+
|
101
|
+
def at_stop(&block)
|
102
|
+
if block
|
103
|
+
@at_stop << block
|
104
|
+
else
|
105
|
+
@at_stop
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
def wait_till_terminated
|
110
|
+
self_read = setup_signals
|
111
|
+
|
112
|
+
while readable_io = IO.select([self_read]) # rubocop:disable Lint/AssignmentInCondition
|
113
|
+
signal = readable_io.first[0].gets.strip
|
114
|
+
raise Interrupt, "SIG#{signal} received"
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
def setup_signals
|
119
|
+
self_read, self_write = IO.pipe
|
120
|
+
|
121
|
+
%w[INT TERM].each do |signal|
|
122
|
+
trap signal do
|
123
|
+
self_write.puts signal
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
self_read
|
128
|
+
end
|
129
|
+
|
130
|
+
def print_version!
|
131
|
+
logger.info "AnyCable version: #{AnyCable::VERSION} (proto_version: #{AnyCable::PROTO_VERSION})"
|
132
|
+
end
|
133
|
+
|
134
|
+
def boot_app!
|
135
|
+
@boot_file ||= try_detect_app
|
136
|
+
|
137
|
+
if boot_file.nil?
|
138
|
+
$stdout.puts(
|
139
|
+
"Couldn't find an application to load. " \
|
140
|
+
"Please specify the explicit path via -r option, e.g:" \
|
141
|
+
" anycable -r ./config/boot.rb or anycable -r /app/config/load_me.rb"
|
142
|
+
)
|
143
|
+
exit(1)
|
144
|
+
end
|
145
|
+
|
146
|
+
begin
|
147
|
+
require boot_file
|
148
|
+
rescue LoadError => e
|
149
|
+
$stdout.puts(
|
150
|
+
"Failed to load application: #{e.message}. " \
|
151
|
+
"Please specify the explicit path via -r option, e.g:" \
|
152
|
+
" anycable -r ./config/boot.rb or anycable -r /app/config/load_me.rb"
|
153
|
+
)
|
154
|
+
exit(1)
|
155
|
+
end
|
156
|
+
end
|
157
|
+
|
158
|
+
def try_detect_app
|
159
|
+
APP_CANDIDATES.detect { |path| File.exist?(path) }
|
160
|
+
end
|
161
|
+
|
162
|
+
def configure_server!
|
163
|
+
AnyCable.server_callbacks.each(&:call)
|
164
|
+
end
|
165
|
+
|
166
|
+
def start_health_server!
|
167
|
+
@health_server = AnyCable::HealthServer.new(
|
168
|
+
server,
|
169
|
+
**config.to_http_health_params
|
170
|
+
)
|
171
|
+
health_server.start
|
172
|
+
|
173
|
+
at_stop { health_server.stop }
|
174
|
+
end
|
175
|
+
|
176
|
+
def start_pubsub!
|
177
|
+
AnyCable.broadcast_adapter.announce!
|
178
|
+
end
|
179
|
+
|
180
|
+
def run_custom_server_command!
|
181
|
+
pid = nil
|
182
|
+
stopped = false
|
183
|
+
command_thread = Thread.new do
|
184
|
+
pid = Process.spawn(server_command)
|
185
|
+
logger.info "Started command: #{server_command} (pid: #{pid})"
|
186
|
+
|
187
|
+
Process.wait pid
|
188
|
+
pid = nil
|
189
|
+
raise Interrupt, "Server command exit unexpectedly" unless stopped
|
190
|
+
end
|
191
|
+
|
192
|
+
command_thread.abort_on_exception = true
|
193
|
+
|
194
|
+
at_stop do
|
195
|
+
stopped = true
|
196
|
+
next if pid.nil?
|
197
|
+
|
198
|
+
Process.kill("SIGTERM", pid)
|
199
|
+
|
200
|
+
logger.info "Wait till process #{pid} stop..."
|
201
|
+
|
202
|
+
tick = 0.0
|
203
|
+
|
204
|
+
loop do
|
205
|
+
tick += 0.2
|
206
|
+
# @type break: nil
|
207
|
+
break if tick > WAIT_PROCESS
|
208
|
+
|
209
|
+
if pid.nil?
|
210
|
+
logger.info "Process #{pid} stopped."
|
211
|
+
# @type break: nil
|
212
|
+
break
|
213
|
+
end
|
214
|
+
end
|
215
|
+
end
|
216
|
+
end
|
217
|
+
|
218
|
+
# Add default exceptions handler: print error message to log
|
219
|
+
def log_errors!
|
220
|
+
if AnyCable.config.debug?
|
221
|
+
# Print error with backtrace in debug mode
|
222
|
+
AnyCable.capture_exception do |e|
|
223
|
+
stack = e.backtrace
|
224
|
+
backtrace = stack ? ":\n#{stack.take(20).join("\n")}" : ""
|
225
|
+
AnyCable.logger.error("#{e.message}#{backtrace}")
|
226
|
+
end
|
227
|
+
else
|
228
|
+
AnyCable.capture_exception { |e| AnyCable.logger.error(e.message) }
|
229
|
+
end
|
230
|
+
end
|
231
|
+
|
232
|
+
def verify_connection_factory!
|
233
|
+
return if AnyCable.connection_factory
|
234
|
+
|
235
|
+
logger.error "AnyCable connection factory must be configured. " \
|
236
|
+
"Make sure you've required a gem (e.g. `anycable-rails`) or " \
|
237
|
+
"configured `AnyCable.connection_factory` yourself"
|
238
|
+
exit(1)
|
239
|
+
end
|
240
|
+
|
241
|
+
def verify_server_builder!
|
242
|
+
return if AnyCable.server_builder
|
243
|
+
|
244
|
+
logger.error "AnyCable server builder must be configured. " \
|
245
|
+
"Make sure you've required a gem (e.g. `anycable-grpc`) or " \
|
246
|
+
"configured `AnyCable.server_builder` yourself"
|
247
|
+
exit(1)
|
248
|
+
end
|
249
|
+
|
250
|
+
def parse_gem_options!(args)
|
251
|
+
config.parse_options!(args)
|
252
|
+
rescue OptionParser::InvalidOption => e
|
253
|
+
$stdout.puts e.message
|
254
|
+
$stdout.puts "Run anycable -h to see available options"
|
255
|
+
exit(1)
|
256
|
+
end
|
257
|
+
|
258
|
+
def parse_cli_options!(args)
|
259
|
+
unknown_opts = []
|
260
|
+
|
261
|
+
parser = build_cli_parser
|
262
|
+
|
263
|
+
begin
|
264
|
+
parser.parse!(args)
|
265
|
+
rescue OptionParser::InvalidOption => e
|
266
|
+
unknown_opts << e.args[0]
|
267
|
+
unless args.size.zero?
|
268
|
+
unknown_opts << args.shift unless args.first.start_with?("-")
|
269
|
+
retry
|
270
|
+
end
|
271
|
+
end
|
272
|
+
|
273
|
+
unknown_opts
|
274
|
+
end
|
275
|
+
|
276
|
+
def build_cli_parser
|
277
|
+
OptionParser.new do |o|
|
278
|
+
o.on "-v", "--version", "Print version and exit" do |_arg|
|
279
|
+
$stdout.puts "AnyCable v#{AnyCable::VERSION}"
|
280
|
+
exit(0)
|
281
|
+
end
|
282
|
+
|
283
|
+
o.on "-r", "--require [PATH|DIR]", "Location of application file to require" do |arg|
|
284
|
+
@boot_file = arg
|
285
|
+
end
|
286
|
+
|
287
|
+
o.on "--server-command VALUE", "Command to run WebSocket server" do |arg|
|
288
|
+
@server_command = arg
|
289
|
+
end
|
290
|
+
|
291
|
+
o.on_tail "-h", "--help", "Show help" do
|
292
|
+
$stdout.puts usage
|
293
|
+
exit(0)
|
294
|
+
end
|
295
|
+
end
|
296
|
+
end
|
297
|
+
|
298
|
+
def usage
|
299
|
+
usage_header =
|
300
|
+
<<~HELP
|
301
|
+
anycable: run AnyCable RPC server (https://anycable.io)
|
302
|
+
|
303
|
+
VERSION
|
304
|
+
anycable/#{AnyCable::VERSION}
|
305
|
+
|
306
|
+
USAGE
|
307
|
+
$ anycable [options]
|
308
|
+
|
309
|
+
CLI
|
310
|
+
-r, --require=path Location of application file to require, default: "config/environment.rb"
|
311
|
+
--server-command=command Command to run WebSocket server
|
312
|
+
-v, --version Print version and exit
|
313
|
+
-h, --help Show this help
|
314
|
+
HELP
|
315
|
+
|
316
|
+
[usage_header, *AnyCable::Config.usages].join("\n")
|
317
|
+
end
|
318
|
+
end
|
319
|
+
end
|
@@ -0,0 +1,127 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "anyway_config"
|
4
|
+
|
5
|
+
require "uri"
|
6
|
+
|
7
|
+
module AnyCable
|
8
|
+
# AnyCable configuration.
|
9
|
+
class Config < Anyway::Config
|
10
|
+
class << self
|
11
|
+
# Add usage txt for CLI
|
12
|
+
def usage(txt)
|
13
|
+
usages << txt
|
14
|
+
end
|
15
|
+
|
16
|
+
def usages
|
17
|
+
@usages ||= []
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
config_name :anycable
|
22
|
+
|
23
|
+
attr_config(
|
24
|
+
## PubSub
|
25
|
+
broadcast_adapter: :redis,
|
26
|
+
|
27
|
+
### Redis options
|
28
|
+
redis_url: ENV.fetch("REDIS_URL", "redis://localhost:6379/5"),
|
29
|
+
redis_sentinels: nil,
|
30
|
+
redis_channel: "__anycable__",
|
31
|
+
|
32
|
+
### HTTP broadcasting options
|
33
|
+
http_broadcast_url: "http://localhost:8090/_broadcast",
|
34
|
+
http_broadcast_secret: nil,
|
35
|
+
|
36
|
+
### Logging options
|
37
|
+
log_file: nil,
|
38
|
+
log_level: "info",
|
39
|
+
debug: false, # Shortcut to enable debug level and verbose logging
|
40
|
+
|
41
|
+
### Health check options
|
42
|
+
http_health_port: nil,
|
43
|
+
http_health_path: "/health",
|
44
|
+
|
45
|
+
### Misc options
|
46
|
+
version_check_enabled: true
|
47
|
+
)
|
48
|
+
|
49
|
+
alias_method :version_check_enabled?, :version_check_enabled
|
50
|
+
|
51
|
+
flag_options :debug
|
52
|
+
|
53
|
+
on_load do
|
54
|
+
# @type self : AnyCable::Config
|
55
|
+
self.debug = debug != false
|
56
|
+
end
|
57
|
+
|
58
|
+
def log_level
|
59
|
+
debug? ? "debug" : super
|
60
|
+
end
|
61
|
+
|
62
|
+
def http_health_port_provided?
|
63
|
+
!http_health_port.nil? && http_health_port != ""
|
64
|
+
end
|
65
|
+
|
66
|
+
usage <<~TXT
|
67
|
+
APPLICATION
|
68
|
+
--broadcast-adapter=type Pub/sub adapter type for broadcasts, default: redis
|
69
|
+
--log-level=level Logging level, default: "info"
|
70
|
+
--log-file=path Path to log file, default: <none> (log to STDOUT)
|
71
|
+
--debug Turn on verbose logging ("debug" level and verbose logging on)
|
72
|
+
|
73
|
+
HTTP HEALTH CHECKER
|
74
|
+
--http-health-port=port Port to run HTTP health server on, default: <none> (disabled)
|
75
|
+
--http-health-path=path Endpoint to server health cheks, default: "/health"
|
76
|
+
|
77
|
+
REDIS PUB/SUB
|
78
|
+
--redis-url=url Redis URL for pub/sub, default: REDIS_URL or "redis://localhost:6379/5"
|
79
|
+
--redis-channel=name Redis channel for broadcasting, default: "__anycable__"
|
80
|
+
--redis-sentinels=<...hosts> Redis Sentinel followers addresses (as a comma-separated list), default: nil
|
81
|
+
|
82
|
+
HTTP PUB/SUB
|
83
|
+
--http-broadcast-url HTTP pub/sub endpoint URL, default: "http://localhost:8090/_broadcast"
|
84
|
+
--http-broadcast-secret HTTP pub/sub authorization secret, default: <none> (disabled)
|
85
|
+
TXT
|
86
|
+
|
87
|
+
# Build Redis parameters
|
88
|
+
def to_redis_params
|
89
|
+
# @type var base_params: { url: String, sentinels: Array[untyped]?, ssl_params: Hash[Symbol, untyped]? }
|
90
|
+
base_params = {url: redis_url}
|
91
|
+
base_params.tap do |params|
|
92
|
+
sentinels = redis_sentinels
|
93
|
+
next if sentinels.nil? || sentinels.empty?
|
94
|
+
|
95
|
+
sentinels = Array(sentinels) unless sentinels.is_a?(Array)
|
96
|
+
|
97
|
+
next if sentinels.empty?
|
98
|
+
|
99
|
+
params[:sentinels] = sentinels.map { |sentinel| parse_sentinel(sentinel) }
|
100
|
+
end.tap do |params|
|
101
|
+
next unless redis_url.match?(/rediss:\/\//)
|
102
|
+
|
103
|
+
params[:ssl_params] = {verify_mode: OpenSSL::SSL::VERIFY_NONE}
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
# Build HTTP health server parameters
|
108
|
+
def to_http_health_params
|
109
|
+
{
|
110
|
+
port: http_health_port,
|
111
|
+
path: http_health_path
|
112
|
+
}
|
113
|
+
end
|
114
|
+
|
115
|
+
private
|
116
|
+
|
117
|
+
def parse_sentinel(sentinel)
|
118
|
+
return sentinel.transform_keys!(&:to_sym) if sentinel.is_a?(Hash)
|
119
|
+
|
120
|
+
uri = URI.parse("redis://#{sentinel}")
|
121
|
+
|
122
|
+
{host: uri.host, port: uri.port}.tap do |opts|
|
123
|
+
opts[:password] = uri.password if uri.password
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|
127
|
+
end
|