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            // Inject Origin header for mutation methods (POST, PUT, PATCH)
157            // Chrome sends this even for same-origin requests to prevent CSRF.
158            if current_method == "POST" || current_method == "PUT" || current_method == "PATCH" {
159                if let Ok(val) =
160                    http::header::HeaderValue::from_str(&parsed_url.origin().ascii_serialization())
161                {
162                    request.headers_mut().insert("origin", val);
163                }
164            }
165
166            // Injects Chrome-identical headers, handling dynamic Sec-Fetch and Priority states.
167            let is_initial = hop == 0;
168            inject_chrome_headers(
169                request.headers_mut(),
170                &self.profile,
171                &sec_fetch_site,
172                is_initial,
173            );
174
175            // Connection acquisition logic.
176            let conn = {
177                let mut pool = self.pool.lock().map_err(|_| {
178                    Error::Connect(std::io::Error::other("connection pool poisoned"))
179                })?;
180                pool.remove(&key)
181            };
182
183            let mut h2_client = if let Some(mut c) = conn {
184                // Verify if the pooled connection is still active and ready for a new stream.
185                match c.h2.ready().await {
186                    Ok(h2) => {
187                        c.h2 = h2;
188                        c
189                    }
190                    Err(_) => self.dial(authority, port, &self.profile).await?,
191                }
192            } else {
193                self.dial(authority, port, &self.profile).await?
194            };
195
196            let mut response = h2_client.send(request, current_body.clone()).await?;
197
198            // Return the connection to the pool for potential reuse.
199            if let Ok(mut pool) = self.pool.lock() {
200                pool.insert(key, h2_client);
201            }
202
203            self.store_cookies(&response, &parsed_url);
204
205            let status = response.status();
206            if status.is_redirection() {
207                if let Some(location) = response.headers().get("location") {
208                    let loc_str = location.to_str().unwrap_or("");
209                    let next_url = parsed_url
210                        .join(loc_str)
211                        .map_err(|e| Error::InvalidUrl(e.to_string()))?;
212
213                    // Redirect Mutation: Rotate POST to GET on standard redirects.
214                    if status == http::StatusCode::MOVED_PERMANENTLY
215                        || status == http::StatusCode::FOUND
216                        || status == http::StatusCode::SEE_OTHER
217                    {
218                        current_method = "GET".to_string();
219                        current_body = None;
220                    }
221
222                    // sec-fetch-site computation: Once cross-site, always cross-site.
223                    if !is_cross_site {
224                        if next_url.origin() == parsed_url.origin() {
225                            sec_fetch_site = "same-origin".to_string();
226                        } else if next_url.domain() == parsed_url.domain() {
227                            sec_fetch_site = "same-site".to_string();
228                        } else {
229                            sec_fetch_site = "cross-site".to_string();
230                            is_cross_site = true;
231                        }
232                    }
233
234                    current_url_str = next_url.to_string();
235                    continue;
236                }
237            }
238
239            response.set_url(current_url_str);
240            return Ok(response);
241        }
242
243        Err(Error::Connect(std::io::Error::other(
244            "Redirect limit exceeded (max 10)",
245        )))
246    }
247
248    /// Dials a new connection following the profile's transport constraints.
249    async fn dial(
250        &self,
251        authority: &str,
252        port: u16,
253        profile: &ChromeProfile,
254    ) -> Result<QuikConnection> {
255        let addr_str = format!("{}:{}", authority, port);
256        let addr = addr_str.to_socket_addrs()?.next().ok_or_else(|| {
257            std::io::Error::new(std::io::ErrorKind::NotFound, "could not resolve host")
258        })?;
259
260        connect(authority, port, addr, profile, self.proxy.as_ref()).await
261    }
262
263    /// Persists `Set-Cookie` headers from a response into the synchronized cookie store.
264    fn store_cookies(&self, resp: &Response, url: &Url) {
265        if let Ok(mut store) = self.cookie_store.write() {
266            for v in resp.headers().get_all("set-cookie").iter() {
267                if let Ok(cookie_str) = v.to_str() {
268                    let _ = store.parse(cookie_str, url);
269                }
270            }
271        }
272    }
273}
274
275/// A builder for constructing a `Client` with specific identity and transport settings.
276#[derive(Default)]
277pub struct ClientBuilder {
278    profile: Option<ChromeProfile>,
279    proxy: Option<Proxy>,
280    cookie_store: Option<Arc<RwLock<CookieStore>>>,
281}
282
283impl ClientBuilder {
284    /// Sets the Chrome identity profile.
285    pub fn profile(mut self, profile: ChromeProfile) -> Self {
286        self.profile = Some(profile);
287        self
288    }
289
290    /// Configures an outbound proxy.
291    pub fn proxy(mut self, proxy: Proxy) -> Self {
292        self.proxy = Some(proxy);
293        self
294    }
295
296    /// Provides a pre-existing synchronized cookie store.
297    pub fn cookie_store(mut self, store: Arc<RwLock<CookieStore>>) -> Self {
298        self.cookie_store = Some(store);
299        self
300    }
301
302    /// Finalizes the configuration and constructs a `Client`.
303    pub fn build(self) -> Result<Client> {
304        let profile = self
305            .profile
306            .unwrap_or_else(|| crate::profile::chrome_134::profile(Platform::MacOsArm));
307
308        Ok(Client {
309            pool: Arc::new(Mutex::new(HashMap::new())),
310            profile,
311            proxy: self.proxy,
312            cookie_store: self
313                .cookie_store
314                .unwrap_or_else(|| Arc::new(RwLock::new(CookieStore::default()))),
315        })
316    }
317}