raptor 0.2.0 → 0.3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8391f9399db2e88dab106ad995a34060e6ae07f34c84825bfdcb066cd129dad4
4
- data.tar.gz: 25cb3658e2aded284f302b84a1ee91d67344a5b5dd36c6c0e854381cfb4df048
3
+ metadata.gz: 7c247b4c221d0f19ff1383ca0a4092a54bc12cd2f945655fa24e1ab49745eb70
4
+ data.tar.gz: cb932a7a048d87eb0913f8c0dbe52750ea4e525e4c4dc32e5fcc531af0daaf41
5
5
  SHA512:
6
- metadata.gz: e398237ccb391aff663c4d75e5470c0e4e0e6f6b273e26bface4cd6793691c3297053ab0a5d744deac8fd087c4f7dec0beb8426d8a762d3a089902d11dbe53e5
7
- data.tar.gz: c4cfd5f7ffdcf7f970cc8c85868629bac6c6061312120c0dc2a3c64fa6431cf475bc8fbb877dce0bad3febc2bd87ccc5342e2bc9c8dad196c69306431ae91345
6
+ metadata.gz: d8e1543054862e8a87c7261884348456da13d4432c3424687b74f506b884283d2773068b6beb10cbe9e209e969b98a918720861b3d0e05bc04d930ba38ec1921
7
+ data.tar.gz: f215cc4a92af86542d49f2c4772f0a65af09988779bbb1c44512ab075d4fb5c0758cb51d9765a0ee9d08fe340d31b42cea1610b021c443818fe40fbdcea63b93
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.3.0] - 2026-05-25
4
+
5
+ - Load cluster options from a Ruby config file via `--config`
6
+ - Replace workers one at a time on `SIGUSR2` (phased restart)
7
+ - Invoke `:on_error` callback with `(env, exception)` when the Rack app raises
8
+ - Spool request bodies larger than `--body-spool-threshold` to a tempfile
9
+ - Reject HTTP/1.1 requests larger than `--max-body-size` with a 413 response
10
+ - Write pidfile via `--pidfile`
11
+ - Set `SO_REUSEPORT` on TCP listeners where supported
12
+
3
13
  ## [0.2.0] - 2026-05-25
4
14
 
5
15
  - Add Rack handler for booting via `rackup` or `rails server`
data/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # Raptor
2
2
 
3
- Raptor is a high-performance, multi-threaded, multi-process Ruby Rack 3 web server that leverages Ractors for parallel
4
- HTTP/1.1 and HTTP/2 request processing, native C extensions for HTTP parsing and HPACK compression, and NIO for
5
- non-blocking I/O.
3
+ Raptor is a high-performance, preloading, multi-process, multi-threaded Ruby 4+ web server implementing Rack 3+,
4
+ leveraging Ractors for parallel HTTP/1.1 and HTTP/2 request processing, native C extensions for HTTP parsing and HPACK
5
+ compression, and NIO for non-blocking I/O.
6
6
 
7
7
  ## Installation
8
8
 
@@ -31,9 +31,9 @@ run proc { |_env| [200, { "content-type" => "text/plain" }, ["Hello, World!"]] }
31
31
  ```
32
32
  > bundle exec raptor -t 3 -w 4 hello_world.ru
33
33
  Raptor Cluster initializing:
34
- ├─ Version: 0.1.0
34
+ ├─ Version: 0.3.0
35
35
  ├─ Ruby Version: ruby 4.0.4 (2026-05-12 revision b89eb1bcbf) +YJIT +PRISM [arm64-darwin23]
36
- ├─ Master PID: 71052
36
+ ├─ Master PID: 31504
37
37
  │ └─ 4 worker processes
38
38
  │ ├─ 1 server thread
39
39
  │ ├─ 1 reactor thread
@@ -42,10 +42,10 @@ Raptor Cluster initializing:
42
42
  │ ├─ 3 worker threads
43
43
  │ └─ 1 stats thread
44
44
  └─ Listening on 0.0.0.0:9292
45
- [71054] Worker 0 booted
46
- [71055] Worker 1 booted
47
- [71056] Worker 2 booted
48
- [71057] Worker 3 booted
45
+ [31506] Worker 0 booted
46
+ [31507] Worker 1 booted
47
+ [31508] Worker 2 booted
48
+ [31509] Worker 3 booted
49
49
  ```
50
50
 
51
51
  ```
@@ -60,9 +60,9 @@ Also works with `rackup` and `rails server`:
60
60
  > bundle exec rails server -u raptor
61
61
  ```
62
62
 
63
- ## Benchmarks
63
+ ## (Micro) Benchmarks
64
64
 
65
- Raptor 0.1.0 vs Puma 8.0.1:
65
+ Raptor 0.3.0 vs Puma 8.0.1:
66
66
 
67
67
  | Protocol | Raptor | Puma |
68
68
  | --------------------- | ------------ | ------------ |
@@ -44,7 +44,8 @@ module Rackup
44
44
  "Port=PORT" => "Port to listen on (default: #{DEFAULT_OPTIONS[:Port]})",
45
45
  "Threads=NUM" => "Number of threads per worker (default: 3)",
46
46
  "Ractors=NUM" => "Number of pipeline ractors per worker (default: 1)",
47
- "Workers=NUM" => "Number of worker processes (default: nprocessors)"
47
+ "Workers=NUM" => "Number of worker processes (default: nprocessors)",
48
+ "Config=PATH" => "Load additional configuration from PATH"
48
49
  }
49
50
  end
50
51
 
@@ -67,20 +68,31 @@ module Rackup
67
68
  end
68
69
  end
69
70
 
70
- host = options[:Host] || defaults[:Host]
71
- port = options[:Port] || defaults[:Port]
72
-
73
71
  cli_defaults = ::Raptor::CLI::DEFAULT_OPTIONS
72
+ config = options[:Config] ? ::Raptor::CLI.load_config_file(options[:Config]) : {}
74
73
 
