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