quicsilver 0.3.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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +1 -1
  3. data/.github/workflows/cibuildgem.yaml +93 -0
  4. data/.gitignore +3 -1
  5. data/CHANGELOG.md +32 -0
  6. data/Gemfile.lock +20 -2
  7. data/README.md +92 -29
  8. data/Rakefile +67 -2
  9. data/benchmarks/concurrent.rb +2 -2
  10. data/benchmarks/rails.rb +3 -3
  11. data/benchmarks/throughput.rb +2 -2
  12. data/examples/README.md +44 -91
  13. data/examples/benchmark.rb +111 -0
  14. data/examples/connection_pool_demo.rb +47 -0
  15. data/examples/example_helper.rb +18 -0
  16. data/examples/falcon_middleware.rb +44 -0
  17. data/examples/feature_demo.rb +125 -0
  18. data/examples/grpc_style.rb +97 -0
  19. data/examples/minimal_http3_server.rb +6 -18
  20. data/examples/priorities.rb +60 -0
  21. data/examples/protocol_http_server.rb +31 -0
  22. data/examples/rack_http3_server.rb +8 -20
  23. data/examples/rails_feature_test.rb +260 -0
  24. data/examples/simple_client_test.rb +2 -2
  25. data/examples/streaming_sse.rb +33 -0
  26. data/examples/trailers.rb +69 -0
  27. data/ext/quicsilver/extconf.rb +14 -0
  28. data/ext/quicsilver/quicsilver.c +39 -0
  29. data/lib/quicsilver/client/client.rb +138 -39
  30. data/lib/quicsilver/client/connection_pool.rb +106 -0
  31. data/lib/quicsilver/libmsquic.2.dylib +0 -0
  32. data/lib/quicsilver/protocol/adapter.rb +176 -0
  33. data/lib/quicsilver/protocol/control_stream_parser.rb +106 -0
  34. data/lib/quicsilver/protocol/frame_parser.rb +142 -0
  35. data/lib/quicsilver/protocol/frame_reader.rb +55 -0
  36. data/lib/quicsilver/protocol/frames.rb +18 -7
  37. data/lib/quicsilver/protocol/priority.rb +56 -0
  38. data/lib/quicsilver/protocol/qpack/encoder.rb +39 -1
  39. data/lib/quicsilver/protocol/qpack/header_block_decoder.rb +16 -1
  40. data/lib/quicsilver/protocol/request_parser.rb +28 -140
  41. data/lib/quicsilver/protocol/response_encoder.rb +27 -2
  42. data/lib/quicsilver/protocol/response_parser.rb +22 -130
  43. data/lib/quicsilver/protocol/stream_input.rb +98 -0
  44. data/lib/quicsilver/protocol/stream_output.rb +59 -0
  45. data/lib/quicsilver/quicsilver.bundle +0 -0
  46. data/lib/quicsilver/server/request_handler.rb +96 -44
  47. data/lib/quicsilver/server/server.rb +316 -42
  48. data/lib/quicsilver/transport/configuration.rb +10 -1
  49. data/lib/quicsilver/transport/connection.rb +92 -63
  50. data/lib/quicsilver/version.rb +1 -1
  51. data/lib/quicsilver.rb +26 -3
  52. data/quicsilver.gemspec +10 -2
  53. metadata +69 -5
  54. data/examples/setup_certs.sh +0 -57
@@ -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
@@ -10,6 +10,7 @@ module Quicsilver
10
10
  FRAME_PUSH_PROMISE = 0x05
11
11
  FRAME_GOAWAY = 0x07
12
12
  FRAME_MAX_PUSH_ID = 0x0d
13
+ FRAME_PRIORITY_UPDATE = 0xF0700 # RFC 9218 — request stream priority update
13
14
 
14
15
  # Frame types forbidden on request streams (RFC 9114 Section 7.2.4, 7.2.6, 7.2.7)
15
16
  CONTROL_ONLY_FRAMES = [FRAME_CANCEL_PUSH, FRAME_SETTINGS, FRAME_GOAWAY, FRAME_MAX_PUSH_ID].freeze
@@ -230,15 +231,25 @@ module Quicsilver
230
231
  frame_type + frame_length + payload
231
232
  end
232
233
 
