Skip to main content

blitz_net/
lib.rs

1//! Networking (HTTP, filesystem, Data URIs) for Blitz
2//!
3//! Provides an implementation of the [`blitz_traits::net::NetProvider`] trait.
4
5use blitz_traits::net::{AbortSignal, Body, Bytes, NetHandler, NetProvider, NetWaker, Request};
6use data_url::DataUrl;
7use std::{
8    collections::HashMap,
9    marker::PhantomData,
10    pin::Pin,
11    sync::{Arc, Mutex},
12    task::Poll,
13};
14use tokio::sync::Semaphore;
15
16#[cfg(feature = "cache")]
17use http_cache_reqwest::{CACacheManager, Cache, CacheMode, HttpCache, HttpCacheOptions};
18
19const USER_AGENT: &str = "Mozilla/5.0 (X11; Linux x86_64; rv:60.0) Gecko/20100101 Firefox/81.0";
20
21/// Matches real browsers' per-origin cap of 6.
22const PER_HOST_MAX_CONCURRENT: usize = 6;
23
24type HostLimits = Arc<Mutex<HashMap<String, Arc<Semaphore>>>>;
25
26#[cfg(feature = "cache")]
27type Client = reqwest_middleware::ClientWithMiddleware;
28#[cfg(not(feature = "cache"))]
29type Client = reqwest::Client;
30
31#[cfg(feature = "cache")]
32type RequestBuilder = reqwest_middleware::RequestBuilder;
33#[cfg(not(feature = "cache"))]
34type RequestBuilder = reqwest::RequestBuilder;
35
36#[cfg(feature = "cache")]
37fn get_cache_path() -> std::path::PathBuf {
38    use directories::ProjectDirs;
39    let path = ProjectDirs::from("com", "DioxusLabs", "Blitz")
40        .expect("Failed to find cache directory")
41        .cache_dir()
42        .to_owned();
43    #[cfg(feature = "tracing")]
44    tracing::info!(path = ?path.display(), "Using cache dir");
45    path
46}
47
48#[cfg(target_arch = "wasm32")]
49fn spawn(fut: impl Future + 'static) {
50    wasm_bindgen_futures::spawn_local(async move {
51        fut.await;
52    });
53}
54
55#[cfg(not(target_arch = "wasm32"))]
56fn spawn<F>(fut: F)
57where
58    F: Future + Send + 'static,
59    F::Output: Send + 'static,
60{
61    tokio::spawn(fut);
62}
63
64pub struct Provider {
65    client: Client,
66    waker: Arc<dyn NetWaker>,
67    per_host_limits: HostLimits,
68    #[cfg(feature = "cache")]
69    cache_manager: CACacheManager,
70}
71impl Provider {
72    pub fn new(waker: Option<Arc<dyn NetWaker>>) -> Self {
73        let builder = reqwest::Client::builder();
74        #[cfg(feature = "cookies")]
75        let builder = builder.cookie_store(true);
76        let client = builder.build().unwrap();
77
78        #[cfg(feature = "cache")]
79        let cache_manager = CACacheManager::new(get_cache_path(), true);
80
81        #[cfg(feature = "cache")]
82        let client = reqwest_middleware::ClientBuilder::new(client)
83            .with(Cache(HttpCache {
84                mode: CacheMode::Default,
85                manager: cache_manager.clone(),
86                options: HttpCacheOptions::default(),
87            }))
88            .build();
89
90        let waker = waker.unwrap_or(Arc::new(DummyNetWaker));
91        Self {
92            client,
93            waker,
94            per_host_limits: Arc::new(Mutex::new(HashMap::new())),
95            #[cfg(feature = "cache")]
96            cache_manager,
97        }
98    }
99    pub fn shared(waker: Option<Arc<dyn NetWaker>>) -> Arc<dyn NetProvider> {
100        Arc::new(Self::new(waker))
101    }
102    pub fn is_empty(&self) -> bool {
103        Arc::strong_count(&self.waker) == 1
104    }
105    pub fn count(&self) -> usize {
106        Arc::strong_count(&self.waker) - 1
107    }
108
109    #[cfg(feature = "cache")]
110    pub async fn clear_cache(&self) {
111        if let Err(e) = self.cache_manager.clear().await {
112            #[cfg(feature = "tracing")]
113            tracing::error!("Failed to clear HTTP cache: {:?}", e);
114            #[cfg(not(feature = "tracing"))]
115            let _ = e;
116        }
117    }
118}
119impl Provider {
120    async fn fetch_inner(
121        client: Client,
122        request: Request,
123        per_host_limits: HostLimits,
124    ) -> Result<(String, Bytes), ProviderError> {
125        match request.url.scheme() {
126            "data" => {
127                let data_url = DataUrl::process(request.url.as_str())?;
128                let decoded = data_url.decode_to_vec()?;
129                Ok((request.url.to_string(), Bytes::from(decoded.0)))
130            }
131            "file" => {
132                let file_content = std::fs::read(request.url.path())?;
133                Ok((request.url.to_string(), Bytes::from(file_content)))
134            }
135            _ => Self::fetch_http(client, request, per_host_limits).await,
136        }
137    }
138
139    async fn fetch_http(
140        client: Client,
141        request: Request,
142        per_host_limits: HostLimits,
143    ) -> Result<(String, Bytes), ProviderError> {
144        // Acquire a per-host permit, held for the duration of the request, to
145        // keep total in-flight requests per origin bounded.
146        let host_key = request
147            .url
148            .host_str()
149            .map(str::to_owned)
150            .unwrap_or_default();
151        let semaphore = {
152            let mut map = per_host_limits.lock().unwrap();
153            map.entry(host_key)
154                .or_insert_with(|| Arc::new(Semaphore::new(PER_HOST_MAX_CONCURRENT)))
155                .clone()
156        };
157        let _permit = semaphore
158            .acquire()
159            .await
160            .expect("per-host semaphore was closed");
161
162        let mut req = client
163            .request(request.method, request.url)
164            .headers(request.headers)
165            .header("User-Agent", USER_AGENT);
166
167        if let Some(content_type) = request.content_type.as_ref() {
168            req = req.header("Content-Type", content_type);
169        }
170
171        let req = req
172            .apply_body(request.body, request.content_type.as_deref())
173            .await;
174        let response = req.send().await?;
175        let status = response.status();
176        let final_url = response.url().to_string();
177
178        if status.is_success() {
179            return Ok((final_url, response.bytes().await?));
180        }
181
182        #[cfg(feature = "tracing")]
183        tracing::warn!(
184            url = final_url.as_str(),
185            status = status.as_u16(),
186            "HTTP error status"
187        );
188        Err(ProviderError::HttpStatus {
189            status,
190            url: final_url,
191        })
192    }
193
194    #[allow(clippy::type_complexity)]
195    pub fn fetch_with_callback(
196        &self,
197        request: Request,
198        callback: Box<dyn FnOnce(Result<(String, Bytes), ProviderError>) + Send + Sync + 'static>,
199    ) {
200        #[cfg(feature = "tracing")]
201        let url = request.url.to_string();
202
203        let client = self.client.clone();
204        let per_host_limits = self.per_host_limits.clone();
205        spawn(async move {
206            let result = Self::fetch_inner(client, request, per_host_limits).await;
207
208            #[cfg(feature = "tracing")]
209            if let Err(e) = &result {
210                #[cfg(feature = "tracing")]
211                tracing::error!(url = url.as_str(), error = ?e, "Fetching");
212            } else {
213                #[cfg(feature = "tracing")]
214                tracing::info!(url = url.as_str(), "Success fetching");
215            }
216
217            callback(result);
218        });
219    }
220
221    pub async fn fetch_async(&self, request: Request) -> Result<(String, Bytes), ProviderError> {
222        #[cfg(feature = "tracing")]
223        let url = request.url.to_string();
224
225        let client = self.client.clone();
226        let per_host_limits = self.per_host_limits.clone();
227        let result = Self::fetch_inner(client, request, per_host_limits).await;
228
229        #[cfg(feature = "tracing")]
230        if let Err(e) = &result {
231            #[cfg(feature = "tracing")]
232            tracing::error!(url = url.as_str(), error = ?e, "Fetching");
233        } else {
234            #[cfg(feature = "tracing")]
235            tracing::info!(url = url.as_str(), "Success fetching");
236        }
237
238        result
239    }
240}
241
242impl NetProvider for Provider {
243    fn fetch(&self, doc_id: usize, mut request: Request, handler: Box<dyn NetHandler>) {
244        let client = self.client.clone();
245        let per_host_limits = self.per_host_limits.clone();
246
247        #[cfg(feature = "tracing")]
248        tracing::info!(url = request.url.as_str(), "Fetching");
249
250        let waker = self.waker.clone();
251        spawn(async move {
252            #[cfg(feature = "tracing")]
253            let url = request.url.to_string();
254
255            let signal = request.signal.take();
256            let result = if let Some(signal) = signal {
257                AbortFetch::new(
258                    signal,
259                    Box::pin(
260                        async move { Self::fetch_inner(client, request, per_host_limits).await },
261                    ),
262                )
263                .await
264            } else {
265                Self::fetch_inner(client, request, per_host_limits).await
266            };
267
268            waker.wake(doc_id);
269
270            match result {
271                Ok((response_url, bytes)) => {
272                    handler.bytes(response_url, bytes);
273                    #[cfg(feature = "tracing")]
274                    tracing::info!(url = url.as_str(), "Success fetching");
275                }
276                Err(e) => {
277                    #[cfg(feature = "tracing")]
278                    tracing::error!(url = url.as_str(), error = ?e, "Error fetching");
279                    #[cfg(not(feature = "tracing"))]
280                    let _ = e;
281                }
282            };
283        });
284    }
285}
286
287struct AbortFetch<F, T> {
288    signal: AbortSignal,
289    future: F,
290    _rt: PhantomData<T>,
291}
292
293impl<F, T> AbortFetch<F, T> {
294    fn new(signal: AbortSignal, future: F) -> Self {
295        Self {
296            signal,
297            future,
298            _rt: PhantomData,
299        }
300    }
301}
302
303impl<F, T> Future for AbortFetch<F, T>
304where
305    F: Future + Unpin + 'static,
306    F::Output: Into<Result<T, ProviderError>> + 'static,
307    T: Unpin,
308{
309    type Output = Result<T, ProviderError>;
310
311    fn poll(
312        mut self: std::pin::Pin<&mut Self>,
313        cx: &mut std::task::Context<'_>,
314    ) -> std::task::Poll<Self::Output> {
315        if self.signal.aborted() {
316            return Poll::Ready(Err(ProviderError::Abort));
317        }
318
319        match Pin::new(&mut self.future).poll(cx) {
320            Poll::Ready(output) => Poll::Ready(output.into()),
321            Poll::Pending => Poll::Pending,
322        }
323    }
324}
325
326#[derive(Debug)]
327pub enum ProviderError {
328    Abort,
329    Io(std::io::Error),
330    DataUrl(data_url::DataUrlError),
331    DataUrlBase64(data_url::forgiving_base64::InvalidBase64),
332    ReqwestError(reqwest::Error),
333    #[cfg(feature = "cache")]
334    ReqwestMiddlewareError(reqwest_middleware::Error),
335    HttpStatus {
336        status: reqwest::StatusCode,
337        url: String,
338    },
339}
340
341impl std::fmt::Display for ProviderError {
342    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
343        match self {
344            Self::Abort => write!(f, "request aborted"),
345            Self::Io(e) => write!(f, "io error: {e}"),
346            Self::DataUrl(e) => write!(f, "data url error: {e:?}"),
347            Self::DataUrlBase64(e) => write!(f, "data url base64 error: {e:?}"),
348            Self::ReqwestError(e) => write!(f, "reqwest error: {e}"),
349            #[cfg(feature = "cache")]
350            Self::ReqwestMiddlewareError(e) => write!(f, "reqwest middleware error: {e}"),
351            Self::HttpStatus { status, url } => write!(f, "HTTP {status} for {url}"),
352        }
353    }
354}
355
356impl From<std::io::Error> for ProviderError {
357    fn from(value: std::io::Error) -> Self {
358        Self::Io(value)
359    }
360}
361
362impl From<data_url::DataUrlError> for ProviderError {
363    fn from(value: data_url::DataUrlError) -> Self {
364        Self::DataUrl(value)
365    }
366}
367
368impl From<data_url::forgiving_base64::InvalidBase64> for ProviderError {
369    fn from(value: data_url::forgiving_base64::InvalidBase64) -> Self {
370        Self::DataUrlBase64(value)
371    }
372}
373
374impl From<reqwest::Error> for ProviderError {
375    fn from(value: reqwest::Error) -> Self {
376        Self::ReqwestError(value)
377    }
378}
379
380#[cfg(feature = "cache")]
381impl From<reqwest_middleware::Error> for ProviderError {
382    fn from(value: reqwest_middleware::Error) -> Self {
383        Self::ReqwestMiddlewareError(value)
384    }
385}
386
387trait ReqwestExt {
388    async fn apply_body(self, body: Body, content_type: Option<&str>) -> Self;
389}
390impl ReqwestExt for RequestBuilder {
391    async fn apply_body(self, body: Body, content_type: Option<&str>) -> Self {
392        match body {
393            Body::Bytes(bytes) => self.body(bytes),
394            Body::Form(form_data) => match content_type {
395                Some("application/x-www-form-urlencoded") => self.form(&form_data),
396                #[cfg(feature = "multipart")]
397                Some("multipart/form-data") => {
398                    use blitz_traits::net::Entry;
399                    use blitz_traits::net::EntryValue;
400                    let mut form_data = form_data;
401                    let mut form = reqwest::multipart::Form::new();
402                    for Entry { name, value } in form_data.0.drain(..) {
403                        form = match value {
404                            EntryValue::String(value) => form.text(name, value),
405                            EntryValue::File(path_buf) => form
406                                .file(name, path_buf)
407                                .await
408                                .expect("Couldn't read form file from disk"),
409                            EntryValue::EmptyFile => form.part(
410                                name,
411                                reqwest::multipart::Part::bytes(&[])
412                                    .mime_str("application/octet-stream")
413                                    .unwrap(),
414                            ),
415                        };
416                    }
417                    self.multipart(form)
418                }
419                _ => self,
420            },
421            Body::Empty => self,
422        }
423    }
424}
425
426struct DummyNetWaker;
427impl NetWaker for DummyNetWaker {
428    fn wake(&self, _client_id: usize) {}
429}