browser_control/session/
freshness.rs1use std::time::Duration;
4
5use anyhow::{bail, Result};
6use serde::Deserialize;
7use serde_json::Value;
8
9pub const DEFAULT_MAX_AGE: Duration = Duration::from_secs(10 * 60);
10pub const DEFAULT_MAX_AGE_STR: &str = "10m";
11pub const CHECK_TIMEOUT: Duration = Duration::from_secs(10);
12pub const RELOAD_READY_TIMEOUT: Duration = Duration::from_secs(20);
13pub const READY_POLL_INTERVAL: Duration = Duration::from_millis(200);
14
15pub const PAGE_FRESHNESS_EXPR: &str = r#"(() => ({
16 href: location.href,
17 ageMs: Math.max(0, Date.now() - performance.timeOrigin),
18 readyState: document.readyState
19}))()"#;
20
21pub const READY_STATE_EXPR: &str = "document.readyState";
22
23#[derive(Debug, Clone, Deserialize, PartialEq)]
24pub struct PageFreshness {
25 pub href: String,
26 #[serde(rename = "ageMs")]
27 pub age_ms: f64,
28}
29
30impl PageFreshness {
31 pub fn should_reload(&self, max_age: Duration) -> bool {
32 is_reloadable_url(&self.href) && self.age_ms >= max_age.as_millis() as f64
33 }
34}
35
36pub fn parse_page_freshness(value: Value) -> Result<PageFreshness> {
37 Ok(serde_json::from_value(value)?)
38}
39
40pub fn is_ready(value: &Value) -> bool {
41 matches!(value.as_str(), Some("complete"))
42}
43
44fn is_reloadable_url(url: &str) -> bool {
45 url.starts_with("http://") || url.starts_with("https://")
46}
47
48pub fn parse_max_age(raw: &str) -> Result<Duration> {
49 let raw = raw.trim();
50 if raw.is_empty() {
51 bail!("--max-age must not be empty");
52 }
53
54 let mut rest = raw;
55 let mut total_ms: u128 = 0;
56 while !rest.is_empty() {
57 let digits_len = rest
58 .char_indices()
59 .take_while(|(_, ch)| ch.is_ascii_digit())
60 .map(|(idx, ch)| idx + ch.len_utf8())
61 .last()
62 .unwrap_or(0);
63 if digits_len == 0 {
64 bail!("invalid --max-age `{raw}`: expected a number");
65 }
66 let n: u128 = rest[..digits_len].parse()?;
67 rest = rest[digits_len..].trim_start();
68
69 let unit_len = rest
70 .char_indices()
71 .take_while(|(_, ch)| ch.is_ascii_alphabetic())
72 .map(|(idx, ch)| idx + ch.len_utf8())
73 .last()
74 .unwrap_or(0);
75 let unit = if unit_len == 0 {
76 "s"
77 } else {
78 &rest[..unit_len]
79 };
80 rest = rest[unit_len..].trim_start();
81
82 let factor = match unit {
83 "ms" => 1,
84 "s" | "sec" | "secs" | "second" | "seconds" => 1_000,
85 "m" | "min" | "mins" | "minute" | "minutes" => 60_000,
86 "h" | "hr" | "hrs" | "hour" | "hours" => 3_600_000,
87 other => bail!("invalid --max-age unit `{other}`: expected ms, s, m, or h"),
88 };
89 total_ms = total_ms
90 .checked_add(n.checked_mul(factor).ok_or_else(|| {
91 anyhow::anyhow!("invalid --max-age `{raw}`: duration is too large")
92 })?)
93 .ok_or_else(|| anyhow::anyhow!("invalid --max-age `{raw}`: duration is too large"))?;
94 }
95
96 Ok(Duration::from_millis(total_ms.try_into()?))
97}
98
99#[cfg(test)]
100mod tests {
101 use super::*;
102 use serde_json::json;
103
104 #[test]
105 fn parse_max_age_accepts_common_units() {
106 assert_eq!(parse_max_age("500ms").unwrap(), Duration::from_millis(500));
107 assert_eq!(parse_max_age("30s").unwrap(), Duration::from_secs(30));
108 assert_eq!(parse_max_age("10m").unwrap(), Duration::from_secs(600));
109 assert_eq!(parse_max_age("1h").unwrap(), Duration::from_secs(3600));
110 assert_eq!(parse_max_age("1h 30m").unwrap(), Duration::from_secs(5400));
111 assert_eq!(parse_max_age("42").unwrap(), Duration::from_secs(42));
112 }
113
114 #[test]
115 fn page_freshness_only_reloads_http_pages_over_age() {
116 let info = parse_page_freshness(json!({
117 "href": "https://example.com/app",
118 "ageMs": 700_000.0,
119 "readyState": "complete"
120 }))
121 .unwrap();
122 assert!(info.should_reload(Duration::from_secs(600)));
123
124 let blank = PageFreshness {
125 href: "about:blank".to_string(),
126 age_ms: 700_000.0,
127 };
128 assert!(!blank.should_reload(Duration::from_secs(600)));
129 }
130}