75
- {
74
+ binds = if options[:Host] || options[:Port]
75
+ host = options[:Host] || defaults[:Host]
76
+ port = options[:Port] || defaults[:Port]
77
+ ["tcp://#{host}:#{port}"]
78
+ else
79
+ config[:binds] || ["tcp://#{defaults[:Host]}:#{defaults[:Port]}"]
80
+ end
81
+
82
+ result = {
76
83
  app: app,
77
- binds: ["tcp://#{host}:#{port}"],
78
- threads: (options[:Threads] || cli_defaults[:threads]).to_i,
79
- ractors: (options[:Ractors] || cli_defaults[:ractors]).to_i,
80
- workers: (options[:Workers] || Etc.nprocessors).to_i,
81
- client: cli_defaults[:client],
82
- stats_file: nil
84
+ binds: binds,
85
+ threads: (options[:Threads] || config[:threads] || cli_defaults[:threads]).to_i,
86
+ ractors: (options[:Ractors] || config[:ractors] || cli_defaults[:ractors]).to_i,
87
+ workers: (options[:Workers] || config[:workers] || Etc.nprocessors).to_i,
88
+ client: cli_defaults[:client].merge(config[:client] || {})
83
89
  }
90
+
91
+ [:rackup, :on_error, :stats_file, :pidfile].each do |key|
92
+ result[key] = config[key] if config.key?(key)
93
+ end
94
+
95
+ result
84
96
  end
85
97
  private_class_method :build_cluster_options
86
98
  end
data/lib/raptor/binder.rb CHANGED
@@ -173,6 +173,7 @@ module Raptor
173
173
 
174
174
  tcp_server = TCPServer.new(host, port)
175
175
  tcp_server.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEADDR, true)
176
+ tcp_server.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEPORT, true) if Socket.const_defined?(:SO_REUSEPORT)
176
177
  tcp_server.listen SOCKET_BACKLOG
177
178
 
178
179
  [tcp_server]
data/lib/raptor/cli.rb CHANGED
@@ -32,14 +32,35 @@ module Raptor
32
32
  ractors: 1,
33
33
  workers: DEFAULT_WORKER_COUNT,
34
34
  rackup: "config.ru",
35
- stats_file: "tmp/raptor.json",
36
35
  client: {
37
36
  first_data_timeout: 30,
38
37
  chunk_data_timeout: 10,
39
38
  persistent_data_timeout: 65,
39
+ max_body_size: nil,
40
+ body_spool_threshold: 1024 * 1024,
40
41
  },
42
+ stats_file: "tmp/raptor.json",
43
+ pidfile: nil,
41
44
  }.freeze
42
45
 
46
+ # Loads a configuration file and returns the hash it evaluates to.
47
+ #
48
+ # The file is evaluated at the top level so constants like `Raptor::*` resolve
49
+ # the same as in a regular Ruby script. The final expression must be a Hash
50
+ # of cluster options (the same keys accepted by {Raptor::Cluster#initialize}).
51
+ #
52
+ # @param path [String] path to a Ruby file that evaluates to a Hash
53
+ # @return [Hash{Symbol => untyped}] cluster options
54
+ # @raise [ArgumentError] if the file does not evaluate to a Hash
55
+ #
56
+ # @rbs (String path) -> Hash[Symbol, untyped]
57
+ def self.load_config_file(path)
58
+ config = eval(File.read(path), TOPLEVEL_BINDING, path, 1)
59
+ raise ArgumentError, "Config file at #{path.inspect} must return a Hash, got #{config.class}" unless config.is_a?(Hash)
60
+
61
+ config
62
+ end
63
+
43
64
  # @rbs @command: Symbol
44
65
  # @rbs @options: Hash[Symbol, untyped]
45
66
  # @rbs @parser: OptionParser
@@ -70,6 +91,9 @@ module Raptor
70
91
  end
71
92
  @options = DEFAULT_OPTIONS.dup
72
93
  @options[:client] = @options[:client].dup
94
+
95
+ apply_config_file(extract_config_path(argv))
96
+
73
97
  @parser = create_parser
74
98
  @parser.parse!(argv)
75
99
 
@@ -111,6 +135,49 @@ module Raptor
111
135
  end
112
136
  end
113
137
 
138
+ # Scans argv for a `-c`/`--config` flag and returns the configured path.
139
+ #
140
+ # The pre-scan runs before the main OptionParser pass so the config file
141
+ # can be applied as a base layer that CLI args then override. All four
142
+ # OptionParser-accepted forms (`-c PATH`, `-cPATH`, `--config PATH`,
143
+ # `--config=PATH`) are recognized.
144
+ #
145
+ # @param argv [Array<String>] command-line arguments to scan
146
+ # @return [String, nil] the config path, or nil if no flag was supplied
147
+ #
148
+ # @rbs (Array[String] argv) -> String?
149
+ def extract_config_path(argv)
150
+ argv.each_with_index do |arg, i|
151
+ case arg
152
+ when "-c", "--config" then return argv[i + 1]
153
+ when /\A--config=(.*)\z/, /\A-c(.+)\z/ then return Regexp.last_match(1)
154
+ end
155
+ end
156
+ nil
157
+ end
158
+
159
+ # Loads a config file and merges it into `@options` over the defaults.
160
+ #
161
+ # Top-level keys replace defaults; the nested `:client` hash is merged
162
+ # key-by-key so a config file does not need to restate every client option.
163
+ #
164
+ # @param path [String, nil] path to the config file, or nil to no-op
165
+ # @return [void]
166
+ #
167
+ # @rbs (String? path) -> void
168
+ def apply_config_file(path)
169
+ return unless path
170
+
171
+ config = self.class.load_config_file(path)
172
+ config.each do |key, value|
173
+ if key == :client && value.is_a?(Hash)
174
+ @options[:client] = @options[:client].merge(value)
175
+ else
176
+ @options[key] = value
177
+ end
178
+ end
179
+ end
180
+
114
181
  # Creates the OptionParser instance with all supported command-line options.
115
182
  #
116
183
  # @return [OptionParser] configured option parser
@@ -120,6 +187,10 @@ module Raptor
120
187
  OptionParser.new do |opts|
121
188
  opts.banner = "Usage: raptor [options] [rackup file]"
122
189
 
190
+ opts.on("-c", "--config PATH", String, "Load configuration from PATH") do
191
+ # Loaded in #initialize before parsing so CLI args can override config values
192
+ end
193
+
123
194
  opts.on("-b", "--bind URI", String, "Bind address (default: tcp://0.0.0.0:9292)") do |bind|
