Skip to main content

ephem_debugger_rs/
protocol.rs

1//! NDJSON IPC protocol types matching `shared/protocol/schema.json`.
2//!
3//! All structs derive `Serialize` and `Deserialize` for NDJSON transport.
4//! The [`LogEntry`] enum uses an internally-tagged representation keyed on
5//! `"type"` so it round-trips with the Node.js / Go implementations.
6
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::hash::{BuildHasher, Hasher, RandomState};
10use std::time::{SystemTime, UNIX_EPOCH};
11
12// ---------------------------------------------------------------------------
13// Log entries
14// ---------------------------------------------------------------------------
15
16/// Discriminated union of all log entry types.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18#[serde(tag = "type")]
19pub enum LogEntry {
20    /// A captured console log message.
21    #[serde(rename = "console")]
22    Console(ConsoleEntry),
23    /// A captured runtime error.
24    #[serde(rename = "error")]
25    Error(ErrorEntry),
26    /// A captured network request.
27    #[serde(rename = "network")]
28    Network(NetworkEntry),
29    /// A snapshot of browser application state.
30    #[serde(rename = "app")]
31    App(AppEntry),
32}
33
34impl LogEntry {
35    /// Returns the entry's unique identifier, if set.
36    pub fn id(&self) -> Option<&str> {
37        match self {
38            Self::Console(e) => e.id.as_deref(),
39            Self::Error(e) => e.id.as_deref(),
40            Self::Network(e) => e.id.as_deref(),
41            Self::App(e) => e.id.as_deref(),
42        }
43    }
44
45    /// Returns the entry timestamp (Unix epoch milliseconds).
46    pub fn timestamp(&self) -> i64 {
47        match self {
48            Self::Console(e) => e.timestamp,
49            Self::Error(e) => e.timestamp,
50            Self::Network(e) => e.timestamp,
51            Self::App(e) => e.timestamp,
52        }
53    }
54
55    /// Sets the entry's unique identifier.
56    pub fn set_id(&mut self, id: String) {
57        match self {
58            Self::Console(e) => e.id = Some(id),
59            Self::Error(e) => e.id = Some(id),
60            Self::Network(e) => e.id = Some(id),
61            Self::App(e) => e.id = Some(id),
62        }
63    }
64
65    /// Returns the source field value.
66    pub fn source(&self) -> &str {
67        match self {
68            Self::Console(e) => &e.source,
69            Self::Error(e) => &e.source,
70            Self::Network(e) => &e.source,
71            Self::App(e) => &e.source,
72        }
73    }
74}
75
76/// A captured console log message.
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct ConsoleEntry {
79    /// Unique entry identifier (6-char hex).
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub id: Option<String>,
82    /// Log level: `"log"`, `"warn"`, `"error"`, `"debug"`, or `"info"`.
83    pub level: String,
84    /// Serialized console arguments.
85    pub args: Vec<serde_json::Value>,
86    /// Unix epoch milliseconds.
87    pub timestamp: i64,
88    /// `"browser"` or `"server"`.
89    pub source: String,
90}
91
92/// A captured runtime error.
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct ErrorEntry {
95    /// Unique entry identifier (6-char hex).
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub id: Option<String>,
98    /// Error message.
99    pub message: String,
100    /// Stack trace, if available.
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub stack: Option<String>,
103    /// Unix epoch milliseconds.
104    pub timestamp: i64,
105    /// `"browser"` or `"server"`.
106    pub source: String,
107    /// URL where the error occurred.
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub url: Option<String>,
110    /// Component name where the error occurred.
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub component: Option<String>,
113}
114
115/// A captured network request (fetch, XHR, or WebSocket).
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct NetworkEntry {
118    /// Unique entry identifier (6-char hex).
119    #[serde(skip_serializing_if = "Option::is_none")]
120    pub id: Option<String>,
121    /// Request URL.
122    pub url: String,
123    /// HTTP method.
124    pub method: String,
125    /// HTTP status code.
126    pub status: i32,
127    /// Duration in milliseconds.
128    pub duration: f64,
129    /// Unix epoch milliseconds.
130    pub timestamp: i64,
131    /// Whether the request failed.
132    pub failed: bool,
133    /// `"browser"` (network entries always come from the browser).
134    pub source: String,
135    /// Transport kind: `"fetch"`, `"xhr"`, or `"ws"`.
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub kind: Option<String>,
138    /// Request headers.
139    #[serde(rename = "requestHeaders", skip_serializing_if = "Option::is_none")]
140    pub request_headers: Option<HashMap<String, String>>,
141    /// Response headers.
142    #[serde(rename = "responseHeaders", skip_serializing_if = "Option::is_none")]
143    pub response_headers: Option<HashMap<String, String>>,
144    /// Response body (capped at 4KB, captured for failed requests only).
145    #[serde(rename = "responseBody", skip_serializing_if = "Option::is_none")]
146    pub response_body: Option<String>,
147    /// WebSocket connection identifier.
148    #[serde(rename = "connectionId", skip_serializing_if = "Option::is_none")]
149    pub connection_id: Option<String>,
150    /// Number of WebSocket messages exchanged.
151    #[serde(rename = "messageCount", skip_serializing_if = "Option::is_none")]
152    pub message_count: Option<i32>,
153    /// WebSocket readyState at capture time.
154    #[serde(rename = "wsReadyState", skip_serializing_if = "Option::is_none")]
155    pub ws_ready_state: Option<i32>,
156}
157
158/// Cookie information.
159#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct CookieInfo {
161    /// Cookie name.
162    pub name: String,
163    /// Cookie value.
164    pub value: String,
165    /// Cookie domain.
166    #[serde(skip_serializing_if = "Option::is_none")]
167    pub domain: Option<String>,
168    /// Cookie path.
169    #[serde(skip_serializing_if = "Option::is_none")]
170    pub path: Option<String>,
171    /// Expiry timestamp.
172    #[serde(skip_serializing_if = "Option::is_none")]
173    pub expires: Option<f64>,
174    /// Secure flag.
175    #[serde(skip_serializing_if = "Option::is_none")]
176    pub secure: Option<bool>,
177    /// SameSite attribute.
178    #[serde(rename = "sameSite", skip_serializing_if = "Option::is_none")]
179    pub same_site: Option<String>,
180}
181
182/// Service worker information.
183#[derive(Debug, Clone, Serialize, Deserialize)]
184pub struct ServiceWorkerInfo {
185    /// Service worker scope.
186    pub scope: String,
187    /// Script URL.
188    #[serde(rename = "scriptURL")]
189    pub script_url: String,
190    /// Service worker state.
191    pub state: String,
192}
193
194/// Cache storage information.
195#[derive(Debug, Clone, Serialize, Deserialize)]
196pub struct CacheInfo {
197    /// Cache name.
198    pub name: String,
199    /// Number of entries in the cache.
200    #[serde(rename = "entryCount")]
201    pub entry_count: i32,
202}
203
204/// Browser permission state.
205#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct PermissionInfo {
207    /// Permission name.
208    pub name: String,
209    /// Permission state: `"granted"`, `"denied"`, or `"prompt"`.
210    pub state: String,
211}
212
213/// Browser storage quota and usage estimate.
214#[derive(Debug, Clone, Serialize, Deserialize)]
215pub struct StorageEstimate {
216    /// Bytes used.
217    pub usage: f64,
218    /// Total quota in bytes.
219    pub quota: f64,
220}
221
222/// A snapshot of browser application state.
223#[derive(Debug, Clone, Serialize, Deserialize)]
224pub struct AppEntry {
225    /// Unique entry identifier (6-char hex).
226    #[serde(skip_serializing_if = "Option::is_none")]
227    pub id: Option<String>,
228    /// Unix epoch milliseconds.
229    pub timestamp: i64,
230    /// `"browser"` (app entries always come from the browser).
231    pub source: String,
232    /// Browser cookies.
233    #[serde(skip_serializing_if = "Option::is_none")]
234    pub cookies: Option<Vec<CookieInfo>>,
235    /// Local storage key-value pairs.
236    #[serde(rename = "localStorage", skip_serializing_if = "Option::is_none")]
237    pub local_storage: Option<HashMap<String, String>>,
238    /// Session storage key-value pairs.
239    #[serde(rename = "sessionStorage", skip_serializing_if = "Option::is_none")]
240    pub session_storage: Option<HashMap<String, String>>,
241    /// Registered service workers.
242    #[serde(rename = "serviceWorkers", skip_serializing_if = "Option::is_none")]
243    pub service_workers: Option<Vec<ServiceWorkerInfo>>,
244    /// Cache storage entries.
245    #[serde(rename = "cacheStorage", skip_serializing_if = "Option::is_none")]
246    pub cache_storage: Option<Vec<CacheInfo>>,
247    /// Browser permissions.
248    #[serde(skip_serializing_if = "Option::is_none")]
249    pub permissions: Option<Vec<PermissionInfo>>,
250    /// Storage estimate.
251    #[serde(rename = "storageEstimate", skip_serializing_if = "Option::is_none")]
252    pub storage_estimate: Option<StorageEstimate>,
253}
254
255// ---------------------------------------------------------------------------
256// Session & query types
257// ---------------------------------------------------------------------------
258
259/// Describes the running debugger session.
260#[derive(Debug, Clone, Serialize, Deserialize)]
261pub struct SessionInfo {
262    /// Unique session identifier.
263    #[serde(rename = "sessionId")]
264    pub session_id: String,
265    /// Web framework name (e.g. `"axum"`, `"actix"`).
266    pub framework: String,
267    /// HTTP port the application listens on.
268    pub port: i32,
269    /// Process ID.
270    pub pid: u32,
271    /// Session start time (Unix epoch milliseconds).
272    #[serde(rename = "startedAt")]
273    pub started_at: i64,
274    /// IPC socket path or TCP address.
275    #[serde(rename = "socketPath")]
276    pub socket_path: String,
277}
278
279/// Query filters sent by the CLI client.
280#[derive(Debug, Clone, Default, Serialize, Deserialize)]
281pub struct Filters {
282    /// Look up a single entry by ID.
283    #[serde(skip_serializing_if = "Option::is_none")]
284    pub id: Option<String>,
285    /// Look up multiple entries by ID.
286    #[serde(skip_serializing_if = "Option::is_none")]
287    pub ids: Option<Vec<String>>,
288    /// Return entries from the last N milliseconds.
289    #[serde(skip_serializing_if = "Option::is_none")]
290    pub last: Option<f64>,
291    /// Filter by log level.
292    #[serde(skip_serializing_if = "Option::is_none")]
293    pub level: Option<String>,
294    /// Filter network entries by HTTP status.
295    #[serde(skip_serializing_if = "Option::is_none")]
296    pub status: Option<f64>,
297    /// If true, return only failed network requests.
298    #[serde(skip_serializing_if = "Option::is_none")]
299    pub failed: Option<bool>,
300    /// Max number of entries to return.
301    #[serde(skip_serializing_if = "Option::is_none")]
302    pub limit: Option<usize>,
303    /// Filter entries by source (`"browser"` or `"server"`).
304    #[serde(skip_serializing_if = "Option::is_none")]
305    pub source: Option<String>,
306    /// Sub-command qualifier.
307    #[serde(skip_serializing_if = "Option::is_none")]
308    pub subcommand: Option<String>,
309    /// Filter by name.
310    #[serde(skip_serializing_if = "Option::is_none")]
311    pub name: Option<String>,
312    /// Filter by key.
313    #[serde(skip_serializing_if = "Option::is_none")]
314    pub key: Option<String>,
315    /// Filter by storage type.
316    #[serde(rename = "storageType", skip_serializing_if = "Option::is_none")]
317    pub storage_type: Option<String>,
318}
319
320/// NDJSON request message sent from the CLI to the bridge.
321#[derive(Debug, Clone, Serialize, Deserialize)]
322pub struct QueryRequest {
323    /// Request identifier for correlation.
324    pub id: String,
325    /// Command name: `"errors"`, `"console"`, `"network"`, `"status"`, `"all"`, `"app"`, `"push"`.
326    pub command: String,
327    /// Optional query filters.
328    #[serde(skip_serializing_if = "Option::is_none")]
329    pub filters: Option<Filters>,
330    /// Raw entry data for push commands.
331    #[serde(skip_serializing_if = "Option::is_none")]
332    pub data: Option<serde_json::Value>,
333}
334
335/// NDJSON response message sent from the bridge to the CLI.
336#[derive(Debug, Clone, Serialize, Deserialize)]
337pub struct QueryResponse {
338    /// Correlation identifier matching the request.
339    pub id: String,
340    /// Whether the command succeeded.
341    pub ok: bool,
342    /// Result entries.
343    pub data: Vec<serde_json::Value>,
344    /// Session metadata (included in most responses).
345    #[serde(skip_serializing_if = "Option::is_none")]
346    pub session: Option<SessionInfo>,
347    /// Error message when `ok` is `false`.
348    #[serde(skip_serializing_if = "Option::is_none")]
349    pub error: Option<String>,
350}
351
352// ---------------------------------------------------------------------------
353// Helper functions
354// ---------------------------------------------------------------------------
355
356/// Generate a random 6-character hex identifier.
357pub fn generate_id() -> String {
358    let s = RandomState::new();
359    let mut h = s.build_hasher();
360    h.write_u128(
361        SystemTime::now()
362            .duration_since(UNIX_EPOCH)
363            .unwrap_or_default()
364            .as_nanos(),
365    );
366    format!("{:06x}", h.finish() & 0xFFFFFF)
367}
368
369/// Returns the current time as Unix epoch milliseconds.
370pub fn now_millis() -> i64 {
371    SystemTime::now()
372        .duration_since(UNIX_EPOCH)
373        .unwrap_or_default()
374        .as_millis() as i64
375}
376
377/// Compute the IPC socket path for the given working directory.
378///
379/// On Windows this returns the path to `.debugger/bridge.addr` (TCP fallback).
380/// On Unix this returns `<cwd>/.debugger/bridge.sock`.
381pub fn compute_socket_path(cwd: &str) -> String {
382    if cfg!(windows) {
383        // On Windows we use TCP with a bridge.addr file, same as Go.
384        // The "socket path" stored in session info is the named pipe path
385        // for compatibility, but the bridge actually listens on TCP.
386        use std::collections::hash_map::DefaultHasher;
387        use std::hash::Hash;
388        let mut hasher = DefaultHasher::new();
389        cwd.to_lowercase().hash(&mut hasher);
390        let hash = format!("{:016x}", hasher.finish());
391        format!(r"\\.\pipe\debugger-{}", &hash[..8])
392    } else {
393        format!("{cwd}/.debugger/bridge.sock")
394    }
395}
396
397/// Create a new [`SessionInfo`] for the given framework and port.
398pub fn create_session(framework: &str, port: i32) -> SessionInfo {
399    let cwd = std::env::current_dir()
400        .unwrap_or_default()
401        .to_string_lossy()
402        .to_string();
403    SessionInfo {
404        session_id: generate_id(),
405        framework: framework.to_string(),
406        port,
407        pid: std::process::id(),
408        started_at: now_millis(),
409        socket_path: compute_socket_path(&cwd),
410    }
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416
417    #[test]
418    fn generate_id_is_six_hex_chars() {
419        let id = generate_id();
420        assert_eq!(id.len(), 6);
421        assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
422    }
423
424    #[test]
425    fn console_entry_round_trip() {
426        let entry = LogEntry::Console(ConsoleEntry {
427            id: Some("abc123".to_string()),
428            level: "info".to_string(),
429            args: vec![serde_json::Value::String("hello".to_string())],
430            timestamp: 1700000000000,
431            source: "server".to_string(),
432        });
433        let json = serde_json::to_string(&entry).unwrap();
434        assert!(json.contains(r#""type":"console"#));
435        let parsed: LogEntry = serde_json::from_str(&json).unwrap();
436        assert_eq!(parsed.id(), Some("abc123"));
437    }
438
439    #[test]
440    fn query_request_round_trip() {
441        let req = QueryRequest {
442            id: "req1".to_string(),
443            command: "console".to_string(),
444            filters: Some(Filters {
445                level: Some("error".to_string()),
446                limit: Some(10),
447                ..Default::default()
448            }),
449            data: None,
450        };
451        let json = serde_json::to_string(&req).unwrap();
452        let parsed: QueryRequest = serde_json::from_str(&json).unwrap();
453        assert_eq!(parsed.command, "console");
454        assert_eq!(parsed.filters.as_ref().unwrap().level.as_deref(), Some("error"));
455    }
456
457    #[test]
458    fn now_millis_is_reasonable() {
459        let ms = now_millis();
460        // Should be after 2020-01-01
461        assert!(ms > 1_577_836_800_000);
462    }
463}