Packages
atproto_browser
0.1.0
A browser-native atproto OAuth + XRPC client for Gleam: WebCrypto DPoP, PKCE, and async transport, for building a public-client SPA with no server-side session custody.
Current section
Files
Jump to
Current section
Files
src/atproto/browser/xrpc.gleam
//// Async transport-agnostic XRPC plumbing for the browser: the javascript-target
//// mirror of `atproto/xrpc.gleam`, but every call returns a `Promise` since
//// browser `fetch` is unavoidably async. `atproto/xrpc.gleam`'s `Client` type
//// is honestly synchronous — satisfied by Erlang's genuinely blocking `httpc`
//// — and that contract can't be honestly satisfied by real browser fetch, so
//// this is a parallel client, not a drop-in replacement. Ships a ready-made
//// `fetch_client()` sender built on `gleam_fetch`, so a caller doesn't need
//// to write their own. Transport failures use `atproto_core/xrpc.TransportError`
//// from `atproto_client`, so both targets share one error vocabulary.
import atproto_core/xrpc.{type TransportError}
import gleam/bit_array
import gleam/dynamic/decode
import gleam/fetch
import gleam/http
import gleam/http/request.{type Request}
import gleam/http/response.{type Response}
import gleam/javascript/promise.{type Promise}
import gleam/json.{type Json}
import gleam/option.{type Option, None, Some}
import gleam/result
import gleam/string
pub type Client {
Client(
send: fn(Request(BitArray)) ->
Promise(Result(Response(BitArray), TransportError)),
)
}
pub type XrpcError {
RequestFailed(TransportError)
BadStatus(
status: Int,
error: Option(String),
message: Option(String),
body: String,
)
DecodeFailed(String)
}
pub fn describe(error: XrpcError) -> String {
case error {
RequestFailed(e) -> "request failed: " <> xrpc.transport_error_to_string(e)
BadStatus(status, code, message, _) ->
"HTTP "
<> string.inspect(status)
<> { option.map(code, fn(c) { " " <> c }) |> option.unwrap("") }
<> { option.map(message, fn(m) { ": " <> m }) |> option.unwrap("") }
DecodeFailed(e) -> "decode failed: " <> e
}
}
/// A `Client` backed by real browser `fetch`.
pub fn fetch_client() -> Client {
Client(send: fn(req) {
use sent <- promise.await(fetch.send_bits(req))
case sent {
Error(e) -> promise.resolve(Error(fetch_error(e)))
Ok(resp) -> {
use read <- promise.map(fetch.read_bytes_body(resp))
case read {
Ok(resp) -> Ok(resp)
Error(e) -> Error(fetch_error(e))
}
}
}
})
}
fn fetch_error(e: fetch.FetchError) -> TransportError {
case e {
fetch.NetworkError(m) -> xrpc.ConnectionFailed(m)
fetch.UnableToReadBody -> xrpc.Other("unable to read response body")
fetch.InvalidJsonBody -> xrpc.Other("invalid JSON body")
}
}
pub fn send_text(
client: Client,
req: Request(String),
) -> Promise(Result(Response(String), TransportError)) {
use sent <- promise.map(client.send(request.map(req, bit_array.from_string)))
case sent {
Error(e) -> Error(e)
Ok(resp) ->
case bit_array.to_string(resp.body) {
Ok(body) -> Ok(response.set_body(resp, body))
Error(_) -> Error(xrpc.Other("response body is not valid utf-8"))
}
}
}
pub fn get(
client: Client,
url: String,
token: Option(String),
) -> Promise(Result(Response(String), XrpcError)) {
use base <- try_request(url, token)
use sent <- promise.map(send_text(client, base))
sent
|> result.map_error(RequestFailed)
|> result.try(check_ok)
}
/// GET returning the raw bytes (e.g. blob or image downloads).
pub fn get_bits(
client: Client,
url: String,
token: Option(String),
) -> Promise(Result(Response(BitArray), XrpcError)) {
use base <- try_request(url, token)
use sent <- promise.map(client.send(request.map(base, bit_array.from_string)))
sent
|> result.map_error(RequestFailed)
|> result.try(check_ok_bits)
}
pub fn post_json(
client: Client,
url: String,
token: Option(String),
body: Json,
) -> Promise(Result(Response(String), XrpcError)) {
use base <- try_request(url, token)
let req =
base
|> request.set_method(http.Post)
|> request.set_header("content-type", "application/json")
|> request.set_body(json.to_string(body))
use sent <- promise.map(send_text(client, req))
sent |> result.map_error(RequestFailed) |> result.try(check_ok)
}
/// POST raw bytes (e.g. `uploadBlob`); the response is decoded as text (JSON).
pub fn post_bits(
client: Client,
url: String,
token: Option(String),
body: BitArray,
content_type: String,
) -> Promise(Result(Response(String), XrpcError)) {
use base <- try_request(url, token)
let req =
base
|> request.set_method(http.Post)
|> request.set_header("content-type", content_type)
|> request.map(bit_array.from_string)
|> request.set_body(body)
use sent <- promise.map(client.send(req))
case sent {
Error(e) -> Error(RequestFailed(e))
Ok(resp) ->
case bit_array.to_string(resp.body) {
Ok(text) -> check_ok(response.set_body(resp, text))
Error(_) -> Error(DecodeFailed("response body is not valid utf-8"))
}
}
}
pub fn parse(body: String, decoder: decode.Decoder(a)) -> Result(a, XrpcError) {
json.parse(body, decoder)
|> result.map_error(fn(e) { DecodeFailed(string.inspect(e)) })
}
fn with_auth(req: Request(String), token: Option(String)) -> Request(String) {
case token {
Some(t) -> request.set_header(req, "authorization", "Bearer " <> t)
None -> req
}
}
/// Build the authenticated base request and hand it to `next`, or resolve
/// immediately with `RequestFailed(InvalidUrl)`.
fn try_request(
url: String,
token: Option(String),
next: fn(Request(String)) -> Promise(Result(a, XrpcError)),
) -> Promise(Result(a, XrpcError)) {
case request.to(url) {
Error(_) -> promise.resolve(Error(RequestFailed(xrpc.InvalidUrl(url))))
Ok(base) -> next(with_auth(base, token))
}
}
fn check_ok(resp: Response(String)) -> Result(Response(String), XrpcError) {
case resp.status >= 200 && resp.status < 300 {
True -> Ok(resp)
False -> {
let #(error, message) = parse_error(resp.body)
Error(BadStatus(resp.status, error, message, resp.body))
}
}
}
fn check_ok_bits(
resp: Response(BitArray),
) -> Result(Response(BitArray), XrpcError) {
case resp.status >= 200 && resp.status < 300 {
True -> Ok(resp)
False -> {
let body =
bit_array.to_string(resp.body) |> result.unwrap("<binary body>")
let #(error, message) = parse_error(body)
Error(BadStatus(resp.status, error, message, body))
}
}
}
fn parse_error(body: String) -> #(Option(String), Option(String)) {
let decoder = {
use error <- decode.optional_field(
"error",
None,
decode.optional(decode.string),
)
use message <- decode.optional_field(
"message",
None,
decode.optional(decode.string),
)
decode.success(#(error, message))
}
json.parse(body, decoder) |> result.unwrap(#(None, None))
}