defmodule HTTP.WebTransport do @moduledoc """ Browser-like WebTransport client API for Elixir. This module implements the public API shape and lifecycle management for WebTransport sessions. Browser-compatible network interoperability requires a backend that speaks WebTransport over HTTP/3; the default backend is currently a placeholder that reports `:quic_backend_unavailable`. """ alias HTTP.WebTransport.CloseInfo alias HTTP.WebTransport.DatagramDuplexStream alias HTTP.WebTransport.Options alias HTTP.WebTransport.Promise alias HTTP.WebTransport.SendGroup alias HTTP.WebTransport.Session alias HTTP.WebTransport.StreamQueue defstruct [:pid, :ref, :url] @type state :: :connecting | :connected | :draining | :closed | :failed @type reliability :: String.t() @type congestion_control :: :default | :throughput | :low_latency @type t :: %__MODULE__{pid: pid() | nil, ref: reference() | nil, url: String.t() | nil} @call_timeout 5_000 @spec new(String.t() | URI.t(), keyword() | map()) :: t() | {:error, term()} def new(url, init \\ []) do ref = make_ref() with {:ok, options} <- Options.new(url, put_ref(init, ref)), {:ok, pid} <- DynamicSupervisor.start_child( HTTP.WebTransport.SessionSupervisor, {Session, options} ) do %__MODULE__{pid: pid, ref: ref, url: options.url} end end @spec ready(t()) :: Promise.t() def ready(%__MODULE__{pid: pid}), do: %Promise{pid: pid, kind: :ready} @spec closed(t()) :: Promise.t() def closed(%__MODULE__{pid: pid}), do: %Promise{pid: pid, kind: :closed} @spec draining(t()) :: Promise.t() def draining(%__MODULE__{pid: pid}), do: %Promise{pid: pid, kind: :draining} @spec await_ready(t(), timeout()) :: :ok | {:error, term()} def await_ready(transport, timeout \\ :infinity), do: transport |> ready() |> Promise.await(timeout) @spec await_closed(t(), timeout()) :: {:ok, CloseInfo.t()} | {:error, term()} def await_closed(transport, timeout \\ :infinity), do: transport |> closed() |> Promise.await(timeout) @spec await_draining(t(), timeout()) :: :ok | {:error, term()} def await_draining(transport, timeout \\ :infinity), do: transport |> draining() |> Promise.await(timeout) @spec datagrams(t()) :: DatagramDuplexStream.t() def datagrams(%__MODULE__{} = transport), do: %DatagramDuplexStream{transport: transport} @spec incoming_bidirectional_streams(t()) :: StreamQueue.t() def incoming_bidirectional_streams(%__MODULE__{} = transport) do %StreamQueue{transport: transport, kind: :incoming_bidirectional} end @spec incoming_unidirectional_streams(t()) :: StreamQueue.t() def incoming_unidirectional_streams(%__MODULE__{} = transport) do %StreamQueue{transport: transport, kind: :incoming_unidirectional} end @spec url(t()) :: String.t() | nil def url(%__MODULE__{url: url}), do: url @spec state(t()) :: state() def state(transport), do: connection_call(transport, :state, :closed) @spec reliability(t()) :: reliability() def reliability(transport), do: connection_call(transport, :reliability, "pending") @spec congestion_control(t()) :: congestion_control() def congestion_control(transport), do: connection_call(transport, :congestion_control, :default) @spec response_headers(t()) :: [{String.t(), String.t()}] | nil def response_headers(transport), do: connection_call(transport, :response_headers, nil) @spec protocol(t()) :: String.t() def protocol(transport), do: connection_call(transport, :protocol, "") @spec supports_reliable_only?() :: false def supports_reliable_only?, do: false @spec create_send_group(t()) :: SendGroup.t() def create_send_group(%__MODULE__{} = transport) do %SendGroup{transport: transport, ref: make_ref()} end @spec create_bidirectional_stream(t(), keyword() | map()) :: {:ok, HTTP.WebTransport.BidirectionalStream.t()} | {:error, term()} def create_bidirectional_stream(transport, options \\ []) do options = if is_map(options), do: Map.to_list(options), else: options connection_call(transport, {:create_bidirectional_stream, options}, {:error, :closed}) end @spec create_unidirectional_stream(t(), keyword() | map()) :: {:ok, HTTP.WebTransport.SendStream.t()} | {:error, term()} def create_unidirectional_stream(transport, options \\ []) do options = if is_map(options), do: Map.to_list(options), else: options connection_call(transport, {:create_unidirectional_stream, options}, {:error, :closed}) end @spec get_stats(t()) :: {:ok, HTTP.WebTransport.Stats.t()} | {:error, term()} def get_stats(transport), do: connection_call(transport, :get_stats, {:error, :closed}) @spec close(t()) :: :ok | {:error, term()} def close(transport), do: close(transport, []) @spec close(t(), keyword() | map()) :: :ok | {:error, term()} def close(transport, options) do options = if is_map(options), do: Map.to_list(options), else: options with {:ok, close_info} <- close_info(options) do connection_call(transport, {:close, close_info}, :ok) end end defp close_info(options) do reason = Keyword.get(options, :reason, "") close_code = Keyword.get(options, :close_code, 0) cond do not is_integer(close_code) or close_code < 0 -> {:error, :invalid_close_code} not is_binary(reason) -> {:error, :invalid_close_reason} byte_size(reason) > 1_024 -> {:error, :close_reason_too_long} true -> {:ok, %CloseInfo{close_code: close_code, reason: reason}} end end defp connection_call(%__MODULE__{pid: pid}, request, default) when is_pid(pid) do GenServer.call(pid, request, @call_timeout) catch :exit, _reason -> default end defp connection_call(_transport, _request, default), do: default defp put_ref(init, ref) when is_map(init), do: Map.put(init, :ref, ref) defp put_ref(init, ref) when is_list(init), do: Keyword.put(init, :ref, ref) end