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,
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 { path: String, message: String },
41}
42
43/// How much of a transfer has arrived.
44#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
45pub struct HttpProgress {
46    /// Bytes transferred so far, counting anything a resumed transfer skipped.
47    pub transferred: u64,
48    /// The whole size, when the server said. A chunked response does not, and
49    /// a progress bar that invents a total for one is lying about it.
50    pub total: Option<u64>,
51}
52
53impl HttpProgress {
54    /// How far through the transfer is, in `0..=1`, or `None` when the server
55    /// never said how large it is.
56    pub fn fraction(&self) -> Option<f32> {
57        let total = self.total?;
58        if total == 0 {
59            return None;
60        }
61        Some((self.transferred as f32 / total as f32).clamp(0.0, 1.0))
62    }
63
64    /// Whether everything the server promised has arrived.
65    pub fn is_complete(&self) -> bool {
66        self.total.is_some_and(|total| self.transferred >= total)
67    }
68}
69
70/// What a progress report is handed to.
71pub type ProgressHandler = Arc<dyn Fn(HttpProgress) + Send + Sync>;
72
73/// Cancellation and progress shared with an in-flight request.
74///
75/// One control covers every request rather than only file downloads: a slow
76/// response is a slow response whether it is being written to disk or read into
77/// memory, and a screen that leaves is a screen that leaves.
78#[derive(Clone, Default)]
79pub struct HttpControl {
80    cancelled: Arc<AtomicBool>,
81    progress: Option<ProgressHandler>,
82}
83
84impl HttpControl {
85    /// A live control with no progress reporting.
86    pub fn new() -> Self {
87        Self::default()
88    }
89
90    /// Reports transferred and, where the server says so, total bytes.
91    ///
92    /// Called from whichever thread is doing the transfer, so the handler must
93    /// be prepared for that — the framework's own event stream is the ordinary
94    /// way to get it back to composition.
95    pub fn with_progress(
96        mut self,
97        progress: impl Fn(HttpProgress) + Send + Sync + 'static,
98    ) -> Self {
99        self.progress = Some(Arc::new(progress));
100        self
101    }
102
103    /// Requests cancellation.
104    ///
105    /// The transfer stops at its next chunk boundary. A partial file left by a
106    /// cancelled download stays on disk, which is what makes the next attempt
107    /// able to resume rather than start again.
108    pub fn cancel(&self) {
109        self.cancelled.store(true, Ordering::Release);
110    }
111
112    /// Whether cancellation was requested.
113    pub fn is_cancelled(&self) -> bool {
114        self.cancelled.load(Ordering::Acquire)
115    }
116
117    /// Hands a progress reading to whatever [`with_progress`](Self::with_progress)
118    /// registered, and does nothing where nothing did.
119    ///
120    /// Called by the backend doing the transfer - including an
121    /// [`HttpClient`] an application provides through [`local_http_client`],
122    /// which is why this is part of the control's public surface rather than
123    /// private to the backends shipped here.
124    pub fn report(&self, progress: HttpProgress) {
125        if let Some(handler) = &self.progress {
126            handler(progress);
127        }
128    }
129}
130
131/// The verbs a request can use.
132#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
133pub enum HttpMethod {
134    #[default]
135    Get,
136    Head,
137    Post,
138    Put,
139    Delete,
140}
141
142impl HttpMethod {
143    /// The name that goes on the wire.
144    pub fn name(self) -> &'static str {
145        match self {
146            HttpMethod::Get => "GET",
147            HttpMethod::Head => "HEAD",
148            HttpMethod::Post => "POST",
149            HttpMethod::Put => "PUT",
150            HttpMethod::Delete => "DELETE",
151        }
152    }
153}
154
155/// One request.
156#[derive(Clone, Debug, Default, PartialEq, Eq)]
157pub struct HttpRequest {
158    pub url: String,
159    pub method: HttpMethod,
160    pub headers: Vec<(String, String)>,
161    pub body: Option<Vec<u8>>,
162    /// Resume an interrupted transfer from this byte offset.
163    ///
164    /// Sent as a `Range` header. A server free to ignore it answers `200` with
165    /// the whole body instead of `206` with the rest, which
166    /// [`HttpResponse::resumed`] reports — a caller that appends to a partial
167    /// file must check it, or it writes the start of the file over the middle.
168    pub resume_from: Option<u64>,
169}
170
171impl HttpRequest {
172    /// A `GET` for `url`.
173    pub fn get(url: impl Into<String>) -> Self {
174        Self {
175            url: url.into(),
176            ..Self::default()
177        }
178    }
179
180    pub fn method(mut self, method: HttpMethod) -> Self {
181        self.method = method;
182        self
183    }
184
185    pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
186        self.headers.push((name.into(), value.into()));
187        self
188    }
189
190    pub fn body(mut self, body: impl Into<Vec<u8>>) -> Self {
191        self.body = Some(body.into());
192        self
193    }
194
195    /// Resumes an interrupted transfer from `offset`.
196    pub fn resume_from(mut self, offset: u64) -> Self {
197        self.resume_from = (offset > 0).then_some(offset);
198        self
199    }
200}
201
202#[cfg(not(target_arch = "wasm32"))]
203pub type HttpFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, HttpError>> + Send + 'a>>;
204
205#[cfg(target_arch = "wasm32")]
206pub type HttpFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, HttpError>> + 'a>>;
207
208/// A response body, read a chunk at a time.
209///
210/// A body is not a `Vec<u8>`: a model pack, a video, an application package are
211/// all larger than the memory a phone will hand over, and the only way to read
212/// one is to not hold it. Everything that wants the whole thing anyway asks for
213/// it explicitly through [`HttpResponse::read_all`].
214pub trait HttpBody {
215    /// The next chunk, or `None` at the end of the body.
216    fn read_chunk(&self) -> HttpFuture<'_, Option<Vec<u8>>>;
217}
218
219/// Shared handle to a response body.
220///
221/// Bounded exactly as [`HttpFuture`] is: a native transfer runs on a thread of
222/// its own and its body crosses to whoever awaits it, while a browser's body is
223/// a `ReadableStream` reader that belongs to the one thread a page has and
224/// cannot claim otherwise.
225#[cfg(not(target_arch = "wasm32"))]
226pub type HttpBodyRef = Arc<dyn HttpBody + Send + Sync>;
227
228/// Shared handle to a response body.
229///
230/// A browser's body is a `ReadableStream` reader that belongs to the one
231/// thread a page has and cannot claim otherwise, so it is never `Send + Sync`
232/// there; `Rc` avoids paying for synchronisation the target cannot use.
233#[cfg(target_arch = "wasm32")]
234pub type HttpBodyRef = std::rc::Rc<dyn HttpBody>;
235
236/// Wraps `body` in an [`HttpBodyRef`].
237///
238/// A trait-object alias cannot expose its own `new`, so this is the one place
239/// that picks `Arc` on native and `Rc` on wasm; callers that need an
240/// [`HttpBodyRef`] from a concrete body go through this instead of repeating
241/// that choice.
242#[cfg(not(target_arch = "wasm32"))]
243pub fn http_body_ref<B: HttpBody + Send + Sync + 'static>(body: B) -> HttpBodyRef {
244    Arc::new(body)
245}
246
247/// See the native definition of [`http_body_ref`] for why this is `Rc` on wasm.
248#[cfg(target_arch = "wasm32")]
249pub fn http_body_ref<B: HttpBody + 'static>(body: B) -> HttpBodyRef {
250    std::rc::Rc::new(body)
251}
252
253struct EmptyBody;
254
255impl HttpBody for EmptyBody {
256    fn read_chunk(&self) -> HttpFuture<'_, Option<Vec<u8>>> {
257        Box::pin(async { Ok(None) })
258    }
259}
260
261/// A body already in memory, handed out in `CHUNK_LEN`-sized pieces.
262///
263/// For a backend that has the bytes already — a cache, a bundled asset, a test
264/// double — so it satisfies the same chunked contract as one still on the wire
265/// and its callers cannot tell the difference.
266pub struct BytesBody {
267    bytes: Vec<u8>,
268    offset: std::sync::Mutex<usize>,
269    chunk: usize,
270}
271
272impl BytesBody {
273    /// A body over `bytes`, delivered in one chunk per read.
274    pub fn new(bytes: impl Into<Vec<u8>>) -> Self {
275        Self::chunked(bytes, usize::MAX)
276    }
277
278    /// A body over `bytes`, delivered at most `chunk` bytes at a time.
279    pub fn chunked(bytes: impl Into<Vec<u8>>, chunk: usize) -> Self {
280        Self {
281            bytes: bytes.into(),
282            offset: std::sync::Mutex::new(0),
283            chunk: chunk.max(1),
284        }
285    }
286
287    /// How long the body is.
288    pub fn len(&self) -> u64 {
289        self.bytes.len() as u64
290    }
291
292    /// Whether the body carries nothing.
293    pub fn is_empty(&self) -> bool {
294        self.bytes.is_empty()
295    }
296}
297
298impl HttpBody for BytesBody {
299    fn read_chunk(&self) -> HttpFuture<'_, Option<Vec<u8>>> {
300        Box::pin(async move {
301            let mut offset = self
302                .offset
303                .lock()
304                .unwrap_or_else(|error| error.into_inner());
305            if *offset >= self.bytes.len() {
306                return Ok(None);
307            }
308            let end = offset.saturating_add(self.chunk).min(self.bytes.len());
309            let chunk = self.bytes[*offset..end].to_vec();
310            *offset = end;
311            Ok(Some(chunk))
312        })
313    }
314}
315
316/// What a server answered, with its body still to be read.
317pub struct HttpResponse {
318    /// The status line's code.
319    pub status: u16,
320    /// The response headers, with their names lower-cased.
321    pub headers: Vec<(String, String)>,
322    /// How long the body is, when the server said.
323    pub content_length: Option<u64>,
324    /// Whether the server honoured [`HttpRequest::resume_from`].
325    ///
326    /// `false` from a resumed request means the body starts at the beginning
327    /// again, and a caller appending to a partial file must truncate it first.
328    pub resumed: bool,
329    /// The URL the request went to, for the errors reading the body reports.
330    pub url: String,
331    body: HttpBodyRef,
332}
333
334impl HttpResponse {
335    /// Builds a response around a body.
336    pub fn new(url: impl Into<String>, status: u16, body: HttpBodyRef) -> Self {
337        Self {
338            status,
339            headers: Vec::new(),
340            content_length: None,
341            resumed: false,
342            url: url.into(),
343            body,
344        }
345    }
346
347    /// A response with no body, which is what a `HEAD` answers with.
348    pub fn empty(url: impl Into<String>, status: u16) -> Self {
349        Self::new(url, status, http_body_ref(EmptyBody))
350    }
351
352    pub fn with_headers(mut self, headers: Vec<(String, String)>) -> Self {
353        self.headers = headers;
354        self
355    }
356
357    pub fn with_content_length(mut self, content_length: Option<u64>) -> Self {
358        self.content_length = content_length;
359        self
360    }
361
362    pub fn with_resumed(mut self, resumed: bool) -> Self {
363        self.resumed = resumed;
364        self
365    }
366
367    /// Whether the status is in the 2xx range.
368    pub fn is_success(&self) -> bool {
369        (200..300).contains(&self.status)
370    }
371
372    /// One header, matched without regard to case.
373    pub fn header(&self, name: &str) -> Option<&str> {
374        let name = name.to_ascii_lowercase();
375        self.headers
376            .iter()
377            .find(|(key, _)| key.eq_ignore_ascii_case(&name))
378            .map(|(_, value)| value.as_str())
379    }
380
381    /// Fails with [`HttpError::HttpStatus`] unless the status is a success.
382    pub fn error_for_status(self) -> Result<Self, HttpError> {
383        if self.is_success() {
384            Ok(self)
385        } else {
386            Err(HttpError::HttpStatus {
387                url: self.url.clone(),
388                status: self.status,
389            })
390        }
391    }
392
393    /// The next chunk of the body, or `None` at its end.
394    pub async fn read_chunk(&self) -> Result<Option<Vec<u8>>, HttpError> {
395        self.body.read_chunk().await
396    }
397
398    /// The whole body.
399    ///
400    /// Named for what it does, so a caller reading something that might not fit
401    /// in memory can see that it is asking for exactly that.
402    pub async fn read_all(&self) -> Result<Vec<u8>, HttpError> {
403        let mut out = Vec::with_capacity(self.content_length.unwrap_or(0).min(1 << 20) as usize);
404        while let Some(chunk) = self.read_chunk().await? {
405            out.extend_from_slice(&chunk);
406        }
407        Ok(out)
408    }
409
410    /// The whole body as text.
411    pub async fn read_text(&self) -> Result<String, HttpError> {
412        String::from_utf8(self.read_all().await?).map_err(|error| HttpError::InvalidResponse {
413            url: self.url.clone(),
414            message: error.to_string(),
415        })
416    }
417}
418
419/// A platform's HTTP.
420///
421/// One method carries the whole contract: everything else — text, bytes, a file
422/// on disk — is that method plus what the caller does with the body, so a
423/// backend implements the transfer once and cannot make `get_text` behave
424/// differently from `download_to`.
425pub trait HttpClient: Send + Sync {
426    /// Sends `request`, resolving when the response's status line has arrived
427    /// and its body is ready to be read.
428    fn send<'a>(
429        &'a self,
430        request: &'a HttpRequest,
431        control: HttpControl,
432    ) -> HttpFuture<'a, HttpResponse>;
433
434    /// The body of a successful `GET`, as text.
435    fn get_text<'a>(&'a self, url: &'a str) -> HttpFuture<'a, String> {
436        Box::pin(async move {
437            self.send(&HttpRequest::get(url), HttpControl::new())
438                .await?
439                .error_for_status()?
440                .read_text()
441                .await
442        })
443    }
444
445    /// The body of a successful `GET`.
446    fn get_bytes<'a>(&'a self, url: &'a str) -> HttpFuture<'a, Vec<u8>> {
447        Box::pin(async move {
448            self.send(&HttpRequest::get(url), HttpControl::new())
449                .await?
450                .error_for_status()?
451                .read_all()
452                .await
453        })
454    }
455
456    /// Streams a response into `target`, resuming from its current length when
457    /// the server supports byte ranges, and returns the file's whole length.
458    ///
459    /// This is the appropriate call for model packs and other files that should
460    /// not be held in memory. A cancelled transfer leaves the partial file
461    /// where it is, which is what lets the next attempt continue rather than
462    /// start again.
463    #[cfg(not(target_arch = "wasm32"))]
464    fn download_to<'a>(
465        &'a self,
466        url: &'a str,
467        target: &'a Path,
468        control: HttpControl,
469    ) -> HttpFuture<'a, u64> {
470        Box::pin(async move { download_through(self, url, target, control).await })
471    }
472}
473
474#[cfg(not(target_arch = "wasm32"))]
475async fn download_through<C: HttpClient + ?Sized>(
476    client: &C,
477    url: &str,
478    target: &Path,
479    control: HttpControl,
480) -> Result<u64, HttpError> {
481    use std::io::Write;
482
483    if control.is_cancelled() {
484        return Err(HttpError::Cancelled);
485    }
486    let existing = std::fs::metadata(target)
487        .map(|metadata| metadata.len())
488        .unwrap_or(0);
489    let mut request = HttpRequest::get(url);
490    if existing > 0 {
491        request = request.resume_from(existing);
492    }
493    let response = client
494        .send(&request, control.clone())
495        .await?
496        .error_for_status()?;
497
498    let base = if response.resumed { existing } else { 0 };
499    let total = response.content_length.map(|remaining| base + remaining);
500    let mut output = if response.resumed {
501        std::fs::OpenOptions::new().append(true).open(target)
502    } else {
503        std::fs::File::create(target)
504    }
505    .map_err(|error| HttpError::FileWrite {
506        path: target.display().to_string(),
507        message: error.to_string(),
508    })?;
509
510    let mut transferred = base;
511    control.report(HttpProgress { transferred, total });
512    while let Some(chunk) = response.read_chunk().await? {
513        if control.is_cancelled() {
514            return Err(HttpError::Cancelled);
515        }
516        output
517            .write_all(&chunk)
518            .map_err(|error| HttpError::FileWrite {
519                path: target.display().to_string(),
520                message: error.to_string(),
521            })?;
522        transferred += chunk.len() as u64;
523        control.report(HttpProgress { transferred, total });
524    }
525    output.flush().map_err(|error| HttpError::FileWrite {
526        path: target.display().to_string(),
527        message: error.to_string(),
528    })?;
529    Ok(transferred)
530}
531
532pub type HttpClientRef = Arc<dyn HttpClient>;
533
534#[cfg(not(target_arch = "wasm32"))]
535pub async fn map_ordered_concurrent<I, T, F, Fut>(
536    items: &[I],
537    concurrency: usize,
538    task: F,
539) -> Result<Vec<T>, HttpError>
540where
541    I: Clone + Send,
542    T: Send,
543    F: Fn(I) -> Fut + Send + Sync + 'static,
544    Fut: Future<Output = T> + Send,
545{
546    let task = Arc::new(task);
547    let mut results = Vec::with_capacity(items.len());
548
549    for chunk in items.chunks(concurrency.max(1)) {
550        let chunk_results = std::thread::scope(|scope| {
551            let mut handles = Vec::with_capacity(chunk.len());
552            for item in chunk.iter().cloned() {
553                let task = Arc::clone(&task);
554                handles.push(scope.spawn(move || pollster::block_on(task(item))));
555            }
556
557            let mut chunk_results = Vec::with_capacity(handles.len());
558            for handle in handles {
559                let value = handle.join().map_err(|_| HttpError::WorkerPanicked {
560                    operation: "ordered concurrent task",
561                })?;
562                chunk_results.push(value);
563            }
564            Ok::<Vec<T>, HttpError>(chunk_results)
565        })?;
566        results.extend(chunk_results);
567    }
568
569    Ok(results)
570}
571
572#[cfg(all(target_arch = "wasm32", feature = "web-http"))]
573pub async fn map_ordered_concurrent<I, T, F, Fut>(
574    items: &[I],
575    concurrency: usize,
576    task: F,
577) -> Result<Vec<T>, HttpError>
578where
579    I: Clone,
580    F: Fn(I) -> Fut + Clone,
581    Fut: Future<Output = T>,
582{
583    let mut results = stream::iter(items.iter().cloned().enumerate().map(|(index, item)| {
584        let task = task.clone();
585        async move { (index, task(item).await) }
586    }))
587    .buffer_unordered(concurrency.max(1))
588    .collect::<Vec<_>>()
589    .await;
590
591    results.sort_by_key(|(index, _)| *index);
592    Ok(results.into_iter().map(|(_, value)| value).collect())
593}
594
595#[cfg(all(target_arch = "wasm32", not(feature = "web-http")))]
596pub async fn map_ordered_concurrent<I, T, F, Fut>(
597    items: &[I],
598    _concurrency: usize,
599    task: F,
600) -> Result<Vec<T>, HttpError>
601where
602    I: Clone,
603    F: Fn(I) -> Fut,
604    Fut: Future<Output = T>,
605{
606    Ok(map_ordered_sequential(items, task).await)
607}
608
609#[cfg(all(target_arch = "wasm32", not(feature = "web-http")))]
610async fn map_ordered_sequential<I, T, F, Fut>(items: &[I], task: F) -> Vec<T>
611where
612    I: Clone,
613    F: Fn(I) -> Fut,
614    Fut: Future<Output = T>,
615{
616    let mut results = Vec::with_capacity(items.len());
617    for item in items.iter().cloned() {
618        results.push(task(item).await);
619    }
620    results
621}
622
623/// What a [`StubHttpClient`] answers a request with.
624pub type StubAnswer = dyn Fn(&HttpRequest) -> Result<HttpResponse, HttpError> + Send + Sync;
625
626/// An HTTP client that answers from a function rather than a network.
627///
628/// A screen driven from a recorded payload, a robot run that must not depend on
629/// a server, a test that wants a specific failure: all of them want one thing,
630/// which is to decide what a request answers. Written as a client each time,
631/// that is the same dozen lines of body plumbing repeated — and each copy is a
632/// chance to answer differently from the real client.
633///
634/// ```ignore
635/// let client: HttpClientRef = Arc::new(StubHttpClient::from_text(move |url| {
636///     Ok(fixture_for(url))
637/// }));
638/// ```
639pub struct StubHttpClient {
640    answer: Box<StubAnswer>,
641}
642
643impl StubHttpClient {
644    /// Answers from a function of the whole request.
645    pub fn new(
646        answer: impl Fn(&HttpRequest) -> Result<HttpResponse, HttpError> + Send + Sync + 'static,
647    ) -> Self {
648        Self {
649            answer: Box::new(answer),
650        }
651    }
652
653    /// Answers every request with `body` and a `200`.
654    pub fn with_body(body: impl Into<Vec<u8>>) -> Self {
655        let body = body.into();
656        Self::new(move |request| {
657            Ok(HttpResponse::new(
658                request.url.clone(),
659                200,
660                http_body_ref(BytesBody::new(body.clone())),
661            ))
662        })
663    }
664
665    /// Answers with text, or an error, from a function of the URL.
666    pub fn from_text(
667        answer: impl Fn(&str) -> Result<String, HttpError> + Send + Sync + 'static,
668    ) -> Self {
669        Self::new(move |request| {
670            let body = answer(&request.url)?;
671            Ok(HttpResponse::new(
672                request.url.clone(),
673                200,
674                http_body_ref(BytesBody::new(body)),
675            ))
676        })
677    }
678}
679
680impl HttpClient for StubHttpClient {
681    fn send<'a>(
682        &'a self,
683        request: &'a HttpRequest,
684        _control: HttpControl,
685    ) -> HttpFuture<'a, HttpResponse> {
686        Box::pin(async move { (self.answer)(request) })
687    }
688}
689
690#[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
691const CHUNK_LEN: usize = 64 * 1024;
692
693struct DefaultHttpClient {
694    #[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
695    native_client: Result<reqwest::blocking::Client, HttpError>,
696}
697
698impl DefaultHttpClient {
699    fn new() -> Self {
700        Self {
701            #[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
702            native_client: build_native_client(),
703        }
704    }
705}
706
707#[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
708struct ResponseHead {
709    status: u16,
710    headers: Vec<(String, String)>,
711    content_length: Option<u64>,
712    resumed: bool,
713}
714
715#[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
716struct ChannelBody {
717    chunks: crate::async_io::ChunkStream<HttpError>,
718}
719
720#[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
721impl HttpBody for ChannelBody {
722    fn read_chunk(&self) -> HttpFuture<'_, Option<Vec<u8>>> {
723        Box::pin(self.chunks.next())
724    }
725}
726
727#[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
728async fn send_native(
729    client: reqwest::blocking::Client,
730    request: HttpRequest,
731    control: HttpControl,
732) -> Result<HttpResponse, HttpError> {
733    use crate::async_io::{ChunkChannel, Signal};
734
735    let head_signal: Signal<Result<ResponseHead, HttpError>> = Signal::new();
736    let (chunks, stream) = ChunkChannel::<HttpError>::new();
737    let worker_head = head_signal.clone();
738    let url = request.url.clone();
739    let worker_url = url.clone();
740
741    std::thread::Builder::new()
742        .name("cranpose-http".to_string())
743        .spawn(move || {
744            let outcome = read_native_response(&client, &request, &control, &chunks, &worker_head);
745            match outcome {
746                Ok(()) => chunks.finish(),
747                Err(error) => {
748                    worker_head.set(Err(error.clone()));
749                    chunks.fail(error);
750                }
751            }
752            let _ = worker_url;
753        })
754        .map_err(|error| HttpError::RequestFailed {
755            url: url.clone(),
756            message: format!("could not start the transfer: {error}"),
757        })?;
758
759    let head = head_signal
760        .wait()
761        .await
762        .ok_or_else(|| HttpError::RequestFailed {
763            url: url.clone(),
764            message: "the transfer ended before a response arrived".to_string(),
765        })??;
766
767    Ok(HttpResponse::new(
768        url,
769        head.status,
770        http_body_ref(ChannelBody { chunks: stream }),
771    )
772    .with_headers(head.headers)
773    .with_content_length(head.content_length)
774    .with_resumed(head.resumed))
775}
776
777#[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
778fn read_native_response(
779    client: &reqwest::blocking::Client,
780    request: &HttpRequest,
781    control: &HttpControl,
782    chunks: &crate::async_io::ChunkChannel<HttpError>,
783    head_signal: &crate::async_io::Signal<Result<ResponseHead, HttpError>>,
784) -> Result<(), HttpError> {
785    use std::io::Read;
786
787    if control.is_cancelled() {
788        return Err(HttpError::Cancelled);
789    }
790    let method = match request.method {
791        HttpMethod::Get => reqwest::Method::GET,
792        HttpMethod::Head => reqwest::Method::HEAD,
793        HttpMethod::Post => reqwest::Method::POST,
794        HttpMethod::Put => reqwest::Method::PUT,
795        HttpMethod::Delete => reqwest::Method::DELETE,
796    };
797    let mut builder = client.request(method, &request.url);
798    for (name, value) in &request.headers {
799        builder = builder.header(name, value);
800    }
801    if let Some(offset) = request.resume_from {
802        builder = builder.header(reqwest::header::RANGE, format!("bytes={offset}-"));
803    }
804    if let Some(body) = &request.body {
805        builder = builder.body(body.clone());
806    }
807
808    let mut response = builder.send().map_err(|error| HttpError::RequestFailed {
809        url: request.url.clone(),
810        message: error.to_string(),
811    })?;
812
813    let status = response.status();
814    let resumed = request.resume_from.is_some() && status == reqwest::StatusCode::PARTIAL_CONTENT;
815    let content_length = response.content_length();
816    let headers = response
817        .headers()
818        .iter()
819        .map(|(name, value)| {
820            (
821                name.as_str().to_ascii_lowercase(),
822                value.to_str().unwrap_or_default().to_string(),
823            )
824        })
825        .collect();
826    head_signal.set(Ok(ResponseHead {
827        status: status.as_u16(),
828        headers,
829        content_length,
830        resumed,
831    }));
832
833    let mut buffer = vec![0u8; CHUNK_LEN];
834    let mut transferred = 0u64;
835    loop {
836        if control.is_cancelled() {
837            return Err(HttpError::Cancelled);
838        }
839        let count = response
840            .read(&mut buffer)
841            .map_err(|error| HttpError::BodyReadFailed {
842                url: request.url.clone(),
843                message: error.to_string(),
844            })?;
845        if count == 0 {
846            break;
847        }
848        transferred += count as u64;
849        control.report(HttpProgress {
850            transferred,
851            total: content_length,
852        });
853        if !chunks.push(buffer[..count].to_vec()) {
854            break;
855        }
856    }
857    Ok(())
858}
859
860#[cfg(all(not(target_arch = "wasm32"), not(feature = "http-native")))]
861async fn send_native(
862    _request: HttpRequest,
863    _control: HttpControl,
864) -> Result<HttpResponse, HttpError> {
865    Err(HttpError::UnsupportedFeature {
866        operation: "native HTTP requests",
867        feature: "http-native",
868    })
869}
870
871impl HttpClient for DefaultHttpClient {
872    fn send<'a>(
873        &'a self,
874        request: &'a HttpRequest,
875        control: HttpControl,
876    ) -> HttpFuture<'a, HttpResponse> {
877        Box::pin(async move {
878            #[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
879            {
880                let client = self.native_client.as_ref().map_err(Clone::clone)?.clone();
881                send_native(client, request.clone(), control).await
882            }
883            #[cfg(all(not(target_arch = "wasm32"), not(feature = "http-native")))]
884            {
885                send_native(request.clone(), control).await
886            }
887            #[cfg(target_arch = "wasm32")]
888            {
889                send_web(request, control).await
890            }
891        })
892    }
893}
894
895#[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
896fn build_native_client() -> Result<reqwest::blocking::Client, HttpError> {
897    use std::time::Duration;
898
899    configure_native_client_builder(
900        reqwest::blocking::Client::builder()
901            .connect_timeout(Duration::from_secs(30))
902            .timeout(None)
903            .user_agent(concat!("cranpose/", env!("CARGO_PKG_VERSION"))),
904    )?
905    .build()
906    .map_err(|err| HttpError::ClientInit(err.to_string()))
907}
908
909#[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
910fn configure_native_client_builder(
911    builder: reqwest::blocking::ClientBuilder,
912) -> Result<reqwest::blocking::ClientBuilder, HttpError> {
913    #[cfg(target_os = "android")]
914    {
915        Ok(builder.tls_certs_only(android_root_certificates()?))
916    }
917
918    #[cfg(not(target_os = "android"))]
919    {
920        Ok(builder)
921    }
922}
923
924#[cfg(all(target_os = "android", feature = "http-native"))]
925fn android_root_certificates() -> Result<Vec<reqwest::Certificate>, HttpError> {
926    certificates_from_der_chain(
927        webpki_root_certs::TLS_SERVER_ROOT_CERTS
928            .iter()
929            .map(|certificate| certificate.as_ref()),
930    )
931}
932
933#[cfg(any(
934    all(test, not(target_arch = "wasm32"), feature = "http-native"),
935    all(target_os = "android", feature = "http-native")
936))]
937fn certificates_from_der_chain<'a, I>(
938    certificates: I,
939) -> Result<Vec<reqwest::Certificate>, HttpError>
940where
941    I: IntoIterator<Item = &'a [u8]>,
942{
943    certificates
944        .into_iter()
945        .enumerate()
946        .map(|(index, der)| {
947            reqwest::Certificate::from_der(der).map_err(|err| {
948                HttpError::ClientInit(format!(
949                    "Failed to load TLS root certificate {index}: {err}"
950                ))
951            })
952        })
953        .collect()
954}
955
956#[cfg(all(target_arch = "wasm32", feature = "web-http"))]
957struct FetchBody {
958    url: String,
959    reader: web_sys::ReadableStreamDefaultReader,
960    control: HttpControl,
961    transferred: std::cell::Cell<u64>,
962    total: Option<u64>,
963}
964
965#[cfg(all(target_arch = "wasm32", feature = "web-http"))]
966impl HttpBody for FetchBody {
967    fn read_chunk(&self) -> HttpFuture<'_, Option<Vec<u8>>> {
968        use wasm_bindgen_futures::JsFuture;
969
970        Box::pin(async move {
971            if self.control.is_cancelled() {
972                let _ = self.reader.cancel();
973                return Err(HttpError::Cancelled);
974            }
975            let result = JsFuture::from(self.reader.read()).await.map_err(|error| {
976                HttpError::BodyReadFailed {
977                    url: self.url.clone(),
978                    message: format!("{error:?}"),
979                }
980            })?;
981            let done = js_sys::Reflect::get(&result, &wasm_bindgen::JsValue::from_str("done"))
982                .ok()
983                .and_then(|value| value.as_bool())
984                .unwrap_or(true);
985            if done {
986                return Ok(None);
987            }
988            let value = js_sys::Reflect::get(&result, &wasm_bindgen::JsValue::from_str("value"))
989                .map_err(|error| HttpError::BodyReadFailed {
990                    url: self.url.clone(),
991                    message: format!("{error:?}"),
992                })?;
993            let chunk = js_sys::Uint8Array::new(&value).to_vec();
994            self.transferred
995                .set(self.transferred.get() + chunk.len() as u64);
996            self.control.report(HttpProgress {
997                transferred: self.transferred.get(),
998                total: self.total,
999            });
1000            Ok(Some(chunk))
1001        })
1002    }
1003}
1004
1005#[cfg(all(target_arch = "wasm32", feature = "web-http"))]
1006async fn send_web(request: &HttpRequest, control: HttpControl) -> Result<HttpResponse, HttpError> {
1007    use wasm_bindgen::JsCast;
1008    use wasm_bindgen_futures::JsFuture;
1009    use web_sys::{Request, RequestInit, RequestMode, Response};
1010
1011    if control.is_cancelled() {
1012        return Err(HttpError::Cancelled);
1013    }
1014    let options = RequestInit::new();
1015    options.set_method(request.method.name());
1016    options.set_mode(RequestMode::Cors);
1017    if let Some(body) = &request.body {
1018        options.set_body(&js_sys::Uint8Array::from(body.as_slice()).into());
1019    }
1020
1021    let fetch_request =
1022        Request::new_with_str_and_init(&request.url, &options).map_err(|error| {
1023            HttpError::RequestFailed {
1024                url: request.url.clone(),
1025                message: format!("{error:?}"),
1026            }
1027        })?;
1028    let headers = fetch_request.headers();
1029    for (name, value) in &request.headers {
1030        headers
1031            .set(name, value)
1032            .map_err(|error| HttpError::RequestFailed {
1033                url: request.url.clone(),
1034                message: format!("{error:?}"),
1035            })?;
1036    }
1037    if let Some(offset) = request.resume_from {
1038        headers
1039            .set("Range", &format!("bytes={offset}-"))
1040            .map_err(|error| HttpError::RequestFailed {
1041                url: request.url.clone(),
1042                message: format!("{error:?}"),
1043            })?;
1044    }
1045
1046    let window = web_sys::window().ok_or(HttpError::NoWindow)?;
1047    let value = JsFuture::from(window.fetch_with_request(&fetch_request))
1048        .await
1049        .map_err(|error| HttpError::RequestFailed {
1050            url: request.url.clone(),
1051            message: format!("{error:?}"),
1052        })?;
1053    let response: Response = value.dyn_into().map_err(|_| HttpError::InvalidResponse {
1054        url: request.url.clone(),
1055        message: "the browser answered with something that is not a Response".to_string(),
1056    })?;
1057
1058    let status = response.status();
1059    let mut header_pairs = Vec::new();
1060    let entries = js_sys::try_iter(&response.headers()).ok().flatten();
1061    if let Some(entries) = entries {
1062        for entry in entries.flatten() {
1063            let pair = js_sys::Array::from(&entry);
1064            if pair.length() >= 2 {
1065                let name = pair.get(0).as_string().unwrap_or_default();
1066                let value = pair.get(1).as_string().unwrap_or_default();
1067                header_pairs.push((name.to_ascii_lowercase(), value));
1068            }
1069        }
1070    }
1071    let total = header_pairs
1072        .iter()
1073        .find(|(name, _)| name == "content-length")
1074        .and_then(|(_, value)| value.parse::<u64>().ok());
1075    let resumed = request.resume_from.is_some() && status == 206;
1076
1077    let body = response.body().ok_or_else(|| HttpError::InvalidResponse {
1078        url: request.url.clone(),
1079        message: "the response carries no body".to_string(),
1080    })?;
1081    let reader: web_sys::ReadableStreamDefaultReader =
1082        body.get_reader()
1083            .dyn_into()
1084            .map_err(|_| HttpError::InvalidResponse {
1085                url: request.url.clone(),
1086                message: "the response body cannot be read in chunks".to_string(),
1087            })?;
1088
1089    Ok(HttpResponse::new(
1090        request.url.clone(),
1091        status,
1092        http_body_ref(FetchBody {
1093            url: request.url.clone(),
1094            reader,
1095            control,
1096            transferred: std::cell::Cell::new(0),
1097            total,
1098        }),
1099    )
1100    .with_headers(header_pairs)
1101    .with_content_length(total)
1102    .with_resumed(resumed))
1103}
1104
1105#[cfg(all(target_arch = "wasm32", not(feature = "web-http")))]
1106async fn send_web(request: &HttpRequest, _control: HttpControl) -> Result<HttpResponse, HttpError> {
1107    let _ = request;
1108    Err(HttpError::UnsupportedFeature {
1109        operation: "web HTTP requests",
1110        feature: "web-http",
1111    })
1112}
1113
1114pub fn default_http_client() -> HttpClientRef {
1115    Arc::new(DefaultHttpClient::new())
1116}
1117
1118pub fn local_http_client() -> CompositionLocal<HttpClientRef> {
1119    thread_local! {
1120        static LOCAL_HTTP_CLIENT: std::cell::RefCell<Option<CompositionLocal<HttpClientRef>>> = const { std::cell::RefCell::new(None) };
1121    }
1122
1123    LOCAL_HTTP_CLIENT.with(|cell| {
1124        let mut local = cell.borrow_mut();
1125        local
1126            .get_or_insert_with(|| compositionLocalOfWithPolicy(default_http_client, Arc::ptr_eq))
1127            .clone()
1128    })
1129}
1130
1131#[cfg(test)]
1132mod tests {
1133    #[cfg(not(target_arch = "wasm32"))]
1134    use std::sync::atomic::{AtomicU64, Ordering};
1135    use std::{cell::RefCell, rc::Rc};
1136
1137    use cranpose_core::CompositionLocalProvider;
1138
1139    use super::*;
1140    use crate::run_test_composition;
1141
1142    #[test]
1143    fn a_response_carries_what_a_resumed_download_needs_to_know() {
1144        let response = HttpResponse::empty("https://host/big.bin", 206)
1145            .with_content_length(Some(4_096))
1146            .with_resumed(true);
1147        assert_eq!(response.content_length, Some(4_096));
1148        assert!(response.resumed);
1149        assert!(response.is_success());
1150
1151        let restarted = HttpResponse::empty("https://host/big.bin", 200)
1152            .with_content_length(None)
1153            .with_resumed(false);
1154        assert_eq!(restarted.content_length, None);
1155        assert!(!restarted.resumed);
1156        assert!(restarted.is_success());
1157    }
1158
1159    #[test]
1160    fn the_stub_client_answers_every_url_with_the_same_body() {
1161        let client = StubHttpClient::with_body(b"pong".to_vec());
1162        for url in ["https://host/a", "https://host/b?query=1"] {
1163            let request = HttpRequest::get(url);
1164            let response = pollster::block_on(client.send(&request, HttpControl::new()))
1165                .expect("the stub answers every request");
1166            assert_eq!(response.status, 200);
1167            assert!(response.is_success());
1168            assert_eq!(response.url, url);
1169            let body = pollster::block_on(response.read_all()).expect("body");
1170            assert_eq!(body.as_slice(), b"pong");
1171        }
1172    }
1173
1174    #[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
1175    use std::thread;
1176
1177    struct TestHttpClient;
1178
1179    impl HttpClient for TestHttpClient {
1180        fn send<'a>(
1181            &'a self,
1182            request: &'a HttpRequest,
1183            _control: HttpControl,
1184        ) -> HttpFuture<'a, HttpResponse> {
1185            Box::pin(async move {
1186                Ok(HttpResponse::new(
1187                    request.url.clone(),
1188                    200,
1189                    Arc::new(BytesBody::new("ok")),
1190                ))
1191            })
1192        }
1193    }
1194
1195    #[test]
1196    fn a_control_shares_cancellation_and_reports_progress() {
1197        let reported = Arc::new(AtomicU64::new(0));
1198        let recorder = Arc::clone(&reported);
1199        let control = HttpControl::new().with_progress(move |progress| {
1200            assert_eq!(progress.total, Some(20));
1201            recorder.store(progress.transferred, Ordering::Release);
1202        });
1203        let clone = control.clone();
1204        control.report(HttpProgress {
1205            transferred: 12,
1206            total: Some(20),
1207        });
1208        assert_eq!(reported.load(Ordering::Acquire), 12);
1209        assert!(!clone.is_cancelled());
1210        control.cancel();
1211        assert!(
1212            clone.is_cancelled(),
1213            "a control handed to a transfer must see the cancellation the caller made"
1214        );
1215    }
1216
1217    #[test]
1218    fn progress_reports_a_fraction_only_when_the_server_said_how_large_it_is() {
1219        assert_eq!(
1220            HttpProgress {
1221                transferred: 5,
1222                total: Some(20)
1223            }
1224            .fraction(),
1225            Some(0.25)
1226        );
1227        assert_eq!(
1228            HttpProgress {
1229                transferred: 5,
1230                total: None
1231            }
1232            .fraction(),
1233            None,
1234            "a chunked response has no total, and inventing one is lying about it"
1235        );
1236        assert_eq!(
1237            HttpProgress {
1238                transferred: 5,
1239                total: Some(0)
1240            }
1241            .fraction(),
1242            None
1243        );
1244        assert!(
1245            HttpProgress {
1246                transferred: 20,
1247                total: Some(20)
1248            }
1249            .is_complete()
1250        );
1251    }
1252
1253    #[test]
1254    fn a_request_carries_what_it_asks_for() {
1255        let request = HttpRequest::get("https://example.test/thing")
1256            .method(HttpMethod::Post)
1257            .header("Accept", "application/json")
1258            .body(b"payload".to_vec())
1259            .resume_from(4096);
1260        assert_eq!(request.method.name(), "POST");
1261        assert_eq!(
1262            request.headers,
1263            vec![("Accept".to_string(), "application/json".to_string())]
1264        );
1265        assert_eq!(request.body.as_deref(), Some(b"payload".as_slice()));
1266        assert_eq!(request.resume_from, Some(4096));
1267        assert_eq!(
1268            HttpRequest::get("https://example.test/thing")
1269                .resume_from(0)
1270                .resume_from,
1271            None,
1272            "resuming from the beginning is not resuming"
1273        );
1274    }
1275
1276    #[test]
1277    fn a_response_reads_its_headers_without_regard_to_case() {
1278        let response = HttpResponse::empty("https://example.test", 200).with_headers(vec![
1279            ("content-type".to_string(), "text/plain".to_string()),
1280            ("etag".to_string(), "\"abc\"".to_string()),
1281        ]);
1282        assert_eq!(response.header("Content-Type"), Some("text/plain"));
1283        assert_eq!(response.header("ETAG"), Some("\"abc\""));
1284        assert_eq!(response.header("missing"), None);
1285        assert!(response.is_success());
1286    }
1287
1288    #[test]
1289    fn a_failing_status_is_an_error_the_caller_can_stop_on() {
1290        let response = HttpResponse::empty("https://example.test", 404);
1291        assert!(!response.is_success());
1292        let error = response
1293            .error_for_status()
1294            .err()
1295            .expect("404 is not success");
1296        assert!(matches!(error, HttpError::HttpStatus { status: 404, .. }));
1297    }
1298
1299    #[test]
1300    fn a_body_read_in_chunks_reassembles_to_what_was_sent() {
1301        let response = HttpResponse::new(
1302            "https://example.test",
1303            200,
1304            Arc::new(BytesBody::chunked("cranpose streams bodies", 4)),
1305        );
1306        let mut chunks = Vec::new();
1307        while let Some(chunk) = pollster::block_on(response.read_chunk()).expect("a chunk") {
1308            assert!(chunk.len() <= 4, "a chunked body honours its chunk size");
1309            chunks.push(chunk);
1310        }
1311        assert!(chunks.len() > 1, "the body arrived in pieces");
1312        let joined = chunks.concat();
1313        assert_eq!(
1314            String::from_utf8(joined).expect("text"),
1315            "cranpose streams bodies"
1316        );
1317    }
1318
1319    #[test]
1320    fn reading_a_body_as_text_rejects_bytes_that_are_not_text() {
1321        let response = HttpResponse::new(
1322            "https://example.test",
1323            200,
1324            Arc::new(BytesBody::new(vec![0xff, 0xfe])),
1325        );
1326        assert!(matches!(
1327            pollster::block_on(response.read_text()),
1328            Err(HttpError::InvalidResponse { .. })
1329        ));
1330    }
1331
1332    #[test]
1333    fn an_empty_body_ends_immediately() {
1334        let response = HttpResponse::empty("https://example.test", 204);
1335        assert_eq!(
1336            pollster::block_on(response.read_all()).expect("an empty body reads"),
1337            Vec::<u8>::new()
1338        );
1339    }
1340
1341    #[test]
1342    fn default_http_client_is_available() {
1343        let client = default_http_client();
1344        let cloned = client.clone();
1345        assert_eq!(Arc::strong_count(&client), 2);
1346        drop(cloned);
1347        assert_eq!(Arc::strong_count(&client), 1);
1348    }
1349
1350    #[test]
1351    fn the_native_transfer_awaits_its_response_rather_than_blocking_on_it() {
1352        let source = include_str!("http.rs");
1353        let send = source
1354            .split("async fn send_native(")
1355            .nth(1)
1356            .expect("the native send");
1357        let body = send.split("\nasync fn ").next().unwrap_or(send);
1358        assert!(
1359            body.contains("head_signal\n        .wait()\n        .await"),
1360            "the native send must await the response head"
1361        );
1362        let blocking = ["pollster", "::", "block_on"].concat();
1363        assert!(
1364            !body.contains(&blocking),
1365            "the native send must not block the thread that polled it"
1366        );
1367    }
1368
1369    #[test]
1370    fn default_http_client_has_no_process_global_native_client_cache() {
1371        let source = include_str!("http.rs");
1372        let once_lock = ["Once", "Lock"].concat();
1373        let static_client = ["static ", "CLIENT"].concat();
1374        let native_client_fn = ["fn ", "native_client()"].concat();
1375
1376        assert!(
1377            !source.contains(&static_client)
1378                && !source.contains(&native_client_fn)
1379                && !source.contains(&once_lock),
1380            "native HTTP client state must be owned by DefaultHttpClient instead of a process-global cache"
1381        );
1382    }
1383
1384    #[test]
1385    fn every_convenience_reads_the_body_the_backend_produced() {
1386        let client = TestHttpClient;
1387        assert_eq!(
1388            pollster::block_on(client.get_bytes("https://example.com")).expect("bytes"),
1389            b"ok".to_vec()
1390        );
1391        assert_eq!(
1392            pollster::block_on(client.get_text("https://example.com")).expect("text"),
1393            "ok"
1394        );
1395    }
1396
1397    #[test]
1398    fn map_ordered_concurrent_preserves_input_order() {
1399        let inputs = [3usize, 1, 4, 1, 5];
1400        let outputs = pollster::block_on(map_ordered_concurrent(&inputs, 2, |value| async move {
1401            value * 10
1402        }))
1403        .expect("ordered concurrent mapping");
1404
1405        assert_eq!(outputs, vec![30, 10, 40, 10, 50]);
1406    }
1407
1408    #[test]
1409    fn native_ordered_concurrency_is_not_tied_to_native_http_client_feature() {
1410        let source = include_str!("http.rs");
1411        assert!(
1412            source.contains(
1413                "#[cfg(not(target_arch = \"wasm32\"))]\npub async fn map_ordered_concurrent"
1414            ),
1415            "native ordered concurrency must stay available without the HTTP client feature"
1416        );
1417        assert!(
1418            !source.contains(
1419                "all(not(target_arch = \"wasm32\"), feature = \"http-native\")]\npub async fn map_ordered_concurrent"
1420            ) && !source.contains(
1421                "all(not(target_arch = \"wasm32\"), not(feature = \"http-native\"))]\npub async fn map_ordered_concurrent"
1422            ),
1423            "native ordered concurrency must not split into HTTP-enabled and HTTP-disabled behavior"
1424        );
1425    }
1426
1427    #[cfg(not(target_arch = "wasm32"))]
1428    #[test]
1429    fn map_ordered_concurrent_reports_worker_panic() {
1430        let inputs = [1usize];
1431        let should_panic = Arc::new(std::sync::atomic::AtomicBool::new(true));
1432        let should_panic_for_task = Arc::clone(&should_panic);
1433
1434        let error = pollster::block_on(map_ordered_concurrent(&inputs, 1, move |_| {
1435            let should_panic = Arc::clone(&should_panic_for_task);
1436            async move {
1437                if should_panic.load(std::sync::atomic::Ordering::SeqCst) {
1438                    panic!("test worker panic");
1439                }
1440                1usize
1441            }
1442        }))
1443        .expect_err("worker panic should be reported");
1444
1445        assert!(matches!(error, HttpError::WorkerPanicked { .. }));
1446    }
1447
1448    #[test]
1449    fn local_http_client_can_be_overridden() {
1450        let local = local_http_client();
1451        let default_client = default_http_client();
1452        let custom_client: HttpClientRef = Arc::new(TestHttpClient);
1453        let captured = Rc::new(RefCell::new(None));
1454
1455        {
1456            let captured_for_closure = Rc::clone(&captured);
1457            let custom_client = custom_client.clone();
1458            let local_for_provider = local.clone();
1459            let local_for_read = local.clone();
1460            run_test_composition(move || {
1461                let captured = Rc::clone(&captured_for_closure);
1462                let local_for_read = local_for_read.clone();
1463                CompositionLocalProvider(
1464                    vec![local_for_provider.provides(custom_client.clone())],
1465                    move || {
1466                        let current = local_for_read.current();
1467                        *captured.borrow_mut() = Some(current);
1468                    },
1469                );
1470            });
1471        }
1472
1473        let current = captured.borrow().as_ref().expect("client captured").clone();
1474        assert!(Arc::ptr_eq(&current, &custom_client));
1475        assert!(!Arc::ptr_eq(&current, &default_client));
1476    }
1477
1478    #[cfg(all(not(target_arch = "wasm32"), not(feature = "http-native")))]
1479    #[test]
1480    fn default_http_client_reports_disabled_native_http_feature() {
1481        let error = pollster::block_on(default_http_client().get_text("https://example.com"))
1482            .expect_err("native HTTP should be feature-gated");
1483
1484        assert!(matches!(
1485            error,
1486            HttpError::UnsupportedFeature {
1487                feature: "http-native",
1488                ..
1489            }
1490        ));
1491    }
1492
1493    #[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
1494    #[test]
1495    fn native_http_client_builds() {
1496        build_native_client().expect("native HTTP client should initialize");
1497    }
1498
1499    #[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
1500    #[test]
1501    fn certificates_from_der_chain_accepts_valid_roots() {
1502        let certificates = certificates_from_der_chain(
1503            webpki_root_certs::TLS_SERVER_ROOT_CERTS
1504                .iter()
1505                .take(3)
1506                .map(|certificate| certificate.as_ref()),
1507        )
1508        .expect("root certificates should parse");
1509
1510        assert_eq!(certificates.len(), 3);
1511    }
1512
1513    #[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
1514    fn local_server(
1515        body: Vec<u8>,
1516        chunk: usize,
1517        delay: std::time::Duration,
1518        supports_range: bool,
1519    ) -> Option<(String, thread::JoinHandle<()>)> {
1520        use std::{
1521            io::{Read, Write},
1522            net::TcpListener,
1523        };
1524
1525        let listener = match TcpListener::bind("127.0.0.1:0") {
1526            Ok(listener) => listener,
1527            Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
1528                eprintln!("skipping local HTTP server bind in restricted environment: {error}");
1529                return None;
1530            }
1531            Err(error) => panic!("bind local test server: {error}"),
1532        };
1533        let address = listener.local_addr().expect("local test server address");
1534        let handle = thread::spawn(move || {
1535            let (mut stream, _) = listener.accept().expect("accept local test request");
1536            let mut request = [0u8; 2048];
1537            let read = stream.read(&mut request).expect("read local test request");
1538            let request = String::from_utf8_lossy(&request[..read]).to_string();
1539
1540            let range_from = supports_range
1541                .then(|| {
1542                    request
1543                        .lines()
1544                        .find(|line| line.to_ascii_lowercase().starts_with("range:"))
1545                        .and_then(|line| line.split("bytes=").nth(1))
1546                        .and_then(|value| value.trim_end_matches('-').trim().parse::<u64>().ok())
1547                })
1548                .flatten();
1549
1550            let payload = match range_from {
1551                Some(offset) if (offset as usize) < body.len() => &body[offset as usize..],
1552                Some(_) => &body[body.len()..],
1553                None => &body[..],
1554            };
1555            let status = if range_from.is_some() {
1556                "206 Partial Content"
1557            } else {
1558                "200 OK"
1559            };
1560            write!(
1561                stream,
1562                "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
1563                payload.len()
1564            )
1565            .expect("write local test response head");
1566            for piece in payload.chunks(chunk.max(1)) {
1567                if stream.write_all(piece).is_err() {
1568                    return;
1569                }
1570                let _ = stream.flush();
1571                if !delay.is_zero() {
1572                    thread::sleep(delay);
1573                }
1574            }
1575        });
1576        Some((format!("http://{address}"), handle))
1577    }
1578
1579    #[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
1580    #[test]
1581    fn a_native_body_arrives_in_pieces() {
1582        let body = vec![b'x'; 200 * 1024];
1583        let Some((url, server)) = local_server(
1584            body.clone(),
1585            16 * 1024,
1586            std::time::Duration::from_millis(5),
1587            false,
1588        ) else {
1589            return;
1590        };
1591
1592        let client = default_http_client();
1593        let response = pollster::block_on(client.send(&HttpRequest::get(&url), HttpControl::new()))
1594            .expect("a response");
1595        assert!(response.is_success());
1596        assert_eq!(response.content_length, Some(body.len() as u64));
1597
1598        let mut received = Vec::new();
1599        let mut chunks = 0usize;
1600        while let Some(chunk) = pollster::block_on(response.read_chunk()).expect("a chunk") {
1601            chunks += 1;
1602            received.extend_from_slice(&chunk);
1603        }
1604        server.join().expect("the local server finishes");
1605        assert_eq!(received.len(), body.len());
1606        assert!(
1607            chunks > 1,
1608            "a 200 KiB body must arrive in more than one piece, saw {chunks}"
1609        );
1610    }
1611
1612    #[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
1613    #[test]
1614    fn a_native_transfer_reports_progress_as_it_runs() {
1615        let body = vec![b'y'; 200 * 1024];
1616        let Some((url, server)) = local_server(
1617            body.clone(),
1618            16 * 1024,
1619            std::time::Duration::from_millis(2),
1620            false,
1621        ) else {
1622            return;
1623        };
1624
1625        let reports = Arc::new(std::sync::Mutex::new(Vec::new()));
1626        let recorder = Arc::clone(&reports);
1627        let control = HttpControl::new().with_progress(move |progress| {
1628            recorder
1629                .lock()
1630                .unwrap_or_else(|error| error.into_inner())
1631                .push(progress);
1632        });
1633
1634        let client = default_http_client();
1635        let response =
1636            pollster::block_on(client.send(&HttpRequest::get(&url), control)).expect("a response");
1637        let received = pollster::block_on(response.read_all()).expect("the body");
1638        server.join().expect("the local server finishes");
1639
1640        assert_eq!(received.len(), body.len());
1641        let reports = reports
1642            .lock()
1643            .unwrap_or_else(|error| error.into_inner())
1644            .clone();
1645        assert!(
1646            reports.len() > 1,
1647            "progress must move more than once over a 200 KiB transfer, saw {}",
1648            reports.len()
1649        );
1650        assert!(
1651            reports
1652                .windows(2)
1653                .all(|pair| pair[1].transferred >= pair[0].transferred),
1654            "progress must not go backwards: {reports:?}"
1655        );
1656        assert_eq!(
1657            reports.last().map(|progress| progress.transferred),
1658            Some(body.len() as u64)
1659        );
1660    }
1661
1662    #[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
1663    #[test]
1664    fn a_cancelled_native_transfer_stops() {
1665        let body = vec![b'z'; 512 * 1024];
1666        let Some((url, server)) =
1667            local_server(body, 8 * 1024, std::time::Duration::from_millis(5), false)
1668        else {
1669            return;
1670        };
1671
1672        let control = HttpControl::new();
1673        let client = default_http_client();
1674        let response = pollster::block_on(client.send(&HttpRequest::get(&url), control.clone()))
1675            .expect("a response");
1676
1677        let first = pollster::block_on(response.read_chunk()).expect("a chunk");
1678        assert!(first.is_some());
1679        control.cancel();
1680
1681        let mut ended = false;
1682        for _ in 0..64 {
1683            match pollster::block_on(response.read_chunk()) {
1684                Ok(Some(_)) => continue,
1685                Ok(None) => {
1686                    ended = true;
1687                    break;
1688                }
1689                Err(HttpError::Cancelled) => {
1690                    ended = true;
1691                    break;
1692                }
1693                Err(other) => panic!("unexpected error after cancelling: {other}"),
1694            }
1695        }
1696        drop(response);
1697        let _ = server.join();
1698        assert!(ended, "a cancelled transfer must end rather than run on");
1699    }
1700
1701    #[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
1702    #[test]
1703    fn a_download_resumes_from_what_is_already_on_disk() {
1704        let body: Vec<u8> = (0..4096u32).map(|value| value as u8).collect();
1705        let directory = crate::test_scratch_dir("http-resume");
1706        let target = directory.join("payload.bin");
1707        let _ = std::fs::remove_file(&target);
1708        std::fs::write(&target, &body[..1024]).expect("a partial file");
1709
1710        let Some((url, server)) = local_server(body.clone(), 1024, std::time::Duration::ZERO, true)
1711        else {
1712            return;
1713        };
1714        let client = default_http_client();
1715        let written = pollster::block_on(client.download_to(&url, &target, HttpControl::new()))
1716            .expect("the download finishes");
1717        server.join().expect("the local server finishes");
1718
1719        assert_eq!(written, body.len() as u64);
1720        assert_eq!(std::fs::read(&target).expect("the file"), body);
1721        let _ = std::fs::remove_file(&target);
1722    }
1723
1724    #[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
1725    #[test]
1726    fn a_server_that_ignores_a_range_restarts_the_file_rather_than_appending() {
1727        let body: Vec<u8> = (0..4096u32).map(|value| value as u8).collect();
1728        let directory = crate::test_scratch_dir("http-restart");
1729        let target = directory.join("payload.bin");
1730        let _ = std::fs::remove_file(&target);
1731        std::fs::write(&target, &body[..1024]).expect("a partial file");
1732
1733        let Some((url, server)) =
1734            local_server(body.clone(), 1024, std::time::Duration::ZERO, false)
1735        else {
1736            return;
1737        };
1738        let client = default_http_client();
1739        let written = pollster::block_on(client.download_to(&url, &target, HttpControl::new()))
1740            .expect("the download finishes");
1741        server.join().expect("the local server finishes");
1742
1743        assert_eq!(written, body.len() as u64);
1744        assert_eq!(
1745            std::fs::read(&target).expect("the file"),
1746            body,
1747            "the partial file must be replaced, not appended to"
1748        );
1749        let _ = std::fs::remove_file(&target);
1750    }
1751
1752    #[cfg(all(not(target_arch = "wasm32"), feature = "http-native"))]
1753    #[test]
1754    fn default_http_client_fetches_text_from_local_server() {
1755        use std::{
1756            io::{Read, Write},
1757            net::TcpListener,
1758        };
1759
1760        let listener = match TcpListener::bind("127.0.0.1:0") {
1761            Ok(listener) => listener,
1762            Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => {
1763                eprintln!("skipping local HTTP server bind in restricted test environment: {err}");
1764                return;
1765            }
1766            Err(err) => panic!("bind local test server: {err}"),
1767        };
1768        let address = listener
1769            .local_addr()
1770            .expect("read local test server address");
1771        let server = thread::spawn(move || {
1772            let (mut stream, _) = listener.accept().expect("accept local test request");
1773            let mut request = [0_u8; 1024];
1774            let _ = stream.read(&mut request).expect("read local test request");
1775            let body = "cranpose-http-test";
1776            write!(
1777                stream,
1778                "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1779                body.len(),
1780                body
1781            )
1782            .expect("write local test response");
1783        });
1784
1785        let url = format!("http://{address}");
1786        let text = pollster::block_on(default_http_client().get_text(&url))
1787            .expect("fetch text from local test server");
1788        server.join().expect("join local test server");
1789
1790        assert_eq!(text, "cranpose-http-test");
1791    }
1792}