234
+ # Generate a random GREASE ID (RFC 9297): 31 * n + 33
235
+ def grease_id
236
+ 31 * rand(0..20) + 33
237
+ end
238
+
233
239
  # Build control stream data
234
- def build_control_stream
240
+ # @param max_field_section_size [Integer, nil] Advertise SETTINGS_MAX_FIELD_SECTION_SIZE (0x06)
241
+ # to the peer (RFC 9114 §4.2.2 / §7.2.4.1). nil = don't advertise.
242
+ def build_control_stream(max_field_section_size: nil)
235
243
  stream_type = [0x00].pack('C') # Control stream type
236
- settings = build_settings_frame({
237
- 0x01 => 0, # QPACK_MAX_TABLE_CAPACITY = 0 (no dynamic table)
238
- 0x07 => 0 # QPACK_BLOCKED_STREAMS = 0
239
- })
240
-
241
- stream_type + settings
244
+ settings_hash = {
245
+ 0x01 => 0, # QPACK_MAX_TABLE_CAPACITY = 0 (no dynamic table)
246
+ 0x07 => 0, # QPACK_BLOCKED_STREAMS = 0
247
+ 0x08 => 1, # SETTINGS_ENABLE_CONNECT_PROTOCOL (RFC 9220)
248
+ }
249
+ settings_hash[0x06] = max_field_section_size if max_field_section_size # SETTINGS_MAX_FIELD_SECTION_SIZE
250
+ settings_hash[grease_id] = grease_id # GREASE setting (RFC 9297)
251
+
252
+ stream_type + build_settings_frame(settings_hash)
242
253
  end
243
254
 
244
255
  # Build GOAWAY frame (RFC 9114 Section 7.2.6)
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quicsilver
4
+ module Protocol
5
+ # HTTP Extensible Priorities (RFC 9218).
6
+ #
7
+ # Parses the `priority` header from HTTP requests. Urgency ranges from
8
+ # 0 (highest) to 7 (lowest), defaulting to 3. Incremental indicates
9
+ # whether partial data is useful to the client (e.g. progressive images).
10
+ #
11
+ # Usage:
12
+ # priority = Priority.parse("u=0, i")
13
+ # priority.urgency # => 0
14
+ # priority.incremental # => true
15
+ #
16
+ # priority = Priority.parse(nil) # default
17
+ # priority.urgency # => 3
18
+ # priority.incremental # => false
19
+ class Priority
20
+ DEFAULT_URGENCY = 3
21
+ MIN_URGENCY = 0
22
+ MAX_URGENCY = 7
23
+
24
+ attr_reader :urgency, :incremental
25
+
26
+ def initialize(urgency: DEFAULT_URGENCY, incremental: false)
27
+ @urgency = urgency.clamp(MIN_URGENCY, MAX_URGENCY)
28
+ @incremental = incremental
29
+ end
30
+
31
+ # Parse a priority header value (RFC 9218 §4, Structured Field Values).
32
+ # Returns a Priority with defaults for missing or invalid values.
33
+ def self.parse(value)
34
+ return new unless value && !value.empty?
35
+
36
+ urgency = DEFAULT_URGENCY
37
+ incremental = false
38
+
39
+ value.split(",").each do |param|
40
+ param = param.strip
41
+ if param.start_with?("u=")
42
+ urgency = param[2..].to_i
43
+ elsif param == "i"
44
+ incremental = true
45
+ elsif param == "i=?0"
46
+ incremental = false
47
+ elsif param == "i=?1"
48
+ incremental = true
49
+ end
50
+ end
51
+
52
+ new(urgency: urgency, incremental: incremental)
53
+ end
54
+ end
55
+ end
56
+ end
@@ -71,7 +71,7 @@ module Quicsilver
71
71
  name = name.to_s
72
72
  value = value.to_s
73
73
  # Downcase only if needed (most HTTP/3 headers are already lowercase)
74
- name = name.downcase if name =~ /[A-Z]/
74
+ name = name.downcase if name.match?(/[A-Z]/)
75
75
 
76
76
  cache_key = "#{name}\0#{value}"
77
77
 
@@ -166,8 +166,46 @@ module Quicsilver
166
166
  out << value_b
167
167
  end
168
168
 
