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;