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
data/lib/quicsilver/client.rb
DELETED
|
@@ -1,261 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative 'http3/request_encoder'
|
|
4
|
-
require_relative 'http3/response_parser'
|
|
5
|
-
require_relative "event_loop"
|
|
6
|
-
require "timeout"
|
|
7
|
-
|
|
8
|
-
module Quicsilver
|
|
9
|
-
class Client
|
|
10
|
-
attr_reader :hostname, :port, :unsecure, :connection_timeout
|
|
11
|
-
|
|
12
|
-
AlreadyConnectedError = Class.new(StandardError)
|
|
13
|
-
NotConnectedError = Class.new(StandardError)
|
|
14
|
-
StreamFailedToOpenError = Class.new(StandardError)
|
|
15
|
-
|
|
16
|
-
FINISHED_EVENTS = %w[RECEIVE_FIN RECEIVE].freeze
|
|
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, 5000)
|
|
23
|
-
|
|
24
|
-
@connection_data = nil
|
|
25
|
-
@connected = false
|
|
26
|
-
@connection_start_time = nil
|
|
27
|
-
|
|
28
|
-
@response_buffers = {} # stream_id => accumulated data
|
|
29
|
-
@pending_requests = {}
|
|
30
|
-
@mutex = Mutex.new
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
def connect
|
|
34
|
-
raise AlreadyConnectedError if @connected
|
|
35
|
-
|
|
36
|
-
Quicsilver.open_connection
|
|
37
|
-
|
|
38
|
-
config = Quicsilver.create_configuration(@unsecure)
|
|
39
|
-
raise ConnectionError, "Failed to create configuration" if config.nil?
|
|
40
|
-
|
|
41
|
-
# Create connection (returns [handle, context])
|
|
42
|
-
# Pass self so C extension can route callbacks to this instance
|
|
43
|
-
@connection_data = Quicsilver.create_connection(self)
|
|
44
|
-
raise ConnectionError, "Failed to create connection" if @connection_data.nil?
|
|
45
|
-
|
|
46
|
-
connection_handle, context_handle = @connection_data
|
|
47
|
-
|
|
48
|
-
# Start the connection
|
|
49
|
-
success = Quicsilver.start_connection(connection_handle, config, @hostname, @port)
|
|
50
|
-
unless success
|
|
51
|
-
Quicsilver.close_configuration(config)
|
|
52
|
-
cleanup_failed_connection
|
|
53
|
-
raise ConnectionError, "Failed to start connection"
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
result = Quicsilver.wait_for_connection(context_handle, @connection_timeout)
|
|
57
|
-
handle_connection_result(result, config)
|
|
58
|
-
|
|
59
|
-
@connected = true
|
|
60
|
-
@connection_start_time = Time.now
|
|
61
|
-
|
|
62
|
-
send_control_stream
|
|
63
|
-
Quicsilver.close_configuration(config) # Clean up config since connection is established
|
|
64
|
-
|
|
65
|
-
Quicsilver.event_loop.start
|
|
66
|
-
self
|
|
67
|
-
rescue => e
|
|
68
|
-
cleanup_failed_connection
|
|
69
|
-
|
|
70
|
-
if e.is_a?(ConnectionError) || e.is_a?(TimeoutError)
|
|
71
|
-
raise e
|
|
72
|
-
else
|
|
73
|
-
raise ConnectionError, "Connection failed: #{e.message}"
|
|
74
|
-
end
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
def disconnect
|
|
78
|
-
return unless @connection_data
|
|
79
|
-
|
|
80
|
-
@connected = false
|
|
81
|
-
|
|
82
|
-
# Wake up pending requests
|
|
83
|
-
@mutex.synchronize do
|
|
84
|
-
@pending_requests.each_value { |q| q.push(nil) }
|
|
85
|
-
@pending_requests.clear
|
|
86
|
-
@response_buffers.clear
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
Quicsilver.close_connection_handle(@connection_data) if @connection_data
|
|
90
|
-
@connection_data = nil
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
def get(path, **opts)
|
|
94
|
-
request("GET", path, **opts)
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
def post(path, **opts)
|
|
98
|
-
request("POST", path, **opts)
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
def patch(path, **opts)
|
|
102
|
-
request("PATCH", path, **opts)
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
def delete(path, **opts)
|
|
106
|
-
request("DELETE", path, **opts)
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
def head(path, **opts)
|
|
110
|
-
request("HEAD", path, **opts)
|
|
111
|
-
end
|
|
112
|
-
|
|
113
|
-
def request(method, path, headers: {}, body: nil, timeout: 5000)
|
|
114
|
-
raise NotConnectedError unless @connected
|
|
115
|
-
response_queue = Queue.new
|
|
116
|
-
|
|
117
|
-
request = HTTP3::RequestEncoder.new(
|
|
118
|
-
method: method,
|
|
119
|
-
path: path,
|
|
120
|
-
scheme: "https",
|
|
121
|
-
authority: authority,
|
|
122
|
-
headers: headers,
|
|
123
|
-
body: body
|
|
124
|
-
)
|
|
125
|
-
|
|
126
|
-
stream = open_stream
|
|
127
|
-
raise StreamFailedToOpenError unless stream
|
|
128
|
-
|
|
129
|
-
@mutex.synchronize do
|
|
130
|
-
@pending_requests[stream] = response_queue
|
|
131
|
-
end
|
|
132
|
-
|
|
133
|
-
# Send data with FIN flag
|
|
134
|
-
result = Quicsilver.send_stream(stream, request.encode, true)
|
|
135
|
-
|
|
136
|
-
unless result
|
|
137
|
-
@mutex.synchronize { @pending_requests.delete(stream) }
|
|
138
|
-
raise Error, "Failed to send request"
|
|
139
|
-
end
|
|
140
|
-
|
|
141
|
-
response = response_queue.pop(timeout: timeout / 1000.0)
|
|
142
|
-
|
|
143
|
-
raise ConnectionError, "Connection closed" if response.nil? && !@connected
|
|
144
|
-
raise TimeoutError, "Request timeout after #{timeout}ms" if response.nil?
|
|
145
|
-
|
|
146
|
-
response
|
|
147
|
-
rescue Timeout::Error
|
|
148
|
-
@mutex.synchronize { @pending_requests.delete(stream) } if stream
|
|
149
|
-
end
|
|
150
|
-
|
|
151
|
-
def connected?
|
|
152
|
-
@connected && @connection_data && connection_alive?
|
|
153
|
-
end
|
|
154
|
-
|
|
155
|
-
def connection_info
|
|
156
|
-
info = @connection_data ? Quicsilver.connection_status(@connection_data[1]) : {}
|
|
157
|
-
info.merge(hostname: @hostname, port: @port, uptime: connection_uptime)
|
|
158
|
-
end
|
|
159
|
-
|
|
160
|
-
def connection_uptime
|
|
161
|
-
return 0 unless @connection_start_time
|
|
162
|
-
Time.now - @connection_start_time
|
|
163
|
-
end
|
|
164
|
-
|
|
165
|
-
def authority
|
|
166
|
-
"#{@hostname}:#{@port}"
|
|
167
|
-
end
|
|
168
|
-
|
|
169
|
-
# Called directly by C extension via process_events
|
|
170
|
-
# C extension routes to this instance based on client_obj stored in connection context
|
|
171
|
-
# Clients should never call this method directly.
|
|
172
|
-
def handle_stream_event(stream_id, event, data)
|
|
173
|
-
return unless FINISHED_EVENTS.include?(event)
|
|
174
|
-
|
|
175
|
-
@mutex.synchronize do
|
|
176
|
-
case event
|
|
177
|
-
when "RECEIVE"
|
|
178
|
-
@response_buffers[stream_id] ||= StringIO.new
|
|
179
|
-
@response_buffers[stream_id].write(data) # Buffer incoming response data
|
|
180
|
-
when "RECEIVE_FIN"
|
|
181
|
-
stream_handle = data[0, 8].unpack1('Q') if data.bytesize >= 8
|
|
182
|
-
actual_data = data[8..-1] || ""
|
|
183
|
-
|
|
184
|
-
# Get all buffered data
|
|
185
|
-
buffer = @response_buffers.delete(stream_id)
|
|
186
|
-
full_data = ( buffer&.string || "") + actual_data
|
|
187
|
-
|
|
188
|
-
# TODO: needed for streaming later
|
|
189
|
-
@stream_handles ||= {}
|
|
190
|
-
@stream_handles[stream_id] = stream_handle if stream_handle
|
|
191
|
-
|
|
192
|
-
response_parser = Quicsilver::HTTP3::ResponseParser.new(full_data)
|
|
193
|
-
response_parser.parse
|
|
194
|
-
|
|
195
|
-
# Store complete response with body as string
|
|
196
|
-
response = {
|
|
197
|
-
status: response_parser.status,
|
|
198
|
-
headers: response_parser.headers,
|
|
199
|
-
body: response_parser.body.read
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
queue = @pending_requests.delete(stream_handle)
|
|
203
|
-
queue&.push(response) # Unblocks request
|
|
204
|
-
end
|
|
205
|
-
end
|
|
206
|
-
rescue => e
|
|
207
|
-
Quicsilver.logger.error("Error handling client stream: #{e.class} - #{e.message}")
|
|
208
|
-
Quicsilver.logger.debug(e.backtrace.first(5).join("\n"))
|
|
209
|
-
end
|
|
210
|
-
|
|
211
|
-
private
|
|
212
|
-
|
|
213
|
-
def cleanup_failed_connection
|
|
214
|
-
Quicsilver.close_connection_handle(@connection_data) if @connection_data
|
|
215
|
-
@connection_data = nil
|
|
216
|
-
@connected = false
|
|
217
|
-
end
|
|
218
|
-
|
|
219
|
-
def open_stream
|
|
220
|
-
Quicsilver.open_stream(@connection_data, false)
|
|
221
|
-
end
|
|
222
|
-
|
|
223
|
-
def open_unidirectional_stream
|
|
224
|
-
Quicsilver.open_stream(@connection_data, true)
|
|
225
|
-
end
|
|
226
|
-
|
|
227
|
-
def send_control_stream
|
|
228
|
-
# Open unidirectional stream
|
|
229
|
-
stream = open_unidirectional_stream
|
|
230
|
-
|
|
231
|
-
# Build and send control stream data
|
|
232
|
-
control_data = Quicsilver::HTTP3.build_control_stream
|
|
233
|
-
Quicsilver.send_stream(stream, control_data, false)
|
|
234
|
-
|
|
235
|
-
@control_stream = stream
|
|
236
|
-
end
|
|
237
|
-
|
|
238
|
-
def handle_connection_result(result, config)
|
|
239
|
-
if result.key?("error")
|
|
240
|
-
error_status = result["status"]
|
|
241
|
-
error_code = result["code"]
|
|
242
|
-
Quicsilver.close_configuration(config)
|
|
243
|
-
cleanup_failed_connection
|
|
244
|
-
error_msg = "Connection failed with status: 0x#{error_status.to_s(16)}, code: #{error_code}"
|
|
245
|
-
raise ConnectionError, error_msg
|
|
246
|
-
elsif result.key?("timeout")
|
|
247
|
-
Quicsilver.close_configuration(config)
|
|
248
|
-
cleanup_failed_connection
|
|
249
|
-
error_msg = "Connection timed out after #{@connection_timeout}ms"
|
|
250
|
-
raise TimeoutError, error_msg
|
|
251
|
-
end
|
|
252
|
-
end
|
|
253
|
-
|
|
254
|
-
def connection_alive?
|
|
255
|
-
return false unless (info = Quicsilver.connection_status(@connection_data[1]))
|
|
256
|
-
info["connected"] && !info["failed"]
|
|
257
|
-
rescue
|
|
258
|
-
false
|
|
259
|
-
end
|
|
260
|
-
end
|
|
261
|
-
end
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Quicsilver
|
|
4
|
-
class Connection
|
|
5
|
-
attr_reader :handle, :data, :control_stream_id, :qpack_encoder_stream_id, :qpack_decoder_stream_id
|
|
6
|
-
attr_reader :streams
|
|
7
|
-
attr_accessor :server_control_stream # Handle for server's outbound control stream (used to send GOAWAY)
|
|
8
|
-
|
|
9
|
-
def initialize(handle, data)
|
|
10
|
-
@handle = handle
|
|
11
|
-
@data = data
|
|
12
|
-
@streams = {}
|
|
13
|
-
@control_stream_id = nil
|
|
14
|
-
@qpack_encoder_stream_id = nil
|
|
15
|
-
@qpack_decoder_stream_id = nil
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
def set_qpack_encoder_stream(stream_id)
|
|
19
|
-
@qpack_encoder_stream_id = stream_id
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
def set_qpack_decoder_stream(stream_id)
|
|
23
|
-
@qpack_decoder_stream_id = stream_id
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
def set_control_stream(stream_id)
|
|
27
|
-
@control_stream_id = stream_id
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
def add_stream(stream)
|
|
31
|
-
@streams[stream.stream_id] = stream
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
def get_stream(stream_id)
|
|
35
|
-
@streams[stream_id]
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def remove_stream(stream_id)
|
|
39
|
-
@streams.delete(stream_id)
|
|
40
|
-
end
|
|
41
|
-
end
|
|
42
|
-
end
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Quicsilver
|
|
4
|
-
class EventLoop
|
|
5
|
-
def initialize
|
|
6
|
-
@running = false
|
|
7
|
-
@thread = nil
|
|
8
|
-
@mutex = Mutex.new
|
|
9
|
-
end
|
|
10
|
-
|
|
11
|
-
def start
|
|
12
|
-
@mutex.synchronize do
|
|
13
|
-
return if @running
|
|
14
|
-
|
|
15
|
-
@running = true
|
|
16
|
-
@thread = Thread.new do
|
|
17
|
-
while @running
|
|
18
|
-
processed = Quicsilver.process_events
|
|
19
|
-
processed == 0 ? sleep(0.001) : Thread.pass
|
|
20
|
-
end
|
|
21
|
-
end
|
|
22
|
-
end
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
def stop
|
|
26
|
-
@running = false
|
|
27
|
-
@thread&.join(2)
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
def join
|
|
31
|
-
@thread&.join
|
|
32
|
-
end
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
def self.event_loop
|
|
36
|
-
@event_loop ||= EventLoop.new.tap(&:start)
|
|
37
|
-
end
|
|
38
|
-
end
|
|
@@ -1,133 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Quicsilver
|
|
4
|
-
module HTTP3
|
|
5
|
-
class RequestEncoder
|
|
6
|
-
def initialize(method:, path:, scheme: 'https', authority: 'localhost:4433', headers: {}, body: nil)
|
|
7
|
-
@method = method.upcase
|
|
8
|
-
@path = path
|
|
9
|
-
@scheme = scheme
|
|
10
|
-
@authority = authority
|
|
11
|
-
@headers = headers
|
|
12
|
-
@body = body
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
def encode
|
|
16
|
-
frames = []
|
|
17
|
-
|
|
18
|
-
# Build HEADERS frame
|
|
19
|
-
headers_payload = encode_headers
|
|
20
|
-
frames << build_frame(HTTP3::FRAME_HEADERS, headers_payload)
|
|
21
|
-
|
|
22
|
-
# Build DATA frame if body present
|
|
23
|
-
if @body && !@body.empty?
|
|
24
|
-
body_data = @body.is_a?(String) ? @body : @body.join
|
|
25
|
-
frames << build_frame(HTTP3::FRAME_DATA, body_data)
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
frames.join.force_encoding(Encoding::BINARY)
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
private
|
|
32
|
-
|
|
33
|
-
def build_frame(type, payload)
|
|
34
|
-
frame_type = HTTP3.encode_varint(type)
|
|
35
|
-
frame_length = HTTP3.encode_varint(payload.bytesize)
|
|
36
|
-
frame_type + frame_length + payload
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
def encode_headers
|
|
40
|
-
payload = "".b
|
|
41
|
-
|
|
42
|
-
# QPACK prefix: Required Insert Count = 0, Delta Base = 0
|
|
43
|
-
payload += "\x00\x00".b
|
|
44
|
-
|
|
45
|
-
# Encode pseudo-headers using Indexed Field Line with Post-Base Index
|
|
46
|
-
# Pattern: 0x50 (0101 0000) for :method, :scheme
|
|
47
|
-
# Pattern: 0x40 | index for :authority, :path (literal name, literal value)
|
|
48
|
-
|
|
49
|
-
# :method (use literal since GET/POST have specific indices but we want flexibility)
|
|
50
|
-
payload += encode_literal_pseudo_header(':method', @method)
|
|
51
|
-
|
|
52
|
-
# :scheme
|
|
53
|
-
payload += encode_literal_pseudo_header(':scheme', @scheme)
|
|
54
|
-
|
|
55
|
-
# :authority
|
|
56
|
-
payload += encode_literal_pseudo_header(':authority', @authority)
|
|
57
|
-
|
|
58
|
-
# :path
|
|
59
|
-
payload += encode_literal_pseudo_header(':path', @path)
|
|
60
|
-
|
|
61
|
-
# Encode regular headers
|
|
62
|
-
@headers.each do |name, value|
|
|
63
|
-
payload += encode_literal_header(name.to_s.downcase, value.to_s)
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
payload
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
# Literal field line with literal name for pseudo-headers
|
|
70
|
-
# Pattern: 0x50 (indexed name from static table) + value
|
|
71
|
-
def encode_literal_pseudo_header(name, value)
|
|
72
|
-
result = "".b
|
|
73
|
-
|
|
74
|
-
case name
|
|
75
|
-
when ':method'
|
|
76
|
-
# Check if exact match in static table
|
|
77
|
-
index = case @method
|
|
78
|
-
when 'GET' then HTTP3::QPACK_METHOD_GET
|
|
79
|
-
when 'POST' then HTTP3::QPACK_METHOD_POST
|
|
80
|
-
when 'PUT' then HTTP3::QPACK_METHOD_PUT
|
|
81
|
-
when 'DELETE' then HTTP3::QPACK_METHOD_DELETE
|
|
82
|
-
when 'CONNECT' then HTTP3::QPACK_METHOD_CONNECT
|
|
83
|
-
when 'HEAD' then HTTP3::QPACK_METHOD_HEAD
|
|
84
|
-
when 'OPTIONS' then HTTP3::QPACK_METHOD_OPTIONS
|
|
85
|
-
else nil
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
if index
|
|
89
|
-
# Exact match - use indexed field line (0x80 | index)
|
|
90
|
-
result += [0x80 | index].pack('C')
|
|
91
|
-
else
|
|
92
|
-
# No exact match - use literal with name reference
|
|
93
|
-
result += [0x40 | HTTP3::QPACK_METHOD_GET].pack('C') # Use any :method index for name
|
|
94
|
-
result += HTTP3.encode_varint(value.bytesize)
|
|
95
|
-
result += value.b
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
when ':scheme'
|
|
99
|
-
# Check if exact match
|
|
100
|
-
index = (@scheme == 'https' ? HTTP3::QPACK_SCHEME_HTTPS : HTTP3::QPACK_SCHEME_HTTP)
|
|
101
|
-
# Exact match - use indexed field line
|
|
102
|
-
result += [0x80 | index].pack('C')
|
|
103
|
-
|
|
104
|
-
when ':authority', ':path'
|
|
105
|
-
# Name in static table, but value is custom - use literal with name reference
|
|
106
|
-
index = (name == ':authority' ? HTTP3::QPACK_AUTHORITY : HTTP3::QPACK_PATH)
|
|
107
|
-
result += [0x40 | index].pack('C')
|
|
108
|
-
result += HTTP3.encode_varint(value.bytesize)
|
|
109
|
-
result += value.b
|
|
110
|
-
|
|
111
|
-
else
|
|
112
|
-
# Fallback to literal name
|
|
113
|
-
return encode_literal_header(name, value)
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
result
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
# Literal field line with literal name
|
|
120
|
-
# Pattern: 0x20 | name_length, name_bytes, value_length, value_bytes
|
|
121
|
-
def encode_literal_header(name, value)
|
|
122
|
-
result = "".b
|
|
123
|
-
# 0x20 = literal with literal name (no indexing)
|
|
124
|
-
name_len = name.bytesize
|
|
125
|
-
result += [0x20 | (name_len & 0x1F)].pack('C')
|
|
126
|
-
result += name.b
|
|
127
|
-
result += HTTP3.encode_varint(value.bytesize)
|
|
128
|
-
result += value.to_s.b
|
|
129
|
-
result
|
|
130
|
-
end
|
|
131
|
-
end
|
|
132
|
-
end
|
|
133
|
-
end
|
|
@@ -1,176 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'stringio'
|
|
4
|
-
|
|
5
|
-
module Quicsilver
|
|
6
|
-
module HTTP3
|
|
7
|
-
class RequestParser
|
|
8
|
-
attr_reader :frames, :headers, :body
|
|
9
|
-
|
|
10
|
-
def initialize(data)
|
|
11
|
-
@data = data
|
|
12
|
-
@frames = []
|
|
13
|
-
@headers = {}
|
|
14
|
-
@body = StringIO.new
|
|
15
|
-
@body.set_encoding(Encoding::ASCII_8BIT)
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
def parse
|
|
19
|
-
parse!
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
def to_rack_env(stream_info = {})
|
|
23
|
-
return nil if @headers.empty?
|
|
24
|
-
|
|
25
|
-
# Extract path and query string
|
|
26
|
-
path_full = @headers[':path'] || '/'
|
|
27
|
-
path, query = path_full.split('?', 2)
|
|
28
|
-
|
|
29
|
-
# Extract host and port
|
|
30
|
-
authority = @headers[':authority'] || 'localhost:4433'
|
|
31
|
-
host, port = authority.split(':', 2)
|
|
32
|
-
port ||= '4433'
|
|
33
|
-
|
|
34
|
-
env = {
|
|
35
|
-
'REQUEST_METHOD' => @headers[':method'] || 'GET',
|
|
36
|
-
'PATH_INFO' => path,
|
|
37
|
-
'QUERY_STRING' => query || '',
|
|
38
|
-
'SERVER_NAME' => host,
|
|
39
|
-
'SERVER_PORT' => port,
|
|
40
|
-
'SERVER_PROTOCOL' => 'HTTP/3',
|
|
41
|
-
'rack.version' => [1, 3],
|
|
42
|
-
'rack.url_scheme' => @headers[':scheme'] || 'https',
|
|
43
|
-
'rack.input' => @body,
|
|
44
|
-
'rack.errors' => $stderr,
|
|
45
|
-
'rack.multithread' => true,
|
|
46
|
-
'rack.multiprocess' => false,
|
|
47
|
-
'rack.run_once' => false,
|
|
48
|
-
'rack.hijack?' => false,
|
|
49
|
-
'SCRIPT_NAME' => '',
|
|
50
|
-
'CONTENT_LENGTH' => @body.size.to_s,
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
# Add HTTP_HOST from :authority pseudo-header
|
|
54
|
-
if @headers[':authority']
|
|
55
|
-
env['HTTP_HOST'] = @headers[':authority']
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
@headers.each do |name, value|
|
|
59
|
-
next if name.start_with?(':')
|
|
60
|
-
key = name.upcase.tr('-', '_')
|
|
61
|
-
if key == 'CONTENT_TYPE'
|
|
62
|
-
env['CONTENT_TYPE'] = value
|
|
63
|
-
elsif key == 'CONTENT_LENGTH'
|
|
64
|
-
env['CONTENT_LENGTH'] = value
|
|
65
|
-
else
|
|
66
|
-
env["HTTP_#{key}"] = value
|
|
67
|
-
end
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
env
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
private
|
|
74
|
-
|
|
75
|
-
def parse!
|
|
76
|
-
buffer = @data.dup
|
|
77
|
-
offset = 0
|
|
78
|
-
|
|
79
|
-
while offset < buffer.bytesize
|
|
80
|
-
break if buffer.bytesize - offset < 2
|
|
81
|
-
|
|
82
|
-
type, type_len = HTTP3.decode_varint(buffer.bytes, offset)
|
|
83
|
-
length, length_len = HTTP3.decode_varint(buffer.bytes, offset + type_len)
|
|
84
|
-
header_len = type_len + length_len
|
|
85
|
-
|
|
86
|
-
break if buffer.bytesize < offset + header_len + length
|
|
87
|
-
|
|
88
|
-
payload = buffer[offset + header_len, length]
|
|
89
|
-
@frames << { type: type, length: length, payload: payload }
|
|
90
|
-
|
|
91
|
-
case type
|
|
92
|
-
when 0x01 # HEADERS
|
|
93
|
-
parse_headers(payload)
|
|
94
|
-
when 0x00 # DATA
|
|
95
|
-
@body.write(payload)
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
offset += header_len + length
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
@body.rewind
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
def parse_headers(payload)
|
|
105
|
-
# Skip QPACK required insert count (1 byte) + delta base (1 byte)
|
|
106
|
-
offset = 2
|
|
107
|
-
return if payload.bytesize < offset
|
|
108
|
-
|
|
109
|
-
while offset < payload.bytesize
|
|
110
|
-
byte = payload.bytes[offset]
|
|
111
|
-
|
|
112
|
-
# Pattern 1: Indexed Field Line (1Txxxxxx)
|
|
113
|
-
# Use both name AND value from static table
|
|
114
|
-
if (byte & 0x80) == 0x80
|
|
115
|
-
index = byte & 0x3F
|
|
116
|
-
offset += 1
|
|
117
|
-
|
|
118
|
-
field = decode_static_table_field(index)
|
|
119
|
-
if field.is_a?(Hash)
|
|
120
|
-
@headers.merge!(field)
|
|
121
|
-
end
|
|
122
|
-
# Pattern 3: Literal with Name Reference (01NTxxxx)
|
|
123
|
-
# Use name from static table, but value is provided as literal
|
|
124
|
-
elsif (byte & 0xC0) == 0x40
|
|
125
|
-
index = byte & 0x3F
|
|
126
|
-
offset += 1
|
|
127
|
-
|
|
128
|
-
# Get the name from static table
|
|
129
|
-
entry = HTTP3::STATIC_TABLE[index] if index < HTTP3::STATIC_TABLE.size
|
|
130
|
-
name = entry ? entry[0] : nil
|
|
131
|
-
|
|
132
|
-
if name
|
|
133
|
-
# Read literal value that follows
|
|
134
|
-
value_len, len_bytes = HTTP3.decode_varint(payload.bytes, offset)
|
|
135
|
-
offset += len_bytes
|
|
136
|
-
value = payload[offset, value_len]
|
|
137
|
-
offset += value_len
|
|
138
|
-
@headers[name] = value
|
|
139
|
-
end
|
|
140
|
-
# Pattern 5: Literal with literal name (001NHxxx)
|
|
141
|
-
elsif (byte & 0xE0) == 0x20
|
|
142
|
-
name_len = byte & 0x1F
|
|
143
|
-
offset += 1
|
|
144
|
-
name = payload[offset, name_len]
|
|
145
|
-
offset += name_len
|
|
146
|
-
|
|
147
|
-
value_len, len_bytes = HTTP3.decode_varint(payload.bytes, offset)
|
|
148
|
-
offset += len_bytes
|
|
149
|
-
value = payload[offset, value_len]
|
|
150
|
-
offset += value_len
|
|
151
|
-
|
|
152
|
-
@headers[name] = value
|
|
153
|
-
else
|
|
154
|
-
break # Unknown encoding
|
|
155
|
-
end
|
|
156
|
-
end
|
|
157
|
-
end
|
|
158
|
-
|
|
159
|
-
# QPACK static table decoder (RFC 9204 Appendix A)
|
|
160
|
-
# Returns Hash for complete fields, String for name-only fields
|
|
161
|
-
def decode_static_table_field(index)
|
|
162
|
-
return nil if index >= HTTP3::STATIC_TABLE.size
|
|
163
|
-
|
|
164
|
-
name, value = HTTP3::STATIC_TABLE[index]
|
|
165
|
-
|
|
166
|
-
# If value is empty, return just the name (caller provides value)
|
|
167
|
-
# Otherwise return complete field as hash
|
|
168
|
-
if value.empty?
|
|
169
|
-
name
|
|
170
|
-
else
|
|
171
|
-
{name => value}
|
|
172
|
-
end
|
|
173
|
-
end
|
|
174
|
-
end
|
|
175
|
-
end
|
|
176
|
-
end
|