Skip to main content

cdp_server/
registry.rs

1// @trace REQ-CDS-006 [entity:DomainRegistry]
2// DomainHandler registration, lookup and lifecycle callbacks.
3
4use std::any::Any;
5use std::collections::HashMap;
6use std::sync::{Arc, Mutex};
7
8use serde_json::Value;
9
10use crate::{CdpError, DomainHandler, EventSender};
11
12/// Registry of CDP domain handlers. Thread-safe (Mutex-protected).
13/// Generic over handler type H — enables enum dispatch (H=DomainDispatch)
14/// for zero-vtable overhead.
15pub struct DomainRegistry<H: DomainHandler> {
16    handlers: Mutex<HashMap<&'static str, H>>,
17}
18
19/// Empty handler used as the default registry type when no domains are needed.
20/// `CdpServer::new()` creates a `DomainRegistry<EmptyHandler>` internally;
21/// production code should use `CdpServer::with_registry()` with a concrete
22/// handler type (e.g. `DomainDispatch`).
23pub struct EmptyHandler;
24
25impl DomainHandler for EmptyHandler {
26    fn domain_name(&self) -> &'static str {
27        ""
28    }
29    fn handle_command(&self, _: &str, _: Value, _: &dyn EventSender) -> Result<Value, CdpError> {
30        Err(CdpError {
31            code: -32601,
32            message: "empty handler".into(),
33        })
34    }
35}
36
37// ---------------------------------------------------------------------------
38// §2 RegistryDispatch trait — type-erased dispatch for CdpServer
39// ---------------------------------------------------------------------------
40
41/// Type-erased dispatch trait. CdpServer holds `Arc<dyn RegistryDispatch>`
42/// so it works with any `DomainRegistry<H>` regardless of H.
43///
44/// - Normal message routing: `dispatch_command()`, `has_domain()`, etc.
45/// - Type-safe downcast: `as_any()` → `downcast_ref::<DomainRegistry<DomainDispatch>>()`
46pub trait RegistryDispatch: Send + Sync + 'static {
47    /// Dispatch a CDP command. Returns None if domain not found.
48    fn dispatch_command(
49        &self,
50        method: &str,
51        params: Value,
52        event_sender: &dyn EventSender,
53    ) -> Option<Result<Value, CdpError>>;
54
55    /// Dispatch with full routing context: the incoming message (including
56    /// its optional `sessionId` for flattened browser sessions) plus the WS
57    /// session's own target id (from the `/devtools/page/<id>` URL, or the
58    /// browser pseudo-target for `/devtools/browser` connections).
59    ///
60    /// Default impl discards the context and delegates to `dispatch_command`;
61    /// registries that need per-target / per-session routing override this.
62    fn dispatch_message(
63        &self,
64        msg: &crate::protocol::CdpMessage,
65        ws_target_id: &str,
66        event_sender: &dyn EventSender,
67    ) -> Option<Result<Value, CdpError>> {
68        let _ = ws_target_id;
69        self.dispatch_command(
70            &msg.method,
71            msg.params.clone().unwrap_or_default(),
72            event_sender,
73        )
74    }
75
76    /// Notify the handler for `domain` that a session was created.
77    fn notify_session_created(&self, domain: &str, session_id: &str);
78
79    /// Notify handlers for the given domains that a session was destroyed.
80    fn notify_session_destroyed(&self, domains: &[String], session_id: &str);
81
82    /// Check if a domain is registered.
83    fn has_domain(&self, domain: &str) -> bool;
84
85    /// Downcast support — allows callers to recover the concrete registry type.
86    fn as_any(&self) -> &dyn Any;
87}
88
89/// Blanket impl: any `DomainRegistry<H>` is a `RegistryDispatch`.
90impl<H: DomainHandler + 'static> RegistryDispatch for DomainRegistry<H> {
91    fn dispatch_command(
92        &self,
93        method: &str,
94        params: Value,
95        event_sender: &dyn EventSender,
96    ) -> Option<Result<Value, CdpError>> {
97        DomainRegistry::dispatch_command(self, method, params, event_sender)
98    }
99
100    fn notify_session_created(&self, domain: &str, session_id: &str) {
101        DomainRegistry::notify_session_created(self, domain, session_id)
102    }
103
104    fn notify_session_destroyed(&self, domains: &[String], session_id: &str) {
105        DomainRegistry::notify_session_destroyed(self, domains, session_id)
106    }
107
108    fn has_domain(&self, domain: &str) -> bool {
109        DomainRegistry::has_domain(self, domain)
110    }
111
112    fn as_any(&self) -> &dyn Any {
113        self
114    }
115}
116
117/// Convenience downcast on `dyn RegistryDispatch`.
118impl dyn RegistryDispatch {
119    pub fn downcast_ref<T: 'static>(&self) -> Option<&T> {
120        self.as_any().downcast_ref::<T>()
121    }
122}
123
124// ---------------------------------------------------------------------------
125// §3 DomainRegistry<H> implementation
126// ---------------------------------------------------------------------------
127
128impl<H: DomainHandler> Default for DomainRegistry<H> {
129    fn default() -> Self {
130        Self::new()
131    }
132}
133
134impl<H: DomainHandler> DomainRegistry<H> {
135    pub fn new() -> Self {
136        DomainRegistry {
137            handlers: Mutex::new(HashMap::new()),
138        }
139    }
140
141    /// Register a DomainHandler. Returns Err if a handler with the same
142    /// domain_name is already registered (REQ-CDS-006 C5: no overwrite).
143    pub fn register(&self, handler: H) -> Result<(), String> {
144        let mut map = self
145            .handlers
146            .lock()
147            .map_err(|_| -> String { "lock poisoned".into() })?;
148        let name = handler.domain_name();
149        if map.contains_key(name) {
150            return Err(format!("domain '{}' already registered", name));
151        }
152        map.insert(name, handler);
153        Ok(())
154    }
155
156    /// Dispatch a command to the appropriate DomainHandler.
157    /// Extracts domain from method (e.g. "Page.navigate" → "Page").
158    pub fn dispatch_command(
159        &self,
160        method: &str,
161        params: Value,
162        event_sender: &dyn EventSender,
163    ) -> Option<Result<Value, CdpError>> {
164        let domain = method.split('.').next().unwrap_or("");
165        let map = self.handlers.lock().ok()?;
166        let handler = map.get(domain)?;
167        Some(handler.handle_command(method, params, event_sender))
168    }
169
170    /// Notify the DomainHandler for the given domain that a session was created.
171    pub fn notify_session_created(&self, domain: &str, session_id: &str) {
172        if let Ok(map) = self.handlers.lock() {
173            if let Some(handler) = map.get(domain) {
174                handler.on_session_created(session_id);
175            }
176        }
177    }
178
179    /// Notify all DomainHandlers for the given domains that a session was destroyed.
180    pub fn notify_session_destroyed(&self, domains: &[String], session_id: &str) {
181        if let Ok(map) = self.handlers.lock() {
182            for domain in domains {
183                if let Some(handler) = map.get(domain.as_str()) {
184                    handler.on_session_destroyed(session_id);
185                }
186            }
187        }
188    }
189
190    /// Check if a domain is registered.
191    pub fn has_domain(&self, domain: &str) -> bool {
192        self.handlers
193            .lock()
194            .map(|m| m.contains_key(domain))
195            .unwrap_or(false)
196    }
197}
198
199/// Shared registry type used by CdpServer — type-erased via RegistryDispatch.
200pub type SharedRegistry = Arc<dyn RegistryDispatch>;
201
202#[cfg(test)]
203mod tests {
204    use serde_json::json;
205
206    use super::*;
207    use crate::{CdpError, DomainHandler, EventSender};
208
209    struct NoopSender;
210    impl EventSender for NoopSender {
211        fn send_event(&self, _method: &str, _params: Value) {}
212    }
213
214    struct MockHandler {
215        name: &'static str,
216    }
217
218    impl DomainHandler for MockHandler {
219        fn domain_name(&self) -> &'static str {
220            self.name
221        }
222
223        fn handle_command(
224            &self,
225            _command: &str,
226            _params: Value,
227            _event_sender: &dyn EventSender,
228        ) -> Result<Value, CdpError> {
229            Ok(json!({}))
230        }
231
232        fn on_session_created(&self, _session_id: &str) {}
233
234        fn on_session_destroyed(&self, _session_id: &str) {}
235    }
236
237    // @trace TEST-CDS-REG-001 [req:REQ-CDS-006] [level:unit]
238    #[test]
239    fn new_registry_is_empty() {
240        let reg = DomainRegistry::<MockHandler>::new();
241        assert!(!reg.has_domain("Page"));
242    }
243
244    // @trace TEST-CDS-REG-002 [req:REQ-CDS-006] [level:unit]
245    #[test]
246    fn default_same_as_new() {
247        let via_new = DomainRegistry::<MockHandler>::new();
248        let via_default = DomainRegistry::<MockHandler>::default();
249        assert!(!via_new.has_domain("Page"));
250        assert!(!via_default.has_domain("Page"));
251    }
252
253    // @trace TEST-CDS-REG-003 [req:REQ-CDS-006] [level:unit]
254    #[test]
255    fn register_handler_then_has_domain() {
256        let reg = DomainRegistry::<MockHandler>::new();
257        reg.register(MockHandler { name: "Page" }).unwrap();
258        assert!(reg.has_domain("Page"));
259    }
260
261    // @trace TEST-CDS-REG-004 [req:REQ-CDS-006] [level:unit]
262    #[test]
263    fn register_duplicate_returns_err() {
264        let reg = DomainRegistry::<MockHandler>::new();
265        reg.register(MockHandler { name: "Page" }).unwrap();
266        let err = reg.register(MockHandler { name: "Page" }).unwrap_err();
267        assert!(err.contains("'Page'"));
268    }
269
270    // @trace TEST-CDS-REG-005 [req:REQ-CDS-006] [level:unit]
271    #[test]
272    fn register_different_domains_both_present() {
273        let reg = DomainRegistry::<MockHandler>::new();
274        reg.register(MockHandler { name: "Page" }).unwrap();
275        reg.register(MockHandler { name: "Runtime" }).unwrap();
276        assert!(reg.has_domain("Page"));
277        assert!(reg.has_domain("Runtime"));
278    }
279
280    // @trace TEST-CDS-REG-006 [req:REQ-CDS-006] [level:unit]
281    #[test]
282    fn has_domain_unregistered_returns_false() {
283        let reg = DomainRegistry::<MockHandler>::new();
284        reg.register(MockHandler { name: "Page" }).unwrap();
285        assert!(!reg.has_domain("DOM"));
286    }
287
288    // @trace TEST-CDS-REG-007 [req:REQ-CDS-006] [level:unit]
289    #[test]
290    fn dispatch_command_registered_returns_some_ok() {
291        let reg = DomainRegistry::<MockHandler>::new();
292        reg.register(MockHandler { name: "Page" }).unwrap();
293        let result = reg.dispatch_command("Page.navigate", json!(null), &NoopSender);
294        assert!(result.is_some());
295        assert!(result.unwrap().is_ok());
296    }
297
298    // @trace TEST-CDS-REG-008 [req:REQ-CDS-006] [level:unit]
299    #[test]
300    fn dispatch_command_unregistered_returns_none() {
301        let reg = DomainRegistry::<MockHandler>::new();
302        let result = reg.dispatch_command("DOM.getDocument", json!(null), &NoopSender);
303        assert!(result.is_none());
304    }
305
306    // @trace TEST-CDS-REG-009 [req:REQ-CDS-006] [level:unit]
307    #[test]
308    fn dispatch_command_extracts_domain_from_method() {
309        let reg = DomainRegistry::<MockHandler>::new();
310        reg.register(MockHandler { name: "Runtime" }).unwrap();
311        let result = reg.dispatch_command("Runtime.evaluate", json!(null), &NoopSender);
312        assert!(result.is_some());
313    }
314
315    // @trace TEST-CDS-REG-010 [req:REQ-CDS-006] [level:unit]
316    #[test]
317    fn notify_session_created_unregistered_no_panic() {
318        let reg = DomainRegistry::<MockHandler>::new();
319        reg.notify_session_created("Page", "sess-1");
320    }
321
322    // @trace TEST-CDS-REG-011 [req:REQ-CDS-006] [level:unit]
323    #[test]
324    fn notify_session_destroyed_unregistered_no_panic() {
325        let reg = DomainRegistry::<MockHandler>::new();
326        reg.notify_session_destroyed(&["Page".to_string()], "sess-1");
327    }
328
329    // @trace TEST-CDS-REG-012 [req:REQ-CDS-006] [level:unit]
330    #[test]
331    fn dispatch_command_with_valid_json_params() {
332        let reg = DomainRegistry::<MockHandler>::new();
333        reg.register(MockHandler { name: "Page" }).unwrap();
334        let result = reg.dispatch_command(
335            "Page.navigate",
336            json!({ "url": "https://example.com" }),
337            &NoopSender,
338        );
339        assert!(result.is_some());
340        assert!(result.unwrap().is_ok());
341    }
342
343    // @trace TEST-CDS-REG-013 [req:REQ-CDS-006] [level:unit]
344    // Verify enum dispatch works with concrete type (not Box<dyn>)
345    #[test]
346    fn enum_dispatch_with_concrete_type() {
347        enum TestDispatch {
348            Page(MockHandler),
349            Runtime(MockHandler),
350        }
351        impl DomainHandler for TestDispatch {
352            fn domain_name(&self) -> &'static str {
353                match self {
354                    Self::Page(h) => h.domain_name(),
355                    Self::Runtime(h) => h.domain_name(),
356                }
357            }
358            fn handle_command(
359                &self,
360                cmd: &str,
361                p: Value,
362                s: &dyn EventSender,
363            ) -> Result<Value, CdpError> {
364                match self {
365                    Self::Page(h) => h.handle_command(cmd, p, s),
366                    Self::Runtime(h) => h.handle_command(cmd, p, s),
367                }
368            }
369        }
370        let reg = DomainRegistry::<TestDispatch>::new();
371        reg.register(TestDispatch::Page(MockHandler { name: "Page" }))
372            .unwrap();
373        reg.register(TestDispatch::Runtime(MockHandler { name: "Runtime" }))
374            .unwrap();
375        assert!(reg.has_domain("Page"));
376        assert!(reg.has_domain("Runtime"));
377        let result = reg.dispatch_command("Page.navigate", json!(null), &NoopSender);
378        assert!(result.is_some());
379        assert!(result.unwrap().is_ok());
380    }
381}