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;