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