defmodule Web do @moduledoc """ A Universal Fetch Library for Elixir. This module provides a Web-compliant Fetch API for Elixir, matching the WHATWG Fetch standard. It supports zero-buffer streaming, abort signals, and an extensible dispatcher architecture. ## The "Web-First" DSL The `Web` module provides a `__using__` macro that imports common functions and aliases: ```elixir use Web # Now you can use: # fetch/1, fetch/2, await/1 # URL.new, Request.new, Response.new, AbortController.new, etc. ``` ## Examples promise = Web.fetch("https://api.github.com/zen") response = Web.await(promise) is_struct(response, Web.Response) true catch_exit(Web.await(Web.Promise.reject(:boom))) :boom """ alias Web.TypeError defmacro __using__(_opts) do quote do import Web, only: [fetch: 1, fetch: 2, await: 1, atob: 1, btoa: 1] import Web.DSL, only: [new: 2] alias Web.AbortableGovernor alias Web.AbortController alias Web.AbortSignal alias Web.ArrayBuffer alias Web.AsyncContext alias Web.Blob alias Web.CompressionStream alias Web.Console alias Web.CountingGovernor alias Web.CustomEvent alias Web.DecompressionStream alias Web.EventTarget alias Web.File alias Web.FormData alias Web.Governor alias Web.Headers alias Web.Performance alias Web.Promise alias Web.ReadableStream alias Web.ReadableStreamDefaultController alias Web.Request alias Web.Response alias Web.TextDecoder alias Web.TextDecoderStream alias Web.TextEncoder alias Web.TextEncoderStream alias Web.TransformStream alias Web.Uint8Array alias Web.URL alias Web.URLSearchParams alias Web.WritableStream alias Web.WritableStreamDefaultController alias Web.WritableStreamDefaultWriter :ok end end @doc """ Awaits a `Web.Promise` and returns its fulfilled value. ## Examples iex> Web.await(Web.Promise.resolve("success")) "success" iex> catch_exit(Web.await(Web.Promise.reject(:not_found))) :not_found """ defmacro await(expr) do quote do case unquote(expr) do %Web.Promise{task: task} -> try do Task.await(task, :infinity) catch :exit, {{:shutdown, reason}, {Task, :await, _details}} -> exit(reason) :exit, reason -> exit(reason) end other -> raise "await: unexpected result: #{inspect(other)}" end end end @doc """ Decodes a Base64 string using forgiving Base64 semantics. """ @spec atob(String.t()) :: String.t() def atob(string) when is_binary(string) do with {:ok, normalized} <- normalize_base64(string), {:ok, decoded} <- Base.decode64(normalized) do decoded |> :binary.bin_to_list() |> List.to_string() else :error -> raise TypeError.exception("Failed to execute atob: malformed base64 input") {:error, :invalid} -> raise TypeError.exception("Failed to execute atob: malformed base64 input") end end @doc """ Encodes a byte string into Base64. """ @spec btoa(String.t()) :: String.t() def btoa(string) when is_binary(string) do string |> byte_string_to_binary!() |> Base.encode64() end @doc """ Initiates a network fetch. Matches the Javascript Fetch API standard. ## Parameters - `input`: A string representing the URL, OR a pre-constructed `Web.Request` struct. - `init`: A keyword list of options (ignored if `input` is already a Request struct). - `:method` (String.t | atom) - HTTP method (default: "GET"). - `:headers` (Map.t | List.t | Web.Headers.t) - Request headers. - `:body` (any) - Request payload. - `:redirect` (`"follow" | "error" | "manual"`) - Redirect handling mode. - `:signal` (`Web.AbortSignal.t()` | pid | reference) - Optional abort signal. - `:dispatcher` (module) - Optional override for the request handler. ## Returns - `%Web.Promise{}` resolving to `Web.Response.t()` on success. - The promise rejects with `reason` on failure. ## Examples iex> promise = Web.fetch("http://localhost_nxdomain") iex> is_struct(promise, Web.Promise) true iex> req = Web.Request.new("http://localhost_nxdomain") iex> is_struct(Web.fetch(req), Web.Promise) true """ @spec fetch(String.t() | Web.URL.t() | Web.Request.t(), keyword()) :: Web.Promise.t() def fetch(input, init \\ []) def fetch(%Web.Request{} = request, _init) do Web.AbortSignal.check!(request.signal) fetch_request(request) catch :throw, {:abort, reason} -> Web.Promise.reject(reason) end def fetch(%Web.URL{} = input, init) do fetch(Web.Request.new(input, init)) end def fetch(input, init) when is_binary(input) do fetch(Web.Request.new(input, init)) end defp fetch_request(request) do Web.Promise.new(fn resolve, reject -> try do case do_fetch_with_signal(request) do {:ok, response} -> resolve.(response) {:error, reason} -> reject.(reason) end catch # coveralls-ignore-next-line :throw, {:abort, reason} -> reject.(reason) end end) end defp do_fetch_with_signal(%Web.Request{signal: %Web.AbortSignal{} = signal} = request) do Web.AsyncContext.with_signal(signal, fn -> do_fetch(request) end) end defp do_fetch_with_signal(%Web.Request{} = request) do do_fetch(request) end defp do_fetch(request) do Web.AbortSignal.check!(request.signal) dispatcher = request.dispatcher || Web.Resolver.resolve(request.url) dispatcher.fetch(request) end defp byte_string_to_binary!(string) do string |> String.to_charlist() |> Enum.reduce([], fn codepoint, acc when codepoint in 0..255 -> [codepoint | acc] _codepoint, _acc -> raise TypeError.exception( "Failed to execute btoa: input contains characters outside the Latin1 range" ) end) |> Enum.reverse() |> :erlang.list_to_binary() end defp normalize_base64(string) do trimmed = String.replace(string, ~r/[\t\n\f\r ]+/, "") cond do trimmed == "" -> {:ok, ""} Regex.match?(~r/[^A-Za-z0-9+\/=]/, trimmed) -> {:error, :invalid} Regex.match?(~r/=.+[^=]/, trimmed) -> {:error, :invalid} true -> stripped = String.trim_trailing(trimmed, "=") padding_size = byte_size(trimmed) - byte_size(stripped) cond do padding_size > 2 -> {:error, :invalid} rem(byte_size(stripped), 4) == 1 -> {:error, :invalid} true -> required_padding = rem(4 - rem(byte_size(stripped), 4), 4) {:ok, stripped <> String.duplicate("=", required_padding)} end end end end