quicsilver 0.1.0 → 0.3.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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +41 -0
  3. data/.gitignore +3 -1
  4. data/CHANGELOG.md +76 -5
  5. data/Gemfile.lock +18 -4
  6. data/LICENSE +21 -0
  7. data/README.md +33 -53
  8. data/Rakefile +29 -2
  9. data/benchmarks/components.rb +191 -0
  10. data/benchmarks/concurrent.rb +110 -0
  11. data/benchmarks/helpers.rb +88 -0
  12. data/benchmarks/quicsilver_server.rb +46 -0
  13. data/benchmarks/rails.rb +170 -0
  14. data/benchmarks/throughput.rb +113 -0
  15. data/examples/minimal_http3_server.rb +0 -6
  16. data/examples/rack_http3_server.rb +0 -6
  17. data/examples/simple_client_test.rb +26 -0
  18. data/ext/quicsilver/quicsilver.c +615 -138
  19. data/lib/quicsilver/client/client.rb +250 -0
  20. data/lib/quicsilver/client/request.rb +98 -0
  21. data/lib/quicsilver/protocol/frames.rb +327 -0
  22. data/lib/quicsilver/protocol/qpack/decoder.rb +165 -0
  23. data/lib/quicsilver/protocol/qpack/encoder.rb +189 -0
  24. data/lib/quicsilver/protocol/qpack/header_block_decoder.rb +125 -0
  25. data/lib/quicsilver/protocol/qpack/huffman.rb +459 -0
  26. data/lib/quicsilver/protocol/request_encoder.rb +47 -0
  27. data/lib/quicsilver/protocol/request_parser.rb +387 -0
  28. data/lib/quicsilver/protocol/response_encoder.rb +72 -0
  29. data/lib/quicsilver/protocol/response_parser.rb +249 -0
  30. data/lib/quicsilver/server/listener_data.rb +14 -0
  31. data/lib/quicsilver/server/request_handler.rb +86 -0
  32. data/lib/quicsilver/server/request_registry.rb +50 -0
  33. data/lib/quicsilver/server/server.rb +336 -0
  34. data/lib/quicsilver/transport/configuration.rb +132 -0
  35. data/lib/quicsilver/transport/connection.rb +350 -0
  36. data/lib/quicsilver/transport/event_loop.rb +38 -0
  37. data/lib/quicsilver/transport/inbound_stream.rb +33 -0
  38. data/lib/quicsilver/transport/stream.rb +28 -0
  39. data/lib/quicsilver/transport/stream_event.rb +26 -0
  40. data/lib/quicsilver/version.rb +1 -1
  41. data/lib/quicsilver.rb +49 -9
  42. data/lib/rackup/handler/quicsilver.rb +77 -0
  43. data/quicsilver.gemspec +10 -3
  44. metadata +122 -17
  45. data/examples/minimal_http3_client.rb +0 -89
  46. data/lib/quicsilver/client.rb +0 -191
  47. data/lib/quicsilver/http3/request_encoder.rb +0 -112
  48. data/lib/quicsilver/http3/request_parser.rb +0 -158
  49. data/lib/quicsilver/http3/response_encoder.rb +0 -73
  50. data/lib/quicsilver/http3.rb +0 -68
  51. data/lib/quicsilver/listener_data.rb +0 -29
  52. data/lib/quicsilver/server.rb +0 -258
  53. data/lib/quicsilver/server_configuration.rb +0 -49
