Skip to main content

vapour_protocol/
serverlist.rs

1use std::sync::Arc;
2
3use tokio::sync::Mutex;
4
5use crate::error::{Error, Result};
6
7const CM_LIST_URL: &str = "https://api.steampowered.com/ISteamDirectory/GetCMListForConnect/v1/?cellid=0&cmtype=websockets&format=json";
8
9#[derive(Debug, Clone, PartialEq)]
10pub struct CmServer {
11    pub endpoint: String,
12    pub legacy_endpoint: Option<String>,
13    pub data_center: Option<String>,
14    pub realm: Option<String>,
15    pub load: Option<u32>,
16    pub weighted_load: Option<f64>,
17}
18
19impl CmServer {
20    pub fn websocket_url(&self) -> String {
21        format!("wss://{}/cmsocket/", self.endpoint)
22    }
23}
24
25#[derive(Clone, Debug)]
26pub struct ServerListCache {
27    client: reqwest::Client,
28    cache: Arc<Mutex<Vec<CmServer>>>,
29}
30
31impl Default for ServerListCache {
32    fn default() -> Self {
33        Self::new()
34    }
35}
36
37impl ServerListCache {
38    pub fn new() -> Self {
39        Self {
40            client: reqwest::Client::builder()
41                .user_agent("vapour-protocol/0.1")
42                .build()
43                .expect("reqwest client builder is valid"),
44            cache: Arc::new(Mutex::new(Vec::new())),
45        }
46    }
47
48    pub async fn list(&self, force_refresh: bool) -> Result<Vec<CmServer>> {
49        let mut cache = self.cache.lock().await;
50        if force_refresh || cache.is_empty() {
51            *cache = fetch_cm_list(&self.client).await?;
52        }
53
54        Ok(cache.clone())
55    }
56}
57
58pub async fn fetch_cm_list(client: &reqwest::Client) -> Result<Vec<CmServer>> {
59    let response = client.get(CM_LIST_URL).send().await?.error_for_status()?;
60    let directory: DirectoryResponse = response.json().await?;
61
62    if !directory.response.success {
63        return Err(Error::Protocol(
64            "Steam CM directory returned failure".to_owned(),
65        ));
66    }
67
68    let mut servers: Vec<CmServer> = directory
69        .response
70        .serverlist
71        .into_iter()
72        .map(|server| CmServer {
73            endpoint: server.endpoint,
74            legacy_endpoint: server.legacy_endpoint,
75            data_center: server.data_center,
76            realm: server.realm,
77            load: server.load,
78            weighted_load: server.weighted_load,
79        })
80        .collect();
81
82    servers.sort_by(|left, right| {
83        left.weighted_load
84            .partial_cmp(&right.weighted_load)
85            .unwrap_or(std::cmp::Ordering::Equal)
86    });
87
88    if servers.is_empty() {
89        return Err(Error::InvalidResponse(
90            "Steam CM directory returned no servers",
91        ));
92    }
93
94    Ok(servers)
95}
96
97#[derive(Debug, serde::Deserialize)]
98struct DirectoryResponse {
99    response: DirectoryBody,
100}
101
102#[derive(Debug, serde::Deserialize)]
103struct DirectoryBody {
104    #[serde(default)]
105    serverlist: Vec<DirectoryServer>,
106    success: bool,
107}
108
109#[derive(Debug, serde::Deserialize)]
110struct DirectoryServer {
111    endpoint: String,
112    #[serde(default)]
113    legacy_endpoint: Option<String>,
114    #[serde(default, rename = "dc")]
115    data_center: Option<String>,
116    #[serde(default)]
117    realm: Option<String>,
118    #[serde(default)]
119    load: Option<u32>,
120    #[serde(default, rename = "wtd_load")]
121    weighted_load: Option<f64>,
122}