h2

HTTP/2 client and server for Erlang/OTP.

Install

Add to rebar.config:

{deps, [
    {h2, "0.2.0", {git, "https://github.com/benoitc/erlang_h2.git", {tag, "0.2.0"}}}
]}.

Requires Erlang/OTP 24+.

Client

{ok, Conn} = h2:connect("example.com", 443),
{ok, StreamId} = h2:request(Conn, <<"GET">>, <<"/">>, [{<<"host">>, <<"example.com">>}]),
receive
    {h2, Conn, {response, StreamId, Status, _Headers}} ->
        io:format("status: ~p~n", [Status])
end,
receive
    {h2, Conn, {data, StreamId, Body, true}} ->
        io:format("body: ~p~n", [Body])
end,
ok = h2:close(Conn).

Messages delivered to the owner process:

Message Meaning
{h2, Conn, connected} handshake + SETTINGS exchange complete
{h2, Conn, {response, StreamId, Status, Headers}} response headers
{h2, Conn, {data, StreamId, Data, EndStream}} response body fragment
{h2, Conn, {trailers, StreamId, Headers}} response trailers
{h2, Conn, {stream_reset, StreamId, ErrorCode}} peer sent RST_STREAM
{h2, Conn, {goaway, LastStreamId, ErrorCode}} peer is shutting down
{h2, Conn, {closed, Reason}} connection closed

Options to h2:connect/3:

#{transport    => ssl | tcp,            %% default: ssl
  ssl_opts     => [ssl:tls_client_option()],
  verify       => verify_peer | verify_none,
  cacerts      => [binary()],
  settings     => h2_settings:settings(),
  timeout      => timeout()}

Send a request body in chunks:

{ok, Sid} = h2:request(Conn, <<"POST">>, <<"/upload">>, Headers, false),
ok = h2:send_data(Conn, Sid, Chunk1, false),
ok = h2:send_data(Conn, Sid, Chunk2, true).   %% last chunk: EndStream = true

Server

Handler = fun(Conn, StreamId, <<"GET">>, <<"/">>, _Headers) ->
    h2:send_response(Conn, StreamId, 200, [{<<"content-type">>, <<"text/plain">>}]),
    h2:send_data(Conn, StreamId, <<"Hello HTTP/2!">>, true)
end,

{ok, Server} = h2:start_server(8443, #{
    cert    => "server.pem",
    key     => "server.key",
    handler => Handler
}),

Port = h2:server_port(Server),
%% ... later ...
ok = h2:stop_server(Server).

Options to h2:start_server/2,3:

#{cert      := binary() | string(),
  key       := binary() | string(),
  cacerts   => [binary()],
  handler   := fun((Conn, StreamId, Method, Path, Headers) -> any()),
  settings  => h2_settings:settings(),
  acceptors => pos_integer()}        %% default: 10

A module handler (handler => {Mod, Args}) is also supported; Mod:handle_request/5 receives the same arguments.

CONNECT tunnels (RFC 7540 §8.3)

Open a bidirectional byte tunnel through an h2 proxy:

%% Client
{ok, Conn} = h2:connect(ProxyHost, ProxyPort),
{ok, Sid}  = h2:request(Conn, [
    {<<":method">>, <<"CONNECT">>},
    {<<":authority">>, <<"target.example.com:443">>}
]),
receive {h2, Conn, {response, Sid, 200, _}} -> ok end,
ok = h2:send_data(Conn, Sid, <<"raw bytes">>, false),
receive {h2, Conn, {data, Sid, Reply, _}} -> Reply end.

Server handler establishes the tunnel by replying 2xx, then echoes / forwards bytes via set_stream_handler/3:

fun(Conn, Sid, <<"CONNECT">>, _, _Headers) ->
    h2:send_response(Conn, Sid, 200, []),
    h2:set_stream_handler(Conn, Sid, self()),
    tunnel_loop(Conn, Sid)
end.

Tunnel semantics: DATA frames carry raw bytes (no body-length enforcement), END_STREAM is a half-close, trailers are rejected, and Content-Length / Transfer-Encoding on the 2xx response are rejected.

Modules

Module Purpose
h2 Public API (client + server).
h2_connectiongen_statem per-connection state machine.
h2_server TLS listener + acceptor pool.
h2_frame Frame encode/decode.
h2_hpack HPACK encoder/decoder.
h2_settings SETTINGS encode/decode/validate.
h2_error Error code mappings.

Build and test

rebar3 compile
rebar3 eunit          # 286 tests + 800 PropEr properties
rebar3 ct             # 32 compliance + API-parity + tunnel cases
rebar3 ex_doc         # HTML docs

Status

Production-oriented: spec-correct and deterministic under the in-tree CT suite. Intentionally out of scope:

License

Apache License 2.0