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
549#[cfg(not(target_arch = "wasm32"))]
550static PLATFORM_DOWNLOADER: crate::registry::ServiceRegistry<dyn PlatformDownloader> =
551    crate::registry::ServiceRegistry::new();
552
553/// Installs the platform's downloader; [`default_http_client`] then runs
554/// `download_to` through it. iOS installs one on a background `URLSession`.
555// forwards on purpose: the registry is private to this crate, and this is the
556// one way a platform backend installs into it.
557#[cfg(not(target_arch = "wasm32"))]
558pub fn set_platform_downloader(downloader: Arc<dyn PlatformDownloader>) {
559    PLATFORM_DOWNLOADER.set(downloader);
560}
561
562#[cfg(not(target_arch = "wasm32"))]
563async fn download_through<C: HttpClient + ?Sized>(
564    client: &C,
565    url: &str,
566    target: &Path,
567    control: HttpControl,
568) -> Result<u64, HttpError> {
569    use std::io::Write;
570
571    if control.is_cancelled() {
572        return Err(HttpError::Cancelled);
573    }
574    let existing = std::fs::metadata(target).map_or(0, |metadata| metadata.len());
575    let mut request = HttpRequest::get(url);
576    if existing > 0 {
577        request = request.resume_from(existing);
578    }
579    // The request reports nothing itself: its count starts at zero, and this
580    // function reports the whole file's.
581    let response = client
582        .send(&request, control.cancel_only())
583        .await?
584        .error_for_status()?;
585
586    let base = if response.resumed { existing } else { 0 };
587    let total = response.content_length.map(|remaining| base + remaining);
588    let mut output = if response.resumed {
589        std::fs::OpenOptions::new().append(true).open(target)
590    } else {
591        std::fs::File::create(target)
592    }
593    .map_err(|error| file_write_error(target, &error))?;
594
595    let mut transferred = base;
596    control.report(HttpProgress { transferred, total });
597    while let Some(chunk) = response.read_chunk().await? {
598        if control.is_cancelled() {
599            return Err(HttpError::Cancelled);
600        }
601        output
602            .write_all(&chunk)
603            .map_err(|error| file_write_error(target, &error))?;
604        transferred += chunk.len() as u64;
605        control.report(HttpProgress { transferred, total });
606    }
607    output
608        .flush()
609        .map_err(|error| file_write_error(target, &error))?;
610    Ok(transferred)
611}
612
613#[cfg(not(target_arch = "wasm32"))]
614fn file_write_error(target: &Path, error: &std::io::Error) -> HttpError {
615    HttpError::FileWrite {
616        path: target.display().to_string(),
617        kind: error.kind(),
618        message: error.to_string(),
619    }
620}
621
622pub type HttpClientRef = Arc<dyn HttpClient>;
623
624#[cfg(not(target_arch = "wasm32"))]
625pub async fn map_ordered_concurrent<I, T, F, Fut>(
626    items: &[I],
627    concurrency: usize,
628    task: F,
629) -> Result<Vec<T>, HttpError>
630where
631    I: Clone + Send,
632    T: Send,
633    F: Fn(I) -> Fut + Send + Sync + 'static,
634    Fut: Future<Output = T> + Send,
635{
636    let task = Arc::new(task);
637    let mut results = Vec::with_capacity(items.len());
638
639    for chunk in items.chunks(concurrency.max(1)) {
640        let chunk_results = std::thread::scope(|scope| {
641            let mut handles = Vec::with_capacity(chunk.len());
642            for item in chunk.iter().cloned() {
643                let task = Arc::clone(&task);
644                handles.push(scope.spawn(move || pollster::block_on(task(item))));
645            }
646
647            let mut chunk_results = Vec::with_capacity(handles.len());
648            for handle in handles {
649                let value = handle.join().map_err(|_| HttpError::WorkerPanicked {
650                    operation: "ordered concurrent task",
651                })?;
652                chunk_results.push(value);
653            }
654            Ok::<Vec<T>, HttpError>(chunk_results)
655        })?;
656        results.extend(chunk_results);
657    }
658
659    Ok(results)
660}
661
662#[cfg(all(target_arch = "wasm32", feature = "web-http"))]
663pub async fn map_ordered_concurrent<I, T, F, Fut>(
664    items: &[I],
665    concurrency: usize,
666    task: F,
667) -> Result<Vec<T>, HttpError>
668where
669    I: Clone,
670    F: Fn(I) -> Fut + Clone,
671    Fut: Future<Output = T>,
672{
673    let mut results = stream::iter(items.iter().cloned().enumerate().map(|(index, item)| {
674        let task = task.clone();
675        async move { (index, task(item).await) }
676    }))
677    .buffer_unordered(concurrency.max(1))
678    .collect::<Vec<_>>()
679    .await;
680
681    results.sort_by_key(|(index, _)| *index);
682    Ok(results.into_iter().map(|(_, value)| value).collect())
683}
684
685#[cfg(all(target_arch = "wasm32", not(feature = "web-http")))]
686pub async fn map_ordered_concurrent<I, T, F, Fut>(
687    items: &[I],
688    _concurrency: usize,
689    task: F,
690) -> Result<Vec<T>, HttpError>
691where
692    I: Clone,
693    F: Fn(I) -> Fut,
694    Fut: Future<Output = T>,
695{
696    Ok(map_ordered_sequential(items, task).await)
697}
698
699#[cfg(all(target_arch = "wasm32", not(feature = "web-http")))]
700async fn map_ordered_sequential<I, T, F, Fut>(items: &[I], task: F) -> Vec<T>
701where
702    I: Clone,
703    F: Fn(I) -> Fut,
704    Fut: Future<Output = T>,
705{
706    let mut results = Vec::with_capacity(items.len());
707    for item in items.iter().cloned() {
708        results.push(task(item).await);
709    }
710    results
711}
712
713/// What a [`StubHttpClient`] answers a request with.
714pub type StubAnswer = dyn Fn(&HttpRequest) -> Result<HttpResponse, HttpError> + Send + Sync;
715
716/// An HTTP client that answers from a function rather than a network.
717///
718/// A screen driven from a recorded payload, a robot run that must not depend on
719/// a server, a test that wants a specific failure: all of them want one thing,
720/// which is to decide what a request answers. Written as a client each time,
721/// that is the same dozen lines of body plumbing repeated — and each copy is a
722/// chance to answer differently from the real client.
723///
724/// ```ignore
725/// let client: HttpClientRef = Arc::new(StubHttpClient::from_text(move |url| {
726///     Ok(fixture_for(url))
727/// }));
728/// ```
729pub struct StubHttpClient {
730    answer: Box<StubAnswer>,
731}
732
733impl StubHttpClient {
734    /// Answers from a function of the whole request.
735    pub fn new(
736        answer: impl Fn(&HttpRequest) -> Result<HttpResponse, HttpError> + Send + Sync + 'static,
737    ) -> Self {
738        Self {
739            answer: Box::new(answer),
740        }
741    }
742
743    /// Answers every request with `body` and a `200`.
744    pub fn with_body(body: impl Into<Vec<u8>>) -> Self {
745        let body = body.into();
746        Self::new(move |request| {
747            Ok(HttpResponse::new(
748                request.url.clone(),
749                200,
750                http_body_ref(BytesBody::new(body.clone())),
751            ))
752        })
753    }
754
755    /// Answers with text, or an error, from a function of the URL.
756    pub fn from_text(
757        answer: impl Fn(&str) -> Result<String, HttpError> + Send + Sync + 'static,
758    ) -> Self {
759        Self::new(move |request| {
760            let body = answer(&request.url)?;
761            Ok(HttpResponse::new(
762                request.url.clone(),
763                200,
764                http_body_ref(BytesBody::new(body)),
765            ))
766        })
767    }
768}
769
770impl HttpClient for StubHttpClient {
771    fn send<'a>(
772        &'a self,
773        request: &'a HttpRequest,
774        _control: HttpControl,
775    ) -> HttpFuture<'a, HttpResponse> {
776        Box::pin(async move { (self.answer)(request) })
777    }
778}
779
780#[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
781const CHUNK_LEN: usize = 64 * 1024;
782
783#[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
784const IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
785
786struct DefaultHttpClient {
787    #[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
788    native_client: Result<reqwest::blocking::Client, HttpError>,
789}
790
791impl DefaultHttpClient {
792    fn new() -> Self {
793        Self {
794            #[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
795            native_client: build_native_client(IDLE_TIMEOUT),
796        }
797    }
798
799    #[cfg(all(test, not(target_arch = "wasm32"), feature = "http-native"))]
800    fn with_idle_timeout(idle: std::time::Duration) -> Self {
801        Self {
802            native_client: build_native_client(idle),
803        }
804    }
805}
806
807#[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
808struct ResponseHead {
809    status: u16,
810    headers: Vec<(String, String)>,
811    content_length: Option<u64>,
812    resumed: bool,
813}
814
815#[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
816struct NativeTransfer {
817    head: crate::async_io::Signal<Result<ResponseHead, HttpError>>,
818    chunks: crate::async_io::ChunkStream<HttpError>,
819}
820
821#[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
822impl CancelTransfer for NativeTransfer {
823    fn cancel(&self) {
824        self.head.set(Err(HttpError::Cancelled));
825        self.chunks.fail(HttpError::Cancelled);
826    }
827}
828
829#[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
830struct ChannelBody {
831    transfer: Arc<NativeTransfer>,
832}
833
834#[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
835impl HttpBody for ChannelBody {
836    fn read_chunk(&self) -> HttpFuture<'_, Option<Vec<u8>>> {
837        Box::pin(self.transfer.chunks.next())
838    }
839}
840
841#[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
842async fn send_native(
843    client: reqwest::blocking::Client,
844    request: HttpRequest,
845    control: HttpControl,
846) -> Result<HttpResponse, HttpError> {
847    use crate::async_io::{ChunkChannel, Signal};
848
849    let head = Signal::new();
850    let (chunks, stream) = ChunkChannel::<HttpError>::new();
851    let transfer = Arc::new(NativeTransfer {
852        head: head.clone(),
853        chunks: stream,
854    });
855    control.on_cancel(Arc::<NativeTransfer>::downgrade(&transfer));
856    let url = request.url.clone();
857
858    std::thread::Builder::new()
859        .name("cranpose-http".to_string())
860        .spawn(move || {
861            if let Err(error) = read_native_response(&client, request, &control, &chunks, &head) {
862                head.set(Err(error.clone()));
863                chunks.fail(error);
864            }
865        })
866        .map_err(|error| HttpError::RequestFailed {
867            url: url.clone(),
868            message: format!("could not start the transfer: {error}"),
869        })?;
870
871    let head = transfer
872        .head
873        .wait()
874        .await
875        .ok_or_else(|| HttpError::RequestFailed {
876            url: url.clone(),
877            message: "the transfer ended before a response arrived".to_string(),
878        })??;
879
880    Ok(
881        HttpResponse::new(url, head.status, http_body_ref(ChannelBody { transfer }))
882            .with_headers(head.headers)
883            .with_content_length(head.content_length)
884            .with_resumed(head.resumed),
885    )
886}
887
888#[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
889fn read_native_response(
890    client: &reqwest::blocking::Client,
891    request: HttpRequest,
892    control: &HttpControl,
893    chunks: &crate::async_io::ChunkChannel<HttpError>,
894    head_signal: &crate::async_io::Signal<Result<ResponseHead, HttpError>>,
895) -> Result<(), HttpError> {
896    use std::io::Read;
897
898    if control.is_cancelled() {
899        return Err(HttpError::Cancelled);
900    }
901    let HttpRequest {
902        url,
903        method,
904        headers,
905        body,
906        resume_from,
907    } = request;
908    let method = match method {
909        HttpMethod::Get => reqwest::Method::GET,
910        HttpMethod::Head => reqwest::Method::HEAD,
911        HttpMethod::Post => reqwest::Method::POST,
912        HttpMethod::Put => reqwest::Method::PUT,
913        HttpMethod::Delete => reqwest::Method::DELETE,
914    };
915    let mut builder = client.request(method, &url);
916    for (name, value) in headers {
917        builder = builder.header(name, value);
918    }
919    if let Some(offset) = resume_from {
920        builder = builder.header(reqwest::header::RANGE, format!("bytes={offset}-"));
921    }
922    if let Some(body) = body {
923        builder = builder.body(body);
924    }
925
926    let mut response = builder.send().map_err(|error| HttpError::RequestFailed {
927        url: url.clone(),
928        message: error.to_string(),
929    })?;
930
931    let status = response.status();
932    let resumed = resume_from.is_some() && status == reqwest::StatusCode::PARTIAL_CONTENT;
933    let content_length = response.content_length();
934    let headers = response
935        .headers()
936        .iter()
937        .map(|(name, value)| {
938            (
939                name.as_str().to_ascii_lowercase(),
940                value.to_str().unwrap_or_default().to_string(),
941            )
942        })
943        .collect();
944    head_signal.set(Ok(ResponseHead {
945        status: status.as_u16(),
946        headers,
947        content_length,
948        resumed,
949    }));
950
951    let mut buffer = vec![0u8; CHUNK_LEN];
952    let mut transferred = 0u64;
953    loop {
954        if control.is_cancelled() {
955            return Err(HttpError::Cancelled);
956        }
957        let count = response
958            .read(&mut buffer)
959            .map_err(|error| HttpError::BodyReadFailed {
960                url: url.clone(),
961                message: error.to_string(),
962            })?;
963        if count == 0 {
964            break;
965        }
966        transferred += count as u64;
967        control.report(HttpProgress {
968            transferred,
969            total: content_length,
970        });
971        if !chunks.push(buffer[..count].to_vec()) {
972            break;
973        }
974    }
975    Ok(())
976}
977
978#[cfg(all(not(target_arch = "wasm32"), not(feature = "http-native")))]
979async fn send_native(
980    _request: HttpRequest,
981    _control: HttpControl,
982) -> Result<HttpResponse, HttpError> {
983    Err(HttpError::UnsupportedFeature {
984        operation: "native HTTP requests",
985        feature: "http-native",
986    })
987}
988
989impl HttpClient for DefaultHttpClient {
990    fn send<'a>(
991        &'a self,
992        request: &'a HttpRequest,
993        control: HttpControl,
994    ) -> HttpFuture<'a, HttpResponse> {
995        Box::pin(async move {
996            #[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
997            {
998                let client = self.native_client.as_ref().map_err(Clone::clone)?.clone();
999                send_native(client, request.clone(), control).await
1000            }
1001            #[cfg(all(not(target_arch = "wasm32"), not(feature = "http-native")))]
1002            {
1003                send_native(request.clone(), control).await
1004            }
1005            #[cfg(target_arch = "wasm32")]
1006            {
1007                send_web(request, control).await
1008            }
1009        })
1010    }
1011
1012    #[cfg(not(target_arch = "wasm32"))]
1013    fn download_to<'a>(
1014        &'a self,
1015        url: &'a str,
1016        target: &'a Path,
1017        control: HttpControl,
1018    ) -> HttpFuture<'a, u64> {
1019        Box::pin(async move {
1020            match PLATFORM_DOWNLOADER.get() {
1021                Some(downloader) => downloader.download(url, target, control).await,
1022                None => download_through(self, url, target, control).await,
1023            }
1024        })
1025    }
1026}
1027
1028#[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
1029fn build_native_client(idle: std::time::Duration) -> Result<reqwest::blocking::Client, HttpError> {
1030    configure_native_client_builder(
1031        reqwest::blocking::Client::builder()
1032            .connect_timeout(std::time::Duration::from_secs(30))
1033            .timeout(idle)
1034            .user_agent(concat!("cranpose/", env!("CARGO_PKG_VERSION"))),
1035    )?
1036    .build()
1037    .map_err(|err| HttpError::ClientInit(err.to_string()))
1038}
1039
1040#[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
1041fn configure_native_client_builder(
1042    builder: reqwest::blocking::ClientBuilder,
1043) -> Result<reqwest::blocking::ClientBuilder, HttpError> {
1044    #[cfg(target_os = "android")]
1045    {
1046        Ok(builder.tls_certs_only(android_root_certificates()?))
1047    }
1048
1049    #[cfg(not(target_os = "android"))]
1050    {
1051        Ok(builder)
1052    }
1053}
1054
1055#[cfg(all(target_os = "android", feature = "http-native"))]
1056fn android_root_certificates() -> Result<Vec<reqwest::Certificate>, HttpError> {
1057    certificates_from_der_chain(
1058        webpki_root_certs::TLS_SERVER_ROOT_CERTS
1059            .iter()
1060            .map(AsRef::as_ref),
1061    )
1062}
1063
1064#[cfg(any(
1065    all(test, not(target_arch = "wasm32"), feature = "http-native"),
1066    all(target_os = "android", feature = "http-native")
1067))]
1068fn certificates_from_der_chain<'a, I>(
1069    certificates: I,
1070) -> Result<Vec<reqwest::Certificate>, HttpError>
1071where
1072    I: IntoIterator<Item = &'a [u8]>,
1073{
1074    certificates
1075        .into_iter()
1076        .enumerate()
1077        .map(|(index, der)| {
1078            reqwest::Certificate::from_der(der).map_err(|err| {
1079                HttpError::ClientInit(format!(
1080                    "Failed to load TLS root certificate {index}: {err}"
1081                ))
1082            })
1083        })
1084        .collect()
1085}
1086
1087#[cfg(all(target_arch = "wasm32", feature = "web-http"))]
1088struct FetchBody {
1089    url: String,
1090    reader: web_sys::ReadableStreamDefaultReader,
1091    control: HttpControl,
1092    transferred: std::cell::Cell<u64>,
1093    total: Option<u64>,
1094}
1095
1096#[cfg(all(target_arch = "wasm32", feature = "web-http"))]
1097impl HttpBody for FetchBody {
1098    fn read_chunk(&self) -> HttpFuture<'_, Option<Vec<u8>>> {
1099        use wasm_bindgen_futures::JsFuture;
1100
1101        Box::pin(async move {
1102            if self.control.is_cancelled() {
1103                let _ = self.reader.cancel();
1104                return Err(HttpError::Cancelled);
1105            }
1106            let result = JsFuture::from(self.reader.read()).await.map_err(|error| {
1107                HttpError::BodyReadFailed {
1108                    url: self.url.clone(),
1109                    message: format!("{error:?}"),
1110                }
1111            })?;
1112            let done = js_sys::Reflect::get(&result, &wasm_bindgen::JsValue::from_str("done"))
1113                .ok()
1114                .and_then(|value| value.as_bool())
1115                .unwrap_or(true);
1116            if done {
1117                return Ok(None);
1118            }
1119            let value = js_sys::Reflect::get(&result, &wasm_bindgen::JsValue::from_str("value"))
1120                .map_err(|error| HttpError::BodyReadFailed {
1121                    url: self.url.clone(),
1122                    message: format!("{error:?}"),
1123                })?;
1124            let chunk = js_sys::Uint8Array::new(&value).to_vec();
1125            self.transferred
1126                .set(self.transferred.get() + chunk.len() as u64);
1127            self.control.report(HttpProgress {
1128                transferred: self.transferred.get(),
1129                total: self.total,
1130            });
1131            Ok(Some(chunk))
1132        })
1133    }
1134}
1135
1136#[cfg(all(target_arch = "wasm32", feature = "web-http"))]
1137async fn send_web(request: &HttpRequest, control: HttpControl) -> Result<HttpResponse, HttpError> {
1138    use wasm_bindgen::JsCast;
1139    use wasm_bindgen_futures::JsFuture;
1140    use web_sys::{Request, RequestInit, RequestMode, Response};
1141
1142    if control.is_cancelled() {
1143        return Err(HttpError::Cancelled);
1144    }
1145    let options = RequestInit::new();
1146    options.set_method(request.method.name());
1147    options.set_mode(RequestMode::Cors);
1148    if let Some(body) = &request.body {
1149        options.set_body(&js_sys::Uint8Array::from(body.as_slice()).into());
1150    }
1151
1152    let fetch_request =
1153        Request::new_with_str_and_init(&request.url, &options).map_err(|error| {
1154            HttpError::RequestFailed {
1155                url: request.url.clone(),
1156                message: format!("{error:?}"),
1157            }
1158        })?;
1159    let headers = fetch_request.headers();
1160    for (name, value) in &request.headers {
1161        headers
1162            .set(name, value)
1163            .map_err(|error| HttpError::RequestFailed {
1164                url: request.url.clone(),
1165                message: format!("{error:?}"),
1166            })?;
1167    }
1168    if let Some(offset) = request.resume_from {
1169        headers
1170            .set("Range", &format!("bytes={offset}-"))
1171            .map_err(|error| HttpError::RequestFailed {
1172                url: request.url.clone(),
1173                message: format!("{error:?}"),
1174            })?;
1175    }
1176
1177    let window = web_sys::window().ok_or(HttpError::NoWindow)?;
1178    let value = JsFuture::from(window.fetch_with_request(&fetch_request))
1179        .await
1180        .map_err(|error| HttpError::RequestFailed {
1181            url: request.url.clone(),
1182            message: format!("{error:?}"),
1183        })?;
1184    let response: Response = value.dyn_into().map_err(|_| HttpError::InvalidResponse {
1185        url: request.url.clone(),
1186        message: "the browser answered with something that is not a Response".to_string(),
1187    })?;
1188
1189    let status = response.status();
1190    let mut header_pairs = Vec::new();
1191    let entries = js_sys::try_iter(&response.headers()).ok().flatten();
1192    if let Some(entries) = entries {
1193        for entry in entries.flatten() {
1194            let pair = js_sys::Array::from(&entry);
1195            if pair.length() >= 2 {
1196                let name = pair.get(0).as_string().unwrap_or_default();
1197                let value = pair.get(1).as_string().unwrap_or_default();
1198                header_pairs.push((name.to_ascii_lowercase(), value));
1199            }
1200        }
1201    }
1202    let total = header_pairs
1203        .iter()
1204        .find(|(name, _)| name == "content-length")
1205        .and_then(|(_, value)| value.parse::<u64>().ok());
1206    let resumed = request.resume_from.is_some() && status == 206;
1207
1208    let body = response.body().ok_or_else(|| HttpError::InvalidResponse {
1209        url: request.url.clone(),
1210        message: "the response carries no body".to_string(),
1211    })?;
1212    let reader: web_sys::ReadableStreamDefaultReader =
1213        body.get_reader()
1214            .dyn_into()
1215            .map_err(|_| HttpError::InvalidResponse {
1216                url: request.url.clone(),
1217                message: "the response body cannot be read in chunks".to_string(),
1218            })?;
1219
1220    Ok(HttpResponse::new(
1221        request.url.clone(),
1222        status,
1223        http_body_ref(FetchBody {
1224            url: request.url.clone(),
1225            reader,
1226            control,
1227            transferred: std::cell::Cell::new(0),
1228            total,
1229        }),
1230    )
1231    .with_headers(header_pairs)
1232    .with_content_length(total)
1233    .with_resumed(resumed))
1234}
1235
1236#[cfg(all(target_arch = "wasm32", not(feature = "web-http")))]
1237async fn send_web(request: &HttpRequest, _control: HttpControl) -> Result<HttpResponse, HttpError> {
1238    let _ = request;
1239    Err(HttpError::UnsupportedFeature {
1240        operation: "web HTTP requests",
1241        feature: "web-http",
1242    })
1243}
1244
1245/// The platform's HTTP client.
1246///
1247/// A native transfer fails when the response, or the next piece of its body,
1248/// does not arrive within 60 seconds; a body that keeps arriving runs as long
1249/// as it needs. A socket left open by a phone that slept or lost its network
1250/// therefore ends in an error, and [`HttpClient::download_to`] resumes from the
1251/// bytes already on disk at the next attempt.
1252pub fn default_http_client() -> HttpClientRef {
1253    Arc::new(DefaultHttpClient::new())
1254}
1255
1256pub fn local_http_client() -> CompositionLocal<HttpClientRef> {
1257    crate::composition_locals::cached_local(
1258        |locals| &locals.http,
1259        || compositionLocalOfWithPolicy(default_http_client, Arc::ptr_eq),
1260    )
1261}
1262
1263#[cfg(test)]
1264#[path = "tests/http_tests.rs"]
1265mod tests;