169
+ # Pre-computed prefix integer tables for hot encode paths.
170
+ # Key: [prefix_bits, pattern] => Array of frozen encoded strings indexed by value.
171
+ # Covers all values up to max_prefix (single-byte) and a range beyond.
172
+ PREFIXED_INT_CACHE = {}
173
+ [
174
+ [6, 0xC0], # Indexed Field Line
175
+ [4, 0x50], # Literal with Name Reference (static)
176
+ [7, 0x80], # Huffman string length
177
+ [7, 0x00], # Raw string length
178
+ [3, 0x28], # Huffman literal name length
179
+ [3, 0x20], # Raw literal name length
180
+ ].each do |prefix_bits, pattern|
181
+ max_prefix = (1 << prefix_bits) - 1
182
+ # Pre-compute single-byte range + a bit beyond for multi-byte
183
+ limit = [max_prefix + 64, 256].min
184
+ table = Array.new(limit) do |value|
185
+ if value < max_prefix
186
+ (pattern | value).chr(Encoding::BINARY).freeze
187
+ else
188
+ buf = (pattern | max_prefix).chr(Encoding::BINARY)
189
+ v = value - max_prefix
190
+ while v >= 128
191
+ buf << ((v & 0x7F) | 0x80).chr(Encoding::BINARY)
192
+ v >>= 7
193
+ end
194
+ buf << v.chr(Encoding::BINARY)
195
+ buf.freeze
196
+ end
197
+ end
198
+ PREFIXED_INT_CACHE[[prefix_bits, pattern]] = table.freeze
199
+ end
200
+ PREFIXED_INT_CACHE.freeze
201
+
169
202
  # RFC 7541 prefix integer encoding
170
203
  def encode_prefixed_int(value, prefix_bits, pattern)
204
+ table = PREFIXED_INT_CACHE[[prefix_bits, pattern]]
205
+ if table && value < table.size
206
+ return table[value]
207
+ end
208
+
171
209
  max_prefix = (1 << prefix_bits) - 1
172
210
 
173
211
  if value < max_prefix
@@ -51,7 +51,22 @@ module Quicsilver
51
51
  end
52
52
 
53
53
  headers = []
54
- offset = 2 # skip required insert count + delta base prefix
54
+
55
+ # Decode Required Insert Count (RFC 9204 §4.5.1) — 8-bit prefix integer
56
+ required_insert_count, ric_bytes = decode_prefix_integer_str(payload, 0, 8, 0x00)
57
+ offset = ric_bytes
58
+
59
+ # Decode Delta Base (RFC 9204 §4.5.1) — 7-bit prefix integer with sign bit
60
+ delta_base, db_bytes = decode_prefix_integer_str(payload, offset, 7, 0x80)
61
+ offset += db_bytes
62
+
63
+ # Static-only mode: Required Insert Count and Delta Base must be 0
64
+ if required_insert_count != 0 || delta_base != 0
65
+ raise Protocol::FrameError.new(
66
+ "Dynamic QPACK table not supported (required_insert_count=#{required_insert_count}, delta_base=#{delta_base})",
67
+ error_code: Protocol::QPACK_DECOMPRESSION_FAILED
68
+ )
69
+ end
55
70
 
56
71
  while offset < payload.bytesize
57
72
  byte = payload.getbyte(offset)
@@ -1,17 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "stringio"
4
- require_relative "qpack/header_block_decoder"
3
+ require_relative "frame_parser"
4
+ require_relative "priority"
5
5
 
6
6
  module Quicsilver
7
7
  module Protocol
8
- class RequestParser
9
- attr_reader :headers
8
+ class RequestParser < FrameParser
10
9
 
11
- def frames
12
- @frames || []
10
+ # The parsed priority from the `priority` header (RFC 9218).
11
+ # Returns a Priority with defaults if no header present.
12
+ def priority
13
+ @priority ||= Priority.parse(@headers["priority"])
13
14
  end
14
15
 
16
+ METHOD_CONNECT = "CONNECT"
17
+
15
18
  # Known HTTP/3 request pseudo-headers (RFC 9114 §4.3.1)
16
19
  VALID_PSEUDO_HEADERS = %w[:method :scheme :authority :path :protocol].freeze
17
20
  VALID_PSEUDO_SET = VALID_PSEUDO_HEADERS.each_with_object({}) { |h, s| s[h] = true }.freeze
