Skip to main content

rust_mc_status/client/
mod.rs

1#![cfg_attr(docsrs, feature(doc_cfg))]
2pub mod builder;
3pub mod facade;
4
5pub use builder::{JavaPingBuilder, BedrockPingBuilder, ServerPingBuilder};
6pub use facade::{ping_java, ping_bedrock};
7
8use std::time::Duration;
9
10use crate::core::{dns::DnsResolver, cache::{ResponseCache, DEFAULT_RESPONSE_CACHE_SIZE}, address};
11use crate::error::McError;
12use crate::models::*;
13use crate::protocol::{
14    bedrock::BedrockProtocol,
15    java_modern::JavaModernProtocol,
16    PingProtocol, ResolvedTarget,
17};
18use crate::proxy::ProxyConfig;
19use crate::status::{BedrockServerStatus, JavaServerStatus};
20
21const DEFAULT_TIMEOUT:        Duration = Duration::from_secs(10);
22const DEFAULT_MAX_PARALLEL:   usize    = 10;
23const JAVA_DEFAULT_PORT:      u16      = 25565;
24const BEDROCK_DEFAULT_PORT:   u16      = 19132;
25
26// ─── McClient ─────────────────────────────────────────────────────────────────
27
28/// Async client for pinging Minecraft Java and Bedrock servers.
29///
30/// Constructed via [`McClient::builder()`] — there is no `new()`.
31/// Cheaply `Clone`-able — all clones share the same DNS/SRV and response caches.
32///
33/// # Example
34///
35/// ```rust,no_run
36/// use rust_mc_status::McClient;
37/// use std::time::Duration;
38///
39/// # #[tokio::main] async fn main() -> Result<(), rust_mc_status::McError> {
40/// let client = McClient::builder()
41///     .timeout(Duration::from_secs(5))
42///     .max_parallel(20)
43///     .response_cache(Duration::from_secs(30), 256)
44///     .build();
45///
46/// let status = client.java("mc.hypixel.net").await?;
47/// # Ok(()) }
48/// ```
49#[derive(Clone)]
50pub struct McClient {
51    pub(crate) timeout:        Duration,
52    pub(crate) max_parallel:   usize,
53    pub(crate) dns:            DnsResolver,
54    pub(crate) response_cache: ResponseCache,
55    #[cfg(feature = "proxy")]
56    pub(crate) proxy:          Option<ProxyConfig>,
57}
58
59impl McClient {
60    /// Return a [`McClientBuilder`] — the only way to construct a [`McClient`].
61    pub fn builder() -> McClientBuilder { McClientBuilder::new() }
62
63    // ─── Accessors ────────────────────────────────────────────────────────────
64
65    /// Returns the configured request timeout.
66    pub fn timeout(&self) -> Duration { self.timeout }
67
68    /// Returns the configured maximum number of concurrent pings in [`ping_many`](Self::ping_many).
69    pub fn max_parallel(&self) -> usize { self.max_parallel }
70
71    // ─── Tower ────────────────────────────────────────────────────────────────
72
73    /// Wrap this client in a [`tower::Service`] to add middleware such as
74    /// rate limiting, retries, timeouts, and buffering.
75    ///
76    /// See [`McService`](crate::service::McService) and the `tower_usage` example
77    /// for detailed usage.
78    ///
79    /// # Example
80    ///
81    /// ```rust,no_run
82    /// use rust_mc_status::{McClient, McRetryPolicy};
83    /// use tower::ServiceBuilder;
84    /// use std::time::Duration;
85    ///
86    /// # #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> {
87    /// let svc = ServiceBuilder::new()
88    ///     .buffer(64)
89    ///     .retry(McRetryPolicy::new(3))
90    ///     .service(McClient::builder().build().into_service());
91    /// # Ok(()) }
92    /// ```
93    #[cfg(feature = "tower")]
94    #[cfg_attr(docsrs, doc(cfg(feature = "tower")))]
95    pub fn into_service(self) -> crate::service::McService {
96        crate::service::McService::new(self)
97    }
98
99    // ─── Cache control ────────────────────────────────────────────────────────
100
101    /// Clear the DNS and SRV caches.
102    ///
103    /// The next ping to any host will re-resolve DNS from scratch.
104    /// The response cache is unaffected — use [`clear_response_cache`](Self::clear_response_cache)
105    /// to clear it separately.
106    pub async fn clear_caches(&self) { self.dns.clear().await; }
107
108    /// Clear the response cache only.
109    ///
110    /// The next ping to any cached server will perform a live network request.
111    /// DNS/SRV caches are unaffected.
112    pub async fn clear_response_cache(&self) { self.response_cache.clear().await; }
113
114    /// Return a snapshot of current cache entry counts.
115    ///
116    /// Useful for monitoring and debugging — does not block any in-flight pings.
117    pub async fn cache_stats(&self) -> CacheStats {
118        CacheStats {
119            dns_entries:      self.dns.dns_len().await,
120            srv_entries:      self.dns.srv_len().await,
121            response_entries: self.response_cache.len().await,
122        }
123    }
124
125    // ─── Ping API ─────────────────────────────────────────────────────────────
126
127    /// Start a Java Edition ping for `address`.
128    ///
129    /// Returns a [`JavaPingBuilder`] — call `.await` to execute, or chain
130    /// `.timeout(Duration)` to override the timeout for this request only.
131    ///
132    /// # Errors
133    ///
134    /// The returned future may fail with:
135    /// - [`McError::Config`] — if `address` is malformed (bad port, missing `]`).
136    /// - [`McError::Network`] — DNS failure, connection refused, or timeout.
137    /// - [`McError::Protocol`] — unexpected server response.
138    ///
139    /// # Example
140    ///
141    /// ```rust,no_run
142    /// use rust_mc_status::{McClient, StatusExt};
143    /// use std::time::Duration;
144    ///
145    /// # #[tokio::main] async fn main() -> Result<(), rust_mc_status::McError> {
146    /// let client = McClient::builder().build();
147    ///
148    /// // Default timeout
149    /// let s = client.java("mc.hypixel.net").await?;
150    ///
151    /// // Per-request timeout
152    /// let s = client.java("mc.hypixel.net")
153    ///     .timeout(Duration::from_secs(3))
154    ///     .await?;
155    /// # Ok(()) }
156    /// ```
157    pub fn java(&self, address: impl Into<String>) -> JavaPingBuilder {
158        JavaPingBuilder::new(self.clone(), address)
159    }
160
161    /// Start a Bedrock Edition ping for `address`.
162    ///
163    /// Returns a [`BedrockPingBuilder`] — call `.await` to execute, or chain
164    /// `.timeout(Duration)` to override the timeout for this request only.
165    ///
166    /// # Errors
167    ///
168    /// The returned future may fail with:
169    /// - [`McError::Config`] — if `address` is malformed.
170    /// - [`McError::Network`] — DNS failure, UDP send/receive error, or timeout.
171    /// - [`McError::Protocol`] — pong packet too short or malformed MOTD.
172    /// - [`McError::Proxy`] — proxy does not support UDP *(feature = "proxy")*.
173    ///
174    /// # Example
175    ///
176    /// ```rust,no_run
177    /// use rust_mc_status::{McClient, StatusExt};
178    ///
179    /// # #[tokio::main] async fn main() -> Result<(), rust_mc_status::McError> {
180    /// let client = McClient::builder().build();
181    /// let s = client.bedrock("geo.hivebedrock.network:19132").await?;
182    /// println!("{} — {}", s.edition(), s.display_players());
183    /// # Ok(()) }
184    /// ```
185    pub fn bedrock(&self, address: impl Into<String>) -> BedrockPingBuilder {
186        BedrockPingBuilder::new(self.clone(), address)
187    }
188
189    /// Start a ping where the edition is chosen at runtime.
190    ///
191    /// Useful when edition comes from user input or a config file.
192    /// Returns a [`ServerPingBuilder`] — call `.await` for a raw [`ServerStatus`],
193    /// or `.is_online().await` for a simple boolean reachability check.
194    ///
195    /// # Example
196    ///
197    /// ```rust,no_run
198    /// use rust_mc_status::{McClient, ServerEdition};
199    /// use std::time::Duration;
200    ///
201    /// # #[tokio::main] async fn main() -> Result<(), rust_mc_status::McError> {
202    /// let client = McClient::builder().build();
203    /// let edition: ServerEdition = "java".parse()?;
204    ///
205    /// let online = client
206    ///     .server("mc.hypixel.net", edition)
207    ///     .timeout(Duration::from_secs(5))
208    ///     .is_online()
209    ///     .await;
210    ///
211    /// println!("online: {online}");
212    /// # Ok(()) }
213    /// ```
214    pub fn server(&self, address: impl Into<String>, edition: ServerEdition) -> ServerPingBuilder {
215        ServerPingBuilder::new(self.clone(), address, edition)
216    }
217
218    // ─── Batch ────────────────────────────────────────────────────────────────
219
220    /// Ping multiple servers concurrently with bounded parallelism.
221    ///
222    /// All tasks are spawned immediately — a [`Semaphore`] limits how many run
223    /// at once (`max_parallel`, default 10). Results are streamed back as they
224    /// complete so a slow or timed-out server never blocks the rest.
225    ///
226    /// When the response cache is enabled, duplicate addresses in the list
227    /// benefit from in-flight deduplication — only one real ping is made per
228    /// unique key regardless of how many times it appears.
229    ///
230    /// # Return value
231    ///
232    /// A `Vec` of `(ServerInfo, Result<ServerStatus, McError>)` pairs in
233    /// completion order (not input order).  Each entry either contains the
234    /// full [`ServerStatus`] or the error that occurred for that server.
235    ///
236    /// [`Semaphore`]: tokio::sync::Semaphore
237    ///
238    /// All tasks are spawned immediately — a [`Semaphore`] limits how many run
239    /// at once. Results stream back as they complete, so a slow or timed-out
240    /// server never blocks the rest.
241    ///
242    /// Duplicate addresses benefit from in-flight deduplication when the
243    /// response cache is enabled.
244    ///
245    /// [`Semaphore`]: tokio::sync::Semaphore
246    pub async fn ping_many(
247        &self,
248        servers: &[ServerInfo],
249    ) -> Vec<(ServerInfo, Result<ServerStatus, McError>)> {
250        use std::sync::Arc;
251        use tokio::sync::Semaphore;
252        use tokio::task::JoinSet;
253
254        let sem = Arc::new(Semaphore::new(self.max_parallel));
255        let mut set = JoinSet::new();
256
257        for s in servers {
258            let s   = s.clone();
259            let c   = self.clone();
260            let sem = Arc::clone(&sem);
261            set.spawn(async move {
262                let _permit = sem.acquire().await;
263                let result  = match s.edition {
264                    ServerEdition::Java    => c.ping_java_inner(&s.address).await.map(|j| j.0),
265                    ServerEdition::Bedrock => c.ping_bedrock_inner(&s.address).await.map(|b| b.0),
266                };
267                (s, result)
268            });
269        }
270
271        let mut out = Vec::with_capacity(servers.len());
272        while let Some(Ok(item)) = set.join_next().await {
273            out.push(item);
274        }
275        out
276    }
277
278    // ─── Internal ─────────────────────────────────────────────────────────────
279
280    #[inline]
281    fn proxy_ref(&self) -> Option<&ProxyConfig> {
282        #[cfg(feature = "proxy")]
283        return self.proxy.as_ref();
284        #[cfg(not(feature = "proxy"))]
285        return None;
286    }
287
288    pub(crate) async fn ping_java_inner(&self, address: &str) -> Result<JavaServerStatus, McError> {
289        let s = self.ping_with(address, JAVA_DEFAULT_PORT, &JavaModernProtocol, true).await?;
290        Ok(JavaServerStatus(s))
291    }
292
293    pub(crate) async fn ping_bedrock_inner(&self, address: &str) -> Result<BedrockServerStatus, McError> {
294        let s = self.ping_with(address, BEDROCK_DEFAULT_PORT, &BedrockProtocol, false).await?;
295        Ok(BedrockServerStatus(s))
296    }
297
298    pub(crate) async fn ping_with<P: PingProtocol>(
299        &self,
300        addr:         &str,
301        default_port: u16,
302        protocol:     &P,
303        try_srv:      bool,
304    ) -> Result<ServerStatus, McError> {
305        let cache_key = format!("{}:{}", protocol.name(), addr);
306
307        // 1. Completed cache
308        if let Some(cached) = self.response_cache.get(&cache_key).await {
309            return Ok(cached);
310        }
311
312        // 2. In-flight deduplication (thundering herd protection)
313        if let Some(mut rx) = self.response_cache.join_or_register(&cache_key).await {
314            return match rx.recv().await {
315                Ok(Ok(status)) => {
316                    let mut s = status;
317                    s.cached  = true;
318                    s.latency = 0.0;
319                    Ok(s)
320                }
321                Ok(Err(msg)) => Err(McError::invalid_response(msg)),
322                Err(_)       => self.do_ping(addr, default_port, protocol, try_srv, &cache_key).await,
323            };
324        }
325
326        // 3. We are the designated pinger
327        self.do_ping(addr, default_port, protocol, try_srv, &cache_key).await
328    }
329
330    async fn do_ping<P: PingProtocol>(
331        &self,
332        addr:         &str,
333        default_port: u16,
334        protocol:     &P,
335        try_srv:      bool,
336        cache_key:    &str,
337    ) -> Result<ServerStatus, McError> {
338        let (host, port, explicit_port) = address::parse(addr, default_port)?;
339
340        let (actual_host, actual_port) = if try_srv && !explicit_port {
341            self.dns.lookup_srv(host, self.timeout).await
342                .unwrap_or_else(|| (host.to_string(), port))
343        } else {
344            (host.to_string(), port)
345        };
346
347        let (resolved_addr, dns_info) = self.dns.resolve(&actual_host, actual_port).await?;
348
349        let target = ResolvedTarget {
350            addr:     resolved_addr,
351            hostname: host.to_string(),
352            dns_info,
353        };
354
355        match protocol.ping(&target, self.timeout, self.proxy_ref()).await {
356            Ok(result) => {
357                let status = ServerStatus {
358                    online:   true,
359                    ip:       resolved_addr.ip().to_string(),
360                    port:     resolved_addr.port(),
361                    hostname: host.to_string(),
362                    latency:  result.latency,
363                    dns:      Some(target.dns_info),
364                    data:     result.data,
365                    cached:   false,
366                    meta:     result.meta,
367                };
368                self.response_cache.insert(cache_key.to_string(), status.clone()).await;
369                Ok(status)
370            }
371            Err(e) => {
372                self.response_cache.insert_error(cache_key, &e).await;
373                Err(e)
374            }
375        }
376    }
377}
378
379// ─── McClientBuilder ──────────────────────────────────────────────────────────
380
381/// Builder for [`McClient`].
382///
383/// Obtain via [`McClient::builder()`].
384///
385/// All fields are optional — unset fields use sensible defaults.
386///
387/// # Defaults
388///
389/// | Field              | Default  |
390/// |--------------------|----------|
391/// | `timeout`          | 10 s     |
392/// | `max_parallel`     | 10       |
393/// | `dns_cache_size`   | 1024     |
394/// | `response_cache`   | disabled |
395/// | `proxy`            | none     |
396#[must_use = "call .build() to create the McClient"]
397pub struct McClientBuilder {
398    timeout:             Duration,
399    max_parallel:        usize,
400    dns_cache_size:      Option<usize>,
401    response_cache_ttl:  Option<Duration>,
402    response_cache_size: usize,
403    #[cfg(feature = "proxy")]
404    proxy:               Option<ProxyConfig>,
405}
406
407impl Default for McClientBuilder {
408    fn default() -> Self {
409        Self {
410            timeout:             DEFAULT_TIMEOUT,
411            max_parallel:        DEFAULT_MAX_PARALLEL,
412            dns_cache_size:      None,
413            response_cache_ttl:  None,
414            response_cache_size: DEFAULT_RESPONSE_CACHE_SIZE,
415            #[cfg(feature = "proxy")]
416            proxy:               None,
417        }
418    }
419}
420
421impl McClientBuilder {
422    pub(crate) fn new() -> Self { Self::default() }
423
424    /// Maximum time to wait for a single ping (default: 10 s).
425    pub fn timeout(mut self, timeout: Duration) -> Self {
426        self.timeout = timeout;
427        self
428    }
429
430    /// Maximum number of concurrent pings in [`McClient::ping_many`] (default: 10).
431    pub fn max_parallel(mut self, n: usize) -> Self {
432        self.max_parallel = n;
433        self
434    }
435
436    /// LRU capacity for the DNS/SRV cache (default: 1024).
437    pub fn dns_cache_size(mut self, size: usize) -> Self {
438        self.dns_cache_size = Some(size);
439        self
440    }
441
442    /// Enable the response cache with the given TTL and LRU capacity.
443    ///
444    /// A cached response is returned instantly with `latency = 0` and
445    /// `is_cached() = true`.
446    ///
447    /// # Example
448    ///
449    /// ```rust,no_run
450    /// use rust_mc_status::McClient;
451    /// use std::time::Duration;
452    ///
453    /// let client = McClient::builder()
454    ///     .response_cache(Duration::from_secs(30), 100)
455    ///     .build();
456    /// ```
457    pub fn response_cache(mut self, ttl: Duration, size: usize) -> Self {
458        self.response_cache_ttl  = Some(ttl);
459        self.response_cache_size = size;
460        self
461    }
462
463    /// Enable the response cache with only a TTL (capacity defaults to 256).
464    pub fn response_cache_ttl(mut self, ttl: Duration) -> Self {
465        self.response_cache_ttl = Some(ttl);
466        self
467    }
468
469    /// Route all pings through a SOCKS5 proxy.
470    ///
471    /// Java Edition (TCP) is fully supported.
472    /// Bedrock Edition (UDP) requires [`ProxyConfig::socks5_with_udp`].
473    #[cfg(feature = "proxy")]
474    #[cfg_attr(docsrs, doc(cfg(feature = "proxy")))]
475    pub fn proxy(mut self, proxy: ProxyConfig) -> Self {
476        self.proxy = Some(proxy);
477        self
478    }
479
480    /// Consume the builder and return a configured [`McClient`].
481    pub fn build(self) -> McClient {
482        let dns = match self.dns_cache_size {
483            Some(size) => DnsResolver::with_cache_size(size),
484            None       => DnsResolver::new(),
485        };
486        let response_cache = match self.response_cache_ttl {
487            Some(ttl) => ResponseCache::new(ttl, self.response_cache_size),
488            None      => ResponseCache::disabled(),
489        };
490        McClient {
491            timeout:        self.timeout,
492            max_parallel:   self.max_parallel,
493            dns,
494            response_cache,
495            #[cfg(feature = "proxy")]
496            proxy:          self.proxy,
497        }
498    }
499}