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