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}