raptor 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.
@@ -0,0 +1,249 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ require "socket"
5
+ require "uri"
6
+
7
+ module Raptor
8
+ # Manages binding to network addresses and creating listening sockets.
9
+ #
10
+ # Binder handles parsing URI bind specifications, creating TCP, Unix, and SSL
11
+ # server sockets, and managing socket options for optimal performance. It
12
+ # supports binding to multiple addresses simultaneously.
13
+ #
14
+ # @example TCP binding
15
+ # binder = Binder.new(["tcp://0.0.0.0:3000", "tcp://[::1]:3000"])
16
+ # puts binder.addresses #=> ["0.0.0.0:3000", "[::1]:3000"]
17
+ # binder.close
18
+ #
19
+ # @example Unix socket binding
20
+ # binder = Binder.new(["unix:///tmp/raptor.sock"])
21
+ # puts binder.addresses #=> ["/tmp/raptor.sock"]
22
+ # binder.close
23
+ #
24
+ # @example SSL binding
25
+ # binder = Binder.new(["ssl://0.0.0.0:443?cert=/path/to.crt&key=/path/to.key"])
26
+ # puts binder.addresses #=> ["ssl://0.0.0.0:443"]
27
+ # binder.close
28
+ #
29
+ # @example Localhost binding
30
+ # binder = Binder.new(["tcp://localhost:8080"])
31
+ # # Binds to both IPv4 and IPv6 loopback addresses
32
+ #
33
+ class Binder
34
+ SOCKET_BACKLOG = 1024
35
+
36
+ class UnknownBindSchemeError < TypeError
37
+ # @rbs (String scheme) -> void
38
+ def initialize(scheme) = super("unknown scheme: #{scheme.inspect}")
39
+ end
40
+
41
+ # Wraps a TCPServer with an SSL context for accepting SSL connections.
42
+ #
43
+ # Holds both the underlying TCP server and the SSL context together so
44
+ # the server thread can accept a TCP connection and then perform the SSL
45
+ # handshake in a single coordinated step.
46
+ #
47
+ SslListener = Data.define(:tcp_server, :ssl_context) do
48
+ # @rbs () -> TCPServer
49
+ def to_io = tcp_server
50
+
51
+ # @rbs () -> Addrinfo
52
+ def local_address = tcp_server.local_address
53
+
54
+ # @rbs () -> void
55
+ def close = tcp_server.close
56
+ end
57
+
58
+ # @rbs @bind_uris: Array[String]
59
+ # @rbs @listeners: Array[TCPServer | UNIXServer | SslListener]
60
+
61
+ # Array of listening sockets.
62
+ #
63
+ # @return [Array<TCPServer, UNIXServer, SslListener>] the server sockets
64
+ attr_reader :listeners
65
+
66
+ # Creates a new Binder with the specified bind URIs.
67
+ #
68
+ # Parses the provided bind URIs and creates listening sockets for each one.
69
+ # Supports tcp://, unix://, and ssl:// schemes. Localhost is expanded to
70
+ # all available loopback addresses (both IPv4 and IPv6).
71
+ #
72
+ # @param bind_uris [Array<String>] array of URI strings to bind to
73
+ # @return [void]
74
+ # @raise [UnknownBindSchemeError] if a URI has an unsupported scheme
75
+ #
76
+ # @example
77
+ # binder = Binder.new(["tcp://0.0.0.0:3000", "unix:///tmp/raptor.sock"])
78
+ #
79
+ # @rbs (Array[String] bind_uris) -> void
80
+ def initialize(bind_uris)
81
+ @bind_uris = bind_uris
82
+ @listeners = nil
83
+ parse
84
+ end
85
+
86
+ # Returns the bound addresses as strings.
87
+ #
88
+ # TCP listeners are returned as "host:port", Unix listeners as the socket
89
+ # path, and SSL listeners as "ssl://host:port".
90
+ #
91
+ # @return [Array<String>] address strings for each bound listener
92
+ #
93
+ # @example
94
+ # binder.addresses #=> ["127.0.0.1:3000", "/tmp/raptor.sock", "ssl://0.0.0.0:443"]
95
+ #
96
+ # @rbs () -> Array[String]
97
+ def addresses
98
+ @listeners.map do |listener|
99
+ case listener
100
+ when UNIXServer
101
+ listener.path
102
+ when SslListener
103
+ address = listener.local_address
104
+ "ssl://#{address.ip_address}:#{address.ip_port}"
105
+ else
106
+ address = listener.local_address
107
+ "#{address.ip_address}:#{address.ip_port}"
108
+ end
109
+ end
110
+ end
111
+
112
+ # Returns the port number of the first TCP or SSL listener.
113
+ #
114
+ # Used to populate SERVER_PORT in the Rack environment. Returns 0
115
+ # if no TCP or SSL listener is configured (e.g., Unix socket only).
116
+ #
117
+ # @return [Integer] the port number, or 0 if no TCP listener exists
118
+ #
119
+ # @rbs () -> Integer
120
+ def server_port
121
+ tcp_listener = @listeners.find { |listener| listener.is_a?(TCPServer) || listener.is_a?(SslListener) }
122
+ return 0 unless tcp_listener
123
+
124
+ tcp_listener.local_address.ip_port
125
+ end
126
+
127
+ # Closes all listening sockets.
128
+ #
129
+ # @return [void]
130
+ #
131
+ # @rbs () -> void
132
+ def close
133
+ @listeners.each(&:close)
134
+ end
135
+
136
+ private
137
+
138
+ # Parses bind URIs and creates listening sockets.
139
+ #
140
+ # @return [void]
141
+ # @raise [UnknownBindSchemeError] if a URI scheme is not supported
142
+ #
143
+ # @rbs () -> void
144
+ def parse
145
+ @listeners = @bind_uris.map do |bind_uri|
146
+ uri = URI.parse(bind_uri)
147
+ case uri.scheme
148
+ when "tcp"
149
+ create_tcp_listeners(uri.host, uri.port)
150
+ when "unix"
151
+ create_unix_listeners(uri.path)
152
+ when "ssl"
153
+ create_ssl_listeners(uri.host, uri.port, URI.decode_www_form(uri.query || "").to_h)
154
+ else
155
+ raise UnknownBindSchemeError.new(uri.scheme)
156
+ end
157
+ end.tap(&:flatten!)
158
+ end
159
+
160
+ # Creates TCP server sockets for the given host and port.
161
+ #
162
+ # @param host [String, nil] hostname or IP address to bind to
163
+ # @param port [Integer, nil] port number to bind to
164
+ # @return [Array<TCPServer>] array containing the created TCP server socket(s)
165
+ #
166
+ # @rbs (String? host, Integer? port) -> Array[TCPServer]
167
+ def create_tcp_listeners(host, port)
168
+ if host == "localhost"
169
+ return loopback_addresses.map { |address| create_tcp_listeners(address, port) }.flatten
170
+ end
171
+
172
+ host = host[1..-2] if host&.start_with?("[")
173
+
174
+ tcp_server = TCPServer.new(host, port)
175
+ tcp_server.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEADDR, true)
176
+ tcp_server.listen SOCKET_BACKLOG
177
+
178
+ [tcp_server]
179
+ end
180
+
181
+ # Creates a Unix domain server socket at the given path.
182
+ #
183
+ # Removes stale socket files left by crashed processes (when the socket
184
+ # is not currently in use). Registers an at_exit hook to clean up the
185
+ # socket file on normal process exit.
186
+ #
187
+ # @param path [String] filesystem path for the Unix socket
188
+ # @return [Array<UNIXServer>] array containing the created Unix server socket
189
+ # @raise [RuntimeError] if the socket path is already in active use
190
+ #
191
+ # @rbs (String path) -> Array[UNIXServer]
192
+ def create_unix_listeners(path)
193
+ if File.exist?(path)
194
+ begin
195
+ UNIXSocket.new(path).close
196
+ raise "Socket #{path.inspect} is already in use"
197
+ rescue Errno::ECONNREFUSED
198
+ File.delete(path)
199
+ end
200
+ end
201
+
202
+ server = UNIXServer.new(path)
203
+ master_pid = Process.pid
204
+ at_exit { File.delete(path) rescue nil if Process.pid == master_pid }
205
+
206
+ [server]
207
+ end
208
+
209
+ # Creates SSL server sockets for the given host, port, and SSL parameters.
210
+ #
211
+ # Wraps each TCP listener with an SSL context to produce SslListener objects.
212
+ # The ssl_params hash must include "cert" and "key" entries pointing to the
213
+ # certificate and private key files respectively.
214
+ #
215
+ # @param host [String, nil] hostname or IP address to bind to
216
+ # @param port [Integer, nil] port number to bind to
217
+ # @param ssl_params [Hash<String, String>] SSL options ("cert" and "key" paths)
218
+ # @return [Array<SslListener>] array containing the created SSL listener(s)
219
+ #
220
+ # @rbs (String? host, Integer? port, Hash[String, String] ssl_params) -> Array[SslListener]
221
+ def create_ssl_listeners(host, port, ssl_params)
222
+ require "openssl"
223
+
224
+ tcp_servers = create_tcp_listeners(host, port)
225
+
226
+ ssl_context = OpenSSL::SSL::SSLContext.new
227
+ ssl_context.cert = OpenSSL::X509::Certificate.new(File.read(ssl_params["cert"]))
228
+ ssl_context.key = OpenSSL::PKey.read(File.read(ssl_params["key"]))
229
+ ssl_context.alpn_protocols = ["h2", "http/1.1"]
230
+ ssl_context.alpn_select_cb = ->(protocols) { protocols.include?("h2") ? "h2" : "http/1.1" }
231
+ ssl_context.freeze
232
+
233
+ tcp_servers.map { |tcp_server| SslListener.new(tcp_server: tcp_server, ssl_context: ssl_context) }
234
+ end
235
+
236
+ # Returns all available loopback IP addresses.
237
+ #
238
+ # @return [Array<String>] unique loopback addresses (IPv4 and IPv6)
239
+ #
240
+ # @rbs () -> Array[String]
241
+ def loopback_addresses
242
+ Socket.ip_address_list.filter_map do |addrinfo|
243
+ next unless addrinfo.ipv4_loopback? || addrinfo.ipv6_loopback?
244
+
245
+ addrinfo.ip_address
246
+ end.tap(&:uniq!)
247
+ end
248
+ end
249
+ end
data/lib/raptor/cli.rb ADDED
@@ -0,0 +1,171 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ require "etc"
5
+ require "json"
6
+ require "optparse"
7
+
8
+ require_relative "cluster"
9
+
10
+ module Raptor
11
+ # Command-line interface for the Raptor web server.
12
+ #
13
+ # CLI parses command-line arguments and starts the server cluster with the
14
+ # specified configuration options. It supports configuring the number of
15
+ # workers, threads, ractors, bind addresses, and various client timeout
16
+ # settings.
17
+ #
18
+ # @example Basic usage
19
+ # cli = Raptor::CLI.new(["config.ru", "-t", "8", "-w", "4"])
20
+ # cli.run
21
+ #
22
+ # @example With custom timeouts
23
+ # cli = Raptor::CLI.new(["--first-data-timeout", "60", "--threads", "8"])
24
+ # cli.run
25
+ #
26
+ class CLI
27
+ DEFAULT_WORKER_COUNT = Etc.nprocessors
28
+
29
+ DEFAULT_OPTIONS = {
30
+ binds: ["tcp://0.0.0.0:9292"].freeze,
31
+ threads: 3,
32
+ ractors: 1,
33
+ workers: DEFAULT_WORKER_COUNT,
34
+ rackup: "config.ru",
35
+ stats_file: "tmp/raptor.json",
36
+ client: {
37
+ first_data_timeout: 30,
38
+ chunk_data_timeout: 10,
39
+ persistent_data_timeout: 65,
40
+ },
41
+ }.freeze
42
+
43
+ # @rbs @command: Symbol
44
+ # @rbs @options: Hash[Symbol, untyped]
45
+ # @rbs @parser: OptionParser
46
+
47
+ # Creates a new CLI instance and parses command-line arguments.
48
+ #
49
+ # Parses the provided command-line arguments and configures the server
50
+ # options accordingly. A rackup file can be provided as the first
51
+ # positional argument (defaults to config.ru).
52
+ #
53
+ # @param argv [Array<String>] command-line arguments to parse
54
+ # @return [void]
55
+ # @raise [OptionParser::ParseError] if invalid options are provided
56
+ #
57
+ # @example With rackup file
58
+ # cli = CLI.new(["my_app.ru", "-w", "4"])
59
+ #
60
+ # @example With options only
61
+ # cli = CLI.new(["-t", "8", "-r", "2"])
62
+ #
63
+ # @rbs (Array[String] argv) -> void
64
+ def initialize(argv)
65
+ if argv.first == "stats"
66
+ argv.shift
67
+ @command = :stats
68
+ else
69
+ @command = :server
70
+ end
71
+ @options = DEFAULT_OPTIONS.dup
72
+ @options[:client] = @options[:client].dup
73
+ @parser = create_parser
74
+ @parser.parse!(argv)
75
+
76
+ @options[:rackup] = argv.first if @command == :server && argv.first
77
+ end
78
+
79
+ # Runs the requested command.
80
+ #
81
+ # @return [void]
82
+ #
83
+ # @rbs () -> void
84
+ def run
85
+ @command == :stats ? run_stats : Cluster.run(@options)
86
+ end
87
+
88
+ private
89
+
90
+ # Reads and prints the stats file.
91
+ #
92
+ # @return [void]
93
+ #
94
+ # @rbs () -> void
95
+ def run_stats
96
+ stats_file = @options[:stats_file]
97
+
98
+ unless File.exist?(stats_file)
99
+ warn "No stats file at #{stats_file.inspect}. Is Raptor running?"
100
+ exit 1
101
+ end
102
+
103
+ data = JSON.parse(File.read(stats_file), symbolize_names: true)
104
+
105
+ puts "Master PID: #{data[:master_pid]}"
106
+ data[:workers].each_with_index do |worker, index|
107
+ status = worker[:booted] ? "booted" : "starting"
108
+ last_checkin = Time.at(worker[:last_checkin]).strftime("%H:%M:%S")
109
+ puts "Worker #{index}: pid=#{worker[:pid]}, requests=#{worker[:requests]}, " \
110
+ "backlog=#{worker[:backlog]}, #{status}, last_checkin=#{last_checkin}"
111
+ end
112
+ end
113
+
114
+ # Creates the OptionParser instance with all supported command-line options.
115
+ #
116
+ # @return [OptionParser] configured option parser
117
+ #
118
+ # @rbs () -> OptionParser
119
+ def create_parser
120
+ OptionParser.new do |opts|
121
+ opts.banner = "Usage: raptor [options] [rackup file]"
122
+
123
+ opts.on("-b", "--bind URI", String, "Bind address (default: tcp://0.0.0.0:9292)") do |bind|
124
+ if @options[:binds] == DEFAULT_OPTIONS[:binds]
125
+ @options[:binds] = [bind]
126
+ else
127
+ @options[:binds] << bind
128
+ end
129
+ end
130
+
131
+ opts.on("-t", "--threads NUM", Integer, "Number of threads (default: 3)") do |num|
132
+ @options[:threads] = num
133
+ end
134
+
135
+ opts.on("-r", "--ractors NUM", Integer, "Number of ractors (default: 1)") do |num|
136
+ @options[:ractors] = num
137
+ end
138
+
139
+ opts.on("-w", "--workers NUM", Integer, "Number of worker processes (default: #{DEFAULT_WORKER_COUNT})") do |num|
140
+ @options[:workers] = num
141
+ end
142
+
143
+ opts.on("--first-data-timeout SECONDS", Integer, "First data timeout in seconds (default: 30)") do |timeout|
144
+ @options[:client][:first_data_timeout] = timeout
145
+ end
146
+
147
+ opts.on("--chunk-data-timeout SECONDS", Integer, "Chunk data timeout in seconds (default: 10)") do |timeout|
148
+ @options[:client][:chunk_data_timeout] = timeout
149
+ end
150
+
151
+ opts.on("--persistent-data-timeout SECONDS", Integer, "Persistent data timeout in seconds (default: 65)") do |timeout|
152
+ @options[:client][:persistent_data_timeout] = timeout
153
+ end
154
+
155
+ opts.on("--stats-file PATH", String, "Stats file path (default: tmp/raptor.json)") do |path|
156
+ @options[:stats_file] = path
157
+ end
158
+
159
+ opts.on("--help", "Show this help") do
160
+ puts opts
161
+ exit
162
+ end
163
+
164
+ opts.on("-v", "--version", "Show version") do
165
+ puts Raptor::VERSION
166
+ exit
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end