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;