viewpoint_core/page/binding/
mod.rs

1//! Exposed function bindings.
2//!
3//! This module provides functionality for exposing Rust functions to JavaScript
4//! code running in the browser. These functions can be called from JavaScript
5//! and will execute Rust code, returning the result back to JavaScript.
6
7use std::collections::HashMap;
8use std::future::Future;
9use std::pin::Pin;
10use std::sync::Arc;
11
12use tokio::sync::RwLock;
13use tracing::{debug, trace, warn};
14use viewpoint_cdp::protocol::runtime::{AddBindingParams, BindingCalledEvent};
15use viewpoint_cdp::CdpConnection;
16
17use crate::error::PageError;
18
19/// Type alias for the binding callback function.
20///
21/// The callback receives a vector of JSON arguments and returns a JSON result.
22pub type BindingCallback = Box<
23    dyn Fn(Vec<serde_json::Value>) -> Pin<Box<dyn Future<Output = Result<serde_json::Value, String>> + Send>>
24        + Send
25        + Sync,
26>;
27
28/// Manager for exposed function bindings on a page.
29pub struct BindingManager {
30    /// CDP connection.
31    connection: Arc<CdpConnection>,
32    /// Session ID.
33    session_id: String,
34    /// Registered bindings indexed by function name.
35    bindings: Arc<RwLock<HashMap<String, BindingCallback>>>,
36    /// Whether the manager is listening for binding calls.
37    is_listening: std::sync::atomic::AtomicBool,
38}
39
40impl BindingManager {
41    /// Create a new binding manager for a page.
42    pub fn new(connection: Arc<CdpConnection>, session_id: String) -> Self {
43        Self {
44            connection,
45            session_id,
46            bindings: Arc::new(RwLock::new(HashMap::new())),
47            is_listening: std::sync::atomic::AtomicBool::new(false),
48        }
49    }
50
51    /// Expose a function to JavaScript.
52    ///
53    /// The function will be available as `window.<name>()` in JavaScript.
54    /// Arguments are passed as JSON values and the return value is also JSON.
55    ///
56    /// # Example
57    ///
58    /// ```no_run
59    /// use viewpoint_core::page::binding::BindingManager;
60    ///
61    /// # async fn example(manager: BindingManager) -> Result<(), viewpoint_core::CoreError> {
62    /// manager.expose_function("compute", |args| async move {
63    ///     let x: i64 = serde_json::from_value(args[0].clone()).map_err(|e| e.to_string())?;
64    ///     let y: i64 = serde_json::from_value(args[1].clone()).map_err(|e| e.to_string())?;
65    ///     serde_json::to_value(x + y).map_err(|e| e.to_string())
66    /// }).await?;
67    /// # Ok(())
68    /// # }
69    /// ```
70    pub async fn expose_function<F, Fut>(&self, name: &str, callback: F) -> Result<(), PageError>
71    where
72        F: Fn(Vec<serde_json::Value>) -> Fut + Send + Sync + 'static,
73        Fut: Future<Output = Result<serde_json::Value, String>> + Send + 'static,
74    {
75        debug!("Exposing function: {}", name);
76
77        // Add the binding via CDP
78        self.connection
79            .send_command::<_, serde_json::Value>(
80                "Runtime.addBinding",
81                Some(AddBindingParams {
82                    name: name.to_string(),
83                    execution_context_id: None,
84                    execution_context_name: None,
85                }),
86                Some(&self.session_id),
87            )
88            .await?;
89
90        // Create a wrapper script that sets up the JavaScript function
91        let wrapper_script = format!(
92            r"
93            (function() {{
94                const bindingName = {name_json};
95                const bindingFn = window[bindingName];
96                if (!bindingFn) return;
97                
98                // Replace the binding with a proper async function
99                window[bindingName] = async function(...args) {{
100                    const seq = (window.__viewpoint_seq = (window.__viewpoint_seq || 0) + 1);
101                    const payload = JSON.stringify({{ seq, args }});
102                    
103                    return new Promise((resolve, reject) => {{
104                        window.__viewpoint_callbacks = window.__viewpoint_callbacks || {{}};
105                        window.__viewpoint_callbacks[seq] = {{ resolve, reject }};
106                        bindingFn(payload);
107                    }});
108                }};
109            }})();
110            ",
111            name_json = serde_json::to_string(name).unwrap()
112        );
113
114        // Inject the wrapper script
115        self.connection
116            .send_command::<_, serde_json::Value>(
117                "Runtime.evaluate",
118                Some(viewpoint_cdp::protocol::runtime::EvaluateParams {
119                    expression: wrapper_script,
120                    object_group: None,
121                    include_command_line_api: None,
122                    silent: Some(true),
123                    context_id: None,
124                    return_by_value: Some(true),
125                    await_promise: Some(false),
126                }),
127                Some(&self.session_id),
128            )
129            .await?;
130
131        // Store the callback
132        let boxed_callback: BindingCallback = Box::new(move |args| {
133            Box::pin(callback(args))
134        });
135        
136        {
137            let mut bindings = self.bindings.write().await;
138            bindings.insert(name.to_string(), boxed_callback);
139        }
140
141        // Start listening for binding calls if not already
142        self.start_listening().await;
143
144        debug!("Function exposed: {}", name);
145        Ok(())
146    }
147
148    /// Remove an exposed function.
149    pub async fn remove_function(&self, name: &str) -> Result<(), PageError> {
150        debug!("Removing exposed function: {}", name);
151
152        // Remove the binding via CDP
153        self.connection
154            .send_command::<_, serde_json::Value>(
155                "Runtime.removeBinding",
156                Some(viewpoint_cdp::protocol::runtime::RemoveBindingParams {
157                    name: name.to_string(),
158                }),
159                Some(&self.session_id),
160            )
161            .await?;
162
163        // Remove from our registry
164        {
165            let mut bindings = self.bindings.write().await;
166            bindings.remove(name);
167        }
168
169        Ok(())
170    }
171
172    /// Start listening for binding call events.
173    async fn start_listening(&self) {
174        if self.is_listening.swap(true, std::sync::atomic::Ordering::SeqCst) {
175            // Already listening
176            return;
177        }
178
179        let mut events = self.connection.subscribe_events();
180        let session_id = self.session_id.clone();
181        let bindings = self.bindings.clone();
182        let connection = self.connection.clone();
183
184        tokio::spawn(async move {
185            debug!("Binding manager started listening for events");
186
187            while let Ok(event) = events.recv().await {
188                // Filter events for this session
189                if event.session_id.as_deref() != Some(&session_id) {
190                    continue;
191                }
192
193                if event.method == "Runtime.bindingCalled" {
194                    if let Some(params) = &event.params {
195                        if let Ok(binding_event) = serde_json::from_value::<BindingCalledEvent>(params.clone()) {
196                            trace!("Binding called: {}", binding_event.name);
197
198                            // Parse the payload
199                            let payload: Result<BindingPayload, _> = serde_json::from_str(&binding_event.payload);
200                            
201                            if let Ok(payload) = payload {
202                                let bindings_guard = bindings.read().await;
203                                if let Some(callback) = bindings_guard.get(&binding_event.name) {
204                                    // Execute the callback
205                                    let result = callback(payload.args).await;
206                                    drop(bindings_guard);
207
208                                    // Send the result back to JavaScript
209                                    let resolve_script = match result {
210                                        Ok(value) => format!(
211                                            r"
212                                            (function() {{
213                                                const callbacks = window.__viewpoint_callbacks;
214                                                if (callbacks && callbacks[{seq}]) {{
215                                                    callbacks[{seq}].resolve({value});
216                                                    delete callbacks[{seq}];
217                                                }}
218                                            }})();
219                                            ",
220                                            seq = payload.seq,
221                                            value = serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string())
222                                        ),
223                                        Err(error) => format!(
224                                            r"
225                                            (function() {{
226                                                const callbacks = window.__viewpoint_callbacks;
227                                                if (callbacks && callbacks[{seq}]) {{
228                                                    callbacks[{seq}].reject(new Error({error}));
229                                                    delete callbacks[{seq}];
230                                                }}
231                                            }})();
232                                            ",
233                                            seq = payload.seq,
234                                            error = serde_json::to_string(&error).unwrap_or_else(|_| "\"Unknown error\"".to_string())
235                                        ),
236                                    };
237
238                                    let _ = connection
239                                        .send_command::<_, serde_json::Value>(
240                                            "Runtime.evaluate",
241                                            Some(viewpoint_cdp::protocol::runtime::EvaluateParams {
242                                                expression: resolve_script,
243                                                object_group: None,
244                                                include_command_line_api: None,
245                                                silent: Some(true),
246                                                context_id: None,
247                                                return_by_value: Some(true),
248                                                await_promise: Some(false),
249                                            }),
250                                            Some(&session_id),
251                                        )
252                                        .await;
253                                }
254                            } else {
255                                warn!("Failed to parse binding payload: {}", binding_event.payload);
256                            }
257                        }
258                    }
259                }
260            }
261
262            debug!("Binding manager stopped listening");
263        });
264    }
265
266    /// Re-bind all functions after navigation.
267    ///
268    /// This should be called after page navigation to re-inject the wrapper scripts.
269    pub async fn rebind_all(&self) -> Result<(), PageError> {
270        let bindings = self.bindings.read().await;
271        let names: Vec<String> = bindings.keys().cloned().collect();
272        drop(bindings);
273
274        for name in names {
275            // Re-inject the wrapper script
276            let wrapper_script = format!(
277                r"
278                (function() {{
279                    const bindingName = {name_json};
280                    const bindingFn = window[bindingName];
281                    if (!bindingFn) return;
282                    
283                    // Replace the binding with a proper async function
284                    window[bindingName] = async function(...args) {{
285                        const seq = (window.__viewpoint_seq = (window.__viewpoint_seq || 0) + 1);
286                        const payload = JSON.stringify({{ seq, args }});
287                        
288                        return new Promise((resolve, reject) => {{
289                            window.__viewpoint_callbacks = window.__viewpoint_callbacks || {{}};
290                            window.__viewpoint_callbacks[seq] = {{ resolve, reject }};
291                            bindingFn(payload);
292                        }});
293                    }};
294                }})();
295                ",
296                name_json = serde_json::to_string(&name).unwrap()
297            );
298
299            self.connection
300                .send_command::<_, serde_json::Value>(
301                    "Runtime.evaluate",
302                    Some(viewpoint_cdp::protocol::runtime::EvaluateParams {
303                        expression: wrapper_script,
304                        object_group: None,
305                        include_command_line_api: None,
306                        silent: Some(true),
307                        context_id: None,
308                        return_by_value: Some(true),
309                        await_promise: Some(false),
310                    }),
311                    Some(&self.session_id),
312                )
313                .await?;
314        }
315
316        Ok(())
317    }
318}
319
320/// Payload structure for binding calls.
321#[derive(Debug, serde::Deserialize)]
322struct BindingPayload {
323    /// Sequence number for matching responses.
324    seq: u64,
325    /// Function arguments.
326    args: Vec<serde_json::Value>,
327}
328
329// Page impl for exposed function methods
330impl super::Page {
331    /// Expose a Rust function to JavaScript.
332    ///
333    /// The function will be available as `window.<name>()` in JavaScript.
334    /// When called from JavaScript, the function arguments are serialized to JSON,
335    /// the Rust callback is executed, and the result is returned to JavaScript.
336    ///
337    /// # Example
338    ///
339    /// ```no_run
340    /// use viewpoint_core::Page;
341    ///
342    /// # async fn example(page: Page) -> Result<(), viewpoint_core::CoreError> {
343    /// // Expose a simple function
344    /// page.expose_function("add", |args| async move {
345    ///     let x = args[0].as_i64().unwrap_or(0);
346    ///     let y = args[1].as_i64().unwrap_or(0);
347    ///     Ok(serde_json::json!(x + y))
348    /// }).await?;
349    ///
350    /// // Call from JavaScript:
351    /// // const result = await window.add(1, 2); // returns 3
352    ///
353    /// // Expose a function with string processing
354    /// page.expose_function("sha256", |args| async move {
355    ///     let input = args[0].as_str().unwrap_or("");
356    ///     // ... compute hash ...
357    ///     let hash_string = "example_hash";
358    ///     Ok(serde_json::json!(hash_string))
359    /// }).await?;
360    /// # Ok(())
361    /// # }
362    /// ```
363    ///
364    /// # Notes
365    ///
366    /// - The function is re-bound after each navigation
367    /// - Arguments and return values must be JSON-serializable
368    /// - Errors returned from the callback will reject the JavaScript promise
369    pub async fn expose_function<F, Fut>(&self, name: &str, callback: F) -> Result<(), crate::error::PageError>
370    where
371        F: Fn(Vec<serde_json::Value>) -> Fut + Send + Sync + 'static,
372        Fut: std::future::Future<Output = Result<serde_json::Value, String>> + Send + 'static,
373    {
374        self.binding_manager.expose_function(name, callback).await
375    }
376
377    /// Remove an exposed function.
378    ///
379    /// The function will no longer be available in JavaScript after this call.
380    pub async fn remove_exposed_function(&self, name: &str) -> Result<(), crate::error::PageError> {
381        self.binding_manager.remove_function(name).await
382    }
383}
384
385#[cfg(test)]
386mod tests;