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.
- checksums.yaml +7 -0
- data/.buildkite/pipeline.yml +36 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +86 -0
- data/Rakefile +28 -0
- data/exe/raptor +8 -0
- data/ext/raptor_http/extconf.rb +7 -0
- data/ext/raptor_http/raptor_http.c +1248 -0
- data/ext/raptor_http2/extconf.rb +7 -0
- data/ext/raptor_http2/huffman_table.h +4888 -0
- data/ext/raptor_http2/raptor_http2.c +772 -0
- data/lib/raptor/binder.rb +249 -0
- data/lib/raptor/cli.rb +171 -0
- data/lib/raptor/cluster.rb +357 -0
- data/lib/raptor/http2.rb +416 -0
- data/lib/raptor/reactor.rb +411 -0
- data/lib/raptor/request.rb +992 -0
- data/lib/raptor/server.rb +167 -0
- data/lib/raptor/stats.rb +94 -0
- data/lib/raptor/version.rb +6 -0
- data/lib/raptor.rb +13 -0
- data/sig/generated/raptor/binder.rbs +162 -0
- data/sig/generated/raptor/cli.rbs +71 -0
- data/sig/generated/raptor/cluster.rbs +171 -0
- data/sig/generated/raptor/http2.rbs +145 -0
- data/sig/generated/raptor/reactor.rbs +251 -0
- data/sig/generated/raptor/request.rbs +477 -0
- data/sig/generated/raptor/server.rbs +88 -0
- data/sig/generated/raptor/stats.rbs +78 -0
- data/sig/generated/raptor/version.rbs +5 -0
- data/sig/generated/raptor.rbs +9 -0
- metadata +160 -0
|
@@ -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
|