%% Copyright (c) 2026 Benoit Chesneau. %% SPDX-License-Identifier: Apache-2.0 %% %% @doc HTTP/1.1 public API. %% %% Mirrors the surface of `h2' (HTTP/2) and `quic_h3' (HTTP/3) so %% applications can swap protocols without rewriting call sites. %% %% == Client == %% ``` %% {ok, Conn} = h1:connect("example.com", 80, #{}). %% {ok, StreamId} = h1:request(Conn, <<"GET">>, <<"/">>, %% [{<<"host">>, <<"example.com">>}]). %% receive %% {h1, Conn, {response, StreamId, Status, _Headers}} -> ok %% end. %% ok = h1:close(Conn). %% ''' %% %% == Server == %% ``` %% {ok, S} = h1:start_server(8080, #{ %% transport => tcp, %% handler => fun(Conn, Id, _Method, _Path, _Hs) -> %% h1:send_response(Conn, Id, 200, [{<<"content-length">>, <<"2">>}]), %% h1:send_data(Conn, Id, <<"ok">>, true) %% end}). %% ''' -module(h1). %% Client API -export([connect/2, connect/3]). -export([wait_connected/1, wait_connected/2]). -export([request/2, request/3, request/4, request/5]). %% Server API -export([start_server/2, start_server/3, stop_server/1, server_port/1]). -export([send_response/4, respond/5]). %% Common API -export([send_data/3, send_data/4]). -export([send_trailers/3]). -export([cancel/2, cancel/3]). -export([cancel_stream/2, cancel_stream/3]). -export([set_stream_handler/3, set_stream_handler/4, unset_stream_handler/2]). -export([goaway/1, goaway/2]). -export([close/1]). -export([get_settings/1, get_peer_settings/1]). -export([controlling_process/2]). %% HTTP/1.1-specific -export([upgrade/3, upgrade/4]). -export([accept_upgrade/3]). -export([accept_connect/3, accept_connect/4]). -export([continue/2]). -export([pipeline/2]). -type connection() :: pid(). -type stream_id() :: non_neg_integer(). -type headers() :: [{binary(), binary()}]. -type status() :: 100..599. -type server_ref() :: {pid(), reference(), inet:port_number()}. -type connect_opts() :: #{ transport => tcp | ssl, ssl_opts => [ssl:tls_client_option()], connect_timeout => timeout(), timeout => timeout(), pipeline => boolean(), max_keepalive_requests => pos_integer(), idle_timeout => timeout(), request_timeout => timeout() }. -type server_opts() :: #{ transport => tcp | ssl, cert => binary() | string(), key => binary() | string(), cacerts => [binary()], ssl_opts => [ssl:tls_option()], ip => inet:ip_address(), inet6 => boolean(), handler := fun((connection(), stream_id(), binary(), binary(), headers()) -> any()) | module(), acceptors => pos_integer(), handshake_timeout => timeout(), idle_timeout => timeout(), request_timeout => timeout(), max_keepalive_requests => pos_integer() }. -export_type([connection/0, stream_id/0, headers/0, status/0, server_ref/0, connect_opts/0, server_opts/0]). %% ============================================================================ %% Client %% ============================================================================ -spec connect(string() | binary(), inet:port_number()) -> {ok, connection()} | {error, term()}. connect(Host, Port) -> connect(Host, Port, #{}). -spec connect(string() | binary(), inet:port_number(), connect_opts()) -> {ok, connection()} | {error, term()}. connect(Host, Port, Opts) -> h1_client:connect(Host, Port, Opts). -spec wait_connected(connection()) -> ok | {error, term()}. wait_connected(Conn) -> h1_connection:wait_connected(Conn). -spec wait_connected(connection(), timeout()) -> ok | {error, term()}. wait_connected(Conn, Timeout) -> h1_connection:wait_connected(Conn, Timeout). %% @doc Send a request using h2-compatible pseudo-headers. The list %% may contain `:method', `:path', `:authority'; they're translated %% into the HTTP/1.1 request line + `Host' header. -spec request(connection(), headers()) -> {ok, stream_id()} | {error, term()}. request(Conn, Headers) -> request(Conn, Headers, #{}). -spec request(connection(), headers(), map()) -> {ok, stream_id()} | {error, term()}. request(Conn, Headers, Opts) -> {Method, Path, Rest0} = extract_pseudo(Headers), Rest1 = case proplists:is_defined(<<"host">>, Rest0) of true -> Rest0; false -> case proplists:get_value(<<":authority">>, Headers) of undefined -> Rest0; Authority -> [{<<"host">>, Authority} | Rest0] end end, SendOpts = maps:without([protocol, end_stream], Opts), case maps:get(body, Opts, undefined) of undefined -> h1_connection:send_request(Conn, Method, Path, Rest1, SendOpts); Body -> h1_connection:send_request(Conn, Method, Path, Rest1, SendOpts#{body => Body, end_stream => true}) end. -spec request(connection(), binary(), binary(), headers()) -> {ok, stream_id()} | {error, term()}. request(Conn, Method, Path, Headers) -> h1_connection:send_request(Conn, Method, Path, Headers, #{}). -spec request(connection(), binary(), binary(), headers(), binary()) -> {ok, stream_id()} | {error, term()}. request(Conn, Method, Path, Headers, Body) -> h1_connection:send_request(Conn, Method, Path, Headers, #{body => Body, end_stream => true}). extract_pseudo(Headers) -> Method = proplists:get_value(<<":method">>, Headers, <<"GET">>), Path = proplists:get_value(<<":path">>, Headers, <<"/">>), Rest = [{N, V} || {N, V} <- Headers, N =/= <<":method">>, N =/= <<":path">>, N =/= <<":scheme">>, N =/= <<":authority">>, N =/= <<":protocol">>], {Method, Path, Rest}. %% ============================================================================ %% Server %% ============================================================================ -spec start_server(atom(), inet:port_number(), server_opts()) -> {ok, server_ref()} | {error, term()}. start_server(Name, Port, Opts) when is_atom(Name) -> case start_server(Port, Opts) of {ok, Ref} -> persistent_term:put({?MODULE, server, Name}, Ref), {ok, Ref}; Other -> Other end. -spec start_server(inet:port_number(), server_opts()) -> {ok, server_ref()} | {error, term()}. start_server(Port, Opts) -> Transport = maps:get(transport, Opts, tcp), case maps:find(handler, Opts) of {ok, _} -> case Transport of tcp -> start_tcp(Port, Opts); ssl -> start_ssl(Port, Opts) end; error -> {error, {missing_required_option, [handler]}} end. start_tcp(Port, Opts) -> TcpOpts = [binary, {active, false}, {packet, raw}, {reuseaddr, true}, {backlog, 1024}, {nodelay, true}] ++ socket_addr_opts(Opts), case gen_tcp:listen(Port, TcpOpts) of {ok, ListenSocket} -> {ok, {_, Bound}} = inet:sockname(ListenSocket), spawn_listener(gen_tcp, ListenSocket, Bound, Opts); {error, Reason} -> {error, {listen_failed, Reason}} end. start_ssl(Port, Opts) -> case {maps:find(cert, Opts), maps:find(key, Opts)} of {{ok, Cert}, {ok, Key}} -> Defaults = [binary, {active, false}, {packet, raw}, {reuseaddr, true}, {backlog, 1024}, {nodelay, true}, {certfile, to_list(Cert)}, {keyfile, to_list(Key)}, {alpn_preferred_protocols, [<<"http/1.1">>]}], SslOpts = maps:get(ssl_opts, Opts, []), Listen = merge(Defaults ++ socket_addr_opts(Opts), SslOpts), case ssl:listen(Port, Listen) of {ok, ListenSocket} -> {ok, {_, Bound}} = ssl:sockname(ListenSocket), spawn_listener(ssl, ListenSocket, Bound, Opts); {error, Reason} -> {error, {listen_failed, Reason}} end; _ -> {error, {missing_required_option, [cert, key]}} end. spawn_listener(Transport, ListenSocket, Bound, Opts) -> Handler = maps:get(handler, Opts), Acceptors = maps:get(acceptors, Opts, erlang:system_info(schedulers)), ConnOpts = maps:with([idle_timeout, request_timeout, max_keepalive_requests, pipeline, max_line_length, max_empty_lines, max_header_name_size, max_header_value_size, max_headers], Opts), ServerOpts = maps:with([handshake_timeout], Opts), Ref = make_ref(), Args = #{transport => Transport, listen_socket => ListenSocket, acceptor_count => Acceptors, ref => Ref, handler => Handler, conn_opts => ConnOpts, server_opts => ServerOpts}, case h1_sup:start_listener(Args) of {ok, Pid} -> {ok, {Pid, Ref, Bound}}; {error, Reason} -> close_listen(Transport, ListenSocket), {error, Reason} end. close_listen(gen_tcp, S) -> _ = gen_tcp:close(S), ok; close_listen(ssl, S) -> _ = ssl:close(S), ok. -spec stop_server(server_ref()) -> ok. stop_server({Pid, Ref, _Port} = ServerRef) -> erase_server_term(ServerRef), h1_listener:stop(Pid, Ref). %% Drop any name->ref registration made by start_server/3 so repeated %% start/stop cycles don't leak persistent_term entries. erase_server_term(ServerRef) -> _ = [persistent_term:erase(K) || {{?MODULE, server, _} = K, V} <- persistent_term:get(), V =:= ServerRef], ok. -spec server_port(server_ref()) -> inet:port_number(). server_port({_, _, Port}) -> Port. -spec send_response(connection(), stream_id(), status(), headers()) -> ok | {error, term()}. send_response(Conn, StreamId, Status, Headers) -> h1_connection:send_response(Conn, StreamId, Status, Headers). -spec respond(connection(), stream_id(), status(), headers(), iodata()) -> ok | {error, term()}. %% @doc Server: send a complete response (status, headers, and body) in a %% single socket write and end the stream. A Content-Length is added when %% the headers carry neither Content-Length nor Transfer-Encoding, so the %% body is sent fixed-length rather than chunked. Use this for fully-known %% bodies; use send_response/4 + send_data/4 for streaming. respond(Conn, StreamId, Status, Headers, Body) -> h1_connection:respond(Conn, StreamId, Status, Headers, Body). %% ============================================================================ %% Common %% ============================================================================ -spec send_data(connection(), stream_id(), binary()) -> ok | {error, term()}. send_data(Conn, StreamId, Data) -> h1_connection:send_data(Conn, StreamId, Data). -spec send_data(connection(), stream_id(), binary(), boolean()) -> ok | {error, term()}. send_data(Conn, StreamId, Data, EndStream) -> h1_connection:send_data(Conn, StreamId, Data, EndStream). -spec send_trailers(connection(), stream_id(), headers()) -> ok | {error, term()}. send_trailers(Conn, StreamId, Trailers) -> h1_connection:send_trailers(Conn, StreamId, Trailers). -spec cancel(connection(), stream_id()) -> ok | {error, term()}. cancel(Conn, StreamId) -> h1_connection:cancel_stream(Conn, StreamId). -spec cancel(connection(), stream_id(), term()) -> ok | {error, term()}. cancel(Conn, StreamId, Reason) -> h1_connection:cancel_stream(Conn, StreamId, Reason). -spec cancel_stream(connection(), stream_id()) -> ok | {error, term()}. cancel_stream(Conn, StreamId) -> cancel(Conn, StreamId). -spec cancel_stream(connection(), stream_id(), term()) -> ok | {error, term()}. cancel_stream(Conn, StreamId, Reason) -> cancel(Conn, StreamId, Reason). -spec set_stream_handler(connection(), stream_id(), pid()) -> ok | {error, term()}. set_stream_handler(Conn, StreamId, Pid) -> h1_connection:set_stream_handler(Conn, StreamId, Pid). -spec set_stream_handler(connection(), stream_id(), pid(), map()) -> ok | {error, term()}. set_stream_handler(Conn, StreamId, Pid, Opts) -> h1_connection:set_stream_handler(Conn, StreamId, Pid, Opts). -spec unset_stream_handler(connection(), stream_id()) -> ok. unset_stream_handler(Conn, StreamId) -> h1_connection:unset_stream_handler(Conn, StreamId). -spec goaway(connection()) -> ok | {error, term()}. goaway(Conn) -> h1_connection:send_goaway(Conn). -spec goaway(connection(), term()) -> ok | {error, term()}. goaway(Conn, Reason) -> h1_connection:send_goaway(Conn, Reason). -spec close(connection()) -> ok. close(Conn) -> h1_connection:close(Conn). -spec get_settings(connection()) -> map(). get_settings(Conn) -> h1_connection:get_settings(Conn). -spec get_peer_settings(connection()) -> map(). get_peer_settings(Conn) -> h1_connection:get_peer_settings(Conn). -spec controlling_process(connection(), pid()) -> ok | {error, term()}. controlling_process(Conn, Pid) -> h1_connection:controlling_process(Conn, Pid). %% ============================================================================ %% HTTP/1.1-specific %% ============================================================================ %% @doc Client: send an Upgrade request and wait for 101 Switching Protocols. -spec upgrade(connection(), binary(), headers()) -> {ok, stream_id(), term(), binary(), headers()} | {error, term()}. upgrade(Conn, Protocol, Headers) -> h1_connection:upgrade(Conn, Protocol, Headers). -spec upgrade(connection(), binary(), headers(), timeout()) -> {ok, stream_id(), term(), binary(), headers()} | {error, term()}. upgrade(Conn, Protocol, Headers, Timeout) -> h1_connection:upgrade(Conn, Protocol, Headers, Timeout). %% @doc Server: reply 101 Switching Protocols to an upgrade request. -spec accept_upgrade(connection(), stream_id(), headers()) -> {ok, term(), binary()} | {error, term()}. accept_upgrade(Conn, StreamId, ExtraHeaders) -> h1_connection:accept_upgrade(Conn, StreamId, ExtraHeaders). %% @doc Server: reply 200 Connection Established to a classic HTTP/1.1 %% CONNECT (RFC 9110 §9.3.6, RFC 9112 §3.2.3 authority-form request-target) %% and take ownership of the raw socket. Mirror of `accept_upgrade/3' for %% the 101 Switching Protocols case, but writes status 200 and injects no %% Connection/Upgrade headers so bytes after CRLF belong to the tunnel. -spec accept_connect(connection(), stream_id(), headers()) -> {ok, gen_tcp | ssl, term(), binary()} | {error, term()}. accept_connect(Conn, StreamId, ExtraHeaders) -> h1_connection:accept_connect(Conn, StreamId, ExtraHeaders). -spec accept_connect(connection(), stream_id(), headers(), timeout()) -> {ok, gen_tcp | ssl, term(), binary()} | {error, term()}. accept_connect(Conn, StreamId, ExtraHeaders, Timeout) -> h1_connection:accept_connect(Conn, StreamId, ExtraHeaders, Timeout). %% @doc Server: send 100 Continue to a client waiting on Expect. -spec continue(connection(), stream_id()) -> ok | {error, term()}. continue(Conn, StreamId) -> h1_connection:continue(Conn, StreamId). %% @doc Toggle request pipelining on a client connection. -spec pipeline(connection(), boolean()) -> ok | {error, term()}. pipeline(Conn, Enabled) when is_boolean(Enabled) -> h1_connection:set_pipeline(Conn, Enabled). %% ============================================================================ %% Internal %% ============================================================================ %% Build inet listen options from the `ip'/`inet6' server opts. An IPv6 %% `ip' tuple (or `inet6 => true') selects the inet6 family; `ip' sets the %% bind address. Returned as a list suitable for gen_tcp/ssl listen opts. socket_addr_opts(Opts) -> IP = maps:get(ip, Opts, undefined), Family = case {IP, maps:get(inet6, Opts, false)} of {{_, _, _, _, _, _, _, _}, _} -> [inet6]; {_, true} -> [inet6]; _ -> [] end, Addr = case IP of undefined -> []; _ -> [{ip, IP}] end, Family ++ Addr. merge(Default, Override) -> Key = fun({K, _}) -> K; (K) -> K end, Kept = [D || D <- Default, not lists:any(fun(O) -> Key(O) =:= Key(D) end, Override)], Kept ++ Override. to_list(P) when is_list(P) -> P; to_list(P) when is_binary(P) -> binary_to_list(P).