use std::{
collections::HashMap,
sync::Mutex,
time::{Duration, Instant},
};
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub(crate) struct RegionsResponse {
pub regions: Vec<RegionInfo>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct RegionInfo {
pub url: String,
}
pub(crate) fn is_cloud_host(host: &str) -> bool {
host.ends_with(".livekit.cloud") || host.ends_with(".livekit.run")
}
pub(crate) fn parse_max_age(cache_control: &str) -> Option<Duration> {
cache_control.split(',').find_map(|directive| {
let (name, value) = directive.split_once('=')?;
name.trim().eq_ignore_ascii_case("max-age").then_some(())?;
value.trim().parse::<u64>().ok().map(Duration::from_secs)
})
}
struct CachedRegions {
urls: Vec<String>,
fetched_at: Instant,
ttl: Duration,
}
pub(crate) enum Cached {
Fresh(Vec<String>),
Stale(Vec<String>),
Miss,
}
pub(crate) struct RegionCache {
entries: Mutex<HashMap<String, CachedRegions>>,
default_ttl: Duration,
}
impl RegionCache {
pub(crate) const DEFAULT_TTL: Duration = Duration::from_secs(5);
pub(crate) fn new(default_ttl: Duration) -> Self {
Self { entries: Mutex::new(HashMap::new()), default_ttl }
}
pub(crate) fn get(&self, host: &str) -> Cached {
let entries = self.entries.lock().unwrap();
match entries.get(host) {
Some(e) if e.fetched_at.elapsed() < e.ttl => Cached::Fresh(e.urls.clone()),
Some(e) => Cached::Stale(e.urls.clone()),
None => Cached::Miss,
}
}
pub(crate) fn insert(&self, host: String, urls: Vec<String>, max_age: Option<Duration>) {
let ttl = max_age.unwrap_or(self.default_ttl);
self.entries
.lock()
.unwrap()
.insert(host, CachedRegions { urls, fetched_at: Instant::now(), ttl });
}
#[allow(dead_code)]
pub(crate) fn mark_failed(&self, host: &str, failed_url: &str) {
let mut entries = self.entries.lock().unwrap();
if let Some(entry) = entries.get_mut(host) {
entry.urls.retain(|u| u != failed_url);
if entry.urls.is_empty() {
entries.remove(host);
}
}
}
#[allow(dead_code)]
pub(crate) fn invalidate(&self, host: &str) {
self.entries.lock().unwrap().remove(host);
}
#[allow(dead_code)]
pub(crate) fn clear(&self) {
self.entries.lock().unwrap().clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_cloud_host() {
assert!(is_cloud_host("myapp.livekit.cloud"));
assert!(is_cloud_host("myapp.region.livekit.cloud"));
assert!(is_cloud_host("myapp.livekit.run"));
assert!(!is_cloud_host("localhost"));
assert!(!is_cloud_host("example.com"));
assert!(!is_cloud_host("livekit.cloud.example.com"));
assert!(!is_cloud_host("127.0.0.1"));
}
#[test]
fn test_parse_max_age() {
assert_eq!(parse_max_age("max-age=300"), Some(Duration::from_secs(300)));
assert_eq!(parse_max_age("public, max-age=300"), Some(Duration::from_secs(300)));
assert_eq!(parse_max_age("MAX-AGE=0, no-cache"), Some(Duration::ZERO));
assert_eq!(parse_max_age("no-store"), None);
assert_eq!(parse_max_age("max-age=notanumber"), None);
assert_eq!(parse_max_age(""), None);
}
#[test]
fn region_cache_reports_fresh_stale_and_miss() {
let cache = RegionCache::new(RegionCache::DEFAULT_TTL);
let host = "cache-fresh.livekit.cloud";
assert!(matches!(cache.get(host), Cached::Miss), "unknown host is a miss");
let urls = vec!["wss://r1.livekit.cloud".to_string(), "wss://r2.livekit.cloud".to_string()];
cache.insert(host.to_string(), urls.clone(), None);
assert!(
matches!(cache.get(host), Cached::Fresh(u) if u == urls),
"fresh entry is a fresh hit"
);
let stale_host = "cache-stale.livekit.cloud";
let stale_urls = vec!["wss://old.livekit.cloud".to_string()];
if let Some(past) = Instant::now().checked_sub(RegionCache::DEFAULT_TTL * 2) {
cache.entries.lock().unwrap().insert(
stale_host.to_string(),
CachedRegions {
urls: stale_urls.clone(),
fetched_at: past,
ttl: RegionCache::DEFAULT_TTL,
},
);
assert!(
matches!(cache.get(stale_host), Cached::Stale(u) if u == stale_urls),
"expired entry is a stale hit"
);
}
}
#[test]
fn region_cache_honors_server_max_age() {
let cache = RegionCache::new(Duration::from_secs(3600));
let host = "max-age.livekit.cloud";
let urls = vec!["wss://r1.livekit.cloud".to_string()];
cache.insert(host.to_string(), urls.clone(), Some(Duration::ZERO));
assert!(
matches!(cache.get(host), Cached::Stale(u) if u == urls),
"max-age=0 entry is immediately stale despite the long default TTL"
);
}
#[test]
fn region_cache_mark_failed_prunes_then_drops() {
let cache = RegionCache::new(RegionCache::DEFAULT_TTL);
let host = "mark-failed.livekit.cloud";
let r1 = "wss://r1.livekit.cloud".to_string();
let r2 = "wss://r2.livekit.cloud".to_string();
cache.insert(host.to_string(), vec![r1.clone(), r2.clone()], None);
cache.mark_failed(host, &r1);
assert!(
matches!(cache.get(host), Cached::Fresh(u) if u == vec![r2.clone()]),
"failed URL is pruned, the rest remain"
);
cache.mark_failed(host, &r2);
assert!(matches!(cache.get(host), Cached::Miss), "emptied entry is dropped");
cache.mark_failed("unknown.livekit.cloud", &r1);
}
#[test]
fn region_cache_invalidate_and_clear() {
let cache = RegionCache::new(RegionCache::DEFAULT_TTL);
let a = "a.livekit.cloud";
let b = "b.livekit.cloud";
let urls = vec!["wss://r.livekit.cloud".to_string()];
cache.insert(a.to_string(), urls.clone(), None);
cache.insert(b.to_string(), urls.clone(), None);
cache.invalidate(a);
assert!(matches!(cache.get(a), Cached::Miss), "invalidated host is a miss");
assert!(matches!(cache.get(b), Cached::Fresh(_)), "other host is untouched");
cache.clear();
assert!(matches!(cache.get(b), Cached::Miss), "clear removes all entries");
}
}