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