55% %%
66% %% Copyright (c) 2024-2026 Benoit Chesneau
77% %%
8- % %% @doc HTTP/3 thin wrapper for hackney_conn .
8+ % %% @doc HTTP/3 support for hackney .
99% %%
10- % %% This module provides HTTP/3-specific operations to be used by hackney_conn .
10+ % %% This module provides HTTP/3 functionality using the QUIC NIF .
1111% %% It handles stream management, header encoding, and request/response
1212% %% handling for HTTP/3 connections over QUIC.
1313% %%
14+ % %% == Usage ==
15+ % %%
16+ % %% ```
17+ % %% %% Check if HTTP/3 is available
18+ % %% hackney_h3:is_available() -> boolean()
19+ % %%
20+ % %% %% Make a simple GET request
21+ % %% {ok, Status, Headers, Body} = hackney_h3:request(get, "https://cloudflare.com/")
22+ % %%
23+ % %% %% Make a request with options
24+ % %% {ok, Status, Headers, Body} = hackney_h3:request(get, "https://example.com/",
25+ % %% [{<<"user-agent">>, <<"hackney/2.0">>}], <<>>, #{timeout => 30000})
26+ % %% '''
27+ % %%
1428
1529-module (hackney_h3 ).
1630
31+ -include (" hackney_lib.hrl" ).
32+
1733-export ([
18- % % Request operations
34+ % % High-level API
35+ is_available /0 ,
36+ request /2 , request /3 , request /4 , request /5 ,
37+ connect /2 , connect /3 ,
38+ await_response /2 ,
39+ % % Request operations (used by hackney_conn)
1940 send_request /6 ,
2041 send_request_headers /5 ,
2142 send_body_chunk /4 ,
3556-type h3_conn () :: reference ().
3657-type stream_id () :: non_neg_integer ().
3758-type method () :: get | post | put | delete | head | options | patch | atom () | binary ().
59+ -type url () :: binary () | string ().
3860-type headers () :: [{binary (), binary ()}].
61+ -type body () :: binary () | iodata ().
62+ -type response () :: {ok , integer (), headers (), binary ()} | {error , term ()}.
3963-type stream_state () :: waiting_headers | {receiving_body , integer (), headers (), binary ()} | done .
4064-type streams_map () :: #{stream_id () => {term (), stream_state ()}}.
4165
4266-export_type ([h3_conn / 0 , stream_id / 0 , stream_state / 0 , streams_map / 0 ]).
4367
4468% %====================================================================
45- % % API
69+ % % High-level API
70+ % %====================================================================
71+
72+ % % @doc Check if HTTP/3/QUIC support is available.
73+ -spec is_available () -> boolean ().
74+ is_available () ->
75+ hackney_quic :is_available ().
76+
77+ % % @doc Make an HTTP/3 request with default options.
78+ -spec request (method (), url ()) -> response ().
79+ request (Method , Url ) ->
80+ request (Method , Url , [], <<>>, #{}).
81+
82+ % % @doc Make an HTTP/3 request with headers.
83+ -spec request (method (), url (), headers ()) -> response ().
84+ request (Method , Url , Headers ) ->
85+ request (Method , Url , Headers , <<>>, #{}).
86+
87+ % % @doc Make an HTTP/3 request with headers and body.
88+ -spec request (method (), url (), headers (), body ()) -> response ().
89+ request (Method , Url , Headers , Body ) ->
90+ request (Method , Url , Headers , Body , #{}).
91+
92+ % % @doc Make an HTTP/3 request with all options.
93+ % %
94+ % % Options:
95+ % % - timeout: Request timeout in milliseconds (default: 30000)
96+ % % - recv_timeout: Response receive timeout (default: 30000)
97+ % %
98+ -spec request (method (), url (), headers (), body (), map ()) -> response ().
99+ request (Method , Url , Headers , Body , Opts ) ->
100+ # hackney_url {host = Host , port = Port , path = Path } = hackney_url :parse_url (Url ),
101+ HostBin = list_to_binary (Host ),
102+ PathBin = case Path of
103+ <<>> -> <<" /" >>;
104+ _ -> Path
105+ end ,
106+ Timeout = maps :get (timeout , Opts , 30000 ),
107+ case connect (HostBin , Port , Opts ) of
108+ {ok , Conn } ->
109+ try
110+ do_request (Conn , Method , HostBin , PathBin , Headers , Body , Timeout )
111+ after
112+ close (Conn )
113+ end ;
114+ {error , _ } = Error ->
115+ Error
116+ end .
117+
118+ % % @doc Connect to an HTTP/3 server.
119+ -spec connect (binary () | string (), inet :port_number ()) -> {ok , reference ()} | {error , term ()}.
120+ connect (Host , Port ) ->
121+ connect (Host , Port , #{}).
122+
123+ % % @doc Connect to an HTTP/3 server with options.
124+ % % lsquic handles its own UDP socket creation and DNS resolution.
125+ -spec connect (binary () | string (), inet :port_number (), map ()) -> {ok , reference ()} | {error , term ()}.
126+ connect (Host , Port , Opts ) when is_list (Host ) ->
127+ connect (list_to_binary (Host ), Port , Opts );
128+ connect (Host , Port , Opts ) when is_binary (Host ) ->
129+ Timeout = maps :get (timeout , Opts , 30000 ),
130+ case hackney_quic :connect (Host , Port , Opts , self ()) of
131+ {ok , ConnRef } ->
132+ wait_connected (ConnRef , Timeout , erlang :monotonic_time (millisecond ));
133+ {error , _ } = Error ->
134+ Error
135+ end .
136+
137+ % % @doc Wait for an HTTP/3 response.
138+ -spec await_response (reference (), non_neg_integer ()) ->
139+ {ok , integer (), headers (), binary ()} | {error , term ()}.
140+ await_response (ConnRef , StreamId ) ->
141+ await_response_loop (ConnRef , StreamId , 30000 , undefined , [], <<>>, erlang :monotonic_time (millisecond )).
142+
143+ % %====================================================================
144+ % % Request operations (used by hackney_conn)
46145% %====================================================================
47146
48147% % @doc Send a complete HTTP/3 request (headers + body).
@@ -107,6 +206,10 @@ finish_send_body(ConnRef, StreamId, Streams) ->
107206 Error
108207 end .
109208
209+ % %====================================================================
210+ % % Stream management
211+ % %====================================================================
212+
110213% % @doc Open a new stream for a request.
111214-spec new_stream (h3_conn ()) -> {ok , stream_id ()} | {error , term ()}.
112215new_stream (ConnRef ) ->
@@ -116,7 +219,6 @@ new_stream(ConnRef) ->
116219-spec close_stream (h3_conn (), stream_id ()) -> ok .
117220close_stream (_ConnRef , _StreamId ) ->
118221 % % HTTP/3 streams are closed when fin is sent/received
119- % % No explicit close needed
120222 ok .
121223
122224% % @doc Get the state of a specific stream.
@@ -134,6 +236,10 @@ get_stream_state(StreamId, Streams) ->
134236update_stream_state (StreamId , State , Streams ) ->
135237 maps :put (StreamId , State , Streams ).
136238
239+ % %====================================================================
240+ % % Response parsing
241+ % %====================================================================
242+
137243% % @doc Parse response headers from a QUIC stream_headers event.
138244% % Returns {ok, Status, ResponseHeaders} or {error, Reason}.
139245-spec parse_response_headers (headers ()) -> {ok , integer (), headers ()} | {error , term ()}.
@@ -146,6 +252,10 @@ parse_response_headers(Headers) ->
146252 Error
147253 end .
148254
255+ % %====================================================================
256+ % % Connection close
257+ % %====================================================================
258+
149259% % @doc Close the HTTP/3 connection.
150260-spec close (h3_conn ()) -> ok .
151261close (ConnRef ) ->
@@ -160,6 +270,75 @@ close(ConnRef, Reason) ->
160270% % Internal functions
161271% %====================================================================
162272
273+ % % Drive QUIC event loop until connected
274+ wait_connected (ConnRef , Timeout , StartTime ) ->
275+ Elapsed = erlang :monotonic_time (millisecond ) - StartTime ,
276+ Remaining = max (0 , Timeout - Elapsed ),
277+ receive
278+ {select , _Resource , _Ref , ready_input } ->
279+ _ = hackney_quic :process (ConnRef ),
280+ wait_connected (ConnRef , Timeout , StartTime );
281+ {quic , ConnRef , {connected , _Info }} ->
282+ {ok , ConnRef };
283+ {quic , ConnRef , {closed , Reason }} ->
284+ {error , {connection_closed , Reason }};
285+ {quic , ConnRef , {transport_error , Code , Msg }} ->
286+ {error , {transport_error , Code , Msg }}
287+ after Remaining ->
288+ hackney_quic :close (ConnRef , timeout ),
289+ {error , timeout }
290+ end .
291+
292+ do_request (ConnRef , Method , Host , Path , Headers , Body , Timeout ) ->
293+ case hackney_quic :open_stream (ConnRef ) of
294+ {ok , StreamId } ->
295+ AllHeaders = build_request_headers (Method , Host , Path , Headers ),
296+ HasBody = Body =/= <<>> andalso Body =/= [],
297+ Fin = not HasBody ,
298+ case hackney_quic :send_headers (ConnRef , StreamId , AllHeaders , Fin ) of
299+ ok when HasBody ->
300+ case hackney_quic :send_data (ConnRef , StreamId , Body , true ) of
301+ ok ->
302+ await_response_loop (ConnRef , StreamId , Timeout , undefined , [], <<>>, erlang :monotonic_time (millisecond ));
303+ {error , _ } = Error ->
304+ Error
305+ end ;
306+ ok ->
307+ await_response_loop (ConnRef , StreamId , Timeout , undefined , [], <<>>, erlang :monotonic_time (millisecond ));
308+ {error , _ } = Error ->
309+ Error
310+ end ;
311+ {error , _ } = Error ->
312+ Error
313+ end .
314+
315+ await_response_loop (ConnRef , StreamId , Timeout , Status , Headers , AccBody , StartTime ) ->
316+ Elapsed = erlang :monotonic_time (millisecond ) - StartTime ,
317+ Remaining = max (0 , Timeout - Elapsed ),
318+ receive
319+ {select , _Resource , _Ref , ready_input } ->
320+ _ = hackney_quic :process (ConnRef ),
321+ await_response_loop (ConnRef , StreamId , Timeout , Status , Headers , AccBody , StartTime );
322+ {quic , ConnRef , {stream_headers , StreamId , RespHeaders , _Fin }} ->
323+ NewStatus = get_status (RespHeaders ),
324+ FilteredHeaders = filter_pseudo_headers (RespHeaders ),
325+ await_response_loop (ConnRef , StreamId , Timeout , NewStatus , FilteredHeaders , AccBody , StartTime );
326+ {quic , ConnRef , {stream_data , StreamId , Data , Fin }} ->
327+ NewBody = <<AccBody /binary , Data /binary >>,
328+ case Fin of
329+ true ->
330+ {ok , Status , Headers , NewBody };
331+ false ->
332+ await_response_loop (ConnRef , StreamId , Timeout , Status , Headers , NewBody , StartTime )
333+ end ;
334+ {quic , ConnRef , {stream_reset , StreamId , _ErrorCode }} ->
335+ {error , stream_reset };
336+ {quic , ConnRef , {closed , Reason }} ->
337+ {error , {connection_closed , Reason }}
338+ after Remaining ->
339+ {error , timeout }
340+ end .
341+
163342% % @private Build HTTP/3 request headers including pseudo-headers.
164343-spec build_request_headers (method (), binary (), binary (), headers ()) -> headers ().
165344build_request_headers (Method , Host , Path , Headers ) ->
@@ -197,7 +376,17 @@ ensure_binary(B) when is_binary(B) -> B;
197376ensure_binary (L ) when is_list (L ) -> list_to_binary (L );
198377ensure_binary (A ) when is_atom (A ) -> atom_to_binary (A ).
199378
200- % % @private Extract status from HTTP/3 response headers.
379+ % % @private Extract status from HTTP/3 response headers (returns integer or 0).
380+ -spec get_status (headers ()) -> integer ().
381+ get_status (Headers ) ->
382+ case lists :keyfind (<<" :status" >>, 1 , Headers ) of
383+ {_ , StatusBin } ->
384+ binary_to_integer (StatusBin );
385+ false ->
386+ 0
387+ end .
388+
389+ % % @private Extract status from HTTP/3 response headers (returns ok/error tuple).
201390-spec get_status_from_headers (headers ()) -> {ok , integer ()} | {error , no_status | {invalid_status , binary ()}}.
202391get_status_from_headers (Headers ) ->
203392 case lists :keyfind (<<" :status" >>, 1 , Headers ) of
0 commit comments