hackney provides a WebTransport client that mirrors the WebSocket API, so
existing code can move from WebSocket to WebTransport by swapping the ws_
prefix for wt_. It runs over HTTP/3 (QUIC) by default, with HTTP/2 as an
option, and uses the same process-per-connection model.
WebTransport is the analog of an HTTP/2 connection: one connection carries many multiplexed streams plus unreliable datagrams.
{ok, Conn} = hackney:wt_connect(<<"https://example.com/wt">>),
ok = hackney:wt_send(Conn, {binary, <<"Hello!">>}),
{ok, {binary, <<"Hello!">>}} = hackney:wt_recv(Conn),
hackney:wt_close(Conn).Compare with WebSocket; the shape is identical:
{ok, Conn} = hackney:ws_connect(<<"wss://example.com/socket">>),
ok = hackney:ws_send(Conn, {binary, <<"Hello!">>}),
{ok, {binary, <<"Hello!">>}} = hackney:ws_recv(Conn),
hackney:ws_close(Conn).WebTransport always runs over TLS. Use the https:// scheme; wss:// is
accepted as an alias so a URL can carry over unchanged.
{ok, Conn} = hackney:wt_connect(<<"https://example.com/wt">>).{ok, Conn} = hackney:wt_connect(<<"https://example.com/wt">>, [
{transport, h3},
{connect_timeout, 5000},
{recv_timeout, 30000},
{headers, [{<<"authorization">>, <<"Bearer token">>}]}
]).| Option | Default | Description |
|---|---|---|
transport |
h3 |
h3 (QUIC) or h2 |
connect_timeout |
8000 | Session handshake timeout (ms) |
recv_timeout |
infinity | Default receive timeout (ms) |
active |
false |
Active mode: false, true, once |
headers |
[] |
Extra headers for the CONNECT request |
ssl_options |
[] |
TLS options: verify, cacerts/cacertfile, cert/certfile, key/keyfile |
verify |
verify_peer |
verify_peer or verify_none |
compat_mode |
latest |
latest or legacy_browser_compat |
max_recv_buffer |
67108864 | Cap (bytes) on buffered, unread data |
When verifying with no CA configured, hackney uses the bundled certifi
trust store, the same as for HTTPS requests.
wt_send/wt_recv operate on a single persistent bidirectional stream
opened at connect time. This is the drop-in replacement for the WebSocket
message channel.
ok = hackney:wt_send(Conn, {binary, <<1, 2, 3>>}).
ok = hackney:wt_send(Conn, {text, <<"text is sent as bytes">>}).
{ok, {binary, Data}} = hackney:wt_recv(Conn).
{ok, {binary, Data}} = hackney:wt_recv(Conn, 5000). %% With timeoutWebTransport has no message framing of its own. To stay interoperable with
any server, hackney does not add a wire format: bytes are written to the
stream as-is, and a received chunk is returned as {binary, Data}. Chunks
are reliable and ordered, but are not guaranteed to line up with your
send boundaries. If you need message boundaries, delimit them yourself, or
use one stream per message (see below).
Datagrams are unreliable, unordered, and size-limited, like UDP.
ok = hackney:wt_send_datagram(Conn, <<"ping">>).
%% Inbound datagrams arrive on the default channel:
{ok, {datagram, Data}} = hackney:wt_recv(Conn).Open as many streams as you want over one session, just like HTTP/2 multiplexes requests over one connection. Each stream has its own send and receive channel keyed by id.
{ok, StreamId} = hackney:wt_open_stream(Conn, bidi), %% or uni
ok = hackney:wt_stream_send(Conn, StreamId, <<"request">>),
ok = hackney:wt_stream_send(Conn, StreamId, <<"!">>, fin), %% close write side
{ok, Data} = hackney:wt_stream_recv(Conn, StreamId),
{ok, {fin, Last}} = hackney:wt_stream_recv(Conn, StreamId). %% peer ended streamStream lifecycle:
hackney:wt_close_stream(Conn, StreamId). %% graceful FIN
hackney:wt_reset_stream(Conn, StreamId, ErrorCode). %% abort
hackney:wt_stop_sending(Conn, StreamId, ErrorCode). %% ask peer to stopData on streams the server opens (rather than ones you opened) is surfaced
on the default channel as {stream, Id, Data} / {stream_fin, Id, Data}.
In active mode every event is forwarded to the owner process, uniformly tagged with its stream id.
{ok, Conn} = hackney:wt_connect(URL, [{active, true}]),
receive
{hackney_wt, Conn, {binary, Data}} -> handle(Data);
{hackney_wt, Conn, {datagram, Data}} -> handle_dgram(Data);
{hackney_wt, Conn, {stream, Id, Data}} -> handle_stream(Id, Data);
{hackney_wt, Conn, {stream_fin, Id, Data}} -> handle_fin(Id, Data);
{hackney_wt, Conn, closed} -> done;
{hackney_wt_error, Conn, Reason} -> error
end.active, once delivers a single message and reverts to passive:
{ok, Conn} = hackney:wt_connect(URL, [{active, once}]),
receive {hackney_wt, Conn, Msg} -> ok end,
hackney:wt_setopts(Conn, [{active, once}]). %% arm for the next onehackney:wt_close(Conn).
hackney:wt_close(Conn, {0, <<"bye">>}). %% {ErrorCode, Reason}hackney is the WebTransport client. The peer is any WebTransport
server. To run one in Erlang, use the webtransport library (a hackney
dependency, from the erlang-webtransport
project): start a listener and implement the webtransport_handler
behaviour. The handler callbacks are where you receive what the hackney
client sends, and the actions you return are what the client receives back.
-module(my_wt_handler).
-behaviour(webtransport_handler).
-export([init/3, handle_stream/4, handle_stream_fin/4,
handle_datagram/2, handle_stream_closed/3, terminate/2]).
init(_Session, _Req, _Opts) ->
{ok, #{}}.
%% A chunk arrived on a stream (no FIN yet). Reply by returning a `send'
%% action on the SAME stream id; that is what the client reads back.
handle_stream(StreamId, bidi, Data, State) ->
{ok, State, [{send, StreamId, Data}]}; %% echo
handle_stream(_StreamId, uni, _Data, State) ->
{ok, State}.
%% The peer closed its write side (FIN). For a bidi stream you can answer
%% with a final chunk and FIN to close yours.
handle_stream_fin(StreamId, bidi, Data, State) ->
{ok, State, [{send, StreamId, Data, fin}]};
handle_stream_fin(_StreamId, uni, _Data, State) ->
{ok, State}.
handle_datagram(Data, State) ->
{ok, State, [{send_datagram, Data}]}. %% echo a datagram
handle_stream_closed(_StreamId, _Reason, State) ->
{ok, State}.
terminate(_Reason, _State) ->
ok.Available actions a callback may return in its {ok, State, Actions}
result: {send, StreamId, Data}, {send, StreamId, Data, fin},
{send_datagram, Data}, {open_stream, bidi | uni},
{close_stream, StreamId}, {reset_stream, StreamId, Code},
{stop_sending, StreamId, Code}, drain_session,
{close_session, Code, Reason}.
{ok, _} = application:ensure_all_started(webtransport),
{ok, _Pid} = webtransport:start_listener(my_listener, #{
transport => h3, %% or h2
port => 4433,
certfile => "cert.pem",
keyfile => "key.pem",
handler => my_wt_handler
}).
%% ... later
ok = webtransport:stop_listener(my_listener).| hackney client | server callback | reply to client |
|---|---|---|
wt_send(C, {binary, D}) (default stream) |
handle_stream(Id, bidi, D, S) |
{send, Id, D2} → client wt_recv returns {binary, D2} |
wt_send_datagram(C, D) |
handle_datagram(D, S) |
{send_datagram, D2} → client wt_recv returns {datagram, D2} |
wt_open_stream(C, bidi) + wt_stream_send(C, Id, D) |
handle_stream(Id, bidi, D, S) |
{send, Id, D2} → client wt_stream_recv(C, Id) returns {ok, D2} |
wt_stream_send(C, Id, D, fin) |
handle_stream_fin(Id, bidi, D, S) |
{send, Id, D2, fin} → client gets {ok, {fin, D2}} |
Reply on the same stream id you were called with to answer on that
stream. To push data the client did not ask for, return {open_stream, ...}
and send on the new id; the client surfaces it on the default channel as
{stream, Id, Data} (or {hackney_wt, C, {stream, Id, Data}} in active
mode).
Browsers and other WebTransport clients work against the same listener; nothing about the server is hackney-specific.
| WebSocket | WebTransport |
|---|---|
| Single ordered message channel | Many multiplexed streams + datagrams |
| Reliable, framed messages | Reliable streams (no framing) + unreliable datagrams |
ping/pong frames |
Not used (QUIC keepalive); wt_send returns {error, {unsupported_frame, ping}} |
ws:// / wss:// |
https:// (TLS only) |
| HTTP/1.1 Upgrade, proxies | HTTP/3 (QUIC) or HTTP/2, no proxy |
wt_recv/wt_stream_recv return {error, {active_mode, Mode}} in active
mode, {error, timeout} on timeout, and {error, closed} once the session
ends and buffered data is drained.
-module(wt_echo).
-export([run/1]).
run(URL) ->
{ok, Conn} = hackney:wt_connect(URL),
ok = hackney:wt_send(Conn, {binary, <<"hello">>}),
{ok, {binary, Reply}} = hackney:wt_recv(Conn, 5000),
io:format("echo: ~s~n", [Reply]),
hackney:wt_close(Conn).