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