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