Current section
Files
Jump to
Current section
Files
src/webtransport_h3.erl
%% Copyright (c) 2026, Benoit Chesneau.
%% Licensed under the Apache License, Version 2.0.
%%
%% @doc Minimal runtime wrapper for an HTTP/3 WebTransport session.
%%
%% The HTTP/3 CONNECT stream is managed through quic_h3. Native WebTransport
%% streams and datagrams use the underlying QUIC connection.
%%
-module(webtransport_h3).
-include("webtransport.hrl").
-export([new/2, new/3, with_peer_settings/2, with_router/2]).
-export([session_id/1, h3_conn/1, quic_conn/1, peer_settings/1, router/1]).
-export([open_bidi_stream/1, open_uni_stream/1]).
-export([send/4, send_datagram/2]).
-export([close_session/3, drain_session/1]).
-export([reset_stream/4, stop_sending/3]).
-export([decode_stream_header/1, decode_datagram/1]).
-record(state, {
h3_conn :: pid(),
quic_conn :: pid(),
session_id :: non_neg_integer(),
peer_settings = #{} :: map(),
router :: undefined | pid()
}).
-opaque state() :: #state{}.
-export_type([state/0]).
%% ============================================================================
%% Lifecycle
%% ============================================================================
%% @doc Create a new H3 transport state with no router.
-spec new(pid(), non_neg_integer()) -> state().
new(H3Conn, SessionId) ->
new(H3Conn, SessionId, undefined).
%% @doc Create a new H3 transport state, optionally attaching a stream router.
-spec new(pid(), non_neg_integer(), undefined | pid()) -> state().
new(H3Conn, SessionId, Router) ->
QuicConn = quic_h3:get_quic_conn(H3Conn),
PeerSettings =
case quic_h3:get_peer_settings(H3Conn) of
undefined -> #{};
Settings -> Settings
end,
#state{
h3_conn = H3Conn,
quic_conn = QuicConn,
session_id = SessionId,
peer_settings = PeerSettings,
router = Router
}.
%% @doc Return a new state with updated peer settings.
-spec with_peer_settings(state(), map()) -> state().
with_peer_settings(State, PeerSettings) ->
State#state{peer_settings = PeerSettings}.
%% @doc Return a new state with the given stream router pid.
-spec with_router(state(), undefined | pid()) -> state().
with_router(State, Router) ->
State#state{router = Router}.
%% @doc Return the stream router pid, or `undefined' if none is set.
-spec router(state()) -> undefined | pid().
router(#state{router = Router}) ->
Router.
%% @doc Return the WebTransport session ID (the CONNECT stream ID).
-spec session_id(state()) -> non_neg_integer().
session_id(#state{session_id = SessionId}) ->
SessionId.
%% @doc Return the HTTP/3 connection pid.
-spec h3_conn(state()) -> pid().
h3_conn(#state{h3_conn = H3Conn}) ->
H3Conn.
%% @doc Return the underlying QUIC connection pid.
-spec quic_conn(state()) -> pid().
quic_conn(#state{quic_conn = QuicConn}) ->
QuicConn.
%% @doc Return the peer's advertised WebTransport settings.
-spec peer_settings(state()) -> map().
peer_settings(#state{peer_settings = PeerSettings}) ->
PeerSettings.
%% ============================================================================
%% Native WebTransport Streams
%% ============================================================================
%% Native WebTransport streams use the underlying QUIC connection directly.
%% Each stream starts with a header identifying the WebTransport session:
%% - Bidirectional streams: Session ID (varint)
%% - Unidirectional streams: Stream Type 0x54 (varint) + Session ID (varint)
%% @doc Open a new bidirectional WebTransport stream on the QUIC connection.
-spec open_bidi_stream(state()) -> {ok, non_neg_integer(), state()} | {error, term()}.
open_bidi_stream(#state{router = Router, session_id = SessionId} = State) when is_pid(Router) ->
%% Route through the H3 router so stream_type_open for our own local open
%% is ignored (no varint-decode misparse on echoed data).
case webtransport_h3_router:open_bidi_stream(Router, SessionId) of
{ok, StreamId} -> {ok, StreamId, State};
{error, _} = Error -> Error
end;
open_bidi_stream(#state{h3_conn = H3Conn, quic_conn = QuicConn, session_id = SessionId} = State) ->
%% No router (e.g., tests): fall back to direct quic_h3 + send.
case quic_h3:open_bidi_stream(H3Conn, ?WT_BIDI_SIGNAL) of
{ok, StreamId} ->
Header = wt_h3_capsule:encode_bidi_stream_header(SessionId),
case quic:send_data(QuicConn, StreamId, Header, false) of
ok -> {ok, StreamId, State};
{error, _} = Error -> Error
end;
{error, _} = Error ->
Error
end.
%% @doc Open a new unidirectional WebTransport stream on the QUIC connection.
-spec open_uni_stream(state()) -> {ok, non_neg_integer(), state()} | {error, term()}.
open_uni_stream(#state{quic_conn = QuicConn, session_id = SessionId} = State) ->
case quic:open_unidirectional_stream(QuicConn) of
{ok, StreamId} ->
Header = wt_h3_capsule:encode_uni_stream_header(SessionId),
case quic:send_data(QuicConn, StreamId, Header, false) of
ok -> {ok, StreamId, State};
{error, _} = Error -> Error
end;
{error, _} = Error ->
Error
end.
%% @doc Send data on a stream. Routes through H3 for the CONNECT stream, QUIC for native streams.
-spec send(state(), non_neg_integer(), iodata(), boolean()) -> ok | {error, term()}.
send(#state{quic_conn = QuicConn, h3_conn = H3Conn, session_id = SessionId}, StreamId, Data, Fin) ->
case StreamId =:= SessionId of
true ->
%% CONNECT stream - use H3 connection
quic_h3:send_data(H3Conn, StreamId, iolist_to_binary(Data), Fin);
false ->
%% Native WebTransport stream - use QUIC connection directly
quic:send_data(QuicConn, StreamId, iolist_to_binary(Data), Fin)
end.
%% ============================================================================
%% HTTP Datagrams
%% ============================================================================
%% @doc Send an unreliable datagram, prefixed with the quarter stream ID.
-spec send_datagram(state(), binary()) -> ok | {error, term()}.
send_datagram(_State, Data) when byte_size(Data) > ?WT_H3_DATAGRAM_MAX ->
{error, datagram_too_large};
send_datagram(#state{quic_conn = QuicConn, session_id = SessionId}, Data) ->
%% Encode datagram with quarter stream ID prefix
Datagram = wt_h3_capsule:encode_datagram(SessionId, Data),
quic:send_datagram(QuicConn, Datagram).
%% @doc Decode an incoming QUIC datagram into session ID and payload.
-spec decode_datagram(binary()) ->
{ok, non_neg_integer(), binary()} | {more, pos_integer()} | {error, term()}.
decode_datagram(Bin) ->
wt_h3_capsule:decode_datagram(Bin).
%% ============================================================================
%% CONNECT Stream Capsules
%% ============================================================================
%% @doc Send a CLOSE_WEBTRANSPORT_SESSION capsule and FIN the CONNECT stream.
-spec close_session(state(), non_neg_integer(), binary()) -> ok | {error, term()}.
close_session(#state{h3_conn = H3Conn, session_id = SessionId}, ErrorCode, Reason) ->
%% Send CLOSE_WEBTRANSPORT_SESSION capsule
Capsule = wt_h3_capsule:encode(wt_h3_capsule:close_session(ErrorCode, Reason)),
quic_h3:send_data(H3Conn, SessionId, Capsule, true).
%% @doc Send a DRAIN_WEBTRANSPORT_SESSION capsule to signal graceful shutdown.
-spec drain_session(state()) -> ok | {error, term()}.
drain_session(#state{h3_conn = H3Conn, session_id = SessionId}) ->
%% Send DRAIN_WEBTRANSPORT_SESSION capsule
Capsule = wt_h3_capsule:encode(wt_h3_capsule:drain_session()),
quic_h3:send_data(H3Conn, SessionId, Capsule, false).
%% ============================================================================
%% Stream Control
%% ============================================================================
%% @doc Reset a stream with the given application error code mapped to the QUIC error space.
-spec reset_stream(state(), non_neg_integer(), non_neg_integer(), non_neg_integer()) ->
ok | {error, term()}.
reset_stream(#state{quic_conn = QuicConn, h3_conn = H3Conn, session_id = SessionId}, StreamId, ErrorCode, _ReliableSize) ->
%% draft-15 ยง3.3: app error codes are mapped into a reserved QUIC space.
QuicCode = wt_error:to_quic(ErrorCode),
case StreamId =:= SessionId of
true ->
quic_h3:cancel(H3Conn, StreamId, QuicCode);
false ->
quic:reset_stream(QuicConn, StreamId, QuicCode)
end.
%% @doc Request that the peer stop sending on a stream with the given error code.
-spec stop_sending(state(), non_neg_integer(), non_neg_integer()) -> ok | {error, term()}.
stop_sending(#state{quic_conn = QuicConn, h3_conn = H3Conn, session_id = SessionId}, StreamId, ErrorCode) ->
QuicCode = wt_error:to_quic(ErrorCode),
case StreamId =:= SessionId of
true ->
quic_h3:cancel(H3Conn, StreamId, QuicCode);
false ->
quic:stop_sending(QuicConn, StreamId, QuicCode)
end.
%% ============================================================================
%% Incoming Data Helpers
%% ============================================================================
%% @doc Decode the session ID and stream kind from the start of an incoming stream.
-spec decode_stream_header(binary()) ->
{ok, non_neg_integer(), wt_h3_capsule:stream_kind(), binary()} |
{more, pos_integer()} |
{error, term()}.
decode_stream_header(Bin) ->
wt_h3_capsule:decode_stream_header(Bin).