@@ -0,0 +1,250 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+
5
+ module Quicsilver
6
+ class Client
7
+ attr_reader :hostname, :port, :unsecure, :connection_timeout, :request_timeout
8
+
9
+ AlreadyConnectedError = Class.new(StandardError)
10
+ NotConnectedError = Class.new(StandardError)
11
+ StreamFailedToOpenError = Class.new(StandardError)
12
+
13
+ FINISHED_EVENTS = %w[RECEIVE_FIN RECEIVE STREAM_RESET STOP_SENDING].freeze
14
+
15
+ DEFAULT_REQUEST_TIMEOUT = 30 # seconds
16
+ DEFAULT_CONNECTION_TIMEOUT = 5000 # ms
17
+
18
+ def initialize(hostname, port = 4433, options = {})
19
+ @hostname = hostname
20
+ @port = port
21
+ @unsecure = options.fetch(:unsecure, true)
22
+ @connection_timeout = options.fetch(:connection_timeout, DEFAULT_CONNECTION_TIMEOUT)
23
+ @request_timeout = options.fetch(:request_timeout, DEFAULT_REQUEST_TIMEOUT)
24
+ @max_body_size = options[:max_body_size]
25
+ @max_header_size = options[:max_header_size]
26
+
27
+ @connection_data = nil
28
+ @connected = false
29
+ @connection_start_time = nil
30
+
31
+ @response_buffers = {}
32
+ @pending_requests = {} # handle => Request
33
+ @mutex = Mutex.new
34
+ end
35
+
36
+ def connect
37
+ raise AlreadyConnectedError if @connected
38
+
39
+ Quicsilver.open_connection
40
+ config = Quicsilver.create_configuration(@unsecure)
41
+ raise ConnectionError, "Failed to create configuration" if config.nil?
42
+
43
+ start_connection(config)
44
+ @connected = true
45
+ @connection_start_time = Time.now
46
+ send_control_stream
47
+ Quicsilver.event_loop.start
48
+
49
+ self
50
+ rescue => e
51
+ cleanup_failed_connection
52
+ raise e.is_a?(ConnectionError) || e.is_a?(TimeoutError) ? e : ConnectionError.new("Connection failed: #{e.message}")
53
+ ensure
54
+ Quicsilver.close_configuration(config)
55
+ end
56
+
57
+ def disconnect
58
+ return unless @connection_data
59
+
60
+ @connected = false
61
+
62
+ # Fail pending requests
63
+ @mutex.synchronize do
64
+ @pending_requests.each_value { |req| req.fail(0, "Connection closed") }
65
+ @pending_requests.clear
66
+ @response_buffers.clear
67
+ end
68
+
69
+ Quicsilver.close_connection_handle(@connection_data) if @connection_data
70
+ @connection_data = nil
71
+ end
72
+
73
+ # HTTP methods - block gives you request control, no block returns response directly
74
+ #
75
+ # # Simple (blocking)
76
+ # response = client.get("/users")
77
+ #
78
+ # # With control
79
+ # client.get("/users") do |req|
80
+ # req.cancel if should_abort?
81
+ # req.response
82
+ # end
83
+ #
84
+ %i[get post patch delete head put].each do |method|
85
+ define_method(method) do |path, headers: {}, body: nil, &block|
86
+ req = build_request(method.to_s.upcase, path, headers: headers, body: body)
87
+ block ? block.call(req) : req.response
88
+ end
89
+ end
90
+
91
+ # Build and send request, returns Request object for lifecycle control
92
+ def build_request(method, path, headers: {}, body: nil)
93
+ raise NotConnectedError unless @connected
94
+
95
+ stream = open_stream
96
+ raise StreamFailedToOpenError unless stream
97
+
98
+ request = Request.new(self, stream)
99
+ @mutex.synchronize { @pending_requests[stream.handle] = request }
100
+
101
+ send_to_stream(stream, method, path, headers, body)
102
+
103
+ request
104
+ end
105
+
106
+ def connected?
107
+ @connected && @connection_data && connection_alive?
108
+ end
109
+
110
+ def connection_info
111
+ info = @connection_data ? Quicsilver.connection_status(@connection_data[1]) : {}
112
+ info.merge(hostname: @hostname, port: @port, uptime: connection_uptime)
113
+ end
114
+
115
+ def connection_uptime
116
+ return 0 unless @connection_start_time
117
+ Time.now - @connection_start_time
118
+ end
119
+
120
+ def authority
121
+ "#{@hostname}:#{@port}"
122
+ end
123
+
124
+ # Called directly by C extension via dispatch_to_ruby
125
+ def handle_stream_event(stream_id, event, data, _early_data) # :nodoc:
126
+ return unless FINISHED_EVENTS.include?(event)
127
+
128
+ @mutex.synchronize do
129
+ case event
130
+ when "RECEIVE"
131
+ (@response_buffers[stream_id] ||= StringIO.new("".b)).write(data)
132
+
133
+ when "RECEIVE_FIN"
134
+ event = Transport::StreamEvent.new(data, "RECEIVE_FIN")
135
+
136
+ buffer = @response_buffers.delete(stream_id)
137
+ full_data = (buffer&.string || "".b) + event.data
138
+
139
+ response_parser = Protocol::ResponseParser.new(full_data, max_body_size: @max_body_size,
140
+ max_header_size: @max_header_size)
141
+ response_parser.parse
142
+
143
+ response = {
144
+ status: response_parser.status,
145
+ headers: response_parser.headers,
146
+ body: response_parser.body.read
147
+ }
148
+
149
+ request = @pending_requests.delete(event.handle)
150
+ request&.complete(response)
151
+
152
+ when "STREAM_RESET"
153
+ event = Transport::StreamEvent.new(data, "STREAM_RESET")
154
+ request = @pending_requests.delete(event.handle)
155
+ request&.fail(event.error_code, "Stream reset by peer")
156
+
157
+ when "STOP_SENDING"
158
+ event = Transport::StreamEvent.new(data, "STOP_SENDING")
159
+ request = @pending_requests.delete(event.handle)
160
+ request&.fail(event.error_code, "Peer sent STOP_SENDING")
161
+ end
162
+ end
163
+ rescue => e
164
+ Quicsilver.logger.error("Error handling client stream: #{e.class} - #{e.message}")
165
+ Quicsilver.logger.debug(e.backtrace.first(5).join("\n"))
166
+ end
167
+
168
+ private
169
+
170
+ def start_connection(config)
171
+ connection_handle, context_handle = create_connection
172
+ unless Quicsilver.start_connection(connection_handle, config, @hostname, @port)
173
+ cleanup_failed_connection
174
+ raise ConnectionError, "Failed to start connection"
175
+ end
176
+
177
+ result = Quicsilver.wait_for_connection(context_handle, @connection_timeout)
178
+ handle_connection_result(result)
179
+ end
180
+
181
+ def create_connection
182
+ @connection_data = Quicsilver.create_connection(self)
183
+ raise ConnectionError, "Failed to create connection" if @connection_data.nil?
184
+
185
+ @connection_data
186
+ end
187
+
188
+ def cleanup_failed_connection
189
+ Quicsilver.close_connection_handle(@connection_data) if @connection_data
190
+ @connection_data = nil
191
+ @connected = false
192
+ end
193
+
194
+ def open_stream
195
+ handle = Quicsilver.open_stream(@connection_data, false)
196
+ Transport::Stream.new(handle)
197
+ end
198
+
199
+ def open_unidirectional_stream
200
+ handle = Quicsilver.open_stream(@connection_data, true)
201
+ Transport::Stream.new(handle)
202
+ end
203
+
204
+ def send_control_stream
205
+ @control_stream = open_unidirectional_stream
206
+ @control_stream.send(Protocol.build_control_stream)
207
+
208
+ # RFC 9204: QPACK encoder (0x02) and decoder (0x03) streams
209
+ [0x02, 0x03].each do |type|
210
+ stream = open_unidirectional_stream
211
+ stream.send([type].pack("C"))
212
+ end
213
+ end
214
+
215
+ def handle_connection_result(result)
216
+ if result.key?("error")
217
+ cleanup_failed_connection
218
+ raise ConnectionError, "Connection failed: status 0x#{result['status'].to_s(16)}, code: #{result['code']}"
219
+ elsif result.key?("timeout")
220
+ cleanup_failed_connection
221
+ raise TimeoutError, "Connection timed out after #{@connection_timeout}ms"
222
+ end
223
+ end
224
+
225
+ def connection_alive?
226
+ return false unless (info = Quicsilver.connection_status(@connection_data[1]))
227
+ info["connected"] && !info["failed"]
228
+ rescue
229
+ false
230
+ end
231
+
232
+ def send_to_stream(stream, method, path, headers, body)
233
+ encoded_response = Protocol::RequestEncoder.new(
234
+ method: method,
235
+ path: path,
236
+ scheme: "https",
237
+ authority: authority,
238
+ headers: headers,
239
+ body: body
240
+ ).encode
241
+
242
+ result = stream.send(encoded_response, fin: true)
243
+
244
+ unless result
245
+ @mutex.synchronize { @pending_requests.delete(stream.handle) }
246
+ raise Error, "Failed to send request"
247
+ end
248
+ end
249
+ end
250
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quicsilver
4
+ class Client
5
+ class Request
6
+ attr_reader :stream, :status
7
+
8
+ CancelledError = Class.new(StandardError)
9
+
10
+ class ResetError < StandardError
11
+ attr_reader :error_code
12
+
13
+ def initialize(message, error_code = nil)
14
+ super(message)
15
+ @error_code = error_code
16
+ end
17
+ end
18
+
19
+ def initialize(client, stream)
20
+ @client = client
21
+ @stream = stream
22
+ @status = :pending
23
+ @queue = Queue.new
24
+ @mutex = Mutex.new
25
+ @response = nil
26
+ end
27
+
28
+ # Block until response arrives or timeout
29
+ # Returns response hash { status:, headers:, body: }
30
+ # Raises TimeoutError, CancelledError, or ResetError
31
+ def response(timeout: nil)
32
+ timeout ||= @client.request_timeout
33
+ return @response if @status == :completed
34
+
35
+ result = @queue.pop(timeout: timeout)
36
+
37
+ @mutex.synchronize do
38
+ case result
39
+ when nil
40
+ raise Quicsilver::TimeoutError, "Request timeout after #{timeout}s"
41
+ when Hash
42
+ if result[:error]
43
+ @status = :error
44
+ raise ResetError.new(result[:message] || "Stream reset by peer", result[:error_code])
45
+ else
46
+ @status = :completed
47
+ @response = result
48
+ end
49
+ end
50
+ end
51
+
52
+ @response
53
+ end
54
+
55
+ # Cancel the request (sends RESET_STREAM + STOP_SENDING to server)
56
+ # error_code defaults to H3_REQUEST_CANCELLED (0x10c)
57
+ def cancel(error_code: Protocol::H3_REQUEST_CANCELLED)
58
+ @mutex.synchronize do
59
+ return false unless @status == :pending
60
+
61
+ @stream.reset(error_code)
62
+ @stream.stop_sending(error_code)
63
+ @status = :cancelled
64
+ end
65
+ true
66
+ rescue => e
67
+ Quicsilver.logger.error("Failed to cancel request: #{e.message}")
68
+ false
69
+ end
70
+
71
+ def pending?
72
+ @status == :pending
73
+ end
74
+
75
+ def completed?
76
+ @status == :completed
77
+ end
78
+
79
+ def cancelled?
80
+ @status == :cancelled
81
+ end
82
+
83
+ # Called by Client when response arrives
84
+ def complete(response) # :nodoc:
85
+ @queue.push(response)
86
+ end
87
+
88
+ # Called by Client on stream reset from peer or connection close
89
+ def fail(error_code, message = nil) # :nodoc:
90
+ @mutex.synchronize do
91
+ return if @status == :cancelled
92
+ @status = :error
93
+ end
94
+ @queue.push({ error: true, error_code: error_code, message: message })
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,327 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quicsilver
4
+ module Protocol
5
+ # HTTP/3 Frame Types (RFC 9114)
6
+ FRAME_DATA = 0x00
7
+ FRAME_HEADERS = 0x01
8
+ FRAME_CANCEL_PUSH = 0x03
9
+ FRAME_SETTINGS = 0x04
10
+ FRAME_PUSH_PROMISE = 0x05
11
+ FRAME_GOAWAY = 0x07
12
+ FRAME_MAX_PUSH_ID = 0x0d
13
+
14
+ # Frame types forbidden on request streams (RFC 9114 Section 7.2.4, 7.2.6, 7.2.7)
15
+ CONTROL_ONLY_FRAMES = [FRAME_CANCEL_PUSH, FRAME_SETTINGS, FRAME_GOAWAY, FRAME_MAX_PUSH_ID].freeze
16
+
17
+ # HTTP/3 Error Codes (RFC 9114 Section 8.1)
18
+ H3_NO_ERROR = 0x100
19
+ H3_GENERAL_PROTOCOL_ERROR = 0x101
20
+ H3_INTERNAL_ERROR = 0x102
21
+ H3_STREAM_CREATION_ERROR = 0x103
22
+ H3_CLOSED_CRITICAL_STREAM = 0x104
23
+ H3_FRAME_UNEXPECTED = 0x105
24
+ H3_FRAME_ERROR = 0x106
25
+ H3_EXCESSIVE_LOAD = 0x107
26
+ H3_ID_ERROR = 0x108
27
+ H3_SETTINGS_ERROR = 0x109
28
+ H3_MISSING_SETTINGS = 0x10a
29
+ H3_REQUEST_REJECTED = 0x10b
30
+ H3_REQUEST_CANCELLED = 0x10c
31
+ H3_REQUEST_INCOMPLETE = 0x10d
32
+ H3_MESSAGE_ERROR = 0x10e
33
+ H3_CONNECT_ERROR = 0x10f
34
+ H3_VERSION_FALLBACK = 0x110
35
+
36
+ # QPACK Error Codes (RFC 9204 Section 6)
37
+ QPACK_DECOMPRESSION_FAILED = 0x200
38
+ QPACK_ENCODER_STREAM_ERROR = 0x201
39
+ QPACK_DECODER_STREAM_ERROR = 0x202
40
+
41
+ # Protocol errors that carry an HTTP/3 error code for CONNECTION_CLOSE / RESET_STREAM.
42
+ # FrameError → connection error (CONNECTION_CLOSE)
43
+ # MessageError → stream error (RESET_STREAM) on request streams
44
+ class FrameError < StandardError
45
+ attr_reader :error_code
46
+ def initialize(msg = nil, error_code: H3_FRAME_UNEXPECTED)
47
+ @error_code = error_code
48
+ super(msg)
49
+ end
50
+ end
51
+
52
+ class MessageError < StandardError
53
+ attr_reader :error_code
54
+ def initialize(msg = nil, error_code: H3_MESSAGE_ERROR)
55
+ @error_code = error_code
56
+ super(msg)
57
+ end
58
+ end
59
+
60
+ # QPACK Static Table Indices (RFC 9204 Appendix A)
61
+ STATIC_TABLE = [
62
+ [':authority', ''], # 0
63
+ [':path', '/'], # 1
64
+ ['age', '0'], # 2
65
+ ['content-disposition', ''], # 3
66
+ ['content-length', '0'], # 4
67
+ ['cookie', ''], # 5
68
+ ['date', ''], # 6
69
+ ['etag', ''], # 7
70
+ ['if-modified-since', ''], # 8
71
+ ['if-none-match', ''], # 9
72
+ ['last-modified', ''], # 10
73
+ ['link', ''], # 11
74
+ ['location', ''], # 12
75
+ ['referer', ''], # 13
76
+ ['set-cookie', ''], # 14
77
+ [':method', 'CONNECT'], # 15
78
+ [':method', 'DELETE'], # 16
79
+ [':method', 'GET'], # 17
80
+ [':method', 'HEAD'], # 18
81
+ [':method', 'OPTIONS'], # 19
82
+ [':method', 'POST'], # 20
83
+ [':method', 'PUT'], # 21
84
+ [':scheme', 'http'], # 22
85
+ [':scheme', 'https'], # 23
86
+ [':status', '103'], # 24
87
+ [':status', '200'], # 25
88
+ [':status', '304'], # 26
89
+ [':status', '404'], # 27
90
+ [':status', '503'], # 28
91
+ ['accept', '*/*'], # 29
92
+ ['accept', 'application/dns-message'], # 30
93
+ ['accept-encoding', 'gzip, deflate, br'], # 31
94
+ ['accept-ranges', 'bytes'], # 32
95
+ ['access-control-allow-headers', 'cache-control'], # 33
96
+ ['access-control-allow-headers', 'content-type'], # 34
97
+ ['access-control-allow-origin', '*'], # 35
98
+ ['cache-control', 'max-age=0'], # 36
99
+ ['cache-control', 'max-age=2592000'], # 37
100
+ ['cache-control', 'max-age=604800'], # 38
101
+ ['cache-control', 'no-cache'], # 39
102
+ ['cache-control', 'no-store'], # 40
103
+ ['cache-control', 'public, max-age=31536000'], # 41
104
+ ['content-encoding', 'br'], # 42
105
+ ['content-encoding', 'gzip'], # 43
106
+ ['content-type', 'application/dns-message'], # 44
107
+ ['content-type', 'application/javascript'], # 45
108
+ ['content-type', 'application/json'], # 46
109
+ ['content-type', 'application/x-www-form-urlencoded'], # 47
110
+ ['content-type', 'image/gif'], # 48
111
+ ['content-type', 'image/jpeg'], # 49
112
+ ['content-type', 'image/png'], # 50
113
+ ['content-type', 'text/css'], # 51
114
+ ['content-type', 'text/html; charset=utf-8'], # 52
115
+ ['content-type', 'text/plain'], # 53
116
+ ['content-type', 'text/plain;charset=utf-8'], # 54
117
+ ['range', 'bytes=0-'], # 55
118
+ ['strict-transport-security', 'max-age=31536000'], # 56
119
+ ['strict-transport-security', 'max-age=31536000; includeSubDomains'], # 57
120
+ ['strict-transport-security', 'max-age=31536000; includeSubDomains; preload'], # 58
121
+ ['vary', 'accept-encoding'], # 59
122
+ ['vary', 'origin'], # 60
123
+ ['x-content-type-options', 'nosniff'], # 61
124
+ ['x-xss-protection', '1; mode=block'], # 62
125
+ [':status', '100'], # 63
126
+ [':status', '204'], # 64
127
+ [':status', '206'], # 65
128
+ [':status', '302'], # 66
129
+ [':status', '400'], # 67
130
+ [':status', '403'], # 68
131
+ [':status', '421'], # 69
132
+ [':status', '425'], # 70
133
+ [':status', '500'], # 71
134
+ ['accept-language', ''], # 72
135
+ ['access-control-allow-credentials', 'FALSE'], # 73
136
+ ['access-control-allow-credentials', 'TRUE'], # 74
137
+ ['access-control-allow-headers', '*'], # 75
138
+ ['access-control-allow-methods', 'get'], # 76
139
+ ['access-control-allow-methods', 'get, post, options'], # 77
140
+ ['access-control-allow-methods', 'options'], # 78
141
+ ['access-control-expose-headers', 'content-length'], # 79
142
+ ['access-control-request-headers', 'content-type'], # 80
143
+ ['access-control-request-method', 'get'], # 81
144
+ ['access-control-request-method', 'post'], # 82
145
+ ['alt-svc', 'clear'], # 83
146
+ ['authorization', ''], # 84
147
+ ['content-security-policy', "script-src 'none'; object-src 'none'; base-uri 'none'"], # 85
148
+ ['early-data', '1'], # 86
149
+ ['expect-ct', ''], # 87
150
+ ['forwarded', ''], # 88
151
+ ['if-range', ''], # 89
152
+ ['origin', ''], # 90
153
+ ['purpose', 'prefetch'], # 91
154
+ ['server', ''], # 92
155
+ ['timing-allow-origin', '*'], # 93
156
+ ['upgrade-insecure-requests', '1'], # 94
157
+ ['user-agent', ''], # 95
158
+ ['x-forwarded-for', ''], # 96
159
+ ['x-frame-options', 'deny'], # 97
160
+ ['x-frame-options', 'sameorigin'] # 98
161
+ ].freeze
162
+
163
+ # Commonly used indices
164
+ QPACK_AUTHORITY = 0
165
+ QPACK_PATH = 1
166
+ QPACK_CONTENT_LENGTH = 4
167
+ QPACK_METHOD_CONNECT = 15
168
+ QPACK_METHOD_DELETE = 16
169
+ QPACK_METHOD_GET = 17
170
+ QPACK_METHOD_HEAD = 18
171
+ QPACK_METHOD_OPTIONS = 19
172
+ QPACK_METHOD_POST = 20
173
+ QPACK_METHOD_PUT = 21
174
+ QPACK_SCHEME_HTTP = 22
175
+ QPACK_SCHEME_HTTPS = 23
176
+ QPACK_STATUS_200 = 25
177
+ QPACK_STATUS_404 = 27
178
+ QPACK_STATUS_500 = 71
179
+ QPACK_STATUS_400 = 67
180
+ QPACK_CONTENT_TYPE_JSON = 46
181
+ QPACK_CONTENT_TYPE_PLAIN = 53
182
+
183
+ # Maximum stream ID for initial GOAWAY (2^62 - 4, per RFC 9114)
184
+ MAX_STREAM_ID = (2**62) - 4
185
+
186
+ class << self
187
+ # Precomputed varint encodings for single-byte values (0-63)
188
+ VARINT_SMALL = Array.new(64) { |v| [v].pack('C').freeze }.freeze
189
+ VARINT_MED = Array.new(16384 - 64) { |i| v = i + 64; [0x40 | (v >> 8), v & 0xFF].pack('C*').freeze }.freeze
190
+
191
+ # Cache for large varint encodings (values >= 16384)
192
+ VARINT_LARGE_CACHE = {}
193
+ VARINT_LARGE_CACHE_MAX = 256
194
+
195
+ # Encode variable-length integer
196
+ def encode_varint(value)
197
+ if value < 64
198
+ VARINT_SMALL[value]
199
+ elsif value < 16384
200
+ VARINT_MED[value - 64]
201
+ else
202
+ cached = VARINT_LARGE_CACHE[value]
203
+ return cached if cached
204
+
205
+ result = if value < 1073741824
206
+ [0x80 | (value >> 24), (value >> 16) & 0xFF,
207
+ (value >> 8) & 0xFF, value & 0xFF].pack('C*').freeze
208
+ else
209
+ [0xC0 | (value >> 56), (value >> 48) & 0xFF,
210
+ (value >> 40) & 0xFF, (value >> 32) & 0xFF,
211
+ (value >> 24) & 0xFF, (value >> 16) & 0xFF,
212
+ (value >> 8) & 0xFF, value & 0xFF].pack('C*').freeze
213
+ end
214
+
215
+ VARINT_LARGE_CACHE[value] = result if VARINT_LARGE_CACHE.size < VARINT_LARGE_CACHE_MAX
216
+ result
217
+ end
218
+ end
219
+
220
+ def build_settings_frame(settings = {})
221
+ payload = ""
222
+ settings.each do |id, value|
223
+ payload += encode_varint(id)
224
+ payload += encode_varint(value)
225
+ end
226
+
227
+ frame_type = encode_varint(FRAME_SETTINGS)
228
+ frame_length = encode_varint(payload.bytesize)
229
+
230
+ frame_type + frame_length + payload
231
+ end
232
+
233
+ # Build control stream data
234
+ def build_control_stream
235
+ 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
242
+ end
243
+
244
+ # Build GOAWAY frame (RFC 9114 Section 7.2.6)
245
+ # stream_id: The last client-initiated bidirectional stream ID the server will process
246
+ def build_goaway_frame(stream_id)
247
+ frame_type = encode_varint(FRAME_GOAWAY)
248
+ payload = encode_varint(stream_id)
249
+ frame_length = encode_varint(payload.bytesize)
250
+
251
+ frame_type + frame_length + payload
252
+ end
253
+
254
+ # Cache for decode_varint_str: (object_id << 16 | offset) → [value, consumed]
255
+ VARINT_STR_CACHE = {}
256
+ VARINT_STR_CACHE_MAX = 256
257
+
258
+ # Decode variable-length integer from a String using getbyte (no array needed)
259
+ # Returns [value, bytes_consumed]
260
+ def decode_varint_str(str, offset = 0)
261
+ cache_key = (str.object_id << 16) | offset
262
+ cached = VARINT_STR_CACHE[cache_key]
263
+ return cached if cached
264
+
265
+ first = str.getbyte(offset)
266
+ return [0, 0] unless first
267
+
268
+ if first < 0x40
269
+ result = VARINT_DECODE_SMALL[first]
270
+ else
271
+ prefix = (first & 0xC0) >> 6
272
+ length = 1 << prefix
273
+
274
+ return [0, 0] if offset + length > str.bytesize
275
+
276
+ result = case prefix
277
+ when 0
278
+ [first & 0x3F, 1]
279
+ when 1
280
+ [(first & 0x3F) << 8 | str.getbyte(offset + 1), 2]
281
+ when 2
282
+ [(first & 0x3F) << 24 | str.getbyte(offset + 1) << 16 |
283
+ str.getbyte(offset + 2) << 8 | str.getbyte(offset + 3), 4]
284
+ else
285
+ [(first & 0x3F) << 56 | str.getbyte(offset + 1) << 48 |
286
+ str.getbyte(offset + 2) << 40 | str.getbyte(offset + 3) << 32 |
287
+ str.getbyte(offset + 4) << 24 | str.getbyte(offset + 5) << 16 |
288
+ str.getbyte(offset + 6) << 8 | str.getbyte(offset + 7), 8]
289
+ end
290
+ end
291
+
292
+ VARINT_STR_CACHE[cache_key] = result if VARINT_STR_CACHE.size < VARINT_STR_CACHE_MAX
293
+ result
294
+ end
295
+
296
+ # Precomputed decode results for single-byte varints (0-63)
297
+ VARINT_DECODE_SMALL = Array.new(64) { |v| [v, 1].freeze }.freeze
298
+
299
+ # Decode variable-length integer (RFC 9000)
300
+ # Returns [value, bytes_consumed]
301
+ def decode_varint(bytes, offset = 0)
302
+ first = bytes[offset]
303
+ return [0, 0] unless first
304
+
305
+ # Fast path for single-byte varints (most common: frame types, small lengths)
306
+ return VARINT_DECODE_SMALL[first] if first < 0x40
307
+
308
+ prefix = (first & 0xC0) >> 6
309
+ length = 1 << prefix
310
+ return [0, 0] if offset + length > bytes.size
311
+
312
+ case prefix
313
+ when 1
314
+ [(first & 0x3F) << 8 | bytes[offset + 1], 2]
315
+ when 2
316
+ [(first & 0x3F) << 24 | bytes[offset + 1] << 16 |
317
+ bytes[offset + 2] << 8 | bytes[offset + 3], 4]
318
+ else # 3
319
+ [(first & 0x3F) << 56 | bytes[offset + 1] << 48 |
320
+ bytes[offset + 2] << 40 | bytes[offset + 3] << 32 |
321
+ bytes[offset + 4] << 24 | bytes[offset + 5] << 16 |
322
+ bytes[offset + 6] << 8 | bytes[offset + 7], 8]
323
+ end
324
+ end
325
+ end
326
+ end
327
+ end