124
195
  if @options[:binds] == DEFAULT_OPTIONS[:binds]
125
196
  @options[:binds] = [bind]
@@ -152,10 +223,22 @@ module Raptor
152
223
  @options[:client][:persistent_data_timeout] = timeout
153
224
  end
154
225
 
226
+ opts.on("--max-body-size BYTES", Integer, "Maximum request body size in bytes (default: unlimited)") do |bytes|
227
+ @options[:client][:max_body_size] = bytes
228
+ end
229
+
230
+ opts.on("--body-spool-threshold BYTES", Integer, "Spool request bodies larger than this to a tempfile (default: #{1024 * 1024})") do |bytes|
231
+ @options[:client][:body_spool_threshold] = bytes
232
+ end
233
+
155
234
  opts.on("--stats-file PATH", String, "Stats file path (default: tmp/raptor.json)") do |path|
156
235
  @options[:stats_file] = path
157
236
  end
158
237
 
238
+ opts.on("--pidfile PATH", String, "Pidfile path (default: none)") do |path|
239
+ @options[:pidfile] = path
240
+ end
241
+
159
242
  opts.on("--help", "Show this help") do
160
243
  puts opts
161
244
  exit
@@ -58,13 +58,17 @@ module Raptor
58
58
  # @rbs @ractor_count: Integer
59
59
  # @rbs @worker_count: Integer
60
60
  # @rbs @client_options: Hash[Symbol, Integer]
61
+ # @rbs @on_error: ^(Hash[String, untyped]?, Exception) -> void | nil
62
+ # @rbs @stats_file: String?
63
+ # @rbs @pidfile: String?
61
64
  # @rbs @binder: Binder
62
65
  # @rbs @server_port: Integer
63
66
  # @rbs @app: untyped
64
67
  # @rbs @shutdown: bool
65
68
  # @rbs @workers: Hash[Integer, Integer]
66
69
  # @rbs @stats: Stats
67
- # @rbs @stats_file: String?
70
+ # @rbs @phased_restart_requested: bool
71
+ # @rbs @phased_restarting: bool
68
72
 
69
73
  # Creates a new Cluster with the specified configuration.
70
74
  #
@@ -79,7 +83,10 @@ module Raptor
79
83
  # @option options [Array<String>] :binds array of bind URIs
80
84
  # @option options [#call] :app pre-built Rack application
81
85
  # @option options [String] :rackup path to Rack configuration file
82
- # @option options [Hash] :client client timeout configuration
86
+ # @option options [Hash] :client client configuration
87
+ # @option options [#call] :on_error callback invoked with (env, exception) when the Rack app raises
88
+ # @option options [String, nil] :stats_file path to write per-worker stats JSON, or nil to disable
89
+ # @option options [String, nil] :pidfile path to write the master PID to, or nil to disable
83
90
  # @return [void]
84
91
  #
85
92
  # @rbs (Hash[Symbol, untyped] options) -> void
@@ -88,6 +95,9 @@ module Raptor
88
95
  @ractor_count = options[:ractors]
89
96
  @worker_count = options[:workers]
90
97
  @client_options = options[:client]
98
+ @on_error = options[:on_error]
99
+ @stats_file = options[:stats_file]
100
+ @pidfile = options[:pidfile]
91
101
 
92
102
  @binder = Binder.new(options[:binds])
93
103
  @server_port = @binder.server_port
@@ -97,14 +107,16 @@ module Raptor
97
107
  @shutdown = false
98
108
  @workers = {}
99
109
  @stats = Stats.new(@worker_count)
100
- @stats_file = options[:stats_file]
110
+ @phased_restart_requested = false
111
+ @phased_restarting = false
101
112
  end
102
113
 
103
114
  # Starts the multi-process cluster and manages worker processes.
104
115
  #
105
116
  # Forks the configured number of worker processes and monitors them,
106
117
  # automatically restarting any that exit unexpectedly. Handles graceful
107
- # shutdown via INT or TERM signals, and stats logging via USR1.
118
+ # shutdown via INT or TERM signals, stats logging via USR1, and phased
119
+ # restart via USR2.
108
120
  #
109
121
  # Each worker process includes:
110
122
  # - 1 server thread (continuously accepts connections with backpressure control)
@@ -121,6 +133,9 @@ module Raptor
121
133
  trap("INT") { shutdown }
122
134
  trap("TERM") { shutdown }
123
135
  trap("USR1") { log_stats }
136
+ trap("USR2") { @phased_restart_requested = true }
137
+
138
+ File.open(@pidfile, File::CREAT | File::EXCL | File::WRONLY) { |file| file.write(Process.pid.to_s) } if @pidfile
124
139
 
125
140
  @worker_count.times { |index| spawn_worker(index) }
126
141
 
@@ -133,29 +148,18 @@ module Raptor
133
148
  end
134
149
 
135
150
  until @shutdown
136
- begin
137
- pid, status = Process.wait2(-1, Process::WNOHANG)
138
- rescue Errno::ECHILD
139
- break
140
- end
151
+ break if reap_workers == :no_children
141
152
 
142
- if pid
143
- index = @workers.key(pid)
144
- @workers.delete(index)
153
+ perform_phased_restart if @phased_restart_requested && !@phased_restarting
145
154
 
146
- unless @shutdown
147
- warn "[#{Process.pid}] Restarting worker #{index} (#{pid}), #{exit_description(status)}"
148
- spawn_worker(index)
149
- end
150
- else
151
- sleep 0.1
152
- end
155
+ sleep 0.1
153
156
  end
154
157
 
155
158
  @workers.values.each { |pid| Process.kill("TERM", pid) rescue nil }
156
159
  @workers.values.each { |pid| Process.wait(pid) rescue nil }
157
160
  stats_file_thread&.join
158
161
  File.delete(@stats_file) rescue nil if @stats_file
162
+ File.delete(@pidfile) rescue nil if @pidfile
159
163
  @stats.unmap
160
164
  end
161
165
 
@@ -182,6 +186,66 @@ module Raptor
182
186
  @workers[index] = pid
183
187
  end
184
188
 
