Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
# NEWS

4.1.0 - unreleased
------------------

### Added

- WebTransport client API (`hackney:wt_connect/1,2`, `wt_send/2`,
`wt_recv/1,2`, `wt_setopts/2`, `wt_close/1,2`). It mirrors the WebSocket
API so code can switch by swapping the `ws_` prefix for `wt_`. Runs over
HTTP/3 (QUIC) by default, HTTP/2 optional. One session multiplexes many
streams (`wt_open_stream/2`, `wt_stream_send/3,4`, `wt_stream_recv/2,3`,
`wt_close_stream/2`, `wt_reset_stream/3`, `wt_stop_sending/3`) plus
unreliable datagrams (`wt_send_datagram/2`) and `wt_session_info/1`.
Backed by the `webtransport` library. Caller-supplied request headers and
path are checked for CR/LF/NUL, and a buffer cap bounds unread data.
See the WebTransport Guide.

### Dependencies

- Add `webtransport` 0.2.6.

4.0.3 - 2026-05-28
------------------

Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ An HTTP client for Erlang. Simple, reliable, fast.
- **Streaming** - Stream request bodies, response bodies, or both. Handle large files without loading them in memory.
- **Async responses** - Get response chunks as messages. Process other work while waiting.
- **WebSocket support** - Full WebSocket client with the same process-per-connection model.
- **WebTransport support** - WebTransport client over HTTP/3 (or HTTP/2) with a WebSocket-shaped API; switch by swapping the `ws_` prefix for `wt_`.
- **IPv6 first** - Happy Eyeballs algorithm tries IPv6 before IPv4 for faster connections on modern networks.
- **SSL by default** - Secure connections with certificate verification using Mozilla's CA bundle.
- **Automatic decompression** - Transparently decompress gzip/deflate responses with `{auto_decompress, true}`.
Expand Down Expand Up @@ -56,6 +57,7 @@ Payload = <<"{\"key\": \"value\"}">>,
| [HTTP/2 Guide](guides/http2_guide.md) | HTTP/2 protocol, ALPN, multiplexing, flow control |
| [HTTP/3 Guide](guides/http3_guide.md) | HTTP/3 over QUIC, opt-in configuration, Alt-Svc |
| [WebSocket Guide](guides/websocket_guide.md) | Connect, send, receive, active mode |
| [WebTransport Guide](guides/webtransport_guide.md) | Streams, datagrams, multiplexing, server handlers |
| [Design Guide](guides/design.md) | Architecture, pooling, load regulation internals |
| [Migration Guide](guides/MIGRATION.md) | Upgrading from hackney 1.x |
| [API Reference](https://hexdocs.pm/hackney) | Full module documentation |
Expand Down Expand Up @@ -140,6 +142,17 @@ ok = hackney:ws_send(Conn, {text, <<"hello">>}),
hackney:ws_close(Conn).
```

### WebTransport

Same shape as WebSocket, over HTTP/3 (QUIC). Swap `ws_` for `wt_`:

```erlang
{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).
```

### HTTP/2

HTTP/2 is used automatically when the server supports it:
Expand Down
271 changes: 271 additions & 0 deletions guides/webtransport_guide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
# WebTransport Guide

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.

## Quick Start

```erlang
{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:

```erlang
{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).
```

## Connecting

WebTransport always runs over TLS. Use the `https://` scheme; `wss://` is
accepted as an alias so a URL can carry over unchanged.

```erlang
{ok, Conn} = hackney:wt_connect(<<"https://example.com/wt">>).
```

### Connection with Options

```erlang
{ok, Conn} = hackney:wt_connect(<<"https://example.com/wt">>, [
{transport, h3},
{connect_timeout, 5000},
{recv_timeout, 30000},
{headers, [{<<"authorization">>, <<"Bearer token">>}]}
]).
```

### Available Options

| 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.

## The Default Message Channel

`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.

```erlang
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 timeout
```

WebTransport 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

Datagrams are unreliable, unordered, and size-limited, like UDP.

```erlang
ok = hackney:wt_send_datagram(Conn, <<"ping">>).
%% Inbound datagrams arrive on the default channel:
{ok, {datagram, Data}} = hackney:wt_recv(Conn).
```

## Multiplexed Streams

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.

```erlang
{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 stream
```

Stream lifecycle:

```erlang
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 stop
```

Data on streams the server opens (rather than ones you opened) is surfaced
on the default channel as `{stream, Id, Data}` / `{stream_fin, Id, Data}`.

## Active Mode

In active mode every event is forwarded to the owner process, uniformly
tagged with its stream id.

```erlang
{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:

```erlang
{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 one
```

## Closing Connections

```erlang
hackney:wt_close(Conn).
hackney:wt_close(Conn, {0, <<"bye">>}). %% {ErrorCode, Reason}
```

## Server Side

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](https://github.com/benoitc/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.

### Handler

```erlang
-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}`.

### Listener

```erlang
{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).
```

### How client calls map to server callbacks

| 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.

## Differences from WebSocket

| 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.

## Example: Echo Client

```erlang
-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).
```

## Next Steps

- [WebSocket Guide](websocket_guide.md)
- [HTTP/3 Guide](http3_guide.md)
- [Getting Started](../GETTING_STARTED.md)
```
5 changes: 4 additions & 1 deletion rebar.config
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@
{quic, "1.4.5"},
%% Pure Erlang HTTP/2 stack
{h2, "0.6.1"},
%% WebTransport client (HTTP/3 and HTTP/2) - powers the wt_* API
{webtransport, "0.2.6"},
{idna, "~>7.1.0"},
{mimerl, "~>1.4"},
{certifi, "~>2.16.0"},
Expand Down Expand Up @@ -80,6 +82,7 @@
{"guides/http2_guide.md", #{title => "HTTP/2 Guide"}},
{"guides/http3_guide.md", #{title => "HTTP/3 Guide"}},
{"guides/websocket_guide.md", #{title => "WebSocket Guide"}},
{"guides/webtransport_guide.md", #{title => "WebTransport Guide"}},
{"guides/middleware.md", #{title => "Middleware Guide"}},
{"guides/design.md", #{title => "Design Guide"}},
{"guides/MIGRATION.md", #{title => "Migration Guide"}},
Expand Down Expand Up @@ -108,7 +111,7 @@
]},
{base_plt_apps, [erts, stdlib, kernel, crypto, runtime_tools]},
{plt_apps, top_level_deps},
{plt_extra_apps, [quic, h2]},
{plt_extra_apps, [quic, h2, webtransport]},
{plt_location, local},
{plt_prefix, "hackney"},
{base_plt_location, "."},
Expand Down
5 changes: 3 additions & 2 deletions src/hackney.app.src
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
{application, hackney,
[
{description, "Simple HTTP client with HTTP/1.1, HTTP/2, and HTTP/3 support"},
{vsn, "4.0.3"},
{vsn, "4.1.0"},
{registered, [hackney_pool]},
{applications, [kernel,
stdlib,
Expand All @@ -17,7 +17,8 @@
certifi,
ssl_verify_fun,
h2,
quic]},
quic,
webtransport]},
{included_applications, []},
{mod, { hackney_app, []}},
{env, [{timeout, 150000},
Expand Down
Loading
Loading