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,416 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ require "stringio"
5
+
6
+ require "rack"
7
+
8
+ require_relative "raptor_http2"
9
+
10
+ module Raptor
11
+ # Handles HTTP/2 request processing and Rack application integration.
12
+ #
13
+ # Http2 manages the HTTP/2 protocol lifecycle including frame processing,
14
+ # HPACK header compression, stream management, and response writing.
15
+ # It integrates with the same reactor, ractor pool, and thread pool
16
+ # pipeline used by HTTP/1.1 connections.
17
+ #
18
+ class Http2
19
+ FLAG_END_STREAM = 0x1
20
+ FLAG_END_HEADERS = 0x4
21
+ FLAG_ACK = 0x1
22
+ FLAG_PRIORITY = 0x20
23
+
24
+ SERVER_PROTOCOL = "HTTP/2"
25
+ RACK_HEADER_PREFIX = "rack."
26
+ HOP_BY_HOP_HEADERS = Set.new(%w[connection transfer-encoding keep-alive upgrade proxy-connection]).freeze
27
+
28
+ # @rbs @app: ^(Hash[String, untyped]) -> [Integer, Hash[String, String | Array[String]], untyped]
29
+ # @rbs @server_port: Integer
30
+
31
+ # Creates a new Http2 handler.
32
+ #
33
+ # @param app [#call] the Rack application to dispatch requests to
34
+ # @param server_port [Integer] port number used to populate SERVER_PORT in the Rack env
35
+ # @return [void]
36
+ #
37
+ # @rbs (^(Hash[String, untyped]) -> [Integer, Hash[String, String | Array[String]], untyped] app, Integer server_port) -> void
38
+ def initialize(app, server_port)
39
+ @app = app
40
+ @server_port = server_port
41
+ end
42
+
43
+ # Builds the initial server SETTINGS frame to send on connection establishment.
44
+ #
45
+ # @return [String] the encoded SETTINGS frame
46
+ #
47
+ # @rbs () -> String
48
+ def self.build_server_settings_frame
49
+ parser = Http2Parser.new
50
+ settings_payload = parser.build_settings(
51
+ max_concurrent_streams: 100,
52
+ initial_window_size: 65_535
53
+ )
54
+ parser.build_frame(:settings, 0, 0, settings_payload)
55
+ end
56
+
57
+ # Processes HTTP/2 frames from the connection buffer.
58
+ #
59
+ # Parses frames, handles HPACK decoding, tracks stream state, and returns
60
+ # updated connection state along with any outgoing protocol frames and
61
+ # completed stream requests. Ractor-safe.
62
+ #
63
+ # @param data [Hash] the connection state including buffer and HPACK table
64
+ # @return [Hash] updated state with outgoing_frames and completed_requests
65
+ #
66
+ # @rbs (Hash[Symbol, untyped] data) -> Hash[Symbol, untyped]
67
+ def self.process_frames(data)
68
+ parser = Http2Parser.new
69
+ buffer = data[:buffer]
70
+ hpack_table = data[:hpack_table] || []
71
+ streams = data[:http2_streams] ? data[:http2_streams].dup : {}
72
+ outgoing_frames = []
73
+ completed_requests = []
74
+ connection_window = data[:http2_window] || 65_535
75
+ preface_received = data[:http2_preface_received] || false
76
+
77
+ unless preface_received
78
+ if buffer.bytesize >= 24 && buffer.byteslice(0, 24) == Http2Parser.connection_preface
79
+ buffer = buffer.byteslice(24..-1) || ""
80
+ preface_received = true
81
+ else
82
+ return build_result(data, buffer, hpack_table, streams, outgoing_frames, completed_requests, connection_window, preface_received)
83
+ end
84
+ end
85
+
86
+ loop do
87
+ parsed = parser.parse_frame(buffer)
88
+ break unless parsed
89
+
90
+ frame, consumed = parsed
91
+ buffer = buffer.byteslice(consumed..-1) || ""
92
+
93
+ case frame[:type]
94
+ when :settings
95
+ if (frame[:flags] & FLAG_ACK).zero?
96
+ outgoing_frames << parser.build_frame(:settings, FLAG_ACK, 0, nil)
97
+ end
98
+
99
+ when :headers
100
+ stream_id = frame[:stream_id]
101
+ header_payload = frame[:payload]
102
+
103
+ if (frame[:flags] & FLAG_PRIORITY) != 0
104
+ header_payload = header_payload.byteslice(5..-1) || ""
105
+ end
106
+
107
+ decoded_headers, hpack_table = parser.parse_headers(header_payload, hpack_table)
108
+ stream = streams[stream_id] || {}
109
+ stream = stream.merge(headers: decoded_headers)
110
+
111
+ if (frame[:flags] & FLAG_END_STREAM) != 0
112
+ stream = stream.merge(end_stream: true)
113
+ completed_requests << {
114
+ stream_id: stream_id,
115
+ headers: decoded_headers,
116
+ body: stream[:body] || ""
117
+ }
118
+
119
+ streams.delete(stream_id)
120
+ else
121
+ streams[stream_id] = stream
122
+ end
123
+
124
+ when :data
125
+ stream_id = frame[:stream_id]
126
+ stream = streams[stream_id] || {}
127
+ existing_body = stream[:body] || ""
128
+ stream = stream.merge(body: existing_body + frame[:payload])
129
+
130
+ if frame[:payload].bytesize.positive?
131
+ connection_window -= frame[:payload].bytesize
132
+ if connection_window < 32_768
133
+ increment = 65_535 - connection_window
134
+ wu_payload = [increment].pack("N")
135
+ outgoing_frames << parser.build_frame(:window_update, 0, 0, wu_payload)
136
+ outgoing_frames << parser.build_frame(:window_update, 0, stream_id, wu_payload)
137
+ connection_window += increment
138
+ end
139
+ end
140
+
141
+ if (frame[:flags] & FLAG_END_STREAM) != 0
142
+ stream_headers = stream[:headers] || []
143
+ completed_requests << {
144
+ stream_id: stream_id,
145
+ headers: stream_headers,
146
+ body: stream[:body]
147
+ }
148
+
149
+ streams.delete(stream_id)
150
+ else
151
+ streams[stream_id] = stream
152
+ end
153
+
154
+ when :window_update
155
+ parser.parse_window_update(frame[:payload])
156
+
157
+ when :ping
158
+ if (frame[:flags] & FLAG_ACK).zero?
159
+ outgoing_frames << parser.build_frame(:ping, FLAG_ACK, 0, frame[:payload])
160
+ end
161
+
162
+ when :goaway
163
+ break
164
+
165
+ when :rst_stream
166
+ streams.delete(frame[:stream_id])
167
+ end
168
+ end
169
+
170
+ build_result(data, buffer, hpack_table, streams, outgoing_frames, completed_requests, connection_window, preface_received)
171
+ end
172
+
173
+ # Builds a frozen result hash from the current processing state.
174
+ #
175
+ # @param data [Hash] original connection state
176
+ # @param buffer [String] remaining unparsed data
177
+ # @param hpack_table [Array] updated HPACK dynamic table
178
+ # @param streams [Hash] updated stream states
179
+ # @param outgoing_frames [Array<String>] frames to write to the socket
180
+ # @param completed_requests [Array<Hash>] fully received stream requests
181
+ # @param connection_window [Integer] current connection flow control window
182
+ # @param preface_received [Boolean] whether the connection preface has been received
183
+ # @return [Hash] frozen result hash
184
+ #
185
+ # @rbs (Hash[Symbol, untyped] data, String buffer, Array[untyped] hpack_table, Hash[Integer, Hash[Symbol, untyped]] streams, Array[String] outgoing_frames, Array[Hash[Symbol, untyped]] completed_requests, Integer connection_window, bool preface_received) -> Hash[Symbol, untyped]
186
+ def self.build_result(data, buffer, hpack_table, streams, outgoing_frames, completed_requests, connection_window, preface_received)
187
+ Ractor.make_shareable({
188
+ id: data[:id],
189
+ protocol: :http2,
190
+ buffer: buffer || "",
191
+ hpack_table: hpack_table,
192
+ http2_streams: streams,
193
+ http2_window: connection_window,
194
+ http2_preface_received: preface_received,
195
+ outgoing_frames: outgoing_frames,
196
+ completed_requests: completed_requests,
197
+ remote_addr: data[:remote_addr],
198
+ url_scheme: data[:url_scheme]
199
+ })
200
+ end
201
+ private_class_method :build_result
202
+
203
+ # Handles a parsed HTTP/2 request from the ractor pool.
204
+ #
205
+ # Writes outgoing protocol frames to the socket, updates reactor state,
206
+ # and dispatches completed stream requests to the thread pool.
207
+ #
208
+ # @param result [Hash] the parsed result from the ractor pool
209
+ # @param reactor [Reactor] the reactor managing the connection
210
+ # @param thread_pool [AtomicThreadPool] thread pool for Rack app dispatch
211
+ # @return [void]
212
+ #
213
+ # @rbs (Hash[Symbol, untyped] result, Reactor reactor, AtomicThreadPool thread_pool) -> void
214
+ def handle_parsed_request(result, reactor, thread_pool)
215
+ socket = reactor.socket_for(result[:id])
216
+ return unless socket
217
+
218
+ mutex = reactor.mutex_for(result[:id])
219
+
220
+ if result[:outgoing_frames]&.any?
221
+ mutex.synchronize do
222
+ result[:outgoing_frames].each { |frame| socket.write(frame) rescue nil }
223
+ end
224
+ end
225
+
226
+ reactor.update_http2_state(result)
227
+
228
+ result[:completed_requests]&.each do |request|
229
+ stream_id = request[:stream_id]
230
+ remote_addr = result[:remote_addr] || "127.0.0.1"
231
+
232
+ thread_pool << proc do
233
+ dispatch_stream_request(
234
+ socket, mutex, stream_id,
235
+ request[:headers], request[:body],
236
+ remote_addr: remote_addr
237
+ )
238
+ end
239
+ end
240
+ end
241
+
242
+ private
243
+
244
+ # Dispatches a completed stream request to the Rack app and writes
245
+ # the response back as HTTP/2 frames.
246
+ #
247
+ # @param socket [OpenSSL::SSL::SSLSocket] the connection socket
248
+ # @param mutex [Mutex] mutex for serializing writes to the connection socket
249
+ # @param stream_id [Integer] the HTTP/2 stream identifier
250
+ # @param headers [Array<Array(String, String)>] request headers
251
+ # @param body [String] request body
252
+ # @param remote_addr [String] the client IP address
253
+ # @return [void]
254
+ #
255
+ # @rbs (OpenSSL::SSL::SSLSocket socket, Mutex mutex, Integer stream_id, Array[[String, String]] headers, String body, remote_addr: String) -> void
256
+ def dispatch_stream_request(socket, mutex, stream_id, headers, body, remote_addr:)
257
+ env = build_rack_env(headers, body, remote_addr: remote_addr)
258
+ status, response_headers, response_body = @app.call(env)
259
+
260
+ write_http2_response(socket, mutex, stream_id, status, response_headers, response_body)
261
+ rescue
262
+ write_http2_error_response(socket, mutex, stream_id)
263
+ raise
264
+ ensure
265
+ response_body.close if response_body.respond_to?(:close)
266
+ end
267
+
268
+ # Writes a Rack response as HTTP/2 frames to the socket.
269
+ #
270
+ # @param socket [OpenSSL::SSL::SSLSocket] the connection socket
271
+ # @param mutex [Mutex] mutex for serializing writes to the connection socket
272
+ # @param stream_id [Integer] the HTTP/2 stream identifier
273
+ # @param status [Integer] HTTP status code
274
+ # @param headers [Hash] response headers from the Rack application
275
+ # @param body [Object] response body responding to each
276
+ # @return [void]
277
+ #
278
+ # @rbs (OpenSSL::SSL::SSLSocket socket, Mutex mutex, Integer stream_id, Integer status, Hash[String, String | Array[String]] headers, untyped body) -> void
279
+ def write_http2_response(socket, mutex, stream_id, status, headers, body)
280
+ parser = Http2Parser.new
281
+
282
+ header_pairs = [[":status", status.to_s]]
283
+ headers.each do |name, value|
284
+ lowered = name.downcase
285
+ next if lowered.start_with?(RACK_HEADER_PREFIX)
286
+ next if HOP_BY_HOP_HEADERS.include?(lowered)
287
+
288
+ if value.is_a?(Array)
289
+ value.each { |val| header_pairs << [lowered, val.to_s] }
290
+ else
291
+ header_pairs << [lowered, value.to_s]
292
+ end
293
+ end
294
+
295
+ encoded_headers = parser.encode_headers(header_pairs)
296
+ body_chunks = []
297
+ body.each { |chunk| body_chunks << chunk unless chunk.empty? }
298
+
299
+ mutex.synchronize do
300
+ if body_chunks.empty?
301
+ socket.write(parser.build_frame(:headers, FLAG_END_STREAM | FLAG_END_HEADERS, stream_id, encoded_headers))
302
+ else
303
+ socket.write(parser.build_frame(:headers, FLAG_END_HEADERS, stream_id, encoded_headers))
304
+
305
+ body_chunks.each_with_index do |chunk, index|
306
+ last = index == body_chunks.size - 1
307
+ flags = last ? FLAG_END_STREAM : 0
308
+ socket.write(parser.build_frame(:data, flags, stream_id, chunk))
309
+ end
310
+ end
311
+ end
312
+ rescue IOError, Errno::EPIPE
313
+ # Connection closed
314
+ end
315
+
316
+ # Writes a 500 error response as HTTP/2 frames.
317
+ #
318
+ # @param socket [OpenSSL::SSL::SSLSocket] the connection socket
319
+ # @param mutex [Mutex] mutex for serializing writes to the connection socket
320
+ # @param stream_id [Integer] the HTTP/2 stream identifier
321
+ # @return [void]
322
+ #
323
+ # @rbs (OpenSSL::SSL::SSLSocket socket, Mutex mutex, Integer stream_id) -> void
324
+ def write_http2_error_response(socket, mutex, stream_id)
325
+ parser = Http2Parser.new
326
+ encoded = parser.encode_headers([[":status", "500"]])
327
+
328
+ mutex.synchronize do
329
+ socket.write(parser.build_frame(:headers, FLAG_END_STREAM | FLAG_END_HEADERS, stream_id, encoded))
330
+ end
331
+ rescue IOError, Errno::EPIPE
332
+ # Connection closed
333
+ end
334
+
335
+ # Builds a Rack environment hash from HTTP/2 headers and body.
336
+ #
337
+ # Translates HTTP/2 pseudo-headers into Rack-compatible environment keys
338
+ # and populates all required Rack env entries.
339
+ #
340
+ # @param headers [Array<Array(String, String)>] HTTP/2 header pairs
341
+ # @param body [String] the request body
342
+ # @param remote_addr [String] the client IP address
343
+ # @return [Hash] fully populated Rack environment hash
344
+ #
345
+ # @rbs (Array[[String, String]] headers, String body, remote_addr: String) -> Hash[String, untyped]
346
+ def build_rack_env(headers, body, remote_addr:)
347
+ env = {}
348
+
349
+ headers.each do |name, value|
350
+ if name.start_with?(":")
351
+ case name
352
+ when ":method" then env[Rack::REQUEST_METHOD] = value
353
+ when ":path"
354
+ path, query = value.split("?", 2)
355
+ env[Rack::PATH_INFO] = path
356
+ env[Rack::QUERY_STRING] = query || ""
357
+ when ":scheme" then env[Rack::RACK_URL_SCHEME] = value
358
+ when ":authority" then env[Rack::HTTP_HOST] = value
359
+ end
360
+ elsif name == "content-type"
361
+ env["CONTENT_TYPE"] = value
362
+ elsif name == "content-length"
363
+ env["CONTENT_LENGTH"] = value
364
+ else
365
+ rack_key = "HTTP_#{name.upcase.tr("-", "_")}"
366
+ env[rack_key] = value
367
+ end
368
+ end
369
+
370
+ env[Rack::SERVER_PROTOCOL] = SERVER_PROTOCOL
371
+ env[Rack::RACK_VERSION] = Rack::VERSION
372
+ env[Rack::RACK_INPUT] = StringIO.new(body).set_encoding(Encoding::ASCII_8BIT)
373
+ env[Rack::RACK_ERRORS] = $stderr
374
+ env[Rack::RACK_RESPONSE_FINISHED] = []
375
+ env[Rack::RACK_IS_HIJACK] = false
376
+
377
+ env[Rack::SCRIPT_NAME] = "" unless env.key?(Rack::SCRIPT_NAME)
378
+ env[Rack::PATH_INFO] = "" unless env.key?(Rack::PATH_INFO)
379
+ env[Rack::QUERY_STRING] = "" unless env.key?(Rack::QUERY_STRING)
380
+
381
+ if body.bytesize.positive? && !env.key?("CONTENT_LENGTH")
382
+ env["CONTENT_LENGTH"] = body.bytesize.to_s
383
+ end
384
+
385
+ env["REMOTE_ADDR"] = remote_addr
386
+
387
+ populate_server_name_and_port(env)
388
+
389
+ env
390
+ end
391
+
392
+ # Populates SERVER_NAME and SERVER_PORT from the HTTP_HOST header.
393
+ #
394
+ # @param env [Hash] the Rack environment to populate
395
+ # @return [void]
396
+ #
397
+ # @rbs (Hash[String, untyped] env) -> void
398
+ def populate_server_name_and_port(env)
399
+ http_host = env[Rack::HTTP_HOST]
400
+
401
+ if http_host
402
+ if http_host.start_with?("[")
403
+ host = http_host[/\A\[([^\]]+)\]/, 1]
404
+ port = http_host[/\]:(\d+)\z/, 1]
405
+ else
406
+ host, port = http_host.split(":", 2)
407
+ end
408
+ env[Rack::SERVER_NAME] ||= host
409
+ env[Rack::SERVER_PORT] ||= port || @server_port.to_s
410
+ else
411
+ env[Rack::SERVER_NAME] ||= "localhost"
412
+ env[Rack::SERVER_PORT] ||= @server_port.to_s
413
+ end
414
+ end
415
+ end
416
+ end