Skip to main content

browsertap_shared/
session.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5use crate::protocol::{ConsoleEvent, NetworkEvent, SocketState};
6
7/// Maximum number of console events buffered per session.
8pub const MAX_CONSOLE_BUFFER: usize = 500;
9
10/// Maximum number of network events buffered per session.
11pub const MAX_NETWORK_BUFFER: usize = 200;
12
13/// Heartbeat interval expected from browser runtime.
14pub const HEARTBEAT_INTERVAL_SECS: u64 = 5;
15
16/// Session expires if no heartbeat received within this duration.
17pub const HEARTBEAT_TIMEOUT_SECS: u64 = 45;
18
19/// A live browser session connected to the daemon.
20#[derive(Debug, Clone)]
21pub struct Session {
22    pub id: Uuid,
23    pub codename: String,
24    pub url: String,
25    pub title: String,
26    pub user_agent: String,
27    pub top_origin: String,
28    pub socket_state: SocketState,
29    pub connected_at: DateTime<Utc>,
30    pub last_heartbeat: DateTime<Utc>,
31    pub console_buffer: Vec<ConsoleEvent>,
32    pub network_buffer: Vec<NetworkEvent>,
33}
34
35impl Session {
36    pub fn new(
37        id: Uuid,
38        codename: String,
39        url: String,
40        title: String,
41        user_agent: String,
42        top_origin: String,
43    ) -> Self {
44        let now = Utc::now();
45        Self {
46            id,
47            codename,
48            url,
49            title,
50            user_agent,
51            top_origin,
52            socket_state: SocketState::Open,
53            connected_at: now,
54            last_heartbeat: now,
55            console_buffer: Vec::new(),
56            network_buffer: Vec::new(),
57        }
58    }
59
60    /// Check if the session has timed out based on heartbeat.
61    pub fn is_stale(&self) -> bool {
62        let elapsed = Utc::now()
63            .signed_duration_since(self.last_heartbeat)
64            .num_seconds();
65        elapsed > HEARTBEAT_TIMEOUT_SECS as i64
66    }
67
68    /// Update the last heartbeat timestamp.
69    pub fn touch(&mut self) {
70        self.last_heartbeat = Utc::now();
71    }
72
73    /// Append console events, enforcing buffer limit.
74    pub fn push_console_events(&mut self, events: Vec<ConsoleEvent>) {
75        self.console_buffer.extend(events);
76        if self.console_buffer.len() > MAX_CONSOLE_BUFFER {
77            let drain = self.console_buffer.len() - MAX_CONSOLE_BUFFER;
78            self.console_buffer.drain(..drain);
79        }
80    }
81
82    /// Append network events, enforcing buffer limit.
83    pub fn push_network_events(&mut self, events: Vec<NetworkEvent>) {
84        self.network_buffer.extend(events);
85        if self.network_buffer.len() > MAX_NETWORK_BUFFER {
86            let drain = self.network_buffer.len() - MAX_NETWORK_BUFFER;
87            self.network_buffer.drain(..drain);
88        }
89    }
90}
91
92/// Daemon configuration.
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct DaemonConfig {
95    /// HTTPS listen address (default: 127.0.0.1)
96    #[serde(default = "default_host")]
97    pub host: String,
98    /// HTTPS listen port (default: 4455)
99    #[serde(default = "default_port")]
100    pub port: u16,
101    /// Path to TLS certificate file
102    #[serde(default)]
103    pub cert_path: Option<String>,
104    /// Path to TLS private key file
105    #[serde(default)]
106    pub key_path: Option<String>,
107}
108
109fn default_host() -> String {
110    "127.0.0.1".into()
111}
112
113fn default_port() -> u16 {
114    4455
115}
116
117impl Default for DaemonConfig {
118    fn default() -> Self {
119        Self {
120            host: default_host(),
121            port: default_port(),
122            cert_path: None,
123            key_path: None,
124        }
125    }
126}
127
128/// Project-level configuration (browsertap.toml).
129#[derive(Debug, Clone, Serialize, Deserialize, Default)]
130pub struct ProjectConfig {
131    #[serde(default)]
132    pub app_label: Option<String>,
133    #[serde(default)]
134    pub app_url: Option<String>,
135    #[serde(default)]
136    pub daemon_url: Option<String>,
137    #[serde(default)]
138    pub daemon: DaemonConfig,
139    #[serde(default)]
140    pub smoke: SmokeConfig,
141}
142
143/// Smoke test configuration.
144#[derive(Debug, Clone, Serialize, Deserialize, Default)]
145pub struct SmokeConfig {
146    /// Default routes to test.
147    #[serde(default)]
148    pub defaults: Vec<String>,
149    /// Named presets of route lists.
150    #[serde(default)]
151    pub presets: std::collections::HashMap<String, Vec<String>>,
152    /// Known redirects (from -> to).
153    #[serde(default)]
154    pub redirects: std::collections::HashMap<String, String>,
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[test]
162    fn session_heartbeat_and_stale() {
163        let mut session = Session::new(
164            Uuid::new_v4(),
165            "test-fox".into(),
166            "http://localhost:3000".into(),
167            "Test".into(),
168            "Mozilla/5.0".into(),
169            "http://localhost:3000".into(),
170        );
171
172        assert!(!session.is_stale());
173        session.touch();
174        assert!(!session.is_stale());
175    }
176
177    #[test]
178    fn console_buffer_limit() {
179        let mut session = Session::new(
180            Uuid::new_v4(),
181            "test-owl".into(),
182            "http://localhost:3000".into(),
183            "Test".into(),
184            "Mozilla/5.0".into(),
185            "http://localhost:3000".into(),
186        );
187
188        let events: Vec<ConsoleEvent> = (0..600)
189            .map(|i| ConsoleEvent {
190                id: format!("evt-{i}"),
191                timestamp: i as i64,
192                level: crate::protocol::ConsoleLevel::Log,
193                args: vec![serde_json::json!(format!("message {i}"))],
194            })
195            .collect();
196
197        session.push_console_events(events);
198        assert_eq!(session.console_buffer.len(), MAX_CONSOLE_BUFFER);
199    }
200}