Skip to main content

blitz_net/
lib.rs

1//! Networking (HTTP, filesystem, Data URIs) for Blitz
2//!
3//! Provides implementations of [`blitz_traits::net::NetProvider`], which loads
4//! the resources a document needs, and of
5//! [`blitz_traits::platform::FetchProvider`], which serves a guest's `fetch()`.
6//!
7//! Both use one native HTTP/1.1 client, sharing its connection pool, cookie
8//! jar, compression codecs, private cache and six connection slots per origin.
9//! The `http2` feature is reserved for the subsequent HTTP/2 transport.
10//!
11//! Creating a native provider starts nagoya's process background runtime and
12//! the house client's shared reactor if absent. Multiple providers reuse them.
13//! Embedders do not need to enter an executor: callback requests use
14//! `nagoya::spawn`, and async methods also work under `nagoya::block_on`.
15//!
16//! Cookie persistence belongs to the embedder's cookie jar. Disk caching is
17//! attached explicitly with a profile directory; no global cache path is used.
18//! On wasm32, local URL handling remains available, but this native socket
19//! transport does not provide HTTP.
20
21#[cfg(not(target_arch = "wasm32"))]
22mod h1;
23
24use blitz_traits::net::{
25    AbortSignal, Body, Bytes, CookieJar as CookieStorage, NetHandler, NetProvider, NetWaker, Request,
26    http::{HeaderValue, header::CONTENT_TYPE},
27};
28use blitz_traits::platform::{
29    FetchError, FetchHandler, FetchProvider, FetchRequest, FetchResponse, HeaderMap, StatusCode,
30};
31use data_url::DataUrl;
32use std::{marker::PhantomData, pin::Pin, sync::Arc, task::Poll};
33
34/// Cookie storage supplied to [`Provider::with_user_agent_and_cookie_provider`].
35///
36/// This house trait keeps embedders independent of the HTTP client. The provider
37/// consults the jar for each request and stores cookies from every response,
38/// including intermediate redirects and error responses.
39#[cfg(feature = "cookies")]
40pub use blitz_traits::net::CookieJar;
41
42#[cfg(all(feature = "cache", not(target_arch = "wasm32")))]
43pub use h1::cache::PrivateCache;
44
45/// The `User-Agent` sent when a caller does not choose one.
46///
47/// Kept as it was so that existing consumers see no change in what servers
48/// send them. It is worth knowing that this string is a 2020 Firefox on Linux,
49/// and that a large share of the popular web serves a degraded page, a
50/// challenge stub or nothing at all to a client it does not recognise. A
51/// consumer that wants the page a current browser would get has to say so with
52/// [`Provider::with_user_agent`].
53pub const DEFAULT_USER_AGENT: &str =
54    "Mozilla/5.0 (X11; Linux x86_64; rv:60.0) Gecko/20100101 Firefox/81.0";
55
56#[cfg(not(target_arch = "wasm32"))]
57type Client = h1::H1Client;
58
59#[cfg(target_arch = "wasm32")]
60#[derive(Clone)]
61struct Client;
62
63#[cfg(target_arch = "wasm32")]
64impl Client {
65    fn new(
66        user_agent: &str,
67        _cookies: Option<Arc<dyn CookieStorage>>,
68    ) -> Result<Self, FetchError> {
69        HeaderValue::from_str(user_agent)
70            .map_err(|error| FetchError::InvalidRequest(error.to_string()))?;
71        Ok(Self)
72    }
73
74    async fn fetch(&self, _request: FetchRequest) -> Result<FetchResponse, FetchError> {
75        Err(FetchError::Network(
76            "the native HTTP transport is unavailable on wasm32".to_owned(),
77        ))
78    }
79}
80
81#[cfg(target_arch = "wasm32")]
82fn spawn(fut: impl Future + 'static) {
83    wasm_bindgen_futures::spawn_local(async move {
84        fut.await;
85    });
86}
87
88#[cfg(not(target_arch = "wasm32"))]
89fn spawn<F>(fut: F)
90where
91    F: Future + Send + 'static,
92    F::Output: Send + 'static,
93{
94    nagoya::spawn(fut);
95}
96
97/// Document and script networking over one house client.
98///
99/// Native construction initializes the shared runtime and reactor. Supply a
100/// cookie jar for cookie storage and attach a profile cache before sharing the
101/// provider with documents.
102pub struct Provider {
103    client: Client,
104    waker: Arc<dyn NetWaker>,
105    user_agent: Arc<str>,
106    #[cfg(all(feature = "cache", not(target_arch = "wasm32")))]
107    cache_manager: Option<Arc<PrivateCache>>,
108}
109impl Provider {
110    pub fn new(waker: Option<Arc<dyn NetWaker>>) -> Self {
111        Self::with_user_agent(waker, DEFAULT_USER_AGENT)
112    }
113
114    /// A provider that identifies itself as `user_agent`.
115    ///
116    /// The string is sent verbatim on document, subresource and script
117    /// requests alike, because a server that decides what to serve from the
118    /// `User-Agent` will do so on every one of them, and a client that agreed
119    /// with itself on only some would fetch a page assembled for two different
120    /// browsers.
121    pub fn with_user_agent(waker: Option<Arc<dyn NetWaker>>, user_agent: &str) -> Self {
122        Self::with_cookie_jar(waker, user_agent, None)
123    }
124
125    /// A provider that uses an embedder-owned cookie jar and identity.
126    ///
127    /// The jar supplies cookies for each request and receives cookies from
128    /// intermediate redirects, error responses, and final responses.
129    #[cfg(feature = "cookies")]
130    pub fn with_user_agent_and_cookie_provider<C>(
131        waker: Option<Arc<dyn NetWaker>>,
132        user_agent: &str,
133        cookie_provider: Arc<C>,
134    ) -> Self
135    where
136        C: CookieJar,
137    {
138        Self::with_cookie_jar(waker, user_agent, Some(cookie_provider))
139    }
140
141    fn with_cookie_jar(
142        waker: Option<Arc<dyn NetWaker>>,
143        user_agent: &str,
144        cookies: Option<Arc<dyn CookieStorage>>,
145    ) -> Self {
146        let client = Client::new(user_agent, cookies)
147            .expect("failed to initialize the house HTTP client");
148        let waker = waker.unwrap_or(Arc::new(DummyNetWaker));
149        Self {
150            client,
151            waker,
152            user_agent: Arc::from(user_agent),
153            #[cfg(all(feature = "cache", not(target_arch = "wasm32")))]
154            cache_manager: None,
155        }
156    }
157
158    /// Attach a cache already opened for this profile.
159    ///
160    /// Keep one open cache instance per profile. Document and script requests
161    /// share it, and cached Set-Cookie headers are never replayed.
162    #[cfg(all(feature = "cache", not(target_arch = "wasm32")))]
163    pub fn with_cache(mut self, cache: Arc<PrivateCache>) -> Self {
164        self.client = self.client.with_cache(cache.clone());
165        self.cache_manager = Some(cache);
166        self
167    }
168
169    /// Open persisted WorkTable metadata and bodies under the profile directory.
170    ///
171    /// Opening runs on a nagoya worker. Failure leaves the caller free to build
172    /// a provider without a cache instead.
173    #[cfg(all(feature = "cache", not(target_arch = "wasm32")))]
174    pub async fn with_cache_directory(
175        self,
176        profile_directory: impl AsRef<std::path::Path>,
177    ) -> std::io::Result<Self> {
178        let directory = profile_directory.as_ref().to_owned();
179        let cache = nagoya::spawn(async move { PrivateCache::open(directory).await })
180            .await
181            .ok_or_else(|| std::io::Error::other("cache opener was cancelled"))??;
182        Ok(self.with_cache(cache))
183    }
184
185    pub fn shared(waker: Option<Arc<dyn NetWaker>>) -> Arc<dyn NetProvider> {
186        Arc::new(Self::new(waker))
187    }
188    pub fn shared_with_user_agent(
189        waker: Option<Arc<dyn NetWaker>>,
190        user_agent: &str,
191    ) -> Arc<dyn NetProvider> {
192        Arc::new(Self::with_user_agent(waker, user_agent))
193    }
194    /// What this provider identifies itself as.
195    pub fn user_agent(&self) -> &str {
196        &self.user_agent
197    }
198    pub fn is_empty(&self) -> bool {
199        Arc::strong_count(&self.waker) == 1
200    }
201    pub fn count(&self) -> usize {
202        Arc::strong_count(&self.waker) - 1
203    }
204
205    #[cfg(all(feature = "cache", not(target_arch = "wasm32")))]
206    pub async fn clear_cache(&self) {
207        if let Some(cache) = &self.cache_manager {
208            let cache = cache.clone();
209            let result = nagoya::spawn(async move { cache.clear().await }).await;
210            if let Some(Err(e)) = result {
211                #[cfg(feature = "tracing")]
212                tracing::error!("Failed to clear HTTP cache: {:?}", e);
213                #[cfg(not(feature = "tracing"))]
214                let _ = e;
215            }
216        }
217    }
218}
219impl Provider {
220    async fn fetch_inner(
221        client: Client,
222        request: Request,
223    ) -> Result<(String, Bytes), ProviderError> {
224        let response = Self::fetch_response_inner(client, request).await?;
225        Ok((response.url.to_string(), response.body))
226    }
227
228    #[allow(clippy::type_complexity)]
229    pub fn fetch_with_callback(
230        &self,
231        request: Request,
232        callback: Box<dyn FnOnce(Result<(String, Bytes), ProviderError>) + Send + Sync + 'static>,
233    ) {
234        #[cfg(feature = "tracing")]
235        let url = request.url.to_string();
236
237        let client = self.client.clone();
238        spawn(async move {
239            let result = Self::fetch_inner(client, request).await;
240
241            #[cfg(feature = "tracing")]
242            if let Err(e) = &result {
243                tracing::error!(url = url.as_str(), error = ?e, "Fetching");
244            } else {
245                tracing::info!(url = url.as_str(), "Success fetching");
246            }
247
248            callback(result);
249        });
250    }
251
252    pub async fn fetch_async(&self, request: Request) -> Result<(String, Bytes), ProviderError> {
253        #[cfg(feature = "tracing")]
254        let url = request.url.to_string();
255
256        let result = Self::fetch_inner(self.client.clone(), request).await;
257
258        #[cfg(feature = "tracing")]
259        if let Err(e) = &result {
260            tracing::error!(url = url.as_str(), error = ?e, "Fetching");
261        } else {
262            tracing::info!(url = url.as_str(), "Success fetching");
263        }
264
265        result
266    }
267
268    /// Fetch, keeping the response metadata that [`Provider::fetch_async`]
269    /// discards.
270    ///
271    /// HTTP error statuses remain errors on this document-loading API.
272    /// `FetchProvider` instead delivers every HTTP response, including errors.
273    /// A `data:` URL supplies its own Content-Type; a `file:` URL has none.
274    pub async fn fetch_response_async(
275        &self,
276        request: Request,
277    ) -> Result<FetchResponse, ProviderError> {
278        Self::fetch_response_inner(self.client.clone(), request).await
279    }
280
281    async fn fetch_response_inner(
282        client: Client,
283        request: Request,
284    ) -> Result<FetchResponse, ProviderError> {
285        let url = request.url.clone();
286        match url.scheme() {
287            "data" => {
288                // Scoped so the borrow of `url` ends before it is moved into
289                // the response.
290                let (body, headers) = {
291                    let data_url = DataUrl::process(url.as_str())?;
292                    let decoded = data_url.decode_to_vec()?;
293                    let mut headers = HeaderMap::new();
294                    if let Ok(value) = data_url.mime_type().to_string().parse() {
295                        headers.insert(CONTENT_TYPE, value);
296                    }
297                    (Bytes::from(decoded.0), headers)
298                };
299                Ok(FetchResponse::new(url, StatusCode::OK)
300                    .headers(headers)
301                    .body(body))
302            }
303            "file" => {
304                let file_content = std::fs::read(url.path())?;
305                Ok(FetchResponse::new(url, StatusCode::OK).body(Bytes::from(file_content)))
306            }
307            "http" | "https" => {
308                let mut wire_request = FetchRequest::get(request.url).method(request.method);
309                wire_request.headers = request.headers;
310                let content_type = request.content_type.or_else(|| {
311                    wire_request
312                        .headers
313                        .get(CONTENT_TYPE)
314                        .and_then(|value| value.to_str().ok())
315                        .map(str::to_owned)
316                });
317                if let Some(content_type) = &content_type {
318                    let value = HeaderValue::from_str(content_type)
319                        .map_err(|error| ProviderError::InvalidRequest(error.to_string()))?;
320                    wire_request.headers.insert(CONTENT_TYPE, value);
321                }
322                wire_request.body = encode_body(
323                    request.body,
324                    content_type.as_deref(),
325                    &mut wire_request.headers,
326                )?;
327                let response = client
328                    .fetch(wire_request)
329                    .await
330                    .map_err(|error| ProviderError::Network(error.to_string()))?;
331
332                if !response.status.is_success() {
333                    #[cfg(feature = "tracing")]
334                    tracing::warn!(
335                        url = response.url.as_str(),
336                        status = response.status.as_u16(),
337                        "HTTP error status"
338                    );
339                    return Err(ProviderError::HttpStatus {
340                        status: response.status,
341                        url: response.url.to_string(),
342                    });
343                }
344                Ok(response)
345            }
346            scheme => Err(ProviderError::UnsupportedScheme(scheme.to_owned())),
347        }
348    }
349}
350
351/// The `fetch()` path.
352///
353/// Separate from [`Provider::fetch_inner`] rather than layered on it, and the
354/// reason is the whole point of the trait: `fetch_inner` returns
355/// `(String, Bytes)` and turns any non-success status into a `ProviderError`
356/// that [`NetProvider::fetch`] then logs and drops. A caller loading an image
357/// wants exactly that. A `fetch()` caller wants the 404.
358impl Provider {
359    async fn platform_fetch_inner(
360        client: Client,
361        request: FetchRequest,
362    ) -> Result<FetchResponse, FetchError> {
363        match request.url.scheme() {
364            "data" => Self::platform_fetch_data(request),
365            "file" => Self::platform_fetch_file(request),
366            "http" | "https" => client
367                .fetch(request)
368                .await
369                .map_err(|error| FetchError::Network(error.to_string())),
370            scheme => Err(FetchError::UnsupportedScheme(scheme.to_owned())),
371        }
372    }
373
374    /// A `data:` URL, answered as a synthetic 200.
375    ///
376    /// The status and the `Content-Type` are invented, because a data URL has
377    /// no server to supply them, and inventing them is what the fetch
378    /// specification requires: a `data:` response is a 200 whose content type
379    /// is the one encoded in the URL.
380    fn platform_fetch_data(request: FetchRequest) -> Result<FetchResponse, FetchError> {
381        let data_url = DataUrl::process(request.url.as_str())
382            .map_err(|err| FetchError::InvalidRequest(format!("{err:?}")))?;
383        let mime = data_url.mime_type().to_string();
384        let (body, _) = data_url
385            .decode_to_vec()
386            .map_err(|err| FetchError::InvalidRequest(format!("{err:?}")))?;
387
388        let mut headers = HeaderMap::new();
389        if let Ok(value) = mime.parse() {
390            headers.insert(CONTENT_TYPE, value);
391        }
392
393        Ok(FetchResponse::new(request.url, StatusCode::OK)
394            .headers(headers)
395            .body(Bytes::from(body)))
396    }
397
398    /// A `file:` URL, answered as a synthetic 200.
399    ///
400    /// Goes through [`Url::to_file_path`] rather than `Url::path`, which is
401    /// what [`Provider::fetch_inner`] uses. `path` hands back the URL's
402    /// percent-encoded path component as a string, so a file whose name
403    /// contains a space or a `#` is looked up under the wrong name, and on
404    /// Windows the leading slash makes it wrong outright. `to_file_path`
405    /// decodes and refuses a URL that does not name a local path.
406    ///
407    /// **This is not access control, and it is not claiming to be.** Whether a
408    /// document may read local files at all is an origin question, and origins
409    /// are not visible here; see `blitz-platform-api`, which holds the origin
410    /// and is where such a policy belongs.
411    fn platform_fetch_file(request: FetchRequest) -> Result<FetchResponse, FetchError> {
412        let path = request.url.to_file_path().map_err(|()| {
413            FetchError::InvalidRequest(format!("not a local path: {}", request.url))
414        })?;
415
416        let body = std::fs::read(path).map_err(|err| FetchError::Network(err.to_string()))?;
417
418        Ok(FetchResponse::new(request.url, StatusCode::OK).body(Bytes::from(body)))
419    }
420}
421
422impl FetchProvider for Provider {
423    fn fetch(&self, request: FetchRequest, handler: Box<dyn FetchHandler>) {
424        let client = self.client.clone();
425
426        #[cfg(feature = "tracing")]
427        let url = request.url.to_string();
428
429        spawn(async move {
430            let result = Self::platform_fetch_inner(client, request).await;
431
432            #[cfg(feature = "tracing")]
433            match &result {
434                Ok(response) => tracing::info!(
435                    url = url.as_str(),
436                    status = response.status.as_u16(),
437                    "fetch complete"
438                ),
439                Err(error) => tracing::error!(url = url.as_str(), error = ?error, "fetch failed"),
440            }
441
442            handler.complete(result);
443        });
444    }
445}
446
447impl NetProvider for Provider {
448    fn fetch(&self, doc_id: usize, mut request: Request, handler: Box<dyn NetHandler>) {
449        let client = self.client.clone();
450
451        #[cfg(feature = "tracing")]
452        tracing::info!(url = request.url.as_str(), "Fetching");
453
454        let waker = self.waker.clone();
455        spawn(async move {
456            #[cfg(feature = "tracing")]
457            let url = request.url.to_string();
458
459            let signal = request.signal.take();
460            let result = if let Some(signal) = signal {
461                AbortFetch::new(
462                    signal,
463                    Box::pin(async move { Self::fetch_inner(client, request).await }),
464                )
465                .await
466            } else {
467                Self::fetch_inner(client, request).await
468            };
469
470            waker.wake(doc_id);
471
472            match result {
473                Ok((response_url, bytes)) => {
474                    handler.bytes(response_url, bytes);
475                    #[cfg(feature = "tracing")]
476                    tracing::info!(url = url.as_str(), "Success fetching");
477                }
478                Err(e) => {
479                    #[cfg(feature = "tracing")]
480                    tracing::error!(url = url.as_str(), error = ?e, "Error fetching");
481                    #[cfg(not(feature = "tracing"))]
482                    let _ = e;
483                }
484            };
485        });
486    }
487}
488
489struct AbortFetch<F, T> {
490    signal: AbortSignal,
491    future: F,
492    _rt: PhantomData<T>,
493}
494
495impl<F, T> AbortFetch<F, T> {
496    fn new(signal: AbortSignal, future: F) -> Self {
497        Self {
498            signal,
499            future,
500            _rt: PhantomData,
501        }
502    }
503}
504
505impl<F, T> Future for AbortFetch<F, T>
506where
507    F: Future + Unpin + 'static,
508    F::Output: Into<Result<T, ProviderError>> + 'static,
509    T: Unpin,
510{
511    type Output = Result<T, ProviderError>;
512
513    fn poll(
514        mut self: std::pin::Pin<&mut Self>,
515        cx: &mut std::task::Context<'_>,
516    ) -> std::task::Poll<Self::Output> {
517        if self.signal.aborted() {
518            return Poll::Ready(Err(ProviderError::Abort));
519        }
520
521        match Pin::new(&mut self.future).poll(cx) {
522            Poll::Ready(output) => Poll::Ready(output.into()),
523            Poll::Pending => Poll::Pending,
524        }
525    }
526}
527
528#[derive(Debug)]
529pub enum ProviderError {
530    Abort,
531    Io(std::io::Error),
532    DataUrl(data_url::DataUrlError),
533    DataUrlBase64(data_url::forgiving_base64::InvalidBase64),
534    Network(String),
535    InvalidRequest(String),
536    UnsupportedScheme(String),
537    HttpStatus {
538        status: StatusCode,
539        url: String,
540    },
541}
542
543impl std::fmt::Display for ProviderError {
544    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
545        match self {
546            Self::Abort => write!(f, "request aborted"),
547            Self::Io(e) => write!(f, "io error: {e}"),
548            Self::DataUrl(e) => write!(f, "data url error: {e:?}"),
549            Self::DataUrlBase64(e) => write!(f, "data url base64 error: {e:?}"),
550            Self::Network(e) => write!(f, "network error: {e}"),
551            Self::InvalidRequest(e) => write!(f, "invalid request: {e}"),
552            Self::UnsupportedScheme(scheme) => write!(f, "unsupported URL scheme: {scheme}"),
553            Self::HttpStatus { status, url } => write!(f, "HTTP {status} for {url}"),
554        }
555    }
556}
557
558impl std::error::Error for ProviderError {}
559
560impl From<std::io::Error> for ProviderError {
561    fn from(value: std::io::Error) -> Self {
562        Self::Io(value)
563    }
564}
565
566impl From<data_url::DataUrlError> for ProviderError {
567    fn from(value: data_url::DataUrlError) -> Self {
568        Self::DataUrl(value)
569    }
570}
571
572impl From<data_url::forgiving_base64::InvalidBase64> for ProviderError {
573    fn from(value: data_url::forgiving_base64::InvalidBase64) -> Self {
574        Self::DataUrlBase64(value)
575    }
576}
577
578fn encode_body(
579    body: Body,
580    content_type: Option<&str>,
581    headers: &mut HeaderMap,
582) -> Result<Option<Bytes>, ProviderError> {
583    match body {
584        Body::Bytes(bytes) => Ok(Some(bytes)),
585        Body::Empty => Ok(None),
586        Body::Form(form) => {
587            let mime = content_type
588                .and_then(|value| value.split(';').next())
589                .map(str::trim)
590                .unwrap_or("");
591            if mime.eq_ignore_ascii_case("application/x-www-form-urlencoded") {
592                let mut encoded = url::form_urlencoded::Serializer::new(String::new());
593                for entry in &form.0 {
594                    encoded.append_pair(&entry.name, entry.value.as_ref());
595                }
596                return Ok(Some(Bytes::from(encoded.finish())));
597            }
598            if mime.eq_ignore_ascii_case("multipart/form-data") {
599                #[cfg(all(feature = "multipart", not(target_arch = "wasm32")))]
600                {
601                    return encode_multipart(form, headers).map(Some);
602                }
603                #[cfg(not(all(feature = "multipart", not(target_arch = "wasm32"))))]
604                {
605                    let _ = headers;
606                    return Err(ProviderError::InvalidRequest(
607                        "multipart requires the native multipart feature".to_owned(),
608                    ));
609                }
610            }
611            Err(ProviderError::InvalidRequest(
612                "unsupported form Content-Type".to_owned(),
613            ))
614        }
615    }
616}
617
618#[cfg(all(feature = "multipart", not(target_arch = "wasm32")))]
619fn encode_multipart(
620    form: blitz_traits::net::FormData,
621    headers: &mut HeaderMap,
622) -> Result<Bytes, ProviderError> {
623    use blitz_traits::net::{Entry, EntryValue};
624    use psc_nanoid::{Nanoid, alphabet::Base64UrlAlphabet};
625    use std::io::Read;
626
627    let boundary = Nanoid::<21, Base64UrlAlphabet>::new().to_string();
628    let mut encoded = Vec::new();
629    for Entry { name, value } in form.0 {
630        let mut head = format!(
631            "--{boundary}\r\nContent-Disposition: form-data; name=\"{}\"",
632            multipart_parameter(&name),
633        );
634        let body = match value {
635            EntryValue::String(value) => Bytes::from(value),
636            EntryValue::File(path) => {
637                let name = path.file_name().unwrap_or_default().to_string_lossy();
638                head.push_str(&format!(
639                    "; filename=\"{}\"\r\nContent-Type: application/octet-stream",
640                    multipart_parameter(&name),
641                ));
642                let file = std::fs::File::open(path)?;
643                let mut body = Vec::new();
644                file.take(h1::MAX_BODY_BYTES as u64 + 1)
645                    .read_to_end(&mut body)?;
646                if body.len() > h1::MAX_BODY_BYTES {
647                    return Err(ProviderError::InvalidRequest(
648                        "multipart file exceeds the body limit".to_owned(),
649                    ));
650                }
651                Bytes::from(body)
652            }
653            EntryValue::EmptyFile => {
654                head.push_str("; filename=\"\"\r\nContent-Type: application/octet-stream");
655                Bytes::new()
656            }
657        };
658        head.push_str("\r\n\r\n");
659        let required = head
660            .len()
661            .checked_add(body.len())
662            .and_then(|length| length.checked_add(2))
663            .and_then(|length| length.checked_add(encoded.len()))
664            .ok_or_else(|| ProviderError::InvalidRequest("multipart body is too large".to_owned()))?;
665        if required > h1::MAX_BODY_BYTES {
666            return Err(ProviderError::InvalidRequest(
667                "multipart body exceeds the body limit".to_owned(),
668            ));
669        }
670        encoded.extend_from_slice(head.as_bytes());
671        encoded.extend_from_slice(&body);
672        encoded.extend_from_slice(b"\r\n");
673    }
674    let end = format!("--{boundary}--\r\n");
675    if end.len() > h1::MAX_BODY_BYTES.saturating_sub(encoded.len()) {
676        return Err(ProviderError::InvalidRequest(
677            "multipart body exceeds the body limit".to_owned(),
678        ));
679    }
680    encoded.extend_from_slice(end.as_bytes());
681    let content_type = HeaderValue::from_str(&format!("multipart/form-data; boundary={boundary}"))
682        .map_err(|error| ProviderError::InvalidRequest(error.to_string()))?;
683    headers.insert(CONTENT_TYPE, content_type);
684    Ok(Bytes::from(encoded))
685}
686
687#[cfg(all(feature = "multipart", not(target_arch = "wasm32")))]
688fn multipart_parameter(value: &str) -> String {
689    value
690        .replace('\r', "%0D")
691        .replace('\n', "%0A")
692        .replace('"', "%22")
693}
694
695struct DummyNetWaker;
696impl NetWaker for DummyNetWaker {
697    fn wake(&self, _client_id: usize) {}
698}