http_client.rs

  1use anyhow::anyhow;
  2use futures::AsyncReadExt as _;
  3use http_client::{AsyncBody, HttpClient, RedirectPolicy};
  4use std::future::Future;
  5use std::pin::Pin;
  6use std::task::Poll;
  7use wasm_bindgen::JsCast as _;
  8use wasm_bindgen::prelude::*;
  9
 10#[wasm_bindgen]
 11extern "C" {
 12    #[wasm_bindgen(catch, js_name = "fetch")]
 13    fn global_fetch(input: &web_sys::Request) -> Result<js_sys::Promise, JsValue>;
 14}
 15
 16pub struct FetchHttpClient {
 17    user_agent: Option<http_client::http::header::HeaderValue>,
 18}
 19
 20impl Default for FetchHttpClient {
 21    fn default() -> Self {
 22        Self { user_agent: None }
 23    }
 24}
 25
 26#[cfg(feature = "multithreaded")]
 27impl FetchHttpClient {
 28    /// # Safety
 29    ///
 30    /// The caller must ensure that the created `FetchHttpClient` is only used in a single thread environment.
 31    pub unsafe fn new() -> Self {
 32        Self::default()
 33    }
 34
 35    /// # Safety
 36    ///
 37    /// The caller must ensure that the created `FetchHttpClient` is only used in a single thread environment.
 38    pub unsafe fn with_user_agent(user_agent: &str) -> anyhow::Result<Self> {
 39        Ok(Self {
 40            user_agent: Some(http_client::http::header::HeaderValue::from_str(
 41                user_agent,
 42            )?),
 43        })
 44    }
 45}
 46
 47#[cfg(not(feature = "multithreaded"))]
 48impl FetchHttpClient {
 49    pub fn new() -> Self {
 50        Self::default()
 51    }
 52
 53    pub fn with_user_agent(user_agent: &str) -> anyhow::Result<Self> {
 54        Ok(Self {
 55            user_agent: Some(http_client::http::header::HeaderValue::from_str(
 56                user_agent,
 57            )?),
 58        })
 59    }
 60}
 61
 62/// Wraps a `!Send` future to satisfy the `Send` bound on `BoxFuture`.
 63///
 64/// Safety: only valid in WASM contexts where the `FetchHttpClient` is
 65/// confined to a single thread (guaranteed by the caller via unsafe
 66/// constructors when `multithreaded` is enabled, or by the absence of
 67/// threads when it is not).
 68struct AssertSend<F>(F);
 69
 70unsafe impl<F> Send for AssertSend<F> {}
 71
 72impl<F: Future> Future for AssertSend<F> {
 73    type Output = F::Output;
 74
 75    fn poll(self: Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> Poll<Self::Output> {
 76        // Safety: pin projection for a single-field newtype wrapper.
 77        let inner = unsafe { self.map_unchecked_mut(|this| &mut this.0) };
 78        inner.poll(cx)
 79    }
 80}
 81
 82impl HttpClient for FetchHttpClient {
 83    fn user_agent(&self) -> Option<&http_client::http::header::HeaderValue> {
 84        self.user_agent.as_ref()
 85    }
 86
 87    fn proxy(&self) -> Option<&http_client::Url> {
 88        None
 89    }
 90
 91    fn send(
 92        &self,
 93        req: http_client::http::Request<AsyncBody>,
 94    ) -> futures::future::BoxFuture<'static, anyhow::Result<http_client::http::Response<AsyncBody>>>
 95    {
 96        let (parts, body) = req.into_parts();
 97
 98        Box::pin(AssertSend(async move {
 99            let body_bytes = read_body_to_bytes(body).await?;
100
101            let init = web_sys::RequestInit::new();
102            init.set_method(parts.method.as_str());
103
104            if let Some(redirect_policy) = parts.extensions.get::<RedirectPolicy>() {
105                match redirect_policy {
106                    RedirectPolicy::NoFollow => {
107                        init.set_redirect(web_sys::RequestRedirect::Manual);
108                    }
109                    RedirectPolicy::FollowLimit(_) | RedirectPolicy::FollowAll => {
110                        init.set_redirect(web_sys::RequestRedirect::Follow);
111                    }
112                }
113            }
114
115            if let Some(ref bytes) = body_bytes {
116                let uint8array = js_sys::Uint8Array::from(bytes.as_slice());
117                init.set_body(uint8array.as_ref());
118            }
119
120            let url = parts.uri.to_string();
121            let request = web_sys::Request::new_with_str_and_init(&url, &init)
122                .map_err(|error| anyhow!("failed to create fetch Request: {error:?}"))?;
123
124            let request_headers = request.headers();
125            for (name, value) in &parts.headers {
126                let value_str = value
127                    .to_str()
128                    .map_err(|_| anyhow!("non-ASCII header value for {name}"))?;
129                request_headers
130                    .set(name.as_str(), value_str)
131                    .map_err(|error| anyhow!("failed to set header {name}: {error:?}"))?;
132            }
133
134            let promise = global_fetch(&request)
135                .map_err(|error| anyhow!("fetch threw an error: {error:?}"))?;
136            let response_value = wasm_bindgen_futures::JsFuture::from(promise)
137                .await
138                .map_err(|error| anyhow!("fetch failed: {error:?}"))?;
139
140            let web_response: web_sys::Response = response_value
141                .dyn_into()
142                .map_err(|error| anyhow!("fetch result is not a Response: {error:?}"))?;
143
144            let status = web_response.status();
145            let mut builder = http_client::http::Response::builder().status(status);
146
147            // `Headers` is a JS iterable yielding `[name, value]` pairs.
148            // `js_sys::Array::from` calls `Array.from()` which accepts any iterable.
149            let header_pairs = js_sys::Array::from(&web_response.headers());
150            for index in 0..header_pairs.length() {
151                match header_pairs.get(index).dyn_into::<js_sys::Array>() {
152                    Ok(pair) => match (pair.get(0).as_string(), pair.get(1).as_string()) {
153                        (Some(name), Some(value)) => {
154                            builder = builder.header(name, value);
155                        }
156                        (name, value) => {
157                            log::warn!(
158                                "skipping response header at index {index}: \
159                                     name={name:?}, value={value:?}"
160                            );
161                        }
162                    },
163                    Err(entry) => {
164                        log::warn!("skipping non-array header entry at index {index}: {entry:?}");
165                    }
166                }
167            }
168
169            // The entire response body is eagerly buffered into memory via
170            // `arrayBuffer()`. The Fetch API does not expose a synchronous
171            // streaming interface; streaming would require `ReadableStream`
172            // interop which is significantly more complex.
173            let body_promise = web_response
174                .array_buffer()
175                .map_err(|error| anyhow!("failed to initiate response body read: {error:?}"))?;
176            let body_value = wasm_bindgen_futures::JsFuture::from(body_promise)
177                .await
178                .map_err(|error| anyhow!("failed to read response body: {error:?}"))?;
179            let array_buffer: js_sys::ArrayBuffer = body_value
180                .dyn_into()
181                .map_err(|error| anyhow!("response body is not an ArrayBuffer: {error:?}"))?;
182            let response_bytes = js_sys::Uint8Array::new(&array_buffer).to_vec();
183
184            builder
185                .body(AsyncBody::from(response_bytes))
186                .map_err(|error| anyhow!(error))
187        }))
188    }
189}
190
191async fn read_body_to_bytes(mut body: AsyncBody) -> anyhow::Result<Option<Vec<u8>>> {
192    let mut buffer = Vec::new();
193    body.read_to_end(&mut buffer).await?;
194    if buffer.is_empty() {
195        Ok(None)
196    } else {
197        Ok(Some(buffer))
198    }
199}