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,992 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ require "socket"
5
+ require "stringio"
6
+
7
+ require "rack"
8
+
9
+ require_relative "raptor_http"
10
+
11
+ module Raptor
12
+ # Handles HTTP request processing and Rack application integration.
13
+ #
14
+ # Request manages the HTTP parsing pipeline using Ractors and coordinates
15
+ # with the reactor for connection state management. It bridges between the
16
+ # low-level HTTP parsing and high-level Rack application interface, handling
17
+ # both incomplete requests (that need more data) and complete requests
18
+ # (ready for application processing).
19
+ #
20
+ class Request
21
+ BODY_BUFFER_THRESHOLD = 256 * 1024
22
+ FILE_CHUNK_SIZE = 64 * 1024
23
+ KEEPALIVE_BUFFER_SIZE = 64 * 1024
24
+ WRITE_TIMEOUT = 5
25
+ KEEPALIVE_READ_TIMEOUT = 0.001
26
+ MAX_KEEPALIVE_REQUESTS = 100
27
+
28
+ HTTP_SCHEME = "http"
29
+ HTTP_10 = "HTTP/1.0"
30
+ HTTP_11 = "HTTP/1.1"
31
+ STATUS_LINE_CACHE_10 = Hash.new do |h, status|
32
+ reason = Rack::Utils::HTTP_STATUS_CODES[status]
33
+ h[status] = "HTTP/1.0 #{status}#{reason ? " #{reason}" : ""}\r\n".freeze
34
+ end
35
+ STATUS_LINE_CACHE_11 = Hash.new do |h, status|
36
+ reason = Rack::Utils::HTTP_STATUS_CODES[status]
37
+ h[status] = "HTTP/1.1 #{status}#{reason ? " #{reason}" : ""}\r\n".freeze
38
+ end
39
+
40
+ 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
+
43
+ CONNECTION_CLOSE = "close"
44
+ CONNECTION_KEEPALIVE = "keep-alive"
45
+ TRANSFER_ENCODING_CHUNKED = "chunked"
46
+
47
+ HTTP_CONNECTION = "HTTP_CONNECTION"
48
+ HTTP_TRANSFER_ENCODING = "HTTP_TRANSFER_ENCODING"
49
+ RACK_HEADER_PREFIX = "rack."
50
+ RACK_HIJACKED = "rack.hijacked"
51
+ RACK_HIJACK_IO = "rack.hijack_io"
52
+
53
+ ILLEGAL_HEADER_KEY_REGEX = /[\x00-\x20\(\)<>@,;:\\"\/\[\]\?=\{\}\x7F]/
54
+ ILLEGAL_HEADER_VALUE_REGEX = /[\x00-\x08\x0A-\x1F]/
55
+
56
+ class Error < StandardError; end
57
+ class WriteError < Error
58
+ # @rbs () -> String
59
+ def message = "could not write response"
60
+ end
61
+
62
+ # @rbs @app: ^(Hash[String, untyped]) -> [Integer, Hash[String, String | Array[String]], untyped]
63
+ # @rbs @server_port: Integer
64
+
65
+ # Creates a new Request handler.
66
+ #
67
+ # @param app [#call] the Rack application to dispatch complete requests to
68
+ # @param server_port [Integer] port number used to populate SERVER_PORT in the Rack env
69
+ # @return [void]
70
+ #
71
+ # @rbs (^(Hash[String, untyped]) -> [Integer, Hash[String, String | Array[String]], untyped] app, Integer server_port) -> void
72
+ def initialize(app, server_port)
73
+ @app = app
74
+ @server_port = server_port
75
+ end
76
+
77
+ # Returns a Proc for HTTP parsing work in Ractor context.
78
+ #
79
+ # The returned Proc processes raw socket data through the appropriate
80
+ # HTTP parser and returns either a complete request state (ready for
81
+ # app processing) or incomplete request state (needs more data).
82
+ #
83
+ # @return [Proc] a Ractor-safe proc that accepts a state hash and returns an updated state hash
84
+ #
85
+ # @rbs () -> ^(Hash[Symbol, untyped]) -> Hash[Symbol, untyped]
86
+ def http_parser_worker
87
+ proc do |data|
88
+ next Raptor::Http2.process_frames(data) if data[:protocol] == :http2
89
+
90
+ parser = Raptor::HttpParser.new
91
+ env = {}
92
+ nread = parser.execute(env, data[:buffer], 0)
93
+ parse_data = if data[:parse_data]
94
+ data[:parse_data].dup
95
+ else
96
+ { parse_count: 0, content_length: parser.content_length }
97
+ end
98
+ parse_data[:parse_count] += 1
99
+
100
+ message = if parser.finished?
101
+ if parser.has_body?
102
+ body_buffer = data[:buffer].byteslice(nread..-1) || ""
103
+
104
+ if env[HTTP_TRANSFER_ENCODING]&.include?(TRANSFER_ENCODING_CHUNKED)
105
+ decoded_body = String.new
106
+ offset = 0
107
+ chunked_complete = false
108
+
109
+ while offset < body_buffer.bytesize
110
+ crlf = body_buffer.index("\r\n", offset)
111
+ break unless crlf
112
+
113
+ chunk_size = body_buffer.byteslice(offset, crlf - offset).to_i(16)
114
+
115
+ if chunk_size == 0
116
+ chunked_complete = true
117
+ break
118
+ end
119
+
120
+ offset = crlf + 2
121
+ decoded_body << body_buffer.byteslice(offset, chunk_size)
122
+ offset += chunk_size + 2
123
+ end
124
+
125
+ if chunked_complete
126
+ env.delete(HTTP_TRANSFER_ENCODING)
127
+ data.merge(env: env, body: decoded_body, parse_data: parse_data, complete: true)
128
+ else
129
+ data.merge(env: env, parse_data: parse_data)
130
+ end
131
+ elsif parser.content_length > body_buffer.bytesize
132
+ data.merge(env: env, parse_data: parse_data)
133
+ else
134
+ data.merge(env: env, body: body_buffer, parse_data: parse_data, complete: true)
135
+ end
136
+ else
137
+ data.merge(env: env, body: nil, parse_data: parse_data, complete: true)
138
+ end
139
+ else
140
+ data.merge(env: env, parse_data: parse_data)
141
+ end
142
+ Ractor.make_shareable(message)
143
+ end
144
+ end
145
+
146
+ # Handles a parsed HTTP request by either continuing parsing or dispatching to the Rack app.
147
+ #
148
+ # For incomplete requests, updates reactor state and re-registers for more I/O.
149
+ # For complete requests, removes from reactor, builds Rack env, and dispatches to thread pool.
150
+ #
151
+ # @param parsed_request [Hash] the parsed request state from the ractor pool
152
+ # @param reactor [Reactor] the reactor managing the client connection
153
+ # @param thread_pool [AtomicThreadPool] thread pool for application processing
154
+ # @return [void]
155
+ #
156
+ # @rbs (Hash[Symbol, untyped] parsed_request, Reactor reactor, AtomicThreadPool thread_pool) -> void
157
+ def handle_parsed_request(parsed_request, reactor, thread_pool)
158
+ unless parsed_request[:complete]
159
+ reactor.update_state(parsed_request)
160
+ else
161
+ socket = reactor.remove(parsed_request[:id])
162
+ request_count = (parsed_request[:request_count] || 0) + 1
163
+ remote_addr = parsed_request[:remote_addr] || "127.0.0.1"
164
+ url_scheme = parsed_request[:url_scheme] || HTTP_SCHEME
165
+
166
+ thread_pool << proc do
167
+ process_client(
168
+ socket,
169
+ parsed_request[:id],
170
+ parsed_request[:env].dup,
171
+ parsed_request[:parse_data],
172
+ parsed_request[:body],
173
+ reactor,
174
+ thread_pool,
175
+ request_count,
176
+ remote_addr,
177
+ url_scheme
178
+ )
179
+ end
180
+ end
181
+ end
182
+
183
+ private
184
+
185
+ # Processes a client connection by handling the current request and,
186
+ # if keep-alive, eagerly reading subsequent requests inline.
187
+ #
188
+ # @param socket [TCPSocket] the client socket
189
+ # @param id [Integer] unique client identifier
190
+ # @param env [Hash] partial env hash from the HTTP parser
191
+ # @param parse_data [Hash] metadata from the parsing pass
192
+ # @param body [String, nil] decoded request body
193
+ # @param reactor [Reactor] the reactor managing the client connection
194
+ # @param thread_pool [AtomicThreadPool] thread pool for application processing
195
+ # @param request_count [Integer] number of requests handled on this connection
196
+ # @param remote_addr [String] client IP address
197
+ # @param url_scheme [String] "http" or "https"
198
+ # @return [void]
199
+ #
200
+ # @rbs (TCPSocket socket, Integer id, Hash[String, untyped] env, Hash[Symbol, untyped] parse_data, String? body, Reactor reactor, AtomicThreadPool thread_pool, Integer request_count, String remote_addr, String url_scheme) -> void
201
+ def process_client(socket, id, env, parse_data, body, reactor, thread_pool, request_count, remote_addr, url_scheme)
202
+ keep_alive = process_request(socket, env, parse_data, body, request_count, remote_addr, url_scheme)
203
+ eager_keepalive(socket, id, reactor, thread_pool, request_count, remote_addr, url_scheme) if keep_alive
204
+ end
205
+
206
+ # Builds the Rack env, calls the application, and writes the response.
207
+ # Returns true if the connection should be kept alive for further
208
+ # requests, false otherwise (including hijack and error cases).
209
+ #
210
+ # @param socket [TCPSocket] the client socket
211
+ # @param env [Hash] partial env hash from the HTTP parser
212
+ # @param parse_data [Hash] metadata from the parsing pass
213
+ # @param body [String, nil] decoded request body
214
+ # @param request_count [Integer] number of requests handled on this connection
215
+ # @param remote_addr [String] client IP address
216
+ # @param url_scheme [String] "http" or "https"
217
+ # @return [Boolean] true if the connection should be kept alive
218
+ #
219
+ # @rbs (TCPSocket socket, Hash[String, untyped] env, Hash[Symbol, untyped] parse_data, String? body, Integer request_count, String remote_addr, String url_scheme) -> bool
220
+ def process_request(socket, env, parse_data, body, request_count, remote_addr, url_scheme)
221
+ rack_env = nil
222
+ status = nil
223
+ headers = nil
224
+ hijacked = false
225
+ keep_alive = false
226
+ response_started = false
227
+
228
+ begin
229
+ rack_env = build_rack_env(env, parse_data, body, socket, remote_addr: remote_addr, url_scheme: url_scheme)
230
+ status, headers, body = @app.call(rack_env)
231
+
232
+ if rack_env[RACK_HIJACKED]
233
+ hijacked = true
234
+ body.close if body.respond_to?(:close)
235
+ else
236
+ hijacked = headers.is_a?(Hash) && !!headers[Rack::RACK_HIJACK]
237
+ streaming = body.respond_to?(:call) && !body.respond_to?(:each)
238
+ keep_alive = (hijacked || streaming) ? false : keep_alive?(rack_env, request_count)
239
+ response_started = true
240
+ write_response(socket, rack_env, status, headers, body, keep_alive: keep_alive)
241
+ end
242
+
243
+ call_response_finished(rack_env, status, headers, nil)
244
+ keep_alive && !hijacked
245
+ rescue => error
246
+ call_response_finished(rack_env, status, headers, error) if rack_env
247
+ socket.write(ERROR_RESPONSE_500) rescue nil unless response_started || hijacked
248
+ keep_alive = false
249
+ raise
250
+ ensure
251
+ unless hijacked || keep_alive
252
+ socket.close rescue nil
253
+ end
254
+ end
255
+ end
256
+
257
+ # Attempts to read and process subsequent requests inline on a
258
+ # kept-alive connection. Blocks briefly for the next request to avoid
259
+ # a full reactor round-trip. Falls back to the reactor when no data
260
+ # arrives within the timeout, when the thread pool has queued work
261
+ # (deprioritization), or when the request is incomplete.
262
+ #
263
+ # @param socket [TCPSocket] the client socket
264
+ # @param id [Integer] unique client identifier
265
+ # @param reactor [Reactor] the reactor for fallback registration
266
+ # @param thread_pool [AtomicThreadPool] thread pool for deprioritization
267
+ # @param request_count [Integer] number of requests handled on this connection
268
+ # @param remote_addr [String] client IP address
269
+ # @param url_scheme [String] "http" or "https"
270
+ # @return [void]
271
+ #
272
+ # @rbs (TCPSocket socket, Integer id, Reactor reactor, AtomicThreadPool thread_pool, Integer request_count, String remote_addr, String url_scheme) -> void
273
+ def eager_keepalive(socket, id, reactor, thread_pool, request_count, remote_addr, url_scheme)
274
+ loop do
275
+ unless socket.wait_readable(KEEPALIVE_READ_TIMEOUT)
276
+ reactor.persist(socket, id, request_count, remote_addr: remote_addr, url_scheme: url_scheme)
277
+ return
278
+ end
279
+
280
+ data = begin
281
+ socket.read_nonblock(KEEPALIVE_BUFFER_SIZE)
282
+ rescue IO::WaitReadable
283
+ reactor.persist(socket, id, request_count, remote_addr: remote_addr, url_scheme: url_scheme)
284
+ return
285
+ rescue EOFError
286
+ socket.close rescue nil
287
+ return
288
+ end
289
+
290
+ buffer = String.new
291
+ buffer << data
292
+
293
+ while socket.respond_to?(:pending) && socket.pending > 0
294
+ buffer << socket.read_nonblock(socket.pending)
295
+ end
296
+
297
+ parser = HttpParser.new
298
+ env = {}
299
+ nread = parser.execute(env, buffer, 0)
300
+ parse_data = { parse_count: 1, content_length: parser.content_length }
301
+
302
+ body = nil
303
+ if !parser.finished?
304
+ fallback_to_reactor(socket, id, buffer, env, parse_data, reactor, request_count, remote_addr, url_scheme)
305
+ return
306
+ elsif parser.has_body?
307
+ body = buffer.byteslice(nread..-1) || ""
308
+
309
+ chunked = env[HTTP_TRANSFER_ENCODING]&.include?(TRANSFER_ENCODING_CHUNKED)
310
+ if chunked || parser.content_length > body.bytesize
311
+ fallback_to_reactor(socket, id, buffer, env, parse_data, reactor, request_count, remote_addr, url_scheme)
312
+ return
313
+ end
314
+ end
315
+
316
+ request_count += 1
317
+
318
+ if thread_pool.queue_size > 0
319
+ thread_pool << proc do
320
+ process_client(
321
+ socket,
322
+ id,
323
+ env,
324
+ parse_data,
325
+ body,
326
+ reactor,
327
+ thread_pool,
328
+ request_count,
329
+ remote_addr,
330
+ url_scheme
331
+ )
332
+ end
333
+ return
334
+ end
335
+
336
+ keep_alive = process_request(
337
+ socket,
338
+ env,
339
+ parse_data,
340
+ body,
341
+ request_count,
342
+ remote_addr,
343
+ url_scheme
344
+ )
345
+ return unless keep_alive
346
+ end
347
+ end
348
+
349
+ # Re-registers a socket with the reactor for further processing
350
+ # when an incomplete request is received during eager keep-alive.
351
+ #
352
+ # @param socket [TCPSocket] the client socket
353
+ # @param id [Integer] unique client identifier
354
+ # @param buffer [String] the partial request data already read
355
+ # @param env [Hash] partial env hash from the HTTP parser
356
+ # @param parse_data [Hash] metadata from the parsing pass
357
+ # @param reactor [Reactor] the reactor to re-register with
358
+ # @param request_count [Integer] number of requests handled on this connection
359
+ # @param remote_addr [String] client IP address
360
+ # @param url_scheme [String] "http" or "https"
361
+ # @return [void]
362
+ #
363
+ # @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) -> void
364
+ def fallback_to_reactor(socket, id, buffer, env, parse_data, reactor, request_count, remote_addr, url_scheme)
365
+ reactor.persist(socket, id, request_count, remote_addr: remote_addr, url_scheme: url_scheme)
366
+ reactor.update_state(Ractor.make_shareable({
367
+ id: id,
368
+ buffer: buffer,
369
+ env: env,
370
+ request_count: request_count,
371
+ parse_data: parse_data,
372
+ remote_addr: remote_addr,
373
+ url_scheme: url_scheme,
374
+ persisted: true
375
+ }))
376
+ end
377
+
378
+ # Builds a Rack environment hash from parsed HTTP request data.
379
+ #
380
+ # Populates all required Rack env keys including rack.* keys, REMOTE_ADDR,
381
+ # SERVER_NAME, SERVER_PORT, and hijack support.
382
+ #
383
+ # @param env [Hash] partial env hash from the HTTP parser
384
+ # @param parse_data [Hash] metadata from the parsing pass, including content_length
385
+ # @param body [String, nil] decoded request body, or nil if no body
386
+ # @param socket [TCPSocket] the client socket, used for hijack support
387
+ # @param remote_addr [String] client IP address
388
+ # @param url_scheme [String] "http" or "https"
389
+ # @return [Hash] fully populated Rack environment hash
390
+ #
391
+ # @rbs (Hash[String, untyped] env, Hash[Symbol, untyped] parse_data, String? body, TCPSocket socket, ?remote_addr: String, ?url_scheme: String) -> Hash[String, untyped]
392
+ def build_rack_env(env, parse_data, body, socket, remote_addr: "127.0.0.1", url_scheme: HTTP_SCHEME)
393
+ env[Rack::RACK_VERSION] = Rack::VERSION
394
+ env[Rack::RACK_URL_SCHEME] = url_scheme
395
+ env[Rack::RACK_INPUT] = (body ? StringIO.new(body) : StringIO.new).set_encoding(Encoding::ASCII_8BIT)
396
+ env[Rack::RACK_ERRORS] = $stderr
397
+ env[Rack::RACK_RESPONSE_FINISHED] = []
398
+
399
+ env[Rack::RACK_IS_HIJACK] = true
400
+ env[Rack::RACK_HIJACK] = proc do
401
+ env[RACK_HIJACKED] = true
402
+ env[RACK_HIJACK_IO] = socket
403
+ socket
404
+ end
405
+
406
+ env[Rack::RACK_EARLY_HINTS] = proc do |hints|
407
+ send_early_hints(socket, hints) rescue nil
408
+ end
409
+
410
+ env[Rack::SCRIPT_NAME] = "" unless env.key?(Rack::SCRIPT_NAME)
411
+ env[Rack::PATH_INFO] = env.delete(Rack::REQUEST_PATH) if env.key?(Rack::REQUEST_PATH)
412
+ env[Rack::PATH_INFO] = "" unless env.key?(Rack::PATH_INFO)
413
+ env[Rack::QUERY_STRING] = "" unless env.key?(Rack::QUERY_STRING)
414
+
415
+ if (content_length = parse_data[:content_length]).positive?
416
+ env["CONTENT_LENGTH"] = content_length.to_s
417
+ end
418
+
419
+ env["REMOTE_ADDR"] = remote_addr
420
+
421
+ http_host = env[Rack::HTTP_HOST]
422
+ if http_host
423
+ if http_host.start_with?("[")
424
+ host = http_host[/\A\[([^\]]+)\]/, 1]
425
+ port = http_host[/\]:(\d+)\z/, 1]
426
+ else
427
+ host, port = http_host.split(":", 2)
428
+ end
429
+ env[Rack::SERVER_NAME] ||= host
430
+ env[Rack::SERVER_PORT] ||= port || @server_port.to_s
431
+ else
432
+ env[Rack::SERVER_NAME] ||= "localhost"
433
+ env[Rack::SERVER_PORT] ||= @server_port.to_s
434
+ end
435
+
436
+ env
437
+ end
438
+
439
+ # Determines whether the connection should be kept alive after the response.
440
+ #
441
+ # Returns false if the request limit has been reached. For HTTP/1.1, keep-alive
442
+ # is the default unless the client sent Connection: close. For HTTP/1.0,
443
+ # keep-alive must be explicitly requested.
444
+ #
445
+ # @param env [Hash] the Rack environment
446
+ # @param request_count [Integer] number of requests handled on this connection
447
+ # @return [Boolean] true if the connection should be kept alive
448
+ #
449
+ # @rbs (Hash[String, untyped] env, Integer request_count) -> bool
450
+ def keep_alive?(env, request_count)
451
+ return false if request_count >= MAX_KEEPALIVE_REQUESTS
452
+
453
+ connection_header = env[HTTP_CONNECTION]
454
+
455
+ if env[Rack::SERVER_PROTOCOL] == HTTP_11
456
+ !connection_header&.casecmp?(CONNECTION_CLOSE)
457
+ else
458
+ connection_header&.casecmp?(CONNECTION_KEEPALIVE) || false
459
+ end
460
+ end
461
+
462
+ # Sends an HTTP 103 Early Hints response to the client.
463
+ #
464
+ # Skips any hints with illegal header keys or values. No-ops if hints is empty.
465
+ #
466
+ # @param socket [TCPSocket] the client socket to write to
467
+ # @param hints [Hash] header name to value (or array of values) pairs
468
+ # @return [void]
469
+ #
470
+ # @rbs (TCPSocket socket, Hash[String, String | Array[String]] hints) -> void
471
+ def send_early_hints(socket, hints)
472
+ return if hints.empty?
473
+
474
+ response = +"#{HTTP_11} 103 Early Hints\r\n"
475
+ hints.each do |key, value|
476
+ next if illegal_header_key?(key)
477
+
478
+ values = value.is_a?(Array) ? value : [value]
479
+ values.each do |hint_value|
480
+ next if illegal_header_value?(hint_value.to_s)
481
+
482
+ response << "#{key.downcase}: #{hint_value}\r\n"
483
+ end
484
+ end
485
+ response << "\r\n"
486
+
487
+ socket_write(socket, response)
488
+ end
489
+
490
+ # Writes a complete HTTP response to the socket.
491
+ #
492
+ # Handles header normalization, validation, connection management, TCP corking,
493
+ # and dispatches to the appropriate body write strategy.
494
+ #
495
+ # @param socket [TCPSocket] the client socket to write to
496
+ # @param env [Hash] the Rack environment
497
+ # @param status [Integer] HTTP status code
498
+ # @param headers [Hash] response headers from the Rack application
499
+ # @param body [Object] response body (array, enumerable, file, or callable)
500
+ # @param keep_alive [Boolean] whether to send a keep-alive connection header
501
+ # @return [void]
502
+ #
503
+ # @rbs (TCPSocket socket, Hash[String, untyped] env, Integer status, Hash[String, String | Array[String]] headers, untyped body, ?keep_alive: bool) -> void
504
+ def write_response(socket, env, status, headers, body, keep_alive: false)
505
+ validate_status(status)
506
+ response_hijack = headers.is_a?(Hash) ? headers.delete(Rack::RACK_HIJACK) : nil
507
+ headers = normalize_headers(headers)
508
+ validate_headers(headers, status)
509
+
510
+ headers["connection"] = keep_alive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE
511
+
512
+ http_version = env[Rack::SERVER_PROTOCOL] == HTTP_11 ? HTTP_11 : HTTP_10
513
+ no_body = env[Rack::REQUEST_METHOD] == "HEAD" || STATUS_WITH_NO_ENTITY_BODY.include?(status)
514
+
515
+ response = build_status_line(http_version, status)
516
+
517
+ cork_socket(socket)
518
+
519
+ if response_hijack
520
+ write_hijacked_response(socket, response, headers, response_hijack)
521
+ elsif no_body
522
+ write_no_body_response(socket, response, headers, status)
523
+ else
524
+ write_full_response(socket, response, headers, body, http_version)
525
+ end
526
+ ensure
527
+ body.close if body.respond_to?(:close)
528
+ uncork_socket(socket)
529
+ socket.flush rescue nil
530
+ end
531
+
532
+ # Validates that the status code is a valid integer.
533
+ #
534
+ # @param status [Object] the status value to validate
535
+ # @return [void]
536
+ # @raise [TypeError] if status is not an Integer
537
+ # @raise [ArgumentError] if status is less than 100
538
+ #
539
+ # @rbs (Integer status) -> void
540
+ def validate_status(status)
541
+ raise TypeError, "status must be an Integer" unless status.is_a?(Integer)
542
+
543
+ raise ArgumentError, "status must be >= 100" unless status >= 100
544
+ end
545
+
546
+ # Normalizes response headers by downcasing keys and filtering invalid entries.
547
+ #
548
+ # Removes headers with illegal keys, rack.* prefixed headers, and "status" headers.
549
+ # Raises if headers is not a Hash or contains non-String keys.
550
+ #
551
+ # @param headers [Hash] raw headers from the Rack application
552
+ # @return [Hash] normalized headers with lowercased string keys
553
+ # @raise [TypeError] if headers is not a Hash or a key is not a String
554
+ #
555
+ # @rbs (Hash[String, String | Array[String]] headers) -> Hash[String, String | Array[String]]
556
+ def normalize_headers(headers)
557
+ raise TypeError, "headers must be a Hash" unless headers.is_a?(Hash)
558
+
559
+ normalized = {}
560
+ headers.each do |key, value|
561
+ raise TypeError, "header keys must be Strings" unless key.is_a?(String)
562
+
563
+ next if illegal_header_key?(key)
564
+
565
+ normalized_key = key.match?(/[A-Z]/) ? key.downcase : key
566
+ next if normalized_key.start_with?(RACK_HEADER_PREFIX)
567
+ next if normalized_key == "status"
568
+
569
+ normalized[normalized_key] = value
570
+ end
571
+ normalized
572
+ end
573
+
574
+ # Validates that headers are appropriate for the given status code.
575
+ #
576
+ # Raises if content-type or content-length are present for status codes
577
+ # that must not have an entity body (204, 304, 1xx).
578
+ #
579
+ # @param headers [Hash] normalized response headers
580
+ # @param status [Integer] HTTP status code
581
+ # @return [void]
582
+ # @raise [ArgumentError] if a forbidden header is present for the status
583
+ #
584
+ # @rbs (Hash[String, String | Array[String]] headers, Integer status) -> void
585
+ def validate_headers(headers, status)
586
+ if STATUS_WITH_NO_ENTITY_BODY.include?(status)
587
+ raise ArgumentError, "content-type must not be present for status #{status}" if headers.key?(Rack::CONTENT_TYPE)
588
+
589
+ raise ArgumentError, "content-length must not be present for status #{status}" if headers.key?(Rack::CONTENT_LENGTH)
590
+ end
591
+ end
592
+
593
+ # Builds the HTTP status line string.
594
+ #
595
+ # @param http_version [String] "HTTP/1.1" or "HTTP/1.0"
596
+ # @param status [Integer] HTTP status code
597
+ # @return [String] the status line including trailing CRLF
598
+ #
599
+ # @rbs (String http_version, Integer status) -> String
600
+ def build_status_line(http_version, status)
601
+ cache = http_version == HTTP_11 ? STATUS_LINE_CACHE_11 : STATUS_LINE_CACHE_10
602
+ cache[status].dup
603
+ end
604
+
605
+ # Writes response headers and delegates body writing to the hijack callback.
606
+ #
607
+ # Uncorks the socket before calling the hijack so the app has full control
608
+ # of the raw connection.
609
+ #
610
+ # @param socket [TCPSocket] the client socket
611
+ # @param response [String] the status line accumulated so far
612
+ # @param headers [Hash] normalized response headers
613
+ # @param response_hijack [Proc] callable that receives the socket and writes the body
614
+ # @return [void]
615
+ #
616
+ # @rbs (TCPSocket socket, String response, Hash[String, String | Array[String]] headers, ^(TCPSocket) -> void response_hijack) -> void
617
+ def write_hijacked_response(socket, response, headers, response_hijack)
618
+ response << format_headers(headers)
619
+ response << "\r\n"
620
+ socket_write(socket, response)
621
+ uncork_socket(socket)
622
+ response_hijack.call(socket)
623
+ end
624
+
625
+ # Writes a response with no entity body.
626
+ #
627
+ # Used for HEAD requests and status codes that must not carry a body
628
+ # (204, 304, 1xx). Adds a zero content-length for non-no-body statuses
629
+ # that did not supply one.
630
+ #
631
+ # @param socket [TCPSocket] the client socket
632
+ # @param response [String] the status line accumulated so far
633
+ # @param headers [Hash] normalized response headers
634
+ # @param status [Integer] HTTP status code
635
+ # @return [void]
636
+ #
637
+ # @rbs (TCPSocket socket, String response, Hash[String, String | Array[String]] headers, Integer status) -> void
638
+ def write_no_body_response(socket, response, headers, status)
639
+ unless STATUS_WITH_NO_ENTITY_BODY.include?(status)
640
+ headers[Rack::CONTENT_LENGTH] = "0" unless headers.key?(Rack::CONTENT_LENGTH) || headers.key?(Rack::TRANSFER_ENCODING)
641
+ end
642
+
643
+ response << format_headers(headers)
644
+ response << "\r\n"
645
+ socket_write(socket, response)
646
+ end
647
+
648
+ # Writes a complete response with a body.
649
+ #
650
+ # Selects the appropriate write strategy based on body type: callable (streaming),
651
+ # file (zero-copy), array, or generic enumerable. Automatically determines
652
+ # content-length where possible, falling back to chunked transfer encoding
653
+ # for HTTP/1.1 when the length cannot be determined upfront.
654
+ #
655
+ # @param socket [TCPSocket] the client socket
656
+ # @param response [String] the status line accumulated so far
657
+ # @param headers [Hash] normalized response headers
658
+ # @param body [Object] the response body
659
+ # @param http_version [String] "HTTP/1.1" or "HTTP/1.0"
660
+ # @return [void]
661
+ #
662
+ # @rbs (TCPSocket socket, String response, Hash[String, String | Array[String]] headers, untyped body, String http_version) -> void
663
+ def write_full_response(socket, response, headers, body, http_version)
664
+ if body.respond_to?(:call)
665
+ response << format_headers(headers)
666
+ response << "\r\n"
667
+ socket_write(socket, response)
668
+ uncork_socket(socket)
669
+ body.call(socket)
670
+ return
671
+ end
672
+
673
+ content_length = headers[Rack::CONTENT_LENGTH]&.to_i
674
+ use_chunked = false
675
+
676
+ if !content_length || content_length == 0
677
+ calculated_length = calculate_content_length(body)
678
+ if calculated_length
679
+ content_length = calculated_length
680
+ elsif http_version == HTTP_11 && !headers.key?(Rack::TRANSFER_ENCODING)
681
+ use_chunked = true
682
+ end
683
+ end
684
+
685
+ if content_length && content_length >= 0
686
+ headers[Rack::CONTENT_LENGTH] = content_length.to_s
687
+ elsif use_chunked
688
+ headers[Rack::TRANSFER_ENCODING] = TRANSFER_ENCODING_CHUNKED
689
+ end
690
+
691
+ response << format_headers(headers)
692
+ response << "\r\n"
693
+
694
+ if body.respond_to?(:to_path) && (path = body.to_path) && File.readable?(path)
695
+ write_file_body(socket, response, path, content_length, use_chunked)
696
+ elsif body.respond_to?(:to_ary)
697
+ write_array_body(socket, response, body.to_ary, use_chunked)
698
+ elsif body.respond_to?(:each)
699
+ write_enumerable_body(socket, response, body, use_chunked)
700
+ else
701
+ raise TypeError, "body must respond to each, to_ary, or to_path"
702
+ end
703
+
704
+ socket_write(socket, "0\r\n\r\n") if use_chunked
705
+ end
706
+
707
+ # Calculates content length from an array or file body without consuming it.
708
+ #
709
+ # Returns nil for enumerable bodies whose length cannot be determined upfront.
710
+ #
711
+ # @param body [Object] the response body
712
+ # @return [Integer, nil] the byte length, or nil if it cannot be determined
713
+ #
714
+ # @rbs (untyped body) -> Integer?
715
+ def calculate_content_length(body)
716
+ if body.respond_to?(:to_ary)
717
+ array = body.to_ary
718
+ return nil unless array.is_a?(Array)
719
+
720
+ array.sum { |chunk| chunk.is_a?(String) ? chunk.bytesize : 0 }
721
+ elsif body.respond_to?(:to_path) && (path = body.to_path) && File.readable?(path)
722
+ File.size(path)
723
+ else
724
+ nil
725
+ end
726
+ end
727
+
728
+ # Writes a file body to the socket.
729
+ #
730
+ # Uses zero-copy IO.copy_stream for large files, direct buffering for small ones,
731
+ # and chunked encoding when required.
732
+ #
733
+ # @param socket [TCPSocket] the client socket
734
+ # @param response [String] headers already serialized, to be written before the body
735
+ # @param path [String] filesystem path of the file to send
736
+ # @param content_length [Integer, nil] pre-calculated file size
737
+ # @param use_chunked [Boolean] whether to use chunked transfer encoding
738
+ # @return [void]
739
+ #
740
+ # @rbs (TCPSocket socket, String response, String path, Integer? content_length, bool use_chunked) -> void
741
+ def write_file_body(socket, response, path, content_length, use_chunked)
742
+ File.open(path, "rb") do |file|
743
+ if use_chunked
744
+ socket_write(socket, response)
745
+ while (chunk = file.read(FILE_CHUNK_SIZE))
746
+ socket_write(socket, "#{chunk.bytesize.to_s(16)}\r\n#{chunk}\r\n")
747
+ end
748
+ elsif content_length && content_length < BODY_BUFFER_THRESHOLD
749
+ response << file.read(content_length)
750
+ socket_write(socket, response)
751
+ else
752
+ socket_write(socket, response)
753
+ IO.copy_stream(file, socket)
754
+ end
755
+ end
756
+ end
757
+
758
+ # Writes an array body to the socket.
759
+ #
760
+ # Dispatches to the single-chunk or multi-chunk path based on array length.
761
+ #
762
+ # @param socket [TCPSocket] the client socket
763
+ # @param response [String] headers already serialized, to be written before the body
764
+ # @param body_array [Array<String>] the response body chunks
765
+ # @param use_chunked [Boolean] whether to use chunked transfer encoding
766
+ # @return [void]
767
+ #
768
+ # @rbs (TCPSocket socket, String response, Array[String] body_array, bool use_chunked) -> void
769
+ def write_array_body(socket, response, body_array, use_chunked)
770
+ if body_array.length == 1
771
+ write_single_chunk(socket, response, body_array.first, use_chunked)
772
+ else
773
+ write_multiple_chunks(socket, response, body_array, use_chunked)
774
+ end
775
+ end
776
+
777
+ # Writes a single-element array body, optionally buffering it with the headers.
778
+ #
779
+ # Small bodies are concatenated with the headers into one write to reduce
780
+ # system call overhead.
781
+ #
782
+ # @param socket [TCPSocket] the client socket
783
+ # @param response [String] headers already serialized, to be written before the body
784
+ # @param chunk [String] the single body chunk
785
+ # @param use_chunked [Boolean] whether to use chunked transfer encoding
786
+ # @return [void]
787
+ # @raise [TypeError] if the chunk is not a String
788
+ #
789
+ # @rbs (TCPSocket socket, String response, String chunk, bool use_chunked) -> void
790
+ def write_single_chunk(socket, response, chunk, use_chunked)
791
+ raise TypeError, "body must yield String values" unless chunk.is_a?(String)
792
+
793
+ if use_chunked
794
+ response << "#{chunk.bytesize.to_s(16)}\r\n#{chunk}\r\n"
795
+ socket_write(socket, response)
796
+ elsif chunk.bytesize < BODY_BUFFER_THRESHOLD
797
+ socket_write(socket, response << chunk)
798
+ else
799
+ socket_write(socket, response)
800
+ socket_write(socket, chunk)
801
+ end
802
+ end
803
+
804
+ # Writes a multi-element array body to the socket.
805
+ #
806
+ # @param socket [TCPSocket] the client socket
807
+ # @param response [String] headers already serialized, to be written before the body
808
+ # @param body_array [Array<String>] the response body chunks
809
+ # @param use_chunked [Boolean] whether to use chunked transfer encoding
810
+ # @return [void]
811
+ # @raise [TypeError] if any chunk is not a String
812
+ #
813
+ # @rbs (TCPSocket socket, String response, Array[String] body_array, bool use_chunked) -> void
814
+ def write_multiple_chunks(socket, response, body_array, use_chunked)
815
+ if use_chunked
816
+ socket_write(socket, response)
817
+ body_array.each do |chunk|
818
+ raise TypeError, "body must yield String values" unless chunk.is_a?(String)
819
+
820
+ next if chunk.empty?
821
+
822
+ socket_write(socket, "#{chunk.bytesize.to_s(16)}\r\n#{chunk}\r\n")
823
+ end
824
+ else
825
+ body_array.each do |chunk|
826
+ raise TypeError, "body must yield String values" unless chunk.is_a?(String)
827
+
828
+ response << chunk
829
+ end
830
+ socket_write(socket, response)
831
+ end
832
+ end
833
+
834
+ # Writes a generic enumerable body to the socket.
835
+ #
836
+ # @param socket [TCPSocket] the client socket
837
+ # @param response [String] headers already serialized, to be written before the body
838
+ # @param body [Object] any object responding to each
839
+ # @param use_chunked [Boolean] whether to use chunked transfer encoding
840
+ # @return [void]
841
+ # @raise [TypeError] if any yielded chunk is not a String
842
+ #
843
+ # @rbs (TCPSocket socket, String response, untyped body, bool use_chunked) -> void
844
+ def write_enumerable_body(socket, response, body, use_chunked)
845
+ if use_chunked
846
+ socket_write(socket, response)
847
+ body.each do |chunk|
848
+ raise TypeError, "body must yield String values" unless chunk.is_a?(String)
849
+
850
+ next if chunk.empty?
851
+
852
+ socket_write(socket, "#{chunk.bytesize.to_s(16)}\r\n#{chunk}\r\n")
853
+ end
854
+ else
855
+ body.each do |chunk|
856
+ raise TypeError, "body must yield String values" unless chunk.is_a?(String)
857
+
858
+ response << chunk
859
+ end
860
+ socket_write(socket, response)
861
+ end
862
+ end
863
+
864
+ # Returns true if the header key contains characters illegal in HTTP headers.
865
+ #
866
+ # @param key [String] the header key to check
867
+ # @return [Boolean] true if the key is illegal
868
+ #
869
+ # @rbs (String key) -> bool
870
+ def illegal_header_key?(key)
871
+ key.match?(ILLEGAL_HEADER_KEY_REGEX)
872
+ end
873
+
874
+ # Returns true if the header value contains characters illegal in HTTP headers.
875
+ #
876
+ # @param value [String] the header value to check
877
+ # @return [Boolean] true if the value is illegal
878
+ #
879
+ # @rbs (String value) -> bool
880
+ def illegal_header_value?(value)
881
+ value.match?(ILLEGAL_HEADER_VALUE_REGEX)
882
+ end
883
+
884
+ # Formats a headers hash into an HTTP header string.
885
+ #
886
+ # Skips entries with illegal keys or values. Array values are written
887
+ # as separate header lines.
888
+ #
889
+ # @param headers [Hash] normalized response headers
890
+ # @return [String] formatted header lines, each ending with CRLF
891
+ #
892
+ # @rbs (Hash[String, String | Array[String]] headers) -> String
893
+ def format_headers(headers)
894
+ result = +""
895
+ headers.each do |name, value|
896
+ next if illegal_header_key?(name)
897
+
898
+ if value.is_a?(Array)
899
+ value.each do |header_value|
900
+ next if illegal_header_value?(header_value.to_s)
901
+
902
+ result << "#{name}: #{header_value}\r\n"
903
+ end
904
+ else
905
+ next if illegal_header_value?(value.to_s)
906
+
907
+ result << "#{name}: #{value}\r\n"
908
+ end
909
+ end
910
+ result
911
+ end
912
+
913
+ # Calls all rack.response_finished callbacks registered in the environment.
914
+ #
915
+ # Callbacks are called in reverse registration order. Individual callback
916
+ # failures are rescued so all callbacks are always attempted.
917
+ #
918
+ # @param env [Hash, nil] the Rack environment
919
+ # @param status [Integer, nil] the response status code
920
+ # @param headers [Hash, nil] the response headers
921
+ # @param error [Exception, nil] any error raised during processing, or nil on success
922
+ # @return [void]
923
+ #
924
+ # @rbs (Hash[String, untyped] env, Integer? status, Hash[String, String | Array[String]]? headers, Exception? error) -> void
925
+ def call_response_finished(env, status, headers, error)
926
+ return unless env && env[Rack::RACK_RESPONSE_FINISHED].is_a?(Array)
927
+
928
+ env[Rack::RACK_RESPONSE_FINISHED].reverse_each do |callable|
929
+ callable.call(env, status, headers, error) rescue nil
930
+ end
931
+ end
932
+
933
+ # Writes a string to the socket, retrying on partial writes and flow control blocks.
934
+ #
935
+ # Uses write_nonblock with a 5-second writable timeout to avoid blocking the
936
+ # thread indefinitely on slow clients.
937
+ #
938
+ # @param socket [TCPSocket] the socket to write to
939
+ # @param string [String] the data to write
940
+ # @return [void]
941
+ # @raise [WriteError] if the socket is not writable within the timeout or raises IOError
942
+ #
943
+ # @rbs (TCPSocket socket, String string) -> void
944
+ def socket_write(socket, string)
945
+ bytes = 0
946
+ byte_size = string.bytesize
947
+
948
+ while bytes < byte_size
949
+ begin
950
+ bytes += socket.write_nonblock(bytes.zero? ? string : string.byteslice(bytes..-1))
951
+ rescue IO::WaitWritable
952
+ raise WriteError unless socket.wait_writable(WRITE_TIMEOUT)
953
+ retry
954
+ rescue IOError
955
+ raise WriteError
956
+ end
957
+ end
958
+ end
959
+
960
+ if Socket.const_defined?(:TCP_CORK)
961
+ # Enables TCP_CORK on the socket to batch outgoing packets into fewer segments.
962
+ #
963
+ # Only applies to TCP sockets. No-op on non-TCP sockets.
964
+ # Available on Linux only; this method is not defined on other platforms.
965
+ #
966
+ # @param socket [TCPSocket] the socket to cork
967
+ # @return [void]
968
+ #
969
+ # @rbs (TCPSocket socket) -> void
970
+ def cork_socket(socket)
971
+ socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_CORK, 1) if socket.is_a?(TCPSocket)
972
+ end
973
+
974
+ # Disables TCP_CORK on the socket, flushing any buffered packets.
975
+ #
976
+ # Only applies to TCP sockets. No-op on non-TCP sockets.
977
+ # Available on Linux only; this method is not defined on other platforms.
978
+ #
979
+ # @param socket [TCPSocket] the socket to uncork
980
+ # @return [void]
981
+ #
982
+ # @rbs (TCPSocket socket) -> void
983
+ def uncork_socket(socket)
984
+ socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_CORK, 0) if socket.is_a?(TCPSocket)
985
+ end
986
+ else
987
+ def cork_socket(socket); end
988
+
989
+ def uncork_socket(socket); end
990
+ end
991
+ end
992
+ end