Skip to main content

cranpose_services/
http.rs

1#[cfg(not(target_arch = "wasm32"))]
2use std::path::Path;
3use std::{
4    future::Future,
5    pin::Pin,
6    sync::{
7        Arc, Mutex, PoisonError, Weak,
8        atomic::{AtomicBool, Ordering},
9    },
10};
11
12use cranpose_core::{CompositionLocal, compositionLocalOfWithPolicy};
13#[cfg(all(target_arch = "wasm32", feature = "web-http"))]
14use futures_util::{StreamExt, stream};
15
16#[derive(thiserror::Error, Debug, Clone)]
17pub enum HttpError {
18    #[error("Failed to build HTTP client: {0}")]
19    ClientInit(String),
20    #[error("Request failed for {url}: {message}")]
21    RequestFailed { url: String, message: String },
22    #[error("Request failed with status {status} for {url}")]
23    HttpStatus { url: String, status: u16 },
24    #[error("Failed to read response body for {url}: {message}")]
25    BodyReadFailed { url: String, message: String },
26    #[error("Invalid response for {url}: {message}")]
27    InvalidResponse { url: String, message: String },
28    #[error("No window object available")]
29    NoWindow,
30    #[error("{operation} worker thread panicked")]
31    WorkerPanicked { operation: &'static str },
32    #[error("{operation} requires cranpose-services feature `{feature}`")]
33    UnsupportedFeature {
34        operation: &'static str,
35        feature: &'static str,
36    },
37    #[error("Download was cancelled")]
38    Cancelled,
39    #[error("Failed to write downloaded file {path}: {message}")]
40    FileWrite {
41        path: String,
42        /// What the system reported, such as
43        /// [`std::io::ErrorKind::StorageFull`] for a full disk.
44        kind: std::io::ErrorKind,
45        message: String,
46    },
47}
48
49/// How much of a transfer has arrived.
50#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
51pub struct HttpProgress {
52    /// Bytes transferred so far, counting anything a resumed transfer skipped.
53    pub transferred: u64,
54    /// The whole size, when the server said. A chunked response does not, and
55    /// a progress bar that invents a total for one is lying about it.
56    pub total: Option<u64>,
57}
58
59impl HttpProgress {
60    /// How far through the transfer is, in `0..=1`, or `None` when the server
61    /// never said how large it is.
62    pub fn fraction(&self) -> Option<f32> {
63        let total = self.total?;
64        if total == 0 {
65            return None;
66        }
67        Some((self.transferred as f32 / total as f32).clamp(0.0, 1.0))
68    }
69
70    /// Whether everything the server promised has arrived.
71    pub fn is_complete(&self) -> bool {
72        self.total.is_some_and(|total| self.transferred >= total)
73    }
74}
75
76/// What a progress report is handed to.
77pub type ProgressHandler = Arc<dyn Fn(HttpProgress) + Send + Sync>;
78
79/// Cancellation and progress shared with an in-flight request.
80///
81/// One control covers every request rather than only file downloads: a slow
82/// response is a slow response whether it is being written to disk or read into
83/// memory, and a screen that leaves is a screen that leaves.
84#[derive(Clone, Default)]
85pub struct HttpControl {
86    cancel: Arc<CancelState>,
87    progress: Option<ProgressHandler>,
88}
89
90#[derive(Default)]
91struct CancelState {
92    cancelled: AtomicBool,
93    waiting: Mutex<Vec<Weak<dyn CancelTransfer>>>,
94}
95
96trait CancelTransfer: Send + Sync {
97    fn cancel(&self);
98}
99
100impl HttpControl {
101    /// A live control with no progress reporting.
102    pub fn new() -> Self {
103        Self::default()
104    }
105
106    /// Reports transferred and, where the server says so, total bytes.
107    ///
108    /// Called from whichever thread is doing the transfer, so the handler must
109    /// be prepared for that — the framework's own event stream is the ordinary
110    /// way to get it back to composition.
111    pub fn with_progress(
112        mut self,
113        progress: impl Fn(HttpProgress) + Send + Sync + 'static,
114    ) -> Self {
115        self.progress = Some(Arc::new(progress));
116        self
117    }
118
119    /// Requests cancellation.
120    ///
121    /// A native transfer that waits for the response or the next chunk ends
122    /// with [`HttpError::Cancelled`] at once; other backends stop at the next
123    /// chunk boundary. A partial file left by a cancelled download stays on
124    /// disk, which is what makes the next attempt able to resume rather than
125    /// start again.
126    pub fn cancel(&self) {
127        self.cancel.cancelled.store(true, Ordering::Release);
128        let waiting = std::mem::take(
129            &mut *self
130                .cancel
131                .waiting
132                .lock()
133                .unwrap_or_else(PoisonError::into_inner),
134        );
135        for transfer in waiting.iter().filter_map(Weak::upgrade) {
136            transfer.cancel();
137        }
138    }
139
140    /// Whether cancellation was requested.
141    pub fn is_cancelled(&self) -> bool {
142        self.cancel.cancelled.load(Ordering::Acquire)
143    }
144
145    #[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
146    fn on_cancel(&self, transfer: Weak<dyn CancelTransfer>) {
147        let mut waiting = self
148            .cancel
149            .waiting
150            .lock()
151            .unwrap_or_else(PoisonError::into_inner);
152        if self.is_cancelled() {
153            drop(waiting);
154            if let Some(transfer) = transfer.upgrade() {
155                transfer.cancel();
156            }
157            return;
158        }
159        waiting.retain(|transfer| transfer.strong_count() > 0);
160        waiting.push(transfer);
161    }
162
163    /// Hands a progress reading to whatever [`with_progress`](Self::with_progress)
164    /// registered, and does nothing where nothing did.
165    ///
166    /// Called by the backend doing the transfer - including an
167    /// [`HttpClient`] an application provides through [`local_http_client`],
168    /// which is why this is part of the control's public surface rather than
169    /// private to the backends shipped here.
170    pub fn report(&self, progress: HttpProgress) {
171        if let Some(handler) = &self.progress {
172            handler(progress);
173        }
174    }
175}
176
177/// The verbs a request can use.
178#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
179pub enum HttpMethod {
180    #[default]
181    Get,
182    Head,
183    Post,
184    Put,
185    Delete,
186}
187
188impl HttpMethod {
189    /// The name that goes on the wire.
190    pub fn name(self) -> &'static str {
191        match self {
192            HttpMethod::Get => "GET",
193            HttpMethod::Head => "HEAD",
194            HttpMethod::Post => "POST",
195            HttpMethod::Put => "PUT",
196            HttpMethod::Delete => "DELETE",
197        }
198    }
199}
200
201/// One request.
202#[derive(Clone, Debug, Default, PartialEq, Eq)]
203pub struct HttpRequest {
204    pub url: String,
205    pub method: HttpMethod,
206    pub headers: Vec<(String, String)>,
207    pub body: Option<Vec<u8>>,
208    /// Resume an interrupted transfer from this byte offset.
209    ///
210    /// Sent as a `Range` header. A server free to ignore it answers `200` with
211    /// the whole body instead of `206` with the rest, which
212    /// [`HttpResponse::resumed`] reports — a caller that appends to a partial
213    /// file must check it, or it writes the start of the file over the middle.
214    pub resume_from: Option<u64>,
215}
216
217impl HttpRequest {
218    /// A `GET` for `url`.
219    pub fn get(url: impl Into<String>) -> Self {
220        Self {
221            url: url.into(),
222            ..Self::default()
223        }
224    }
225
226    pub fn method(mut self, method: HttpMethod) -> Self {
227        self.method = method;
228        self
229    }
230
231    pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
232        self.headers.push((name.into(), value.into()));
233        self
234    }
235
236    pub fn body(mut self, body: impl Into<Vec<u8>>) -> Self {
237        self.body = Some(body.into());
238        self
239    }
240
241    /// Resumes an interrupted transfer from `offset`.
242    pub fn resume_from(mut self, offset: u64) -> Self {
243        self.resume_from = (offset > 0).then_some(offset);
244        self
245    }
246}
247
248#[cfg(not(target_arch = "wasm32"))]
249pub type HttpFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, HttpError>> + Send + 'a>>;
250
251#[cfg(target_arch = "wasm32")]
252pub type HttpFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, HttpError>> + 'a>>;
253
254/// A response body, read a chunk at a time.
255///
256/// A body is not a `Vec<u8>`: a model pack, a video, an application package are
257/// all larger than the memory a phone will hand over, and the only way to read
258/// one is to not hold it. Everything that wants the whole thing anyway asks for
259/// it explicitly through [`HttpResponse::read_all`].
260pub trait HttpBody {
261    /// The next chunk, or `None` at the end of the body.
262    fn read_chunk(&self) -> HttpFuture<'_, Option<Vec<u8>>>;
263}
264
265/// Shared handle to a response body.
266///
267/// Bounded exactly as [`HttpFuture`] is: a native transfer runs on a thread of
268/// its own and its body crosses to whoever awaits it, while a browser's body is
269/// a `ReadableStream` reader that belongs to the one thread a page has and
270/// cannot claim otherwise.
271#[cfg(not(target_arch = "wasm32"))]
272pub type HttpBodyRef = Arc<dyn HttpBody + Send + Sync>;
273
274/// Shared handle to a response body.
275///
276/// A browser's body is a `ReadableStream` reader that belongs to the one
277/// thread a page has and cannot claim otherwise, so it is never `Send + Sync`
278/// there; `Rc` avoids paying for synchronisation the target cannot use.
279#[cfg(target_arch = "wasm32")]
280pub type HttpBodyRef = std::rc::Rc<dyn HttpBody>;
281
282/// Wraps `body` in an [`HttpBodyRef`].
283///
284/// A trait-object alias cannot expose its own `new`, so this is the one place
285/// that picks `Arc` on native and `Rc` on wasm; callers that need an
286/// [`HttpBodyRef`] from a concrete body go through this instead of repeating
287/// that choice.
288#[cfg(not(target_arch = "wasm32"))]
289pub fn http_body_ref<B: HttpBody + Send + Sync + 'static>(body: B) -> HttpBodyRef {
290    Arc::new(body)
291}
292
293/// See the native definition of [`http_body_ref`] for why this is `Rc` on wasm.
294#[cfg(target_arch = "wasm32")]
295pub fn http_body_ref<B: HttpBody + 'static>(body: B) -> HttpBodyRef {
296    std::rc::Rc::new(body)
297}
298
299struct EmptyBody;
300
301impl HttpBody for EmptyBody {
302    fn read_chunk(&self) -> HttpFuture<'_, Option<Vec<u8>>> {
303        Box::pin(async { Ok(None) })
304    }
305}
306
307/// A body already in memory, handed out in `CHUNK_LEN`-sized pieces.
308///
309/// For a backend that has the bytes already — a cache, a bundled asset, a test
310/// double — so it satisfies the same chunked contract as one still on the wire
311/// and its callers cannot tell the difference.
312pub struct BytesBody {
313    bytes: Vec<u8>,
314    offset: std::sync::Mutex<usize>,
315    chunk: usize,
316}
317
318impl BytesBody {
319    /// A body over `bytes`, delivered in one chunk per read.
320    pub fn new(bytes: impl Into<Vec<u8>>) -> Self {
321        Self::chunked(bytes, usize::MAX)
322    }
323
324    /// A body over `bytes`, delivered at most `chunk` bytes at a time.
325    pub fn chunked(bytes: impl Into<Vec<u8>>, chunk: usize) -> Self {
326        Self {
327            bytes: bytes.into(),
328            offset: std::sync::Mutex::new(0),
329            chunk: chunk.max(1),
330        }
331    }
332
333    /// How long the body is.
334    pub fn len(&self) -> u64 {
335        self.bytes.len() as u64
336    }
337
338    /// Whether the body carries nothing.
339    pub fn is_empty(&self) -> bool {
340        self.bytes.is_empty()
341    }
342}
343
344impl HttpBody for BytesBody {
345    fn read_chunk(&self) -> HttpFuture<'_, Option<Vec<u8>>> {
346        Box::pin(async move {
347            let mut offset = self.offset.lock().unwrap_or_else(PoisonError::into_inner);
348            if *offset >= self.bytes.len() {
349                return Ok(None);
350            }
351            let end = offset.saturating_add(self.chunk).min(self.bytes.len());
352            let chunk = self.bytes[*offset..end].to_vec();
353            *offset = end;
354            Ok(Some(chunk))
355        })
356    }
357}
358
359/// What a server answered, with its body still to be read.
360pub struct HttpResponse {
361    /// The status line's code.
362    pub status: u16,
363    /// The response headers, with their names lower-cased.
364    pub headers: Vec<(String, String)>,
365    /// How long the body is, when the server said.
366    pub content_length: Option<u64>,
367    /// Whether the server honoured [`HttpRequest::resume_from`].
368    ///
369    /// `false` from a resumed request means the body starts at the beginning
370    /// again, and a caller appending to a partial file must truncate it first.
371    pub resumed: bool,
372    /// The URL the request went to, for the errors reading the body reports.
373    pub url: String,
374    body: HttpBodyRef,
375}
376
377impl HttpResponse {
378    /// Builds a response around a body.
379    pub fn new(url: impl Into<String>, status: u16, body: HttpBodyRef) -> Self {
380        Self {
381            status,
382            headers: Vec::new(),
383            content_length: None,
384            resumed: false,
385            url: url.into(),
386            body,
387        }
388    }
389
390    /// A response with no body, which is what a `HEAD` answers with.
391    pub fn empty(url: impl Into<String>, status: u16) -> Self {
392        Self::new(url, status, http_body_ref(EmptyBody))
393    }
394
395    pub fn with_headers(mut self, headers: Vec<(String, String)>) -> Self {
396        self.headers = headers;
397        self
398    }
399
400    pub fn with_content_length(mut self, content_length: Option<u64>) -> Self {
401        self.content_length = content_length;
402        self
403    }
404
405    pub fn with_resumed(mut self, resumed: bool) -> Self {
406        self.resumed = resumed;
407        self
408    }
409
410    /// Whether the status is in the 2xx range.
411    pub fn is_success(&self) -> bool {
412        (200..300).contains(&self.status)
413    }
414
415    /// One header, matched without regard to case.
416    pub fn header(&self, name: &str) -> Option<&str> {
417        let name = name.to_ascii_lowercase();
418        self.headers
419            .iter()
420            .find(|(key, _)| key.eq_ignore_ascii_case(&name))
421            .map(|(_, value)| value.as_str())
422    }
423
424    /// Fails with [`HttpError::HttpStatus`] unless the status is a success.
425    pub fn error_for_status(self) -> Result<Self, HttpError> {
426        if self.is_success() {
427            Ok(self)
428        } else {
429            Err(HttpError::HttpStatus {
430                url: self.url.clone(),
431                status: self.status,
432            })
433        }
434    }
435
436    /// The next chunk of the body, or `None` at its end.
437    pub async fn read_chunk(&self) -> Result<Option<Vec<u8>>, HttpError> {
438        self.body.read_chunk().await
439    }
440
441    /// The whole body.
442    ///
443    /// Named for what it does, so a caller reading something that might not fit
444    /// in memory can see that it is asking for exactly that.
445    pub async fn read_all(&self) -> Result<Vec<u8>, HttpError> {
446        let mut out = Vec::with_capacity(self.content_length.unwrap_or(0).min(1 << 20) as usize);
447        while let Some(chunk) = self.read_chunk().await? {
448            out.extend_from_slice(&chunk);
449        }
450        Ok(out)
451    }
452
453    /// The whole body as text.
454    pub async fn read_text(&self) -> Result<String, HttpError> {
455        String::from_utf8(self.read_all().await?).map_err(|error| HttpError::InvalidResponse {
456            url: self.url.clone(),
457            message: error.to_string(),
458        })
459    }
460}
461
462/// A platform's HTTP.
463///
464/// One method carries the whole contract: everything else — text, bytes, a file
465/// on disk — is that method plus what the caller does with the body, so a
466/// backend implements the transfer once and cannot make `get_text` behave
467/// differently from `download_to`.
468pub trait HttpClient: Send + Sync {
469    /// Sends `request`, resolving when the response's status line has arrived
470    /// and its body is ready to be read.
471    fn send<'a>(
472        &'a self,
473        request: &'a HttpRequest,
474        control: HttpControl,
475    ) -> HttpFuture<'a, HttpResponse>;
476
477    /// The body of a successful `GET`, as text.
478    fn get_text<'a>(&'a self, url: &'a str) -> HttpFuture<'a, String> {
479        Box::pin(async move {
480            self.send(&HttpRequest::get(url), HttpControl::new())
481                .await?
482                .error_for_status()?
483                .read_text()
484                .await
485        })
486    }
487
488    /// The body of a successful `GET`.
489    fn get_bytes<'a>(&'a self, url: &'a str) -> HttpFuture<'a, Vec<u8>> {
490        Box::pin(async move {
491            self.send(&HttpRequest::get(url), HttpControl::new())
492                .await?
493                .error_for_status()?
494                .read_all()
495                .await
496        })
497    }
498
499    /// Streams a response into `target`, resuming from its current length when
500    /// the server supports byte ranges, and returns the file's whole length.
501    ///
502    /// This is the appropriate call for model packs and other files that should
503    /// not be held in memory. A cancelled transfer leaves the partial file
504    /// where it is, which is what lets the next attempt continue rather than
505    /// start again.
506    #[cfg(not(target_arch = "wasm32"))]
507    fn download_to<'a>(
508        &'a self,
509        url: &'a str,
510        target: &'a Path,
511        control: HttpControl,
512    ) -> HttpFuture<'a, u64> {
513        Box::pin(async move { download_through(self, url, target, control).await })
514    }
515}
516
517#[cfg(not(target_arch = "wasm32"))]
518async fn download_through<C: HttpClient + ?Sized>(
519    client: &C,
520    url: &str,
521    target: &Path,
522    control: HttpControl,
523) -> Result<u64, HttpError> {
524    use std::io::Write;
525
526    if control.is_cancelled() {
527        return Err(HttpError::Cancelled);
528    }
529    let existing = std::fs::metadata(target).map_or(0, |metadata| metadata.len());
530    let mut request = HttpRequest::get(url);
531    if existing > 0 {
532        request = request.resume_from(existing);
533    }
534    let response = client
535        .send(&request, control.clone())
536        .await?
537        .error_for_status()?;
538
539    let base = if response.resumed { existing } else { 0 };
540    let total = response.content_length.map(|remaining| base + remaining);
541    let mut output = if response.resumed {
542        std::fs::OpenOptions::new().append(true).open(target)
543    } else {
544        std::fs::File::create(target)
545    }
546    .map_err(|error| file_write_error(target, &error))?;
547
548    let mut transferred = base;
549    control.report(HttpProgress { transferred, total });
550    while let Some(chunk) = response.read_chunk().await? {
551        if control.is_cancelled() {
552            return Err(HttpError::Cancelled);
553        }
554        output
555            .write_all(&chunk)
556            .map_err(|error| file_write_error(target, &error))?;
557        transferred += chunk.len() as u64;
558        control.report(HttpProgress { transferred, total });
559    }
560    output
561        .flush()
562        .map_err(|error| file_write_error(target, &error))?;
563    Ok(transferred)
564}
565
566#[cfg(not(target_arch = "wasm32"))]
567fn file_write_error(target: &Path, error: &std::io::Error) -> HttpError {
568    HttpError::FileWrite {
569        path: target.display().to_string(),
570        kind: error.kind(),
571        message: error.to_string(),
572    }
573}
574
575pub type HttpClientRef = Arc<dyn HttpClient>;
576
577#[cfg(not(target_arch = "wasm32"))]
578pub async fn map_ordered_concurrent<I, T, F, Fut>(
579    items: &[I],
580    concurrency: usize,
581    task: F,
582) -> Result<Vec<T>, HttpError>
583where
584    I: Clone + Send,
585    T: Send,
586    F: Fn(I) -> Fut + Send + Sync + 'static,
587    Fut: Future<Output = T> + Send,
588{
589    let task = Arc::new(task);
590    let mut results = Vec::with_capacity(items.len());
591
592    for chunk in items.chunks(concurrency.max(1)) {
593        let chunk_results = std::thread::scope(|scope| {
594            let mut handles = Vec::with_capacity(chunk.len());
595            for item in chunk.iter().cloned() {
596                let task = Arc::clone(&task);
597                handles.push(scope.spawn(move || pollster::block_on(task(item))));
598            }
599
600            let mut chunk_results = Vec::with_capacity(handles.len());
601            for handle in handles {
602                let value = handle.join().map_err(|_| HttpError::WorkerPanicked {
603                    operation: "ordered concurrent task",
604                })?;
605                chunk_results.push(value);
606            }
607            Ok::<Vec<T>, HttpError>(chunk_results)
608        })?;
609        results.extend(chunk_results);
610    }
611
612    Ok(results)
613}
614
615#[cfg(all(target_arch = "wasm32", feature = "web-http"))]
616pub async fn map_ordered_concurrent<I, T, F, Fut>(
617    items: &[I],
618    concurrency: usize,
619    task: F,
620) -> Result<Vec<T>, HttpError>
621where
622    I: Clone,
623    F: Fn(I) -> Fut + Clone,
624    Fut: Future<Output = T>,
625{
626    let mut results = stream::iter(items.iter().cloned().enumerate().map(|(index, item)| {
627        let task = task.clone();
628        async move { (index, task(item).await) }
629    }))
630    .buffer_unordered(concurrency.max(1))
631    .collect::<Vec<_>>()
632    .await;
633
634    results.sort_by_key(|(index, _)| *index);
635    Ok(results.into_iter().map(|(_, value)| value).collect())
636}
637
638#[cfg(all(target_arch = "wasm32", not(feature = "web-http")))]
639pub async fn map_ordered_concurrent<I, T, F, Fut>(
640    items: &[I],
641    _concurrency: usize,
642    task: F,
643) -> Result<Vec<T>, HttpError>
644where
645    I: Clone,
646    F: Fn(I) -> Fut,
647    Fut: Future<Output = T>,
648{
649    Ok(map_ordered_sequential(items, task).await)
650}
651
652#[cfg(all(target_arch = "wasm32", not(feature = "web-http")))]
653async fn map_ordered_sequential<I, T, F, Fut>(items: &[I], task: F) -> Vec<T>
654where
655    I: Clone,
656    F: Fn(I) -> Fut,
657    Fut: Future<Output = T>,
658{
659    let mut results = Vec::with_capacity(items.len());
660    for item in items.iter().cloned() {
661        results.push(task(item).await);
662    }
663    results
664}
665
666/// What a [`StubHttpClient`] answers a request with.
667pub type StubAnswer = dyn Fn(&HttpRequest) -> Result<HttpResponse, HttpError> + Send + Sync;
668
669/// An HTTP client that answers from a function rather than a network.
670///
671/// A screen driven from a recorded payload, a robot run that must not depend on
672/// a server, a test that wants a specific failure: all of them want one thing,
673/// which is to decide what a request answers. Written as a client each time,
674/// that is the same dozen lines of body plumbing repeated — and each copy is a
675/// chance to answer differently from the real client.
676///
677/// ```ignore
678/// let client: HttpClientRef = Arc::new(StubHttpClient::from_text(move |url| {
679///     Ok(fixture_for(url))
680/// }));
681/// ```
682pub struct StubHttpClient {
683    answer: Box<StubAnswer>,
684}
685
686impl StubHttpClient {
687    /// Answers from a function of the whole request.
688    pub fn new(
689        answer: impl Fn(&HttpRequest) -> Result<HttpResponse, HttpError> + Send + Sync + 'static,
690    ) -> Self {
691        Self {
692            answer: Box::new(answer),
693        }
694    }
695
696    /// Answers every request with `body` and a `200`.
697    pub fn with_body(body: impl Into<Vec<u8>>) -> Self {
698        let body = body.into();
699        Self::new(move |request| {
700            Ok(HttpResponse::new(
701                request.url.clone(),
702                200,
703                http_body_ref(BytesBody::new(body.clone())),
704            ))
705        })
706    }
707
708    /// Answers with text, or an error, from a function of the URL.
709    pub fn from_text(
710        answer: impl Fn(&str) -> Result<String, HttpError> + Send + Sync + 'static,
711    ) -> Self {
712        Self::new(move |request| {
713            let body = answer(&request.url)?;
714            Ok(HttpResponse::new(
715                request.url.clone(),
716                200,
717                http_body_ref(BytesBody::new(body)),
718            ))
719        })
720    }
721}
722
723impl HttpClient for StubHttpClient {
724    fn send<'a>(
725        &'a self,
726        request: &'a HttpRequest,
727        _control: HttpControl,
728    ) -> HttpFuture<'a, HttpResponse> {
729        Box::pin(async move { (self.answer)(request) })
730    }
731}
732
733#[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
734const CHUNK_LEN: usize = 64 * 1024;
735
736#[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
737const IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
738
739struct DefaultHttpClient {
740    #[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
741    native_client: Result<reqwest::blocking::Client, HttpError>,
742}
743
744impl DefaultHttpClient {
745    fn new() -> Self {
746        Self {
747            #[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
748            native_client: build_native_client(IDLE_TIMEOUT),
749        }
750    }
751
752    #[cfg(all(test, not(target_arch = "wasm32"), feature = "http-native"))]
753    fn with_idle_timeout(idle: std::time::Duration) -> Self {
754        Self {
755            native_client: build_native_client(idle),
756        }
757    }
758}
759
760#[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
761struct ResponseHead {
762    status: u16,
763    headers: Vec<(String, String)>,
764    content_length: Option<u64>,
765    resumed: bool,
766}
767
768#[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
769struct NativeTransfer {
770    head: crate::async_io::Signal<Result<ResponseHead, HttpError>>,
771    chunks: crate::async_io::ChunkStream<HttpError>,
772}
773
774#[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
775impl CancelTransfer for NativeTransfer {
776    fn cancel(&self) {
777        self.head.set(Err(HttpError::Cancelled));
778        self.chunks.fail(HttpError::Cancelled);
779    }
780}
781
782#[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
783struct ChannelBody {
784    transfer: Arc<NativeTransfer>,
785}
786
787#[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
788impl HttpBody for ChannelBody {
789    fn read_chunk(&self) -> HttpFuture<'_, Option<Vec<u8>>> {
790        Box::pin(self.transfer.chunks.next())
791    }
792}
793
794#[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
795async fn send_native(
796    client: reqwest::blocking::Client,
797    request: HttpRequest,
798    control: HttpControl,
799) -> Result<HttpResponse, HttpError> {
800    use crate::async_io::{ChunkChannel, Signal};
801
802    let head = Signal::new();
803    let (chunks, stream) = ChunkChannel::<HttpError>::new();
804    let transfer = Arc::new(NativeTransfer {
805        head: head.clone(),
806        chunks: stream,
807    });
808    control.on_cancel(Arc::<NativeTransfer>::downgrade(&transfer));
809    let url = request.url.clone();
810
811    std::thread::Builder::new()
812        .name("cranpose-http".to_string())
813        .spawn(move || {
814            if let Err(error) = read_native_response(&client, request, &control, &chunks, &head) {
815                head.set(Err(error.clone()));
816                chunks.fail(error);
817            }
818        })
819        .map_err(|error| HttpError::RequestFailed {
820            url: url.clone(),
821            message: format!("could not start the transfer: {error}"),
822        })?;
823
824    let head = transfer
825        .head
826        .wait()
827        .await
828        .ok_or_else(|| HttpError::RequestFailed {
829            url: url.clone(),
830            message: "the transfer ended before a response arrived".to_string(),
831        })??;
832
833    Ok(
834        HttpResponse::new(url, head.status, http_body_ref(ChannelBody { transfer }))
835            .with_headers(head.headers)
836            .with_content_length(head.content_length)
837            .with_resumed(head.resumed),
838    )
839}
840
841#[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
842fn read_native_response(
843    client: &reqwest::blocking::Client,
844    request: HttpRequest,
845    control: &HttpControl,
846    chunks: &crate::async_io::ChunkChannel<HttpError>,
847    head_signal: &crate::async_io::Signal<Result<ResponseHead, HttpError>>,
848) -> Result<(), HttpError> {
849    use std::io::Read;
850
851    if control.is_cancelled() {
852        return Err(HttpError::Cancelled);
853    }
854    let HttpRequest {
855        url,
856        method,
857        headers,
858        body,
859        resume_from,
860    } = request;
861    let method = match method {
862        HttpMethod::Get => reqwest::Method::GET,
863        HttpMethod::Head => reqwest::Method::HEAD,
864        HttpMethod::Post => reqwest::Method::POST,
865        HttpMethod::Put => reqwest::Method::PUT,
866        HttpMethod::Delete => reqwest::Method::DELETE,
867    };
868    let mut builder = client.request(method, &url);
869    for (name, value) in headers {
870        builder = builder.header(name, value);
871    }
872    if let Some(offset) = resume_from {
873        builder = builder.header(reqwest::header::RANGE, format!("bytes={offset}-"));
874    }
875    if let Some(body) = body {
876        builder = builder.body(body);
877    }
878
879    let mut response = builder.send().map_err(|error| HttpError::RequestFailed {
880        url: url.clone(),
881        message: error.to_string(),
882    })?;
883
884    let status = response.status();
885    let resumed = resume_from.is_some() && status == reqwest::StatusCode::PARTIAL_CONTENT;
886    let content_length = response.content_length();
887    let headers = response
888        .headers()
889        .iter()
890        .map(|(name, value)| {
891            (
892                name.as_str().to_ascii_lowercase(),
893                value.to_str().unwrap_or_default().to_string(),
894            )
895        })
896        .collect();
897    head_signal.set(Ok(ResponseHead {
898        status: status.as_u16(),
899        headers,
900        content_length,
901        resumed,
902    }));
903
904    let mut buffer = vec![0u8; CHUNK_LEN];
905    let mut transferred = 0u64;
906    loop {
907        if control.is_cancelled() {
908            return Err(HttpError::Cancelled);
909        }
910        let count = response
911            .read(&mut buffer)
912            .map_err(|error| HttpError::BodyReadFailed {
913                url: url.clone(),
914                message: error.to_string(),
915            })?;
916        if count == 0 {
917            break;
918        }
919        transferred += count as u64;
920        control.report(HttpProgress {
921            transferred,
922            total: content_length,
923        });
924        if !chunks.push(buffer[..count].to_vec()) {
925            break;
926        }
927    }
928    Ok(())
929}
930
931#[cfg(all(not(target_arch = "wasm32"), not(feature = "http-native")))]
932async fn send_native(
933    _request: HttpRequest,
934    _control: HttpControl,
935) -> Result<HttpResponse, HttpError> {
936    Err(HttpError::UnsupportedFeature {
937        operation: "native HTTP requests",
938        feature: "http-native",
939    })
940}
941
942impl HttpClient for DefaultHttpClient {
943    fn send<'a>(
944        &'a self,
945        request: &'a HttpRequest,
946        control: HttpControl,
947    ) -> HttpFuture<'a, HttpResponse> {
948        Box::pin(async move {
949            #[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
950            {
951                let client = self.native_client.as_ref().map_err(Clone::clone)?.clone();
952                send_native(client, request.clone(), control).await
953            }
954            #[cfg(all(not(target_arch = "wasm32"), not(feature = "http-native")))]
955            {
956                send_native(request.clone(), control).await
957            }
958            #[cfg(target_arch = "wasm32")]
959            {
960                send_web(request, control).await
961            }
962        })
963    }
964}
965
966#[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
967fn build_native_client(idle: std::time::Duration) -> Result<reqwest::blocking::Client, HttpError> {
968    configure_native_client_builder(
969        reqwest::blocking::Client::builder()
970            .connect_timeout(std::time::Duration::from_secs(30))
971            .timeout(idle)
972            .user_agent(concat!("cranpose/", env!("CARGO_PKG_VERSION"))),
973    )?
974    .build()
975    .map_err(|err| HttpError::ClientInit(err.to_string()))
976}
977
978#[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
979fn configure_native_client_builder(
980    builder: reqwest::blocking::ClientBuilder,
981) -> Result<reqwest::blocking::ClientBuilder, HttpError> {
982    #[cfg(target_os = "android")]
983    {
984        Ok(builder.tls_certs_only(android_root_certificates()?))
985    }
986
987    #[cfg(not(target_os = "android"))]
988    {
989        Ok(builder)
990    }
991}
992
993#[cfg(all(target_os = "android", feature = "http-native"))]
994fn android_root_certificates() -> Result<Vec<reqwest::Certificate>, HttpError> {
995    certificates_from_der_chain(
996        webpki_root_certs::TLS_SERVER_ROOT_CERTS
997            .iter()
998            .map(AsRef::as_ref),
999    )
1000}
1001
1002#[cfg(any(
1003    all(test, not(target_arch = "wasm32"), feature = "http-native"),
1004    all(target_os = "android", feature = "http-native")
1005))]
1006fn certificates_from_der_chain<'a, I>(
1007    certificates: I,
1008) -> Result<Vec<reqwest::Certificate>, HttpError>
1009where
1010    I: IntoIterator<Item = &'a [u8]>,
1011{
1012    certificates
1013        .into_iter()
1014        .enumerate()
1015        .map(|(index, der)| {
1016            reqwest::Certificate::from_der(der).map_err(|err| {
1017                HttpError::ClientInit(format!(
1018                    "Failed to load TLS root certificate {index}: {err}"
1019                ))
1020            })
1021        })
1022        .collect()
1023}
1024
1025#[cfg(all(target_arch = "wasm32", feature = "web-http"))]
1026struct FetchBody {
1027    url: String,
1028    reader: web_sys::ReadableStreamDefaultReader,
1029    control: HttpControl,
1030    transferred: std::cell::Cell<u64>,
1031    total: Option<u64>,
1032}
1033
1034#[cfg(all(target_arch = "wasm32", feature = "web-http"))]
1035impl HttpBody for FetchBody {
1036    fn read_chunk(&self) -> HttpFuture<'_, Option<Vec<u8>>> {
1037        use wasm_bindgen_futures::JsFuture;
1038
1039        Box::pin(async move {
1040            if self.control.is_cancelled() {
1041                let _ = self.reader.cancel();
1042                return Err(HttpError::Cancelled);
1043            }
1044            let result = JsFuture::from(self.reader.read()).await.map_err(|error| {
1045                HttpError::BodyReadFailed {
1046                    url: self.url.clone(),
1047                    message: format!("{error:?}"),
1048                }
1049            })?;
1050            let done = js_sys::Reflect::get(&result, &wasm_bindgen::JsValue::from_str("done"))
1051                .ok()
1052                .and_then(|value| value.as_bool())
1053                .unwrap_or(true);
1054            if done {
1055                return Ok(None);
1056            }
1057            let value = js_sys::Reflect::get(&result, &wasm_bindgen::JsValue::from_str("value"))
1058                .map_err(|error| HttpError::BodyReadFailed {
1059                    url: self.url.clone(),
1060                    message: format!("{error:?}"),
1061                })?;
1062            let chunk = js_sys::Uint8Array::new(&value).to_vec();
1063            self.transferred
1064                .set(self.transferred.get() + chunk.len() as u64);
1065            self.control.report(HttpProgress {
1066                transferred: self.transferred.get(),
1067                total: self.total,
1068            });
1069            Ok(Some(chunk))
1070        })
1071    }
1072}
1073
1074#[cfg(all(target_arch = "wasm32", feature = "web-http"))]
1075async fn send_web(request: &HttpRequest, control: HttpControl) -> Result<HttpResponse, HttpError> {
1076    use wasm_bindgen::JsCast;
1077    use wasm_bindgen_futures::JsFuture;
1078    use web_sys::{Request, RequestInit, RequestMode, Response};
1079
1080    if control.is_cancelled() {
1081        return Err(HttpError::Cancelled);
1082    }
1083    let options = RequestInit::new();
1084    options.set_method(request.method.name());
1085    options.set_mode(RequestMode::Cors);
1086    if let Some(body) = &request.body {
1087        options.set_body(&js_sys::Uint8Array::from(body.as_slice()).into());
1088    }
1089
1090    let fetch_request =
1091        Request::new_with_str_and_init(&request.url, &options).map_err(|error| {
1092            HttpError::RequestFailed {
1093                url: request.url.clone(),
1094                message: format!("{error:?}"),
1095            }
1096        })?;
1097    let headers = fetch_request.headers();
1098    for (name, value) in &request.headers {
1099        headers
1100            .set(name, value)
1101            .map_err(|error| HttpError::RequestFailed {
1102                url: request.url.clone(),
1103                message: format!("{error:?}"),
1104            })?;
1105    }
1106    if let Some(offset) = request.resume_from {
1107        headers
1108            .set("Range", &format!("bytes={offset}-"))
1109            .map_err(|error| HttpError::RequestFailed {
1110                url: request.url.clone(),
1111                message: format!("{error:?}"),
1112            })?;
1113    }
1114
1115    let window = web_sys::window().ok_or(HttpError::NoWindow)?;
1116    let value = JsFuture::from(window.fetch_with_request(&fetch_request))
1117        .await
1118        .map_err(|error| HttpError::RequestFailed {
1119            url: request.url.clone(),
1120            message: format!("{error:?}"),
1121        })?;
1122    let response: Response = value.dyn_into().map_err(|_| HttpError::InvalidResponse {
1123        url: request.url.clone(),
1124        message: "the browser answered with something that is not a Response".to_string(),
1125    })?;
1126
1127    let status = response.status();
1128    let mut header_pairs = Vec::new();
1129    let entries = js_sys::try_iter(&response.headers()).ok().flatten();
1130    if let Some(entries) = entries {
1131        for entry in entries.flatten() {
1132            let pair = js_sys::Array::from(&entry);
1133            if pair.length() >= 2 {
1134                let name = pair.get(0).as_string().unwrap_or_default();
1135                let value = pair.get(1).as_string().unwrap_or_default();
1136                header_pairs.push((name.to_ascii_lowercase(), value));
1137            }
1138        }
1139    }
1140    let total = header_pairs
1141        .iter()
1142        .find(|(name, _)| name == "content-length")
1143        .and_then(|(_, value)| value.parse::<u64>().ok());
1144    let resumed = request.resume_from.is_some() && status == 206;
1145
1146    let body = response.body().ok_or_else(|| HttpError::InvalidResponse {
1147        url: request.url.clone(),
1148        message: "the response carries no body".to_string(),
1149    })?;
1150    let reader: web_sys::ReadableStreamDefaultReader =
1151        body.get_reader()
1152            .dyn_into()
1153            .map_err(|_| HttpError::InvalidResponse {
1154                url: request.url.clone(),
1155                message: "the response body cannot be read in chunks".to_string(),
1156            })?;
1157
1158    Ok(HttpResponse::new(
1159        request.url.clone(),
1160        status,
1161        http_body_ref(FetchBody {
1162            url: request.url.clone(),
1163            reader,
1164            control,
1165            transferred: std::cell::Cell::new(0),
1166            total,
1167        }),
1168    )
1169    .with_headers(header_pairs)
1170    .with_content_length(total)
1171    .with_resumed(resumed))
1172}
1173
1174#[cfg(all(target_arch = "wasm32", not(feature = "web-http")))]
1175async fn send_web(request: &HttpRequest, _control: HttpControl) -> Result<HttpResponse, HttpError> {
1176    let _ = request;
1177    Err(HttpError::UnsupportedFeature {
1178        operation: "web HTTP requests",
1179        feature: "web-http",
1180    })
1181}
1182
1183/// The platform's HTTP client.
1184///
1185/// A native transfer fails when the response, or the next piece of its body,
1186/// does not arrive within 60 seconds; a body that keeps arriving runs as long
1187/// as it needs. A socket left open by a phone that slept or lost its network
1188/// therefore ends in an error, and [`HttpClient::download_to`] resumes from the
1189/// bytes already on disk at the next attempt.
1190pub fn default_http_client() -> HttpClientRef {
1191    Arc::new(DefaultHttpClient::new())
1192}
1193
1194pub fn local_http_client() -> CompositionLocal<HttpClientRef> {
1195    crate::composition_locals::cached_local(
1196        |locals| &locals.http,
1197        || compositionLocalOfWithPolicy(default_http_client, Arc::ptr_eq),
1198    )
1199}
1200
1201#[cfg(test)]
1202#[path = "tests/http_tests.rs"]
1203mod tests;