anycable-core 1.1.0.pre1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +84 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +78 -0
  5. data/bin/anycable +13 -0
  6. data/bin/anycabled +30 -0
  7. data/bin/console +7 -0
  8. data/bin/setup +6 -0
  9. data/lib/anycable.rb +114 -0
  10. data/lib/anycable/broadcast_adapters.rb +34 -0
  11. data/lib/anycable/broadcast_adapters/base.rb +29 -0
  12. data/lib/anycable/broadcast_adapters/http.rb +131 -0
  13. data/lib/anycable/broadcast_adapters/redis.rb +46 -0
  14. data/lib/anycable/cli.rb +319 -0
  15. data/lib/anycable/config.rb +127 -0
  16. data/lib/anycable/exceptions_handling.rb +35 -0
  17. data/lib/anycable/grpc.rb +30 -0
  18. data/lib/anycable/grpc/check_version.rb +33 -0
  19. data/lib/anycable/grpc/config.rb +53 -0
  20. data/lib/anycable/grpc/handler.rb +25 -0
  21. data/lib/anycable/grpc/rpc_services_pb.rb +24 -0
  22. data/lib/anycable/grpc/server.rb +103 -0
  23. data/lib/anycable/health_server.rb +73 -0
  24. data/lib/anycable/middleware.rb +10 -0
  25. data/lib/anycable/middleware_chain.rb +74 -0
  26. data/lib/anycable/middlewares/exceptions.rb +35 -0
  27. data/lib/anycable/protos/rpc_pb.rb +74 -0
  28. data/lib/anycable/rpc.rb +91 -0
  29. data/lib/anycable/rpc/handler.rb +50 -0
  30. data/lib/anycable/rpc/handlers/command.rb +36 -0
  31. data/lib/anycable/rpc/handlers/connect.rb +33 -0
  32. data/lib/anycable/rpc/handlers/disconnect.rb +27 -0
  33. data/lib/anycable/rspec.rb +4 -0
  34. data/lib/anycable/rspec/rpc_command_context.rb +21 -0
  35. data/lib/anycable/socket.rb +169 -0
  36. data/lib/anycable/version.rb +5 -0
  37. data/sig/anycable.rbs +37 -0
  38. data/sig/anycable/broadcast_adapters.rbs +5 -0
  39. data/sig/anycable/cli.rbs +40 -0
  40. data/sig/anycable/config.rbs +46 -0
  41. data/sig/anycable/exceptions_handling.rbs +14 -0
  42. data/sig/anycable/health_server.rbs +21 -0
  43. data/sig/anycable/middleware.rbs +5 -0
  44. data/sig/anycable/middleware_chain.rbs +22 -0
  45. data/sig/anycable/rpc.rbs +143 -0
  46. data/sig/anycable/socket.rbs +40 -0
  47. data/sig/anycable/version.rbs +3 -0
  48. 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
@@ -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