Skip to main content

http_quik/client/
pool.rs

1use std::collections::HashMap;
2use std::net::ToSocketAddrs;
3use std::sync::{Arc, Mutex};
4use url::Url;
5
6use crate::client::connector::{connect, QuikConnection};
7use crate::client::proxy::Proxy;
8use crate::client::request::inject_chrome_headers;
9use crate::client::response::Response;
10use crate::error::{Error, Result};
11use crate::profile::ChromeProfile;
12
13use bytes::Bytes;
14use cookie_store::CookieStore;
15use std::sync::RwLock;
16
17/// A stateful, pooling HTTP client that enforces Chrome transport identity.
18///
19/// The `Client` is the primary entry point for the `http-quik` library. It manages:
20/// 1. **Connection Pooling**: Reuses established HTTP/2 sessions to maintain persistent fingerprints.
21/// 2. **Cookie Persistence**: A synchronized cookie jar shared across all requests.
22/// 3. **Stealth Redirects**: Automatically follows redirects while mutating headers and methods
23///    to match Chromium's behavioral markers.
24/// 4. **OS Auto-Detection**: Defaults to a Chrome profile matched to the host OS,
25///    ensuring consistency between the TLS/H2 persona and the kernel's TCP stack.
26///
27/// # Example
28/// ```rust
29/// use http_quik::Client;
30///
31/// let client = Client::new();
32/// ```
33#[derive(Clone)]
34pub struct Client {
35    /// A synchronized pool of active H2 connections keyed by their origin and proxy.
36    pool: Arc<Mutex<HashMap<String, QuikConnection>>>,
37    /// The canonical identity profile used for all transport-layer operations.
38    profile: ChromeProfile,
39    /// An optional proxy used for all outbound connections.
40    proxy: Option<Proxy>,
41    /// A synchronized cookie jar shared across all requests.
42    ///
43    /// This store is thread-safe and is automatically updated during redirect
44    /// chains and standard request execution.
45    pub cookie_store: Arc<RwLock<CookieStore>>,
46}
47
48impl Default for Client {
49    fn default() -> Self {
50        Self::new()
51    }
52}
53
54impl Client {
55    /// Creates a new `Client` with a Chrome 134 profile auto-matched to the host OS.
56    ///
57    /// The profile is selected at compile time to ensure consistency between
58    /// the TLS/H2 persona and the host kernel's TCP stack.
59    /// For custom profiles or proxies, use [`Client::builder`].
60    pub fn new() -> Self {
61        Self::builder().build().unwrap_or_else(|_| Client {
62            pool: Arc::new(Mutex::new(HashMap::new())),
63            profile: crate::profile::chrome_134::profile_auto(),
64            proxy: None,
65            cookie_store: Arc::new(RwLock::new(CookieStore::default())),
66        })
67    }
68
69    /// Returns a [`ClientBuilder`] to configure a specialized `Client` instance.
70    pub fn builder() -> ClientBuilder {
71        ClientBuilder::default()
72    }
73
74    /// Executes a GET request and follows redirects stealthily.
75    pub async fn get(&self, url: &str) -> Result<Response> {
76        self.execute_with_redirects("GET", url, None).await
77    }
78
79    /// Executes a POST request and follows redirects stealthily.
80    pub async fn post(&self, url: &str, body: Bytes) -> Result<Response> {
81        self.execute_with_redirects("POST", url, Some(body)).await
82    }
83
84    /// Core request execution engine with automated, stateful redirect handling.
85    ///
86    /// This method implements a high-fidelity Chromium redirect state machine:
87    ///
88    /// 1. **Sec-Fetch-Site Evolution**: Dynamically calculates origin relationships
89    ///    (same-origin, same-site, cross-site) across hops to maintain stealth.
90    /// 2. **Header Mutation**: Automatically strips `sec-fetch-user` and
91    ///    `upgrade-insecure-requests` after the first hop, exactly like Chrome.
92    /// 3. **Method Rotation**: Rotates POST requests to GET for 301, 302, and 303
93    ///    status codes to prevent out-of-spec behavioral markers.
94    /// 4. **H2 Multiplexing**: Reuses existing connections from the pool to avoid
95    ///    redundant TLS handshakes that could trigger anti-bot alerts.
96    async fn execute_with_redirects(
97        &self,
98        initial_method: &str,
99        initial_url: &str,
100        initial_body: Option<Bytes>,
101    ) -> Result<Response> {
102        let mut current_url_str = initial_url.to_string();
103        let mut current_method = initial_method.to_string();
104        let mut current_body = initial_body;
105
106        let mut sec_fetch_site = "none".to_string();
107        let mut is_cross_site = false;
108
109        for hop in 0..10 {
110            let parsed_url =
111                Url::parse(&current_url_str).map_err(|e| Error::InvalidUrl(e.to_string()))?;
112            let authority = parsed_url
113                .host_str()
114                .ok_or_else(|| Error::InvalidUrl("missing host".to_string()))?;
115            let port = parsed_url.port().unwrap_or_else(|| {
116                if parsed_url.scheme() == "http" {
117                    80
118                } else {
119                    443
120                }
121            });
122
123            // Build a unique pool key considering the proxy and target origin.
124            let proxy_prefix = self
125                .proxy
126                .as_ref()
127                .map(|p| match p {
128                    Proxy::Http(a) => format!("http://{}@", a),
129                    Proxy::Socks5(a) => format!("socks5://{}@", a),
130                })
131                .unwrap_or_default();
132
133            let key = format!("{}{}:{}", proxy_prefix, authority, port);
134
135            // Extract relevant cookies for the current target URL.
136            let cookie_header = {
137                let store = self
138                    .cookie_store
139                    .read()
140                    .map_err(|_| Error::Connect(std::io::Error::other("cookie store poisoned")))?;
141                let cookies: Vec<_> = store
142                    .matches(&parsed_url)
143                    .iter()
144                    .map(|c| format!("{}={}", c.name(), c.value()))
145                    .collect();
146                if cookies.is_empty() {
147                    None
148                } else {
149                    Some(cookies.join("; "))
150                }
151            };
152
153            let mut request = http::Request::builder()
154                .method(current_method.as_str())
155                .uri(parsed_url.as_str())
156                .body(())
157                .map_err(|e| Error::InvalidUrl(e.to_string()))?;
158
159            if let Some(c) = cookie_header.as_deref() {
160                if let Ok(val) = http::header::HeaderValue::from_str(c) {
161                    request.headers_mut().insert("cookie", val);
162                }
163            }
164
165            // Inject Origin header for mutation methods (POST, PUT, PATCH)
166            // Chrome sends this even for same-origin requests to prevent CSRF.
167            if current_method == "POST" || current_method == "PUT" || current_method == "PATCH" {
168                if let Ok(val) =
169                    http::header::HeaderValue::from_str(&parsed_url.origin().ascii_serialization())
170                {
171                    request.headers_mut().insert("origin", val);
172                }
173            }
174
175            // Injects Chrome-identical headers, handling dynamic Sec-Fetch and Priority states.
176            let is_initial = hop == 0;
177            inject_chrome_headers(
178                request.headers_mut(),
179                &self.profile,
180                &sec_fetch_site,
181                is_initial,
182            );
183
184            // Connection acquisition logic.
185            let conn = {
186                let mut pool = self.pool.lock().map_err(|_| {
187                    Error::Connect(std::io::Error::other("connection pool poisoned"))
188                })?;
189                pool.remove(&key)
190            };
191
192            let mut h2_client = if let Some(mut c) = conn {
193                // Verify if the pooled connection is still active and ready for a new stream.
194                match c.h2.ready().await {
195                    Ok(h2) => {
196                        c.h2 = h2;
197                        c
198                    }
199                    Err(_) => self.dial(authority, port, &self.profile).await?,
200                }
201            } else {
202                self.dial(authority, port, &self.profile).await?
203            };
204
205            let mut response = h2_client.send(request, current_body.clone()).await?;
206
207            // Return the connection to the pool for potential reuse.
208            if let Ok(mut pool) = self.pool.lock() {
209                pool.insert(key, h2_client);
210            }
211
212            self.store_cookies(&response, &parsed_url);
213
214            let status = response.status();
215            if status.is_redirection() {
216                if let Some(location) = response.headers().get("location") {
217                    let loc_str = location.to_str().unwrap_or("");
218                    let next_url = parsed_url
219                        .join(loc_str)
220                        .map_err(|e| Error::InvalidUrl(e.to_string()))?;
221
222                    // Redirect Mutation: Rotate POST to GET on standard redirects.
223                    if status == http::StatusCode::MOVED_PERMANENTLY
224                        || status == http::StatusCode::FOUND
225                        || status == http::StatusCode::SEE_OTHER
226                    {
227                        current_method = "GET".to_string();
228                        current_body = None;
229                    }
230
231                    // sec-fetch-site computation: Once cross-site, always cross-site.
232                    if !is_cross_site {
233                        if next_url.origin() == parsed_url.origin() {
234                            sec_fetch_site = "same-origin".to_string();
235                        } else if next_url.domain() == parsed_url.domain() {
236                            sec_fetch_site = "same-site".to_string();
237                        } else {
238                            sec_fetch_site = "cross-site".to_string();
239                            is_cross_site = true;
240                        }
241                    }
242
243                    current_url_str = next_url.to_string();
244                    continue;
245                }
246            }
247
248            response.set_url(current_url_str);
249            return Ok(response);
250        }
251
252        Err(Error::Connect(std::io::Error::other(
253            "Redirect limit exceeded (max 10)",
254        )))
255    }
256
257    /// Dials a new connection following the profile's transport constraints.
258    async fn dial(
259        &self,
260        authority: &str,
261        port: u16,
262        profile: &ChromeProfile,
263    ) -> Result<QuikConnection> {
264        let addr_str = format!("{}:{}", authority, port);
265        let addr = addr_str.to_socket_addrs()?.next().ok_or_else(|| {
266            std::io::Error::new(std::io::ErrorKind::NotFound, "could not resolve host")
267        })?;
268
269        connect(authority, port, addr, profile, self.proxy.as_ref()).await
270    }
271
272    /// Persists `Set-Cookie` headers from a response into the synchronized cookie store.
273    fn store_cookies(&self, resp: &Response, url: &Url) {
274        if let Ok(mut store) = self.cookie_store.write() {
275            for v in resp.headers().get_all("set-cookie").iter() {
276                if let Ok(cookie_str) = v.to_str() {
277                    let _ = store.parse(cookie_str, url);
278                }
279            }
280        }
281    }
282}
283
284/// A builder for constructing a `Client` with specific identity and transport settings.
285#[derive(Default)]
286pub struct ClientBuilder {
287    profile: Option<ChromeProfile>,
288    proxy: Option<Proxy>,
289    cookie_store: Option<Arc<RwLock<CookieStore>>>,
290}
291
292impl ClientBuilder {
293    /// Sets the Chrome identity profile.
294    pub fn profile(mut self, profile: ChromeProfile) -> Self {
295        self.profile = Some(profile);
296        self
297    }
298
299    /// Configures an outbound proxy.
300    pub fn proxy(mut self, proxy: Proxy) -> Self {
301        self.proxy = Some(proxy);
302        self
303    }
304
305    /// Provides a pre-existing synchronized cookie store.
306    pub fn cookie_store(mut self, store: Arc<RwLock<CookieStore>>) -> Self {
307        self.cookie_store = Some(store);
308        self
309    }
310
311    /// Finalizes the configuration and constructs a `Client`.
312    pub fn build(self) -> Result<Client> {
313        let profile = self
314            .profile
315            .unwrap_or_else(crate::profile::chrome_134::profile_auto);
316
317        Ok(Client {
318            pool: Arc::new(Mutex::new(HashMap::new())),
319            profile,
320            proxy: self.proxy,
321            cookie_store: self
322                .cookie_store
323                .unwrap_or_else(|| Arc::new(RwLock::new(CookieStore::default()))),
324        })
325    }
326}