@@ -20,35 +23,26 @@ module Quicsilver
20
23
  FORBIDDEN_HEADERS = %w[connection transfer-encoding keep-alive upgrade proxy-connection te].freeze
21
24
  FORBIDDEN_SET = FORBIDDEN_HEADERS.each_with_object({}) { |h, s| s[h] = true }.freeze
22
25
 
23
- # Frame types forbidden on request streams — use Set-like hash for O(1) lookup
24
- CONTROL_ONLY_SET = Protocol::CONTROL_ONLY_FRAMES.each_with_object({}) { |f, h| h[f] = true }.freeze
25
-
26
26
  # Cache for validated header results: payload → headers hash
27
27
  # Only used when no custom limits are set (max_header_count, max_header_size)
28
28
  HEADERS_CACHE = {}
29
29
  HEADERS_CACHE_MAX = 256
30
30
 
31
- DEFAULT_DECODER = Qpack::HeaderBlockDecoder.default
32
-
33
31
  def initialize(data, **opts)
32
+ decoder = opts.delete(:decoder) || DEFAULT_DECODER
33
+ super(decoder: decoder, max_body_size: opts[:max_body_size],
34
+ max_header_size: opts[:max_header_size],
35
+ max_header_count: opts[:max_header_count],
36
+ max_frame_payload_size: opts[:max_frame_payload_size])
34
37
  @data = data
35
- if opts.empty?
36
- @decoder = DEFAULT_DECODER
37
- @use_parse_cache = true
38
- else
39
- @decoder = opts[:decoder] || DEFAULT_DECODER
40
- @max_body_size = opts[:max_body_size]
41
- @max_header_size = opts[:max_header_size]
42
- @max_header_count = opts[:max_header_count]
43
- @max_frame_payload_size = opts[:max_frame_payload_size]
44
- @use_parse_cache = @decoder.equal?(DEFAULT_DECODER) && !@max_body_size && !@max_header_size && !@max_header_count && !@max_frame_payload_size
45
- end
38
+ @use_parse_cache = @decoder.equal?(DEFAULT_DECODER) && !@max_body_size && !@max_header_size && !@max_header_count && !@max_frame_payload_size
46
39
  end
47
40
 
48
41
  # Reset parser with new data for object reuse (avoids allocation overhead)
49
42
  def reset(data)
50
43
  @data = data
51
- @headers = nil
44
+ @headers = {}
45
+ @trailers = {}
52
46
  @frames = nil
53
47
  @body = nil
54
48
  @cached_body_str = nil
@@ -76,7 +70,8 @@ module Quicsilver
76
70
  return
77
71
  end
78
72
  end
79
- @headers = nil
73
+ @headers = {}
74
+ @trailers = {}
80
75
  @frames = nil
81
76
  @body = nil
82
77
  @cached_body_str = nil
@@ -84,20 +79,6 @@ module Quicsilver
84
79
  cache_result if @use_parse_cache
85
80
  end
86
81
 
87
- def body
88
- if @body
89
- @body
90
- elsif @cached_body_str
91
- @body = StringIO.new(@cached_body_str)
92
- @body.set_encoding(Encoding::ASCII_8BIT)
93
- @body
94
- else
95
- EMPTY_BODY
96
- end
97
- end
98
-
99
- EMPTY_BODY = StringIO.new("".b).tap { |io| io.set_encoding(Encoding::ASCII_8BIT) }
100
-
101
82
  # Class-level parse result cache
102
83
  PARSE_CACHE = {}
103
84
  PARSE_CACHE_MAX = 128
@@ -146,7 +127,13 @@ module Quicsilver
146
127
 
147
128
  method = @headers[":method"]
148
129
 
149
- if method == "CONNECT"
130
+ if method == METHOD_CONNECT && @headers[":protocol"]
131
+ # Extended CONNECT (RFC 9220) — requires :scheme, :path, :authority, :protocol
132
+ raise Protocol::MessageError, "Extended CONNECT must include :scheme" unless @headers[":scheme"]
133
+ raise Protocol::MessageError, "Extended CONNECT must include :path" unless @headers[":path"]
134
+ raise Protocol::MessageError, "Extended CONNECT must include :authority" unless @headers[":authority"]
135
+ elsif method == METHOD_CONNECT
136
+ # Regular CONNECT (RFC 9114 §4.4)
150
137
  raise Protocol::MessageError, "CONNECT request must include :authority" unless @headers[":authority"]
