Skip to main content

cranpose_services/
http.rs

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