189
+ # Reaps any worker processes that have exited, respawning each one
190
+ # unless the cluster is shutting down.
191
+ #
192
+ # @return [Symbol] :no_children when there are no remaining children, otherwise :reaped
193
+ #
194
+ # @rbs () -> Symbol
195
+ def reap_workers
196
+ loop do
197
+ pid, status = Process.wait2(-1, Process::WNOHANG)
198
+ return :reaped unless pid
199
+
200
+ index = @workers.key(pid)
201
+ @workers.delete(index)
202
+
203
+ unless @shutdown
204
+ warn "[#{Process.pid}] Restarting worker #{index} (#{pid}), #{exit_description(status)}"
205
+ spawn_worker(index)
206
+ end
207
+ end
208
+ rescue Errno::ECHILD
209
+ :no_children
210
+ end
211
+
212
+ # Replaces each worker process one at a time, waiting for the new
213
+ # worker to boot before moving on to the next. Triggered by SIGUSR2.
214
+ #
215
+ # @return [void]
216
+ #
217
+ # @rbs () -> void
218
+ def perform_phased_restart
219
+ @phased_restart_requested = false
220
+ @phased_restarting = true
221
+ puts "[#{Process.pid}] Phased restart starting"
222
+
223
+ begin
224
+ @workers.keys.sort.each do |index|
225
+ return if @shutdown
226
+
227
+ target_pid = @workers[index]
228
+ next unless target_pid
229
+
230
+ Process.kill("TERM", target_pid) rescue nil
231
+
232
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 60
233
+ until @shutdown
234
+ reap_workers
235
+ current = @workers[index]
236
+ break if current && current != target_pid && @stats.all[index][:booted]
237
+ break if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
238
+
239
+ sleep 0.1
240
+ end
241
+ end
242
+
243
+ puts "[#{Process.pid}] Phased restart complete"
244
+ ensure
245
+ @phased_restarting = false
246
+ end
247
+ end
248
+
185
249
  # Runs the full server stack inside a worker process.
186
250
  #
187
251
  # Sets up and coordinates the reactor, server, ractor pool, thread pool,
@@ -217,8 +281,8 @@ module Raptor
217
281
  @app.call(env)
218
282
  }
219
283
  thread_pool = AtomicThreadPool.new(name: "Raptor Workers", size: @thread_count)
