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