151
138
  raise Protocol::MessageError, "CONNECT request must not include :scheme" if @headers[":scheme"]
152
139
  raise Protocol::MessageError, "CONNECT request must not include :path" if @headers[":path"]
@@ -168,15 +155,6 @@ module Quicsilver
168
155
  raise Protocol::MessageError, ":authority and host header must be consistent"
169
156
  end
170
157
  end
171
-
172
- # Content-length vs body size (RFC 9114 §4.1.2)
173
- if @headers["content-length"]
174
- expected = @headers["content-length"].to_i
175
- actual = body.size
176
- unless expected == actual
177
- raise Protocol::MessageError, "Content-length mismatch: header=#{expected}, body=#{actual}"
178
- end
179
- end
180
158
  end
181
159
 
182
160
  def to_rack_env(stream_info = {})
@@ -184,7 +162,7 @@ module Quicsilver
184
162
 
185
163
  method = @headers[":method"]
186
164
 
187
- if method == "CONNECT"
165
+ if method == METHOD_CONNECT
188
166
  return nil unless @headers[":authority"]
189
167
  else
190
168
  return nil unless method && @headers[":scheme"] && @headers[":path"]
@@ -237,76 +215,6 @@ module Quicsilver
237
215
 
238
216
  private
239
217
 
240
- def parse!
241
- @headers = {}
242
- buffer = @data
243
- offset = 0
244
- headers_received = false
245
- buf_size = buffer.bytesize
246
-
247
- while offset < buf_size
248
- break if buf_size - offset < 2
249
-
250
- # Inline single-byte varint fast path (covers frame types 0x00-0x3F)
251
- type_byte = buffer.getbyte(offset)
252
- if type_byte < 0x40
253
- type = type_byte
254
- type_len = 1
255
- else
256
- type, type_len = Protocol.decode_varint_str(buffer, offset)
257
- end
258
-
259
- len_byte = buffer.getbyte(offset + type_len)
260
- if len_byte < 0x40
261
- length = len_byte
262
- length_len = 1
263
- else
264
- length, length_len = Protocol.decode_varint_str(buffer, offset + type_len)
265
- break if length_len == 0
266
- end
267
- break if type_len == 0
268
-
269
- header_len = type_len + length_len
270
-
271
- break if buf_size < offset + header_len + length
272
-
273
- payload = buffer.byteslice(offset + header_len, length)
274
-
275
- if @max_frame_payload_size && length > @max_frame_payload_size
276
- raise Protocol::FrameError, "Frame payload #{length} exceeds limit #{@max_frame_payload_size}"
277
- end
278
-
279
- (@frames ||= []) << { type: type, length: length, payload: payload }
280
-
281
- if CONTROL_ONLY_SET.key?(type)
282
- raise Protocol::FrameError, "Frame type 0x#{type.to_s(16)} not allowed on request streams"
283
- end
284
-
285
- case type
286
- when 0x01 # HEADERS
287
- if @max_header_size && length > @max_header_size
288
- raise Protocol::MessageError, "Header block #{length} exceeds limit #{@max_header_size}"
289
- end
290
- parse_headers(payload)
291
- headers_received = true
292
- when 0x00 # DATA
293
- raise Protocol::FrameError, "DATA frame before HEADERS" unless headers_received
294
- unless @body
295
- @body = StringIO.new
296
- @body.set_encoding(Encoding::ASCII_8BIT)
297
- end
298
- @body.write(payload)
299
- if @max_body_size && @body.size > @max_body_size
300
- raise Protocol::MessageError, "Body size #{@body.size} exceeds limit #{@max_body_size}"
301
- end
302
- end
303
-
304
- offset += header_len + length
305
- end
306
-
307
- @body&.rewind
308
- end
309
-
310
218
  # Decode QPACK header block and validate per RFC 9114 §4.2 and §4.3.1:
311
219
  # - Header names MUST be lowercase
312
220
  # - Pseudo-headers MUST appear before regular headers
@@ -329,7 +237,7 @@ module Quicsilver
329
237
 
330
238
  @decoder.decode(payload) do |name, value|
