Skip to main content

ephem_debugger_rs/
browser.rs

1//! Browser support — embeds the IIFE client and provides route handlers.
2//!
3//! The browser IIFE patches `console.*`, `fetch`, `XMLHttpRequest`, and
4//! `WebSocket` to capture client-side logs, errors, and network requests.
5//! These are sent to the ingest endpoint (`/_/d`) as JSON batches, where
6//! they are deserialized and pushed into the shared [`LogStore`].
7//!
8//! # Routes
9//!
10//! | Path | Method | Purpose |
11//! |------|--------|---------|
12//! | `/_/d.js` | GET | Serve the IIFE script |
13//! | `/_/d` | POST | Ingest browser entries |
14//! | `/_/d` | OPTIONS | CORS preflight |
15//!
16//! Each framework middleware handles these routes differently:
17//!
18//! - **Axum / Poem**: intercepted in the `Service`/`Endpoint` before
19//!   forwarding to the inner handler.
20//! - **Actix**: registered via `browser_config` on `ServiceConfig`.
21//! - **Rocket**: registered via `browser_routes` returning `Vec<Route>`.
22
23use std::sync::Arc;
24
25use crate::protocol::LogEntry;
26use crate::store::LogStore;
27
28/// The browser instrumentation IIFE script, embedded at compile time.
29pub const CLIENT_SCRIPT: &str = include_str!("client.js");
30
31/// Script tags to inject into HTML responses.
32///
33/// Sets the ingest URL and loads the IIFE with `defer` so it runs after
34/// the DOM is ready.
35pub const SCRIPT_TAGS: &str = r#"<script>window.__DEBUGGER_INGEST_URL__="/_/d";</script><script src="/_/d.js" defer></script>"#;
36
37/// Inject debugger script tags into an HTML string before `</body>`.
38///
39/// If the HTML already contains `__DEBUGGER_INGEST_URL__` (i.e. was
40/// already injected), the original string is returned unchanged.
41/// If there is no `</body>` tag, the original string is returned as-is.
42pub fn inject_scripts(html: &str) -> String {
43    if html.contains("__DEBUGGER_INGEST_URL__") {
44        return html.to_string();
45    }
46    if let Some(idx) = html.rfind("</body>") {
47        let mut result = String::with_capacity(html.len() + SCRIPT_TAGS.len());
48        result.push_str(&html[..idx]);
49        result.push_str(SCRIPT_TAGS);
50        result.push_str(&html[idx..]);
51        result
52    } else {
53        html.to_string()
54    }
55}
56
57/// Ingest a batch of browser entries into the store.
58///
59/// Each value in `entries` is attempted as a [`LogEntry`] deserialization.
60/// Entries that fail to deserialize are silently dropped (this is a dev
61/// tool; the browser may send shapes we don't fully model).
62pub fn ingest_entries(store: &Arc<LogStore>, entries: &[serde_json::Value]) {
63    for value in entries {
64        match serde_json::from_value::<LogEntry>(value.clone()) {
65            Ok(entry) => store.push(entry),
66            Err(_) => {
67                // Best-effort: if the entry has a "type" field we
68                // recognise, push it raw into the correct buffer.
69                // Otherwise silently drop.
70            }
71        }
72    }
73}
74
75/// Common CORS headers for the ingest endpoint.
76pub const CORS_HEADERS: [(&str, &str); 4] = [
77    ("access-control-allow-origin", "*"),
78    ("access-control-allow-methods", "POST, OPTIONS"),
79    ("access-control-allow-headers", "content-type"),
80    ("access-control-allow-credentials", "true"),
81];
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86
87    #[test]
88    fn inject_into_html() {
89        let html = "<html><body><h1>Hello</h1></body></html>";
90        let result = inject_scripts(html);
91        assert!(result.contains(SCRIPT_TAGS));
92        assert!(result.contains("</body>"));
93        // Script tags should be before </body>
94        let script_pos = result.find(SCRIPT_TAGS).unwrap();
95        let body_pos = result.rfind("</body>").unwrap();
96        assert!(script_pos < body_pos);
97    }
98
99    #[test]
100    fn inject_idempotent() {
101        let html = "<html><body><h1>Hello</h1></body></html>";
102        let first = inject_scripts(html);
103        let second = inject_scripts(&first);
104        assert_eq!(first, second);
105    }
106
107    #[test]
108    fn inject_no_body_tag() {
109        let html = "<html><h1>Hello</h1></html>";
110        let result = inject_scripts(html);
111        assert_eq!(result, html);
112    }
113
114    #[test]
115    fn client_script_is_not_empty() {
116        assert!(!CLIENT_SCRIPT.is_empty());
117        assert!(CLIENT_SCRIPT.contains("__DEBUGGER_INITIALIZED__"));
118    }
119
120    #[test]
121    fn ingest_valid_console_entry() {
122        let session = crate::protocol::create_session("test", 3000);
123        let store = Arc::new(LogStore::new(session));
124
125        let entries = vec![serde_json::json!({
126            "type": "console",
127            "level": "info",
128            "args": ["hello from browser"],
129            "timestamp": 1700000000000_i64,
130            "source": "browser"
131        })];
132
133        ingest_entries(&store, &entries);
134
135        let resp = store.query("console", &crate::protocol::Filters::default());
136        assert_eq!(resp.data.len(), 1);
137    }
138
139    #[test]
140    fn ingest_invalid_entry_is_dropped() {
141        let session = crate::protocol::create_session("test", 3000);
142        let store = Arc::new(LogStore::new(session));
143
144        let entries = vec![serde_json::json!({
145            "garbage": true
146        })];
147
148        ingest_entries(&store, &entries);
149
150        let resp = store.query("all", &crate::protocol::Filters::default());
151        assert_eq!(resp.data.len(), 0);
152    }
153}