220
- request = Request.new(counting_app, @server_port)
221
- http2 = Http2.new(counting_app, @server_port)
284
+ request = Request.new(counting_app, @server_port, client_options: @client_options, on_error: @on_error)
285
+ http2 = Http2.new(counting_app, @server_port, on_error: @on_error)
222
286
  ractor_pool = RactorPool.new(
223
287
  name: "Raptor Pipeline Workers",
224
288
  size: @ractor_count,
data/lib/raptor/http2.rb CHANGED
@@ -85,17 +85,20 @@ module Raptor
85
85
 
86
86
  # @rbs @app: ^(Hash[String, untyped]) -> [Integer, Hash[String, String | Array[String]], untyped]
87
87
  # @rbs @server_port: Integer
88
+ # @rbs @on_error: ^(Hash[String, untyped]?, Exception) -> void | nil
88
89
 
89
90
  # Creates a new Http2 handler.
90
91
  #
91
92
  # @param app [#call] the Rack application to dispatch requests to
92
93
  # @param server_port [Integer] port number used to populate SERVER_PORT in the Rack env
94
+ # @param on_error [#call, nil] callback invoked with (env, exception) when the Rack app raises
93
95
  # @return [void]
94
96
  #
95
- # @rbs (^(Hash[String, untyped]) -> [Integer, Hash[String, String | Array[String]], untyped] app, Integer server_port) -> void
96
- def initialize(app, server_port)
97
+ # @rbs (^(Hash[String, untyped]) -> [Integer, Hash[String, String | Array[String]], untyped] app, Integer server_port, ?on_error: ^(Hash[String, untyped]?, Exception) -> void | nil) -> void
98
+ def initialize(app, server_port, on_error: nil)
97
99
  @app = app
98
100
  @server_port = server_port
101
+ @on_error = on_error
99
102
  end
100
103
 
101
104
  # Builds the initial server SETTINGS frame to send on connection establishment.
@@ -312,9 +315,14 @@ module Raptor
312
315
  status, response_headers, response_body = @app.call(env)
313
316
 
314
317
  write_http2_response(socket, writer, stream_id, status, response_headers, response_body)
315
- rescue
318
+ rescue => error
316
319
  write_http2_error_response(socket, writer, stream_id)
317
- raise
320
+
321
+ if @on_error
322
+ @on_error.call(env, error) rescue nil
323
+ else
324
+ raise
325
+ end
318
326
  ensure
319
327
  response_body.close if response_body.respond_to?(:close)
320
328
  end
@@ -3,6 +3,7 @@
3
3
 
4
4
  require "socket"
5
5
  require "stringio"
6
+ require "tempfile"
6
7
 
7
8
  require "rack"
8
9
 
@@ -38,7 +39,8 @@ module Raptor
38
39
  end
39
40
 
40
41
  STATUS_WITH_NO_ENTITY_BODY = Set.new([204, 304, *100..199]).freeze
41
- ERROR_RESPONSE_500 = "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
42
+ INTERNAL_SERVER_ERROR_RESPONSE = "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
43
+ CONTENT_TOO_LARGE_RESPONSE = "HTTP/1.1 413 Content Too Large\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
42
44
 
43
45
  CONNECTION_CLOSE = "close"
44
46
  CONNECTION_KEEPALIVE = "keep-alive"
@@ -61,46 +63,58 @@ module Raptor
61
63
 
62
64
  # Decodes a chunked transfer-encoded body buffer.
63
65
  #
64
- # Returns the decoded bytes and a flag indicating whether the terminating
65
- # zero-length chunk was found. The decoder stops at the first unparseable
66
- # boundary (incomplete CRLF) or zero-length chunk.
66
+ # Returns the decoded bytes and a state symbol: `:complete` when the
67
+ # terminating zero-length chunk was found, `:too_large` when the decoded
68
+ # size would exceed `max_size`, or `:incomplete` otherwise.
67
69
  #
68
70
  # @param buffer [String] the raw body buffer to decode
69
- # @return [Array(String, Boolean)] decoded body and completion flag
71
+ # @param max_size [Integer, nil] maximum decoded body size, or nil for unlimited
72
+ # @return [Array(String, Symbol)] decoded body and completion state
70
73
  #
71
- # @rbs (String buffer) -> [String, bool]
72
- def self.decode_chunked(buffer)
74
+ # @rbs (String buffer, ?Integer? max_size) -> [String, Symbol]
75
+ def self.decode_chunked(buffer, max_size = nil)
73
76
  decoded = String.new
74
77
  offset = 0
75
78
 
76
79
  while offset < buffer.bytesize
77
80
  crlf = buffer.index("\r\n", offset)
78
- return [decoded, false] unless crlf
81
+ return [decoded, :incomplete] unless crlf
79
82
 
80
83
  chunk_size = buffer.byteslice(offset, crlf - offset).to_i(16)
81
- return [decoded, true] if chunk_size == 0
84
+ return [decoded, :complete] if chunk_size == 0
85
+ return [decoded, :too_large] if max_size && decoded.bytesize + chunk_size > max_size
82
86
 
83
87
  offset = crlf + 2
84
88
  decoded << buffer.byteslice(offset, chunk_size)
85
89
  offset += chunk_size + 2
86
90
  end
87
91
 
88
- [decoded, false]
92
+ [decoded, :incomplete]
89
93
  end
90
94
 
91
95
  # @rbs @app: ^(Hash[String, untyped]) -> [Integer, Hash[String, String | Array[String]], untyped]
92
96
  # @rbs @server_port: Integer
97
+ # @rbs @max_body_size: Integer?
98
+ # @rbs @body_spool_threshold: Integer?
99
+ # @rbs @on_error: ^(Hash[String, untyped]?, Exception) -> void | nil
93
100
 
94
101
  # Creates a new Request handler.
95
102
  #
96
103
  # @param app [#call] the Rack application to dispatch complete requests to
97
104
  # @param server_port [Integer] port number used to populate SERVER_PORT in the Rack env
105
+ # @param client_options [Hash] client limits configuration
106
+ # @option client_options [Integer, nil] :max_body_size maximum request body size in bytes
107
+ # @option client_options [Integer, nil] :body_spool_threshold spool bodies larger than this to a tempfile
108
+ # @param on_error [#call, nil] callback invoked with (env, exception) when the Rack app raises
98
109
  # @return [void]
99
110
  #
100
- # @rbs (^(Hash[String, untyped]) -> [Integer, Hash[String, String | Array[String]], untyped] app, Integer server_port) -> void
101
- def initialize(app, server_port)
111
+ # @rbs (^(Hash[String, untyped]) -> [Integer, Hash[String, String | Array[String]], untyped] app, Integer server_port, ?client_options: Hash[Symbol, untyped], ?on_error: ^(Hash[String, untyped]?, Exception) -> void | nil) -> void
112
+ def initialize(app, server_port, client_options: {}, on_error: nil)
102
113
  @app = app
103
114
  @server_port = server_port
115
+ @max_body_size = client_options[:max_body_size]
116
+ @body_spool_threshold = client_options[:body_spool_threshold]
117
+ @on_error = on_error
104
118
  end
105
119
 
106
120
  # Eagerly reads and parses the first request on a freshly accepted
@@ -149,12 +163,21 @@ module Raptor
149
163
  fallback_to_reactor(socket, id, buffer, env, parse_data, reactor, 0, remote_addr, url_scheme, persisted: false)
150
164
  return
151
165
  elsif parser.has_body?
166
+ if @max_body_size && parser.content_length > @max_body_size
167
+ reject_oversized(socket)
168
+ return
169
+ end
170
+
152
171
  body = buffer.byteslice(nread..-1) || ""
153
172
 
154
173
  if env[HTTP_TRANSFER_ENCODING]&.include?(TRANSFER_ENCODING_CHUNKED)
155
- body, chunked_complete = Request.decode_chunked(body)
156
- if chunked_complete
174
+ body, chunked_state = Request.decode_chunked(body, @max_body_size)
175
+ case chunked_state
176
+ when :complete
157
177
  env.delete(HTTP_TRANSFER_ENCODING)
178
+ when :too_large
179
+ reject_oversized(socket)
180
+ return
158
181
  else
159
182
  fallback_to_reactor(socket, id, buffer, env, parse_data, reactor, 0, remote_addr, url_scheme, persisted: false)
160
183
  return
@@ -180,6 +203,8 @@ module Raptor
180
203
  #
181
204
  # @rbs () -> ^(Hash[Symbol, untyped]) -> Hash[Symbol, untyped]
182
205
  def http_parser_worker
206
+ max_body_size = @max_body_size
207
+
183
208
  proc do |data|
184
209
  next Raptor::Http2.process_frames(data) if data[:protocol] == :http2
185
210
 
@@ -197,12 +222,17 @@ module Raptor
197
222
  if parser.has_body?
198
223
  body_buffer = data[:buffer].byteslice(nread..-1) || ""
199
224
 
200
- if env[HTTP_TRANSFER_ENCODING]&.include?(TRANSFER_ENCODING_CHUNKED)
201
- decoded_body, chunked_complete = Raptor::Request.decode_chunked(body_buffer)
225
+ if max_body_size && parser.content_length > max_body_size
226
+ data.merge(env: env, body: nil, parse_data: parse_data, complete: true, too_large: true)
227
+ elsif env[HTTP_TRANSFER_ENCODING]&.include?(TRANSFER_ENCODING_CHUNKED)
228
+ decoded_body, chunked_state = Raptor::Request.decode_chunked(body_buffer, max_body_size)
202
229
 
203
- if chunked_complete
230
+ case chunked_state
231
+ when :complete
204
232
  env.delete(HTTP_TRANSFER_ENCODING)
205
233
  data.merge(env: env, body: decoded_body, parse_data: parse_data, complete: true)
234
+ when :too_large
235
+ data.merge(env: env, body: nil, parse_data: parse_data, complete: true, too_large: true)
206
236
  else
207
237
  data.merge(env: env, parse_data: parse_data)
208
238
  end
@@ -233,6 +263,12 @@ module Raptor
233
263
  #
234
264
  # @rbs (Hash[Symbol, untyped] parsed_request, Reactor reactor, AtomicThreadPool thread_pool) -> void
235
265
  def handle_parsed_request(parsed_request, reactor, thread_pool)
266
+ if parsed_request[:too_large]
267
+ socket = reactor.remove(parsed_request[:id])
268
+ reject_oversized(socket) if socket
269
+ return
270
+ end
271
+
236
272
  unless parsed_request[:complete]
237
273
  reactor.update_state(parsed_request)
238
274
  else
@@ -322,10 +358,18 @@ module Raptor
322
358
  keep_alive && !hijacked
323
359
  rescue => error
324
360
  call_response_finished(rack_env, status, headers, error) if rack_env
325
- socket.write(ERROR_RESPONSE_500) rescue nil unless response_started || hijacked
361
+ socket.write(INTERNAL_SERVER_ERROR_RESPONSE) rescue nil unless response_started || hijacked
326
362
  keep_alive = false
327
- raise
363
+
364
+ if @on_error
365
+ @on_error.call(rack_env, error) rescue nil
366
+ else
367
+ raise
368
+ end
328
369
  ensure
370
+ rack_input = rack_env && rack_env[Rack::RACK_INPUT]
371
+ rack_input.close! rescue nil if rack_input.respond_to?(:close!)
372
+
329
373
  unless hijacked || keep_alive
330
374
  socket.close rescue nil
331
375
  end
@@ -459,6 +503,18 @@ module Raptor
459
503
  reactor.update_state(Ractor.make_shareable(state))
460
504
  end
461
505
 
506
+ # Writes a 413 response and closes the socket. Used when a request body
507
+ # exceeds the configured maximum size.
508
+ #
509
+ # @param socket [TCPSocket] the client socket
510
+ # @return [void]
511
+ #
512
+ # @rbs (TCPSocket socket) -> void
513
+ def reject_oversized(socket)
514
+ socket.write(CONTENT_TOO_LARGE_RESPONSE) rescue nil
515
+ socket.close rescue nil
516
+ end
517
+
462
518
  # Builds a Rack environment hash from parsed HTTP request data.
463
519
  #
464
520
  # Populates all required Rack env keys including rack.* keys, REMOTE_ADDR,
@@ -476,7 +532,7 @@ module Raptor
476
532
  def build_rack_env(env, parse_data, body, socket, remote_addr: "127.0.0.1", url_scheme: HTTP_SCHEME)
477
533
  env[Rack::RACK_VERSION] = Rack::VERSION
478
534
  env[Rack::RACK_URL_SCHEME] = url_scheme
479
- env[Rack::RACK_INPUT] = (body ? StringIO.new(body) : StringIO.new).set_encoding(Encoding::ASCII_8BIT)
535
+ env[Rack::RACK_INPUT] = build_rack_input(body)
480
536
  env[Rack::RACK_ERRORS] = $stderr
481
537
  env[Rack::RACK_RESPONSE_FINISHED] = []
482
538
 
@@ -520,6 +576,26 @@ module Raptor
520
576
  env
521
577
  end
522
578
 
579
+ # Builds the `rack.input` IO object for the request body. Returns an
580
+ # in-memory StringIO for bodies up to the spool threshold, or a Tempfile
581
+ # for larger bodies to bound per-worker memory.
582
+ #
583
+ # @param body [String, nil] decoded request body
584
+ # @return [IO] an IO-like object positioned at the start of the body
585
+ #
586
+ # @rbs (String? body) -> IO
587
+ def build_rack_input(body)
588
+ if body && @body_spool_threshold && body.bytesize > @body_spool_threshold
589
+ tempfile = Tempfile.new("raptor-body")
590
+ tempfile.binmode
591
+ tempfile.write(body)
592
+ tempfile.rewind
593
+ tempfile
594
+ else
595
+ (body ? StringIO.new(body) : StringIO.new).set_encoding(Encoding::ASCII_8BIT)
596
+ end
597
+ end
598
+
523
599
  # Determines whether the connection should be kept alive after the response.
524
600
  #
525
601
  # Returns false if the request limit has been reached. For HTTP/1.1, keep-alive
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module Raptor
5
- VERSION = "0.2.0"
5
+ VERSION = "0.3.0"
6
6
  end
data/lib/raptor.rb CHANGED
@@ -5,9 +5,9 @@ require_relative "raptor/version"
5
5
 
6
6
  # Main module for the Raptor web server.
7
7
  #
8
- # Raptor is a high-performance, multi-threaded, multi-process Ruby web server that
9
- # leverages Ractors for parallel HTTP/1.1 and HTTP/2 request processing, native C
10
- # extensions for HTTP parsing and HPACK compression, and NIO for non-blocking I/O.
8
+ # Raptor is a high-performance, preloading, multi-process, multi-threaded Ruby 4+ web server
9
+ # implementing Rack 3+, leveraging Ractors for parallel HTTP/1.1 and HTTP/2 request processing,
10
+ # native C extensions for HTTP parsing and HPACK compression, and NIO for non-blocking I/O.
11
11
  #
12
12
  module Raptor
13
13
  end
@@ -20,6 +20,19 @@ module Raptor
20
20
 
21
21
  DEFAULT_OPTIONS: untyped
22
22
 
23
+ # Loads a configuration file and returns the hash it evaluates to.
24
+ #
25
+ # The file is evaluated at the top level so constants like `Raptor::*` resolve
26
+ # the same as in a regular Ruby script. The final expression must be a Hash
27
+ # of cluster options (the same keys accepted by {Raptor::Cluster#initialize}).
28
+ #
29
+ # @param path [String] path to a Ruby file that evaluates to a Hash
30
+ # @return [Hash{Symbol => untyped}] cluster options
31
+ # @raise [ArgumentError] if the file does not evaluate to a Hash
32
+ #
33
+ # @rbs (String path) -> Hash[Symbol, untyped]
34
+ def self.load_config_file: (String path) -> Hash[Symbol, untyped]
35
+
23
36
  @command: Symbol
24
37
 
25
38
  @options: Hash[Symbol, untyped]
@@ -61,6 +74,30 @@ module Raptor
61
74
  # @rbs () -> void
62
75
  def run_stats: () -> void
63
76
 
77
+ # Scans argv for a `-c`/`--config` flag and returns the configured path.
78
+ #
79
+ # The pre-scan runs before the main OptionParser pass so the config file
80
+ # can be applied as a base layer that CLI args then override. All four
81
+ # OptionParser-accepted forms (`-c PATH`, `-cPATH`, `--config PATH`,
82
+ # `--config=PATH`) are recognized.
83
+ #
84
+ # @param argv [Array<String>] command-line arguments to scan
85
+ # @return [String, nil] the config path, or nil if no flag was supplied
86
+ #
87
+ # @rbs (Array[String] argv) -> String?
88
+ def extract_config_path: (Array[String] argv) -> String?
89
+
90
+ # Loads a config file and merges it into `@options` over the defaults.
91
+ #
92
+ # Top-level keys replace defaults; the nested `:client` hash is merged
93
+ # key-by-key so a config file does not need to restate every client option.
94
+ #
95
+ # @param path [String, nil] path to the config file, or nil to no-op
96
+ # @return [void]
97
+ #
98
+ # @rbs (String? path) -> void
99
+ def apply_config_file: (String? path) -> void
100
+
64
101
  # Creates the OptionParser instance with all supported command-line options.
65
102
  #
66
103
  # @return [OptionParser] configured option parser
@@ -37,7 +37,9 @@ module Raptor
37
37
  # @rbs (Hash[Symbol, untyped] options) -> void
38
38
  def self.run: (Hash[Symbol, untyped] options) -> void
39
39
 
40
- @stats_file: String?
40
+ @phased_restarting: bool
41
+
42
+ @phased_restart_requested: bool
41
43
 
42
44
  @stats: Stats
43
45
 
@@ -51,6 +53,12 @@ module Raptor
51
53
 
52
54
  @binder: Binder
53
55
 
56
+ @pidfile: String?
57
+
58
+ @stats_file: String?
59
+
60
+ @on_error: ^(Hash[String, untyped]?, Exception) -> void | nil
61
+
54
62
  @client_options: Hash[Symbol, Integer]
55
63
 
56
64
  @worker_count: Integer
@@ -72,7 +80,10 @@ module Raptor
72
80
  # @option options [Array<String>] :binds array of bind URIs
73
81
  # @option options [#call] :app pre-built Rack application
74
82
  # @option options [String] :rackup path to Rack configuration file
75
- # @option options [Hash] :client client timeout configuration
83
+ # @option options [Hash] :client client configuration
84
+ # @option options [#call] :on_error callback invoked with (env, exception) when the Rack app raises
85
+ # @option options [String, nil] :stats_file path to write per-worker stats JSON, or nil to disable
86
+ # @option options [String, nil] :pidfile path to write the master PID to, or nil to disable
76
87
  # @return [void]
77
88
  #
78
89
  # @rbs (Hash[Symbol, untyped] options) -> void
@@ -82,7 +93,8 @@ module Raptor
82
93
  #
83
94
  # Forks the configured number of worker processes and monitors them,
84
95
  # automatically restarting any that exit unexpectedly. Handles graceful
85
- # shutdown via INT or TERM signals, and stats logging via USR1.
96
+ # shutdown via INT or TERM signals, stats logging via USR1, and phased
97
+ # restart via USR2.
86
98
  #
87
99
  # Each worker process includes:
88
100
  # - 1 server thread (continuously accepts connections with backpressure control)
@@ -115,6 +127,22 @@ module Raptor
115
127
  # @rbs (Integer index) -> void
116
128
  def spawn_worker: (Integer index) -> void
117
129
 
130
+ # Reaps any worker processes that have exited, respawning each one
131
+ # unless the cluster is shutting down.
132
+ #
133
+ # @return [Symbol] :no_children when there are no remaining children, otherwise :reaped
134
+ #
135
+ # @rbs () -> Symbol
136
+ def reap_workers: () -> Symbol
137
+
138
+ # Replaces each worker process one at a time, waiting for the new
139
+ # worker to boot before moving on to the next. Triggered by SIGUSR2.
140
+ #
141
+ # @return [void]
142
+ #
143
+ # @rbs () -> void
144
+ def perform_phased_restart: () -> void
145
+
118
146
  # Runs the full server stack inside a worker process.
119
147
  #
120
148
  # Sets up and coordinates the reactor, server, ractor pool, thread pool,
@@ -47,6 +47,8 @@ module Raptor
47
47
 
48
48
  HOP_BY_HOP_HEADERS: untyped
49
49
 
50
+ @on_error: ^(Hash[String, untyped]?, Exception) -> void | nil
51
+
50
52
  @server_port: Integer
51
53
 
52
54
  @app: ^(Hash[String, untyped]) -> [ Integer, Hash[String, String | Array[String]], untyped ]
@@ -55,10 +57,11 @@ module Raptor
55
57
  #
56
58
  # @param app [#call] the Rack application to dispatch requests to
57
59
  # @param server_port [Integer] port number used to populate SERVER_PORT in the Rack env
60
+ # @param on_error [#call, nil] callback invoked with (env, exception) when the Rack app raises
58
61
  # @return [void]
59
62
  #
60
- # @rbs (^(Hash[String, untyped]) -> [Integer, Hash[String, String | Array[String]], untyped] app, Integer server_port) -> void
61
- def initialize: (^(Hash[String, untyped]) -> [ Integer, Hash[String, String | Array[String]], untyped ] app, Integer server_port) -> void
63
+ # @rbs (^(Hash[String, untyped]) -> [Integer, Hash[String, String | Array[String]], untyped] app, Integer server_port, ?on_error: ^(Hash[String, untyped]?, Exception) -> void | nil) -> void
64
+ def initialize: (^(Hash[String, untyped]) -> [ Integer, Hash[String, String | Array[String]], untyped ] app, Integer server_port, ?on_error: ^(Hash[String, untyped]?, Exception) -> void | nil) -> void
62
65
 
63
66
  # Builds the initial server SETTINGS frame to send on connection establishment.
64
67
  #
@@ -33,7 +33,9 @@ module Raptor
33
33
 
34
34
  STATUS_WITH_NO_ENTITY_BODY: untyped
35
35
 
36
- ERROR_RESPONSE_500: ::String
36
+ INTERNAL_SERVER_ERROR_RESPONSE: ::String
37
+
38
+ CONTENT_TOO_LARGE_RESPONSE: ::String
37
39
 
38
40
  CONNECTION_CLOSE: ::String
39
41
 
@@ -65,15 +67,22 @@ module Raptor
65
67
 
66
68
  # Decodes a chunked transfer-encoded body buffer.
67
69
  #
68
- # Returns the decoded bytes and a flag indicating whether the terminating
69
- # zero-length chunk was found. The decoder stops at the first unparseable
70
- # boundary (incomplete CRLF) or zero-length chunk.
70
+ # Returns the decoded bytes and a state symbol: `:complete` when the
71
+ # terminating zero-length chunk was found, `:too_large` when the decoded
72
+ # size would exceed `max_size`, or `:incomplete` otherwise.
71
73
  #
72
74
  # @param buffer [String] the raw body buffer to decode
73
- # @return [Array(String, Boolean)] decoded body and completion flag
75
+ # @param max_size [Integer, nil] maximum decoded body size, or nil for unlimited
76
+ # @return [Array(String, Symbol)] decoded body and completion state
74
77
  #
75
- # @rbs (String buffer) -> [String, bool]
76
- def self.decode_chunked: (String buffer) -> [ String, bool ]
78
+ # @rbs (String buffer, ?Integer? max_size) -> [String, Symbol]
79
+ def self.decode_chunked: (String buffer, ?Integer? max_size) -> [ String, Symbol ]
80
+
81
+ @on_error: ^(Hash[String, untyped]?, Exception) -> void | nil
82
+
83
+ @body_spool_threshold: Integer?
84
+
85
+ @max_body_size: Integer?
77
86
 
78
87
  @server_port: Integer
79
88
 
@@ -83,10 +92,14 @@ module Raptor
83
92
  #
84
93
  # @param app [#call] the Rack application to dispatch complete requests to
85
94
  # @param server_port [Integer] port number used to populate SERVER_PORT in the Rack env
95
+ # @param client_options [Hash] client limits configuration
96
+ # @option client_options [Integer, nil] :max_body_size maximum request body size in bytes
97
+ # @option client_options [Integer, nil] :body_spool_threshold spool bodies larger than this to a tempfile
98
+ # @param on_error [#call, nil] callback invoked with (env, exception) when the Rack app raises
86
99
  # @return [void]
87
100
  #
88
- # @rbs (^(Hash[String, untyped]) -> [Integer, Hash[String, String | Array[String]], untyped] app, Integer server_port) -> void
89
- def initialize: (^(Hash[String, untyped]) -> [ Integer, Hash[String, String | Array[String]], untyped ] app, Integer server_port) -> void
101
+ # @rbs (^(Hash[String, untyped]) -> [Integer, Hash[String, String | Array[String]], untyped] app, Integer server_port, ?client_options: Hash[Symbol, untyped], ?on_error: ^(Hash[String, untyped]?, Exception) -> void | nil) -> void
102
+ def initialize: (^(Hash[String, untyped]) -> [ Integer, Hash[String, String | Array[String]], untyped ] app, Integer server_port, ?client_options: Hash[Symbol, untyped], ?on_error: ^(Hash[String, untyped]?, Exception) -> void | nil) -> void
90
103
 
91
104
  # Eagerly reads and parses the first request on a freshly accepted
92
105
  # connection on the server thread, dispatching directly to the thread pool
@@ -203,6 +216,15 @@ module Raptor
203
216
  # @rbs (TCPSocket socket, Integer id, String buffer, Hash[String, untyped] env, Hash[Symbol, untyped] parse_data, Reactor reactor, Integer request_count, String remote_addr, String url_scheme, persisted: bool) -> void
204
217
  def fallback_to_reactor: (TCPSocket socket, Integer id, String buffer, Hash[String, untyped] env, Hash[Symbol, untyped] parse_data, Reactor reactor, Integer request_count, String remote_addr, String url_scheme, persisted: bool) -> void
205
218
 
219
+ # Writes a 413 response and closes the socket. Used when a request body
220
+ # exceeds the configured maximum size.
221
+ #
222
+ # @param socket [TCPSocket] the client socket
223
+ # @return [void]
224
+ #
225
+ # @rbs (TCPSocket socket) -> void
226
+ def reject_oversized: (TCPSocket socket) -> void
227
+
206
228
  # Builds a Rack environment hash from parsed HTTP request data.
207
229
  #
208
230
  # Populates all required Rack env keys including rack.* keys, REMOTE_ADDR,
@@ -219,6 +241,16 @@ module Raptor
219
241
  # @rbs (Hash[String, untyped] env, Hash[Symbol, untyped] parse_data, String? body, TCPSocket socket, ?remote_addr: String, ?url_scheme: String) -> Hash[String, untyped]
220
242
  def build_rack_env: (Hash[String, untyped] env, Hash[Symbol, untyped] parse_data, String? body, TCPSocket socket, ?remote_addr: String, ?url_scheme: String) -> Hash[String, untyped]
221
243
 
244
+ # Builds the `rack.input` IO object for the request body. Returns an
245
+ # in-memory StringIO for bodies up to the spool threshold, or a Tempfile
246
+ # for larger bodies to bound per-worker memory.
247
+ #
248
+ # @param body [String, nil] decoded request body
249
+ # @return [IO] an IO-like object positioned at the start of the body
250
+ #
251
+ # @rbs (String? body) -> IO
252
+ def build_rack_input: (String? body) -> IO
253
+
222
254
  # Determines whether the connection should be kept alive after the response.
223
255
  #
224
256
  # Returns false if the request limit has been reached. For HTTP/1.1, keep-alive
@@ -2,8 +2,8 @@
2
2
 
3
3
  # Main module for the Raptor web server.
4
4
  #
5
- # Raptor is a high-performance, multi-threaded, multi-process Ruby web server that
6
- # leverages Ractors for parallel HTTP/1.1 and HTTP/2 request processing, native C
7
- # extensions for HTTP parsing and HPACK compression, and NIO for non-blocking I/O.
5
+ # Raptor is a high-performance, preloading, multi-process, multi-threaded Ruby 4+ web server
6
+ # implementing Rack 3+, leveraging Ractors for parallel HTTP/1.1 and HTTP/2 request processing,
7
+ # native C extensions for HTTP parsing and HPACK compression, and NIO for non-blocking I/O.
8
8
  module Raptor
9
9
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: raptor
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joshua Young