331
239
  # RFC 9114 §4.2: Header field names MUST be lowercase
332
- if name =~ /[A-Z]/
240
+ if name.match?(/[A-Z]/)
333
241
  raise Protocol::MessageError, "Header name '#{name}' contains uppercase characters"
334
242
  end
335
243
 
@@ -362,26 +270,6 @@ module Quicsilver
362
270
  HEADERS_CACHE[key] = @headers.dup.freeze
363
271
  end
364
272
  end
365
-
366
- # RFC 9110 §5.3: Combine duplicate header values.
367
- # - set-cookie: join with "\n" (Rack convention, MUST NOT combine with comma)
368
- # - cookie: join with "; " (RFC 9114 §4.2.1)
369
- # - all others: join with ", "
370
- def store_header(name, value)
371
- if @headers.key?(name)
372
- separator = case name
373
- when "set-cookie" then "\n"
374
- when "cookie" then "; "
375
- else ", "
376
- end
377
- @headers[name] = "#{@headers[name]}#{separator}#{value}"
378
- else
379
- if @max_header_count && @headers.size >= @max_header_count
380
- raise Protocol::MessageError, "Header count exceeds limit #{@max_header_count}"
381
- end
382
- @headers[name] = value
383
- end
384
- end
385
273
  end
386
274
  end
387
275
  end
@@ -3,12 +3,27 @@
3
3
  module Quicsilver
4
4
  module Protocol
5
5
  class ResponseEncoder
6
- def initialize(status, headers, body, encoder: Qpack::Encoder.new, head_request: false)
6
+ # Encode an informational (1xx) response as a single HEADERS frame.
7
+ # RFC 9114 §4.1: informational responses are encoded as HEADERS with no body.
8
+ def self.encode_informational(status, headers, encoder: Qpack::Encoder.new)
9
+ raise ArgumentError, "Informational status must be 1xx, got #{status}" unless (100..199).include?(status)
10
+
11
+ pairs = [[":status", status.to_s]]
12
+ headers.each { |name, value| pairs << [name.to_s.downcase, value.to_s] }
13
+ encoded = encoder.encode(pairs)
14
+
15
+ Protocol.encode_varint(FRAME_HEADERS) +
16
+ Protocol.encode_varint(encoded.bytesize) +
17
+ encoded
18
+ end
19
+
20
+ def initialize(status, headers, body, encoder: Qpack::Encoder.new, head_request: false, trailers: nil)
7
21
  @status = status
8
22
  @headers = headers
9
23
  @body = body
10
24
  @encoder = encoder
11
25
  @head_request = head_request
26
+ @trailers = trailers
12
27
  end
13
28
 
14
29
  # Buffered encode - returns all frames at once
@@ -20,6 +35,7 @@ module Quicsilver
20
35
  frames << build_frame(FRAME_DATA, chunk) unless chunk.empty?
21
36
  end
22
37
  end
38
+ frames << build_frame(FRAME_HEADERS, @encoder.encode(trailer_headers)) if @trailers&.any?
23
39
  @body.close if @body.respond_to?(:close)
24
40
  frames
25
41
  end
@@ -35,7 +51,10 @@ module Quicsilver
35
51
  last_chunk = chunk
36
52
  end
37
53
 
38
- if last_chunk && !last_chunk.empty?
54
+ if @trailers&.any?
55
+ yield build_frame(FRAME_DATA, last_chunk), false if last_chunk && !last_chunk.empty?
56
+ yield build_frame(FRAME_HEADERS, @encoder.encode(trailer_headers)), true
57
+ elsif last_chunk && !last_chunk.empty?
39
58
  yield build_frame(FRAME_DATA, last_chunk), true
40
59
  else
41
60
  yield "".b, true
@@ -63,10 +82,16 @@ module Quicsilver
63
82
  headers
64
83
  end
65
84
 
85
+ def trailer_headers
86
+ @trailers.map { |name, value| [name.to_s.downcase, value.to_s] }
87
+ end
88
+
66
89
  def build_frame(type, payload)
67
90
  payload = payload.to_s.b
68
91
  Protocol.encode_varint(type) + Protocol.encode_varint(payload.bytesize) + payload
69
92
  end
93
+
94
+
70
95
  end
71
96
  end
72
97
  end