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;