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