anycable 1.0.1 → 1.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.
@@ -1,35 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "anycable/broadcast_adapters/base"
4
-
5
- module AnyCable
6
- module BroadcastAdapters # :nodoc:
7
- module_function
8
-
9
- # rubocop: disable Metrics/AbcSize, Metrics/MethodLength
10
- def lookup_adapter(args)
11
- adapter, options = Array(args)
12
- path_to_adapter = "anycable/broadcast_adapters/#{adapter}"
13
- adapter_class_name = adapter.to_s.split("_").map(&:capitalize).join
14
-
15
- unless BroadcastAdapters.const_defined?(adapter_class_name, false)
16
- begin
17
- require path_to_adapter
18
- rescue LoadError => e
19
- # We couldn't require the adapter itself.
20
- if e.path == path_to_adapter
21
- raise e.class, "Couldn't load the '#{adapter}' broadcast adapter for AnyCable",
22
- e.backtrace
23
- # Bubbled up from the adapter require.
24
- else
25
- raise e.class, "Error loading the '#{adapter}' broadcast adapter for AnyCable",
26
- e.backtrace
27
- end
28
- end
29
- end
30
-
31
- BroadcastAdapters.const_get(adapter_class_name, false).new(**(options || {}))
32
- end
33
- # rubocop: enable Metrics/AbcSize, Metrics/MethodLength
34
- end
35
- end
@@ -1,29 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module AnyCable
4
- module BroadcastAdapters
5
- class Base
6
- def raw_broadcast(_data)
7
- raise NotImplementedError
8
- end
9
-
10
- def broadcast(stream, payload)
11
- raw_broadcast({stream: stream, data: payload}.to_json)
12
- end
13
-
14
- def broadcast_command(command, **payload)
15
- raw_broadcast({command: command, payload: payload}.to_json)
16
- end
17
-
18
- def announce!
19
- logger.info "Broadcasting via #{self.class.name}"
20
- end
21
-
22
- private
23
-
24
- def logger
25
- AnyCable.logger
26
- end
27
- end
28
- end
29
- end
@@ -1,130 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
- require "uri"
5
- require "net/http"
6
-
7
- module AnyCable
8
- module BroadcastAdapters
9
- # HTTP adapter for broadcasting.
10
- #
11
- # Example:
12
- #
13
- # AnyCable.broadast_adapter = :http
14
- #
15
- # It uses configuration from global AnyCable config
16
- # by default.
17
- #
18
- # You can override these params:
19
- #
20
- # AnyCable.broadcast_adapter = :http, url: "http://ws.example.com/_any_cable_"
21
- class Http < Base
22
- # Taken from: https://github.com/influxdata/influxdb-ruby/blob/886058079c66d4fd019ad74ca11342fddb0b753d/lib/influxdb/errors.rb#L18
23
- RECOVERABLE_EXCEPTIONS = [
24
- Errno::ECONNABORTED,
25
- Errno::ECONNREFUSED,
26
- Errno::ECONNRESET,
27
- Errno::EHOSTUNREACH,
28
- Errno::EINVAL,
29
- Errno::ENETUNREACH,
30
- Net::HTTPBadResponse,
31
- Net::HTTPHeaderSyntaxError,
32
- Net::ProtocolError,
33
- SocketError,
34
- (OpenSSL::SSL::SSLError if defined?(OpenSSL))
35
- ].compact.freeze
36
-
37
- OPEN_TIMEOUT = 5
38
- READ_TIMEOUT = 10
39
-
40
- MAX_ATTEMPTS = 3
41
- DELAY = 2
42
-
43
- attr_reader :url, :headers, :authorized
44
- alias authorized? authorized
45
-
46
- def initialize(url: AnyCable.config.http_broadcast_url, secret: AnyCable.config.http_broadcast_secret)
47
- @url = url
48
- @headers = {}
49
- if secret
50
- headers["Authorization"] = "Bearer #{secret}"
51
- @authorized = true
52
- end
53
-
54
- @uri = URI.parse(url)
55
- @queue = Queue.new
56
- end
57
-
58
- def raw_broadcast(payload)
59
- ensure_thread_is_alive
60
- queue << payload
61
- end
62
-
63
- # Wait for background thread to process all the messages
64
- # and stop it
65
- def shutdown
66
- queue << :stop
67
- thread.join if thread&.alive?
68
- rescue Exception => e # rubocop:disable Lint/RescueException
69
- logger.error "Broadcasting thread exited with exception: #{e.message}"
70
- end
71
-
72
- def announce!
73
- logger.info "Broadcasting HTTP url: #{url}#{authorized? ? " (with authorization)" : ""}"
74
- end
75
-
76
- private
77
-
78
- attr_reader :uri, :queue, :thread
79
-
80
- def ensure_thread_is_alive
81
- return if thread&.alive?
82
-
83
- @thread = Thread.new do
84
- loop do
85
- msg = queue.pop
86
- break if msg == :stop
87
-
88
- handle_response perform_request(msg)
89
- end
90
- end
91
- end
92
-
93
- def perform_request(payload)
94
- build_http do |http|
95
- req = Net::HTTP::Post.new(url, {"Content-Type" => "application/json"}.merge(headers))
96
- req.body = payload
97
- http.request(req)
98
- end
99
- end
100
-
101
- def handle_response(response)
102
- return unless response
103
- return if Net::HTTPCreated === response
104
-
105
- logger.error "Broadcast request responded with unexpected status: #{response.code}"
106
- end
107
-
108
- def build_http
109
- retry_count = 0
110
-
111
- begin
112
- http = Net::HTTP.new(uri.host, uri.port)
113
- http.open_timeout = OPEN_TIMEOUT
114
- http.read_timeout = READ_TIMEOUT
115
- http.use_ssl = url.match?(/^https/)
116
- http.verify_mode = OpenSSL::SSL::VERIFY_NONE
117
- yield http
118
- rescue Timeout::Error, *RECOVERABLE_EXCEPTIONS => e
119
- retry_count += 1
120
- return logger.error("Broadcast request failed: #{e.message}") if MAX_ATTEMPTS < retry_count
121
-
122
- sleep((DELAY**retry_count) * retry_count)
123
- retry
124
- ensure
125
- http.finish if http.started?
126
- end
127
- end
128
- end
129
- end
130
- end
@@ -1,43 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- gem "redis", ">= 3"
4
-
5
- require "redis"
6
- require "json"
7
-
8
- module AnyCable
9
- module BroadcastAdapters
10
- # Redis adapter for broadcasting.
11
- #
12
- # Example:
13
- #
14
- # AnyCable.broadast_adapter = :redis
15
- #
16
- # It uses Redis configuration from global AnyCable config
17
- # by default.
18
- #
19
- # You can override these params:
20
- #
21
- # AnyCable.broadcast_adapter = :redis, url: "redis://my_redis", channel: "_any_cable_"
22
- class Redis < Base
23
- attr_reader :redis_conn, :channel
24
-
25
- def initialize(
26
- channel: AnyCable.config.redis_channel,
27
- **options
28
- )
29
- options = AnyCable.config.to_redis_params.merge(options)
30
- @redis_conn = ::Redis.new(options)
31
- @channel = channel
32
- end
33
-
34
- def raw_broadcast(payload)
35
- redis_conn.publish(channel, payload)
36
- end
37
-
38
- def announce!
39
- logger.info "Broadcasting Redis channel: #{channel}"
40
- end
41
- end
42
- end
43
- end
data/lib/anycable/cli.rb DELETED
@@ -1,353 +0,0 @@
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 gRPC server
11
- # rubocop:disable Metrics/ClassLength
12
- class CLI
13
- # (not-so-big) List of common boot files for
14
- # different applications
15
- APP_CANDIDATES = %w[
16
- ./config/anycable.rb
17
- ./config/environment.rb
18
- ].freeze
19
-
20
- # Wait for external process termination (s)
21
- WAIT_PROCESS = 2
22
-
23
- attr_reader :server, :health_server, :embedded
24
- alias embedded? embedded
25
-
26
- def initialize(embedded: false)
27
- @embedded = embedded
28
- end
29
-
30
- # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
31
- def run(args = [])
32
- @at_stop = []
33
-
34
- extra_options = parse_cli_options!(args)
35
-
36
- # Boot app first, 'cause it might change
37
- # configuration, loggin settings, etc.
38
- boot_app! unless embedded?
39
-
40
- parse_gem_options!(extra_options)
41
-
42
- configure_server!
43
-
44
- logger.info "Starting AnyCable gRPC server (pid: #{Process.pid}, workers_num: #{config.rpc_pool_size})"
45
-
46
- print_versions!
47
-
48
- logger.info "Serving #{defined?(::Rails) ? "Rails " : ""}application from #{boot_file}" unless embedded?
49
-
50
- verify_connection_factory!
51
-
52
- log_grpc! if config.log_grpc
53
-
54
- log_errors!
55
-
56
- use_version_check! if config.version_check_enabled?
57
-
58
- @server = AnyCable::Server.new(
59
- host: config.rpc_host,
60
- **config.to_grpc_params,
61
- interceptors: AnyCable.middleware.to_a
62
- )
63
-
64
- # Make sure middlewares are not adding after server has started
65
- AnyCable.middleware.freeze
66
-
67
- start_health_server! if config.http_health_port_provided?
68
- start_pubsub!
69
-
70
- server.start
71
-
72
- run_custom_server_command! unless server_command.nil?
73
-
74
- return if embedded?
75
-
76
- begin
77
- wait_till_terminated
78
- rescue Interrupt => e
79
- logger.info "Stopping... #{e.message}"
80
-
81
- shutdown
82
-
83
- logger.info "Stopped. Good-bye!"
84
- exit(0)
85
- end
86
- end
87
- # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
88
-
89
- def shutdown
90
- at_stop.each(&:call)
91
- server&.stop
92
- end
93
-
94
- private
95
-
96
- attr_reader :boot_file, :server_command
97
-
98
- def config
99
- AnyCable.config
100
- end
101
-
102
- def logger
103
- AnyCable.logger
104
- end
105
-
106
- def at_stop
107
- if block_given?
108
- @at_stop << Proc.new
109
- else
110
- @at_stop
111
- end
112
- end
113
-
114
- def wait_till_terminated
115
- self_read = setup_signals
116
-
117
- while readable_io = IO.select([self_read]) # rubocop:disable Lint/AssignmentInCondition
118
- signal = readable_io.first[0].gets.strip
119
- raise Interrupt, "SIG#{signal} received"
120
- end
121
- end
122
-
123
- def setup_signals
124
- self_read, self_write = IO.pipe
125
-
126
- %w[INT TERM].each do |signal|
127
- trap signal do
128
- self_write.puts signal
129
- end
130
- end
131
-
132
- self_read
133
- end
134
-
135
- def print_versions!
136
- logger.info "AnyCable version: #{AnyCable::VERSION} (proto_version: #{AnyCable::PROTO_VERSION})"
137
- logger.info "gRPC version: #{GRPC::VERSION}"
138
- end
139
-
140
- # rubocop:disable Metrics/MethodLength
141
- def boot_app!
142
- @boot_file ||= try_detect_app
143
-
144
- if boot_file.nil?
145
- $stdout.puts(
146
- "Couldn't find an application to load. " \
147
- "Please specify the explicit path via -r option, e.g:" \
148
- " anycable -r ./config/boot.rb or anycable -r /app/config/load_me.rb"
149
- )
150
- exit(1)
151
- end
152
-
153
- begin
154
- require boot_file
155
- rescue LoadError => e
156
- $stdout.puts(
157
- "Failed to load application: #{e.message}. " \
158
- "Please specify the explicit path via -r option, e.g:" \
159
- " anycable -r ./config/boot.rb or anycable -r /app/config/load_me.rb"
160
- )
161
- exit(1)
162
- end
163
- end
164
- # rubocop:enable Metrics/MethodLength
165
-
166
- def try_detect_app
167
- APP_CANDIDATES.detect { |path| File.exist?(path) }
168
- end
169
-
170
- def configure_server!
171
- AnyCable.server_callbacks.each(&:call)
172
- end
173
-
174
- def use_version_check!
175
- require "anycable/middlewares/check_version"
176
-
177
- AnyCable.middleware.use(
178
- AnyCable::Middlewares::CheckVersion.new(AnyCable::PROTO_VERSION)
179
- )
180
- end
181
-
182
- def start_health_server!
183
- @health_server = AnyCable::HealthServer.new(
184
- server,
185
- **config.to_http_health_params
186
- )
187
- health_server.start
188
-
189
- at_stop { health_server.stop }
190
- end
191
-
192
- def start_pubsub!
193
- AnyCable.broadcast_adapter.announce!
194
- end
195
-
196
- # rubocop: disable Metrics/MethodLength, Metrics/AbcSize
197
- def run_custom_server_command!
198
- pid = nil
199
- stopped = false
200
- command_thread = Thread.new do
201
- pid = Process.spawn(server_command)
202
- logger.info "Started command: #{server_command} (pid: #{pid})"
203
-
204
- Process.wait pid
205
- pid = nil
206
- raise Interrupt, "Server command exit unexpectedly" unless stopped
207
- end
208
-
209
- command_thread.abort_on_exception = true
210
-
211
- at_stop do
212
- stopped = true
213
- next if pid.nil?
214
-
215
- Process.kill("SIGTERM", pid)
216
-
217
- logger.info "Wait till process #{pid} stop..."
218
-
219
- tick = 0
220
-
221
- loop do
222
- tick += 0.2
223
- break if tick > WAIT_PROCESS
224
-
225
- if pid.nil?
226
- logger.info "Process #{pid} stopped."
227
- break
228
- end
229
- end
230
- end
231
- end
232
- # rubocop: enable Metrics/MethodLength, Metrics/AbcSize
233
-
234
- def log_grpc!
235
- ::GRPC.define_singleton_method(:logger) { AnyCable.logger }
236
- end
237
-
238
- # Add default exceptions handler: print error message to log
239
- def log_errors!
240
- if AnyCable.config.debug?
241
- # Print error with backtrace in debug mode
242
- AnyCable.capture_exception do |e|
243
- AnyCable.logger.error("#{e.message}:\n#{e.backtrace.take(20).join("\n")}")
244
- end
245
- else
246
- AnyCable.capture_exception { |e| AnyCable.logger.error(e.message) }
247
- end
248
- end
249
-
250
- def verify_connection_factory!
251
- return if AnyCable.connection_factory
252
-
253
- logger.error "AnyCable connection factory must be configured. " \
254
- "Make sure you've required a gem (e.g. `anycable-rails`) or " \
255
- "configured `AnyCable.connection_factory` yourself"
256
- exit(1)
257
- end
258
-
259
- def parse_gem_options!(args)
260
- config.parse_options!(args)
261
- rescue OptionParser::InvalidOption => e
262
- $stdout.puts e.message
263
- $stdout.puts "Run anycable -h to see available options"
264
- exit(1)
265
- end
266
-
267
- # rubocop:disable Metrics/MethodLength
268
- def parse_cli_options!(args)
269
- unknown_opts = []
270
-
271
- parser = build_cli_parser
272
-
273
- begin
274
- parser.parse!(args)
275
- rescue OptionParser::InvalidOption => e
276
- unknown_opts << e.args[0]
277
- unless args.size.zero?
278
- unknown_opts << args.shift unless args.first.start_with?("-")
279
- retry
280
- end
281
- end
282
-
283
- unknown_opts
284
- end
285
-
286
- def build_cli_parser
287
- OptionParser.new do |o|
288
- o.on "-v", "--version", "Print version and exit" do |_arg|
289
- $stdout.puts "AnyCable v#{AnyCable::VERSION}"
290
- exit(0)
291
- end
292
-
293
- o.on "-r", "--require [PATH|DIR]", "Location of application file to require" do |arg|
294
- @boot_file = arg
295
- end
296
-
297
- o.on "--server-command VALUE", "Command to run WebSocket server" do |arg|
298
- @server_command = arg
299
- end
300
-
301
- o.on_tail "-h", "--help", "Show help" do
302
- $stdout.puts usage
303
- exit(0)
304
- end
305
- end
306
- end
307
- # rubocop:enable Metrics/MethodLength
308
-
309
- def usage
310
- <<~HELP
311
- anycable: run AnyCable gRPC server (https://anycable.io)
312
-
313
- VERSION
314
- anycable/#{AnyCable::VERSION}
315
-
316
- USAGE
317
- $ anycable [options]
318
-
319
- BASIC OPTIONS
320
- -r, --require=path Location of application file to require, default: "config/environment.rb"
321
- --server-command=command Command to run WebSocket server
322
- --rpc-host=host Local address to run gRPC server on, default: "[::]:50051"
323
- --broadcast-adapter=type Pub/sub adapter type for broadcasts, default: redis
324
- --log-level=level Logging level, default: "info"
325
- --log-file=path Path to log file, default: <none> (log to STDOUT)
326
- --log-grpc Enable gRPC logging (disabled by default)
327
- --debug Turn on verbose logging ("debug" level and gRPC logging on)
328
- -v, --version Print version and exit
329
- -h, --help Show this help
330
-
331
- REDIS PUB/SUB OPTIONS
332
- --redis-url=url Redis URL for pub/sub, default: REDIS_URL or "redis://localhost:6379/5"
333
- --redis-channel=name Redis channel for broadcasting, default: "__anycable__"
334
- --redis-sentinels=<...hosts> Redis Sentinel followers addresses (as a comma-separated list), default: nil
335
-
336
- HTTP PUB/SUB OPTIONS
337
- --http-broadcast-url HTTP pub/sub endpoint URL, default: "http://localhost:8090/_broadcast"
338
- --http-broadcast-secret HTTP pub/sub authorization secret, default: <none> (disabled)
339
-
340
- HTTP HEALTH CHECKER OPTIONS
341
- --http-health-port=port Port to run HTTP health server on, default: <none> (disabled)
342
- --http-health-path=path Endpoint to server health cheks, default: "/health"
343
-
344
- GRPC OPTIONS
345
- --rpc-pool-size=size gRPC workers pool size, default: 30
346
- --rpc-max-waiting-requests=num Max waiting requests queue size, default: 20
347
- --rpc-poll-period=seconds Poll period (sec), default: 1
348
- --rpc-pool-keep-alive=seconds Keep-alive polling interval (sec), default: 1
349
- HELP
350
- end
351
- end
352
- # rubocop:enable Metrics/ClassLength
353
- end