quicsilver 0.2.0 → 0.4.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 +4 -4
- data/.github/workflows/ci.yml +4 -5
- data/.github/workflows/cibuildgem.yaml +93 -0
- data/.gitignore +3 -1
- data/CHANGELOG.md +81 -0
- data/Gemfile.lock +26 -4
- data/README.md +95 -31
- data/Rakefile +95 -3
- data/benchmarks/components.rb +191 -0
- data/benchmarks/concurrent.rb +110 -0
- data/benchmarks/helpers.rb +88 -0
- data/benchmarks/quicsilver_server.rb +1 -1
- data/benchmarks/rails.rb +170 -0
- data/benchmarks/throughput.rb +113 -0
- data/examples/README.md +44 -91
- data/examples/benchmark.rb +111 -0
- data/examples/connection_pool_demo.rb +47 -0
- data/examples/example_helper.rb +18 -0
- data/examples/falcon_middleware.rb +44 -0
- data/examples/feature_demo.rb +125 -0
- data/examples/grpc_style.rb +97 -0
- data/examples/minimal_http3_server.rb +6 -18
- data/examples/priorities.rb +60 -0
- data/examples/protocol_http_server.rb +31 -0
- data/examples/rack_http3_server.rb +8 -20
- data/examples/rails_feature_test.rb +260 -0
- data/examples/simple_client_test.rb +2 -2
- data/examples/streaming_sse.rb +33 -0
- data/examples/trailers.rb +69 -0
- data/ext/quicsilver/extconf.rb +14 -0
- data/ext/quicsilver/quicsilver.c +568 -181
- data/lib/quicsilver/client/client.rb +349 -0
- data/lib/quicsilver/client/connection_pool.rb +106 -0
- data/lib/quicsilver/client/request.rb +98 -0
- data/lib/quicsilver/libmsquic.2.dylib +0 -0
- data/lib/quicsilver/protocol/adapter.rb +176 -0
- data/lib/quicsilver/protocol/control_stream_parser.rb +106 -0
- data/lib/quicsilver/protocol/frame_parser.rb +142 -0
- data/lib/quicsilver/protocol/frame_reader.rb +55 -0
- data/lib/quicsilver/{http3.rb → protocol/frames.rb} +146 -30
- data/lib/quicsilver/protocol/priority.rb +56 -0
- data/lib/quicsilver/protocol/qpack/decoder.rb +165 -0
- data/lib/quicsilver/protocol/qpack/encoder.rb +227 -0
- data/lib/quicsilver/protocol/qpack/header_block_decoder.rb +140 -0
- data/lib/quicsilver/protocol/qpack/huffman.rb +459 -0
- data/lib/quicsilver/protocol/request_encoder.rb +47 -0
- data/lib/quicsilver/protocol/request_parser.rb +275 -0
- data/lib/quicsilver/protocol/response_encoder.rb +97 -0
- data/lib/quicsilver/protocol/response_parser.rb +141 -0
- data/lib/quicsilver/protocol/stream_input.rb +98 -0
- data/lib/quicsilver/protocol/stream_output.rb +59 -0
- data/lib/quicsilver/quicsilver.bundle +0 -0
- data/lib/quicsilver/server/listener_data.rb +14 -0
- data/lib/quicsilver/server/request_handler.rb +138 -0
- data/lib/quicsilver/server/request_registry.rb +50 -0
- data/lib/quicsilver/server/server.rb +610 -0
- data/lib/quicsilver/transport/configuration.rb +141 -0
- data/lib/quicsilver/transport/connection.rb +379 -0
- data/lib/quicsilver/transport/event_loop.rb +38 -0
- data/lib/quicsilver/transport/inbound_stream.rb +33 -0
- data/lib/quicsilver/transport/stream.rb +28 -0
- data/lib/quicsilver/transport/stream_event.rb +26 -0
- data/lib/quicsilver/version.rb +1 -1
- data/lib/quicsilver.rb +55 -14
- data/lib/rackup/handler/quicsilver.rb +1 -2
- data/quicsilver.gemspec +13 -3
- metadata +125 -21
- data/benchmarks/benchmark.rb +0 -68
- data/examples/setup_certs.sh +0 -57
- data/lib/quicsilver/client.rb +0 -261
- data/lib/quicsilver/connection.rb +0 -42
- data/lib/quicsilver/event_loop.rb +0 -38
- data/lib/quicsilver/http3/request_encoder.rb +0 -133
- data/lib/quicsilver/http3/request_parser.rb +0 -176
- data/lib/quicsilver/http3/response_encoder.rb +0 -186
- data/lib/quicsilver/http3/response_parser.rb +0 -160
- data/lib/quicsilver/listener_data.rb +0 -29
- data/lib/quicsilver/quic_stream.rb +0 -36
- data/lib/quicsilver/request_registry.rb +0 -48
- data/lib/quicsilver/server.rb +0 -355
- data/lib/quicsilver/server_configuration.rb +0 -78
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "protocol/http/request"
|
|
4
|
+
require "protocol/http/response"
|
|
5
|
+
require "protocol/http/headers"
|
|
6
|
+
require_relative "stream_input"
|
|
7
|
+
require_relative "stream_output"
|
|
8
|
+
|
|
9
|
+
module Quicsilver
|
|
10
|
+
module Protocol
|
|
11
|
+
# Converts between QUIC/HTTP/3 frames and protocol-http Request/Response
|
|
12
|
+
# objects. This enables integration with Falcon and any other server
|
|
13
|
+
# built on protocol-http.
|
|
14
|
+
#
|
|
15
|
+
# Usage:
|
|
16
|
+
# adapter = Protocol::Adapter.new(app)
|
|
17
|
+
# request, body = adapter.build_request(parsed_headers)
|
|
18
|
+
# body.write(chunk) # feed body data
|
|
19
|
+
# body.close_write # signal end of body
|
|
20
|
+
# response = adapter.call(request)
|
|
21
|
+
# adapter.send_response(response, writer)
|
|
22
|
+
#
|
|
23
|
+
class Adapter
|
|
24
|
+
VERSION = "HTTP/3"
|
|
25
|
+
|
|
26
|
+
def initialize(app)
|
|
27
|
+
@app = app
|
|
28
|
+
@qpack_encoder = Quicsilver::Protocol::Qpack::Encoder.new
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Build a Protocol::HTTP::Request from parsed HTTP/3 headers.
|
|
32
|
+
#
|
|
33
|
+
# Returns [request, body] where body is a Protocol::StreamInput that
|
|
34
|
+
# the transport feeds RECEIVE chunks into.
|
|
35
|
+
#
|
|
36
|
+
# @param headers [Hash] Parsed headers from RequestParser (includes pseudo-headers).
|
|
37
|
+
# @return [Array(Protocol::HTTP::Request, Protocol::StreamInput)] request and body.
|
|
38
|
+
# Body is nil for bodyless methods (GET, HEAD, TRACE).
|
|
39
|
+
# Caller feeds RECEIVE data into body via write(), then close_write on FIN.
|
|
40
|
+
def build_request(headers)
|
|
41
|
+
method = headers[":method"]
|
|
42
|
+
scheme = headers[":scheme"] || "https"
|
|
43
|
+
authority = headers[":authority"]
|
|
44
|
+
path = headers[":path"]
|
|
45
|
+
protocol = headers[":protocol"]
|
|
46
|
+
content_length = headers["content-length"]&.to_i
|
|
47
|
+
|
|
48
|
+
protocol_headers = ::Protocol::HTTP::Headers.new
|
|
49
|
+
headers.each do |name, value|
|
|
50
|
+
next if name.start_with?(":")
|
|
51
|
+
protocol_headers.add(name, value)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
body = unless bodyless_request?(method)
|
|
55
|
+
Protocol::StreamInput.new(content_length)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
request = ::Protocol::HTTP::Request.new(
|
|
59
|
+
scheme, authority, method, path, VERSION,
|
|
60
|
+
protocol_headers, body, protocol
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
[request, body]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Send a Protocol::HTTP::Response via a transport writer.
|
|
67
|
+
#
|
|
68
|
+
# Encodes the response headers as an HTTP/3 HEADERS frame, then streams
|
|
69
|
+
# the response body as DATA frames via Protocol::StreamOutput.
|
|
70
|
+
#
|
|
71
|
+
# @param response [Protocol::HTTP::Response] The response to send.
|
|
72
|
+
# @param writer [#call] Transport writer — accepts (data, fin) for sending bytes.
|
|
73
|
+
# @param head_request [Boolean] Whether this was a HEAD request.
|
|
74
|
+
# @return [void]
|
|
75
|
+
def send_response(response, writer, head_request: false)
|
|
76
|
+
status = response.status
|
|
77
|
+
headers = response.headers
|
|
78
|
+
trailers = extract_trailers(headers)
|
|
79
|
+
headers_hash = response_headers_hash(headers)
|
|
80
|
+
body = response.body
|
|
81
|
+
|
|
82
|
+
if body.nil? || head_request
|
|
83
|
+
send_headers_only(status, headers_hash, writer, trailers: trailers)
|
|
84
|
+
elsif body.respond_to?(:read)
|
|
85
|
+
stream_response(status, headers_hash, body, writer, trailers: trailers)
|
|
86
|
+
else
|
|
87
|
+
buffer_response(status, headers_hash, body, writer, trailers: trailers)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Call the protocol-http application with the request.
|
|
92
|
+
#
|
|
93
|
+
# @param request [Protocol::HTTP::Request]
|
|
94
|
+
# @return [Protocol::HTTP::Response]
|
|
95
|
+
def call(request)
|
|
96
|
+
@app.call(request)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
# Methods where a body has no defined semantics (RFC 9110 §9.3.1, §9.3.2, §9.3.8).
|
|
102
|
+
# GET and HEAD SHOULD NOT have a body. TRACE MUST NOT.
|
|
103
|
+
# DELETE, OPTIONS, CONNECT can all have meaningful bodies.
|
|
104
|
+
BODYLESS_METHODS = %w[GET HEAD TRACE].freeze
|
|
105
|
+
|
|
106
|
+
def bodyless_request?(method)
|
|
107
|
+
BODYLESS_METHODS.include?(method)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# No body — send HEADERS with FIN
|
|
111
|
+
def send_headers_only(status, headers, writer, trailers: nil)
|
|
112
|
+
encoder = Quicsilver::Protocol::ResponseEncoder.new(status, headers, [], trailers: trailers)
|
|
113
|
+
writer.call(encoder.encode, true)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Streaming body (protocol-http Body::Readable) — send HEADERS, then
|
|
117
|
+
# stream DATA frames as chunks arrive. Used by Falcon mode.
|
|
118
|
+
def stream_response(status, headers, body, writer, trailers: nil)
|
|
119
|
+
has_trailers = trailers&.any?
|
|
120
|
+
writer.call(build_headers_frame(status, headers), false)
|
|
121
|
+
Protocol::StreamOutput.new(body, &writer).stream(send_fin: !has_trailers)
|
|
122
|
+
writer.call(build_trailer_frame(trailers), true) if has_trailers
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Buffered body (Rack array or enumerable) — encode everything and send.
|
|
126
|
+
def buffer_response(status, headers, body, writer, trailers: nil)
|
|
127
|
+
parts = body.respond_to?(:each) ? body : [body.to_s]
|
|
128
|
+
encoder = Quicsilver::Protocol::ResponseEncoder.new(status, headers, parts, trailers: trailers)
|
|
129
|
+
writer.call(encoder.encode, true)
|
|
130
|
+
ensure
|
|
131
|
+
body.close if body.respond_to?(:close)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Extract trailers from Protocol::HTTP::Headers if present.
|
|
135
|
+
# Returns a Hash or nil.
|
|
136
|
+
def extract_trailers(headers)
|
|
137
|
+
return nil unless headers.respond_to?(:trailer?) && headers.trailer?
|
|
138
|
+
|
|
139
|
+
result = {}
|
|
140
|
+
headers.trailer.each do |name, value|
|
|
141
|
+
result[name] = value
|
|
142
|
+
end
|
|
143
|
+
result
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Convert Protocol::HTTP::Headers to a plain Hash for ResponseEncoder.
|
|
147
|
+
# Only includes headers, not trailers.
|
|
148
|
+
def response_headers_hash(headers)
|
|
149
|
+
return {} unless headers
|
|
150
|
+
|
|
151
|
+
headers.header.to_h
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Build an HTTP/3 HEADERS frame from key-value pairs
|
|
155
|
+
def build_qpack_frame(pairs)
|
|
156
|
+
encoded = @qpack_encoder.encode(pairs)
|
|
157
|
+
Quicsilver::Protocol.encode_varint(Quicsilver::Protocol::FRAME_HEADERS) +
|
|
158
|
+
Quicsilver::Protocol.encode_varint(encoded.bytesize) +
|
|
159
|
+
encoded
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Build a response HEADERS frame (with :status pseudo-header)
|
|
163
|
+
def build_headers_frame(status, headers)
|
|
164
|
+
pairs = [[":status", status.to_s]]
|
|
165
|
+
headers.each { |name, value| pairs << [name.to_s.downcase, value.to_s] }
|
|
166
|
+
build_qpack_frame(pairs)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Build a trailer HEADERS frame
|
|
170
|
+
def build_trailer_frame(trailers)
|
|
171
|
+
pairs = trailers.map { |name, value| [name.to_s.downcase, value.to_s] }
|
|
172
|
+
build_qpack_frame(pairs)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quicsilver
|
|
4
|
+
module Protocol
|
|
5
|
+
# Shared control stream parsing for both server Connection and Client.
|
|
6
|
+
#
|
|
7
|
+
# RFC 9114 §7.2.4: Both endpoints MUST send and process SETTINGS.
|
|
8
|
+
# RFC 9114 §7.2.6: Both endpoints MUST validate incoming GOAWAY.
|
|
9
|
+
#
|
|
10
|
+
# Includer must provide:
|
|
11
|
+
# @settings_received — boolean, initially false
|
|
12
|
+
# @peer_goaway_id — nil initially
|
|
13
|
+
#
|
|
14
|
+
# Includer may override:
|
|
15
|
+
# on_settings_received(settings_hash) — called after SETTINGS parsed
|
|
16
|
+
# on_goaway_received(stream_id) — called after GOAWAY parsed
|
|
17
|
+
# handle_control_frame(type, payload) — called for non-SETTINGS/GOAWAY frames
|
|
18
|
+
module ControlStreamParser
|
|
19
|
+
# RFC 9114 §7.2.4.1 / §11.2.2: HTTP/2 setting identifiers forbidden in HTTP/3
|
|
20
|
+
# 0x00 = SETTINGS_HEADER_TABLE_SIZE (reserved), 0x02-0x05 = various HTTP/2 settings
|
|
21
|
+
# Note: 0x08 (SETTINGS_ENABLE_CONNECT_PROTOCOL) is valid in HTTP/3 per RFC 9220
|
|
22
|
+
HTTP2_SETTINGS = [0x00, 0x02, 0x03, 0x04, 0x05].freeze
|
|
23
|
+
|
|
24
|
+
def parse_control_frames(data)
|
|
25
|
+
first_frame = !@settings_received
|
|
26
|
+
|
|
27
|
+
Protocol::FrameReader.each(data) do |type, payload|
|
|
28
|
+
if first_frame && type != Protocol::FRAME_SETTINGS
|
|
29
|
+
raise Protocol::FrameError.new("First frame on control stream must be SETTINGS",
|
|
30
|
+
error_code: Protocol::H3_MISSING_SETTINGS)
|
|
31
|
+
end
|
|
32
|
+
first_frame = false
|
|
33
|
+
|
|
34
|
+
case type
|
|
35
|
+
when Protocol::FRAME_SETTINGS
|
|
36
|
+
raise Protocol::FrameError, "Duplicate SETTINGS frame on control stream" if @settings_received
|
|
37
|
+
parse_peer_settings(payload)
|
|
38
|
+
@settings_received = true
|
|
39
|
+
when Protocol::FRAME_GOAWAY
|
|
40
|
+
parse_peer_goaway(payload)
|
|
41
|
+
else
|
|
42
|
+
handle_control_frame(type, payload)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def parse_peer_settings(payload)
|
|
48
|
+
offset = 0
|
|
49
|
+
seen = Set.new
|
|
50
|
+
settings = {}
|
|
51
|
+
|
|
52
|
+
while offset < payload.bytesize
|
|
53
|
+
id, id_len = Protocol.decode_varint(payload.bytes, offset)
|
|
54
|
+
value, value_len = Protocol.decode_varint(payload.bytes, offset + id_len)
|
|
55
|
+
break if id_len == 0 || value_len == 0
|
|
56
|
+
|
|
57
|
+
if HTTP2_SETTINGS.include?(id)
|
|
58
|
+
raise Protocol::FrameError.new("HTTP/2 setting identifier 0x#{id.to_s(16)} not allowed in HTTP/3",
|
|
59
|
+
error_code: Protocol::H3_SETTINGS_ERROR)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
raise Protocol::FrameError, "Duplicate setting identifier 0x#{id.to_s(16)}" if seen.include?(id)
|
|
63
|
+
seen.add(id)
|
|
64
|
+
|
|
65
|
+
settings[id] = value
|
|
66
|
+
offset += id_len + value_len
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
on_settings_received(settings)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# RFC 9114 §7.2.6: Validate incoming GOAWAY frame.
|
|
73
|
+
# Stream ID must be a client-initiated bidirectional stream ID (divisible by 4)
|
|
74
|
+
# and must not increase from a previous GOAWAY.
|
|
75
|
+
def parse_peer_goaway(payload)
|
|
76
|
+
stream_id, _ = Protocol.decode_varint(payload.bytes, 0)
|
|
77
|
+
|
|
78
|
+
unless stream_id % 4 == 0
|
|
79
|
+
raise Protocol::FrameError.new(
|
|
80
|
+
"GOAWAY stream ID #{stream_id} is not a client-initiated bidirectional stream ID",
|
|
81
|
+
error_code: Protocol::H3_ID_ERROR)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
if @peer_goaway_id && stream_id > @peer_goaway_id
|
|
85
|
+
raise Protocol::FrameError.new(
|
|
86
|
+
"GOAWAY stream ID #{stream_id} exceeds previous #{@peer_goaway_id}",
|
|
87
|
+
error_code: Protocol::H3_ID_ERROR)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
@peer_goaway_id = stream_id
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
# Override in includer to store settings. Default: no-op.
|
|
96
|
+
def on_settings_received(settings)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Override in includer to handle additional frame types on the control stream.
|
|
100
|
+
# Server handles FORBIDDEN_ON_CONTROL and PRIORITY_UPDATE here.
|
|
101
|
+
# Client ignores unknown frames.
|
|
102
|
+
def handle_control_frame(type, payload)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
require_relative "frame_reader"
|
|
5
|
+
require_relative "qpack/header_block_decoder"
|
|
6
|
+
|
|
7
|
+
module Quicsilver
|
|
8
|
+
module Protocol
|
|
9
|
+
# Base class for HTTP/3 request and response frame parsing.
|
|
10
|
+
#
|
|
11
|
+
# Handles the shared frame walking loop, HEADERS→DATA→HEADERS ordering,
|
|
12
|
+
# trailer parsing, body accumulation, and size limit enforcement.
|
|
13
|
+
#
|
|
14
|
+
# Subclasses implement:
|
|
15
|
+
# - parse_headers(payload) — decode the first HEADERS frame
|
|
16
|
+
class FrameParser
|
|
17
|
+
# Frame types forbidden on request streams — use hash for O(1) lookup
|
|
18
|
+
# Static-only QPACK decoder (no dynamic table). Used by default.
|
|
19
|
+
# Inject a custom decoder via decoder: kwarg for dynamic QPACK support.
|
|
20
|
+
DEFAULT_DECODER = Qpack::HeaderBlockDecoder.default
|
|
21
|
+
|
|
22
|
+
CONTROL_ONLY_SET = Protocol::CONTROL_ONLY_FRAMES.each_with_object({}) { |f, h| h[f] = true }.freeze
|
|
23
|
+
|
|
24
|
+
EMPTY_BODY = StringIO.new("".b).tap { |io| io.set_encoding(Encoding::ASCII_8BIT) }
|
|
25
|
+
|
|
26
|
+
attr_reader :headers, :trailers, :bytes_consumed
|
|
27
|
+
|
|
28
|
+
def frames
|
|
29
|
+
@frames || []
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def initialize(decoder:, max_body_size: nil, max_header_size: nil, max_header_count: nil, max_frame_payload_size: nil)
|
|
33
|
+
@decoder = decoder
|
|
34
|
+
@max_body_size = max_body_size
|
|
35
|
+
@max_header_size = max_header_size
|
|
36
|
+
@max_header_count = max_header_count
|
|
37
|
+
@max_frame_payload_size = max_frame_payload_size
|
|
38
|
+
@headers = {}
|
|
39
|
+
@trailers = {}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def body
|
|
43
|
+
if @body
|
|
44
|
+
@body.rewind
|
|
45
|
+
@body
|
|
46
|
+
elsif @cached_body_str
|
|
47
|
+
@body = StringIO.new(@cached_body_str)
|
|
48
|
+
@body.set_encoding(Encoding::ASCII_8BIT)
|
|
49
|
+
@body
|
|
50
|
+
else
|
|
51
|
+
EMPTY_BODY
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def parse!
|
|
58
|
+
walk_frames(@data)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def walk_frames(buffer)
|
|
62
|
+
@headers = {}
|
|
63
|
+
@trailers = {}
|
|
64
|
+
@body = nil
|
|
65
|
+
@frames = nil
|
|
66
|
+
@bytes_consumed = 0
|
|
67
|
+
headers_received = false
|
|
68
|
+
trailers_received = false
|
|
69
|
+
|
|
70
|
+
@bytes_consumed = FrameReader.each(buffer) do |type, payload|
|
|
71
|
+
if @max_frame_payload_size && payload.bytesize > @max_frame_payload_size
|
|
72
|
+
raise Protocol::FrameError, "Frame payload #{payload.bytesize} exceeds limit #{@max_frame_payload_size}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
(@frames ||= []) << { type: type, length: payload.bytesize, payload: payload }
|
|
76
|
+
|
|
77
|
+
if CONTROL_ONLY_SET.key?(type)
|
|
78
|
+
raise Protocol::FrameError, "Frame type 0x#{type.to_s(16)} not allowed on request streams"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
case type
|
|
82
|
+
when 0x01 # HEADERS
|
|
83
|
+
if @max_header_size && payload.bytesize > @max_header_size
|
|
84
|
+
raise Protocol::MessageError, "Header block #{payload.bytesize} exceeds limit #{@max_header_size}"
|
|
85
|
+
end
|
|
86
|
+
if trailers_received
|
|
87
|
+
raise Protocol::FrameError, "HEADERS frame after trailers"
|
|
88
|
+
elsif headers_received
|
|
89
|
+
parse_trailers(payload)
|
|
90
|
+
trailers_received = true
|
|
91
|
+
else
|
|
92
|
+
parse_headers(payload)
|
|
93
|
+
headers_received = true
|
|
94
|
+
end
|
|
95
|
+
when 0x00 # DATA
|
|
96
|
+
raise Protocol::FrameError, "DATA frame before HEADERS" unless headers_received
|
|
97
|
+
raise Protocol::FrameError, "DATA frame after trailers" if trailers_received
|
|
98
|
+
unless @body
|
|
99
|
+
@body = StringIO.new
|
|
100
|
+
@body.set_encoding(Encoding::ASCII_8BIT)
|
|
101
|
+
end
|
|
102
|
+
@body.write(payload)
|
|
103
|
+
if @max_body_size && @body.size > @max_body_size
|
|
104
|
+
raise Protocol::MessageError, "Body size #{@body.size} exceeds limit #{@max_body_size}"
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
@body&.rewind
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# RFC 9110 §5.3: Combine duplicate header values.
|
|
113
|
+
# - set-cookie: join with "\n" (Rack convention, MUST NOT combine with comma)
|
|
114
|
+
# - cookie: join with "; " (RFC 9114 §4.2.1)
|
|
115
|
+
# - all others: join with ", "
|
|
116
|
+
def store_header(name, value)
|
|
117
|
+
if @headers.key?(name)
|
|
118
|
+
separator = case name
|
|
119
|
+
when "set-cookie" then "\n"
|
|
120
|
+
when "cookie" then "; "
|
|
121
|
+
else ", "
|
|
122
|
+
end
|
|
123
|
+
@headers[name] = "#{@headers[name]}#{separator}#{value}"
|
|
124
|
+
else
|
|
125
|
+
if @max_header_count && @headers.size >= @max_header_count
|
|
126
|
+
raise Protocol::MessageError, "Header count exceeds limit #{@max_header_count}"
|
|
127
|
+
end
|
|
128
|
+
@headers[name] = value
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def parse_trailers(payload)
|
|
133
|
+
@decoder.decode(payload) do |name, value|
|
|
134
|
+
if name.start_with?(":")
|
|
135
|
+
raise Protocol::MessageError, "Pseudo-header '#{name}' in trailers"
|
|
136
|
+
end
|
|
137
|
+
@trailers[name] = value
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quicsilver
|
|
4
|
+
module Protocol
|
|
5
|
+
# Extracts HTTP/3 frames from a byte buffer.
|
|
6
|
+
# Yields [type, payload] pairs. No protocol semantics — just byte-level extraction.
|
|
7
|
+
#
|
|
8
|
+
# Usage:
|
|
9
|
+
# FrameReader.each(buffer) do |type, payload|
|
|
10
|
+
# case type
|
|
11
|
+
# when FRAME_HEADERS then ...
|
|
12
|
+
# when FRAME_DATA then ...
|
|
13
|
+
# end
|
|
14
|
+
# end
|
|
15
|
+
module FrameReader
|
|
16
|
+
def self.each(buffer)
|
|
17
|
+
offset = 0
|
|
18
|
+
buf_size = buffer.bytesize
|
|
19
|
+
|
|
20
|
+
while offset < buf_size
|
|
21
|
+
break if buf_size - offset < 2
|
|
22
|
+
|
|
23
|
+
# Inline single-byte varint fast path (covers frame types 0x00-0x3F)
|
|
24
|
+
type_byte = buffer.getbyte(offset)
|
|
25
|
+
if type_byte < 0x40
|
|
26
|
+
type = type_byte
|
|
27
|
+
type_len = 1
|
|
28
|
+
else
|
|
29
|
+
type, type_len = Protocol.decode_varint_str(buffer, offset)
|
|
30
|
+
break if type_len == 0
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
len_byte = buffer.getbyte(offset + type_len)
|
|
34
|
+
if len_byte < 0x40
|
|
35
|
+
length = len_byte
|
|
36
|
+
length_len = 1
|
|
37
|
+
else
|
|
38
|
+
length, length_len = Protocol.decode_varint_str(buffer, offset + type_len)
|
|
39
|
+
break if length_len == 0
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
header_len = type_len + length_len
|
|
43
|
+
break if buf_size < offset + header_len + length
|
|
44
|
+
|
|
45
|
+
payload = buffer.byteslice(offset + header_len, length)
|
|
46
|
+
offset += header_len + length
|
|
47
|
+
|
|
48
|
+
yield type, payload
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
offset
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|