Skip to main content

bern_script/
lib.rs

1//! Persistent JavaScript contexts. Builds select the system host or Boa.
2//! Values cross the boundary as JSON. Each context has independent globals.
3use serde::{Deserialize, Serialize};
4pub use serde_json::Value;
5#[cfg(not(feature = "boa"))]
6use std::sync::atomic::{AtomicU64, Ordering};
7use std::{
8    collections::{BTreeMap, VecDeque},
9    future::Future,
10    pin::Pin,
11    sync::{Arc, Mutex, OnceLock},
12    task::{Context as TaskContext, Poll, Waker},
13};
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct Error(pub String);
17impl std::fmt::Display for Error {
18    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
19        f.write_str(&self.0)
20    }
21}
22impl std::error::Error for Error {}
23type Result = std::result::Result<Value, Error>;
24struct State {
25    result: Option<Result>,
26    waker: Option<Waker>,
27}
28type Shared = Arc<Mutex<State>>;
29fn finish(state: &Shared, value: Result) {
30    let waker = {
31        let mut state = state.lock().unwrap();
32        if state.result.is_some() {
33            return;
34        }
35        state.result = Some(value);
36        state.waker.take()
37    };
38    if let Some(waker) = waker {
39        waker.wake();
40    }
41}
42/// Await this without blocking the frontend's UI thread.
43pub struct Evaluation {
44    state: Shared,
45}
46impl Future for Evaluation {
47    type Output = Result;
48    fn poll(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll<Result> {
49        let mut state = self.state.lock().unwrap();
50        if let Some(result) = state.result.take() {
51            Poll::Ready(result)
52        } else {
53            state.waker = Some(cx.waker().clone());
54            Poll::Pending
55        }
56    }
57}
58impl Evaluation {
59    pub fn try_result(&self) -> Option<Result> {
60        self.state.lock().unwrap().result.take()
61    }
62}
63#[cfg(not(feature = "boa"))]
64static NEXT: AtomicU64 = AtomicU64::new(1);
65#[cfg(not(feature = "boa"))]
66fn next() -> String {
67    NEXT.fetch_add(1, Ordering::Relaxed).to_string()
68}
69
70pub struct Context {
71    #[cfg(not(feature = "boa"))]
72    id: String,
73    #[cfg(feature = "boa")]
74    worker: std::sync::mpsc::Sender<(String, Shared)>,
75}
76impl Context {
77    pub fn new() -> std::result::Result<Self, Error> {
78        #[cfg(feature = "boa")]
79        let worker = boa::worker()?;
80        #[cfg(not(feature = "boa"))]
81        if !hub().lock().unwrap().available {
82            return Err(Error(
83                "JavaScript host is not running; enable javascript in the target".into(),
84            ));
85        }
86        Ok(Self {
87            #[cfg(not(feature = "boa"))]
88            id: next(),
89            #[cfg(feature = "boa")]
90            worker,
91        })
92    }
93    pub fn eval(&self, source: impl Into<String>) -> Evaluation {
94        let state = Arc::new(Mutex::new(State {
95            result: None,
96            waker: None,
97        }));
98        let source = source.into();
99        #[cfg(feature = "boa")]
100        if self.worker.send((source, state.clone())).is_err() {
101            finish(&state, Err(Error("JavaScript context stopped".into())));
102        }
103        #[cfg(not(feature = "boa"))]
104        {
105            let mut hub = hub().lock().unwrap();
106            if hub.available {
107                let id = next();
108                hub.pending
109                    .insert(id.clone(), (self.id.clone(), None, state.clone()));
110                hub.queue.push_back(Request {
111                    id,
112                    context: self.id.clone(),
113                    source: Some(source),
114                });
115            } else {
116                finish(&state, Err(Error("JavaScript host stopped".into())));
117            }
118        }
119        Evaluation { state }
120    }
121}
122impl Drop for Context {
123    fn drop(&mut self) {
124        #[cfg(not(feature = "boa"))]
125        {
126            let states = {
127                let mut hub = hub().lock().unwrap();
128                hub.queue.retain(|r| r.context != self.id);
129                if hub.available {
130                    hub.queue.push_back(Request {
131                        id: next(),
132                        context: self.id.clone(),
133                        source: None,
134                    });
135                }
136                let ids: Vec<_> = hub
137                    .pending
138                    .iter()
139                    .filter(|(_, p)| p.0 == self.id)
140                    .map(|(id, _)| id.clone())
141                    .collect();
142                ids.into_iter()
143                    .filter_map(|id| hub.pending.remove(&id).map(|p| p.2))
144                    .collect::<Vec<_>>()
145            };
146            for state in states {
147                finish(&state, Err(Error("JavaScript context disposed".into())));
148            }
149        }
150    }
151}
152
153#[derive(Serialize)]
154struct Request {
155    id: String,
156    context: String,
157    source: Option<String>,
158}
159#[derive(Default)]
160struct Hub {
161    available: bool,
162    queue: VecDeque<Request>,
163    pending: BTreeMap<String, (String, Option<f64>, Shared)>,
164}
165fn hub() -> &'static Mutex<Hub> {
166    static HUB: OnceLock<Mutex<Hub>> = OnceLock::new();
167    HUB.get_or_init(Default::default)
168}
169/// Frontend adapter API; application code uses `Context`.
170pub mod host {
171    use super::*;
172    pub fn start() {
173        hub().lock().unwrap().available = true;
174    }
175    pub fn stop() {
176        let states = {
177            let mut hub = hub().lock().unwrap();
178            hub.available = false;
179            hub.queue.clear();
180            std::mem::take(&mut hub.pending)
181        };
182        for (_, (_, _, state)) in states {
183            finish(&state, Err(Error("JavaScript host stopped".into())));
184        }
185    }
186    /// A JSON batch, or an empty string. Called on the UI thread.
187    pub fn poll(now_ms: f64) -> String {
188        let (requests, expired) = {
189            let mut hub = hub().lock().unwrap();
190            let ids: Vec<_> = hub
191                .pending
192                .iter_mut()
193                .filter_map(|(id, p)| {
194                    let deadline = p.1.get_or_insert(now_ms + 30_000.0);
195                    (now_ms >= *deadline).then(|| id.clone())
196                })
197                .collect();
198            let expired: Vec<_> = ids
199                .into_iter()
200                .filter_map(|id| hub.pending.remove(&id))
201                .collect();
202            (hub.queue.drain(..).collect::<Vec<_>>(), expired)
203        };
204        for (_, _, state) in expired {
205            finish(&state, Err(Error("JavaScript evaluation timed out".into())));
206        }
207        if requests.is_empty() {
208            String::new()
209        } else {
210            serde_json::to_string(&requests).unwrap()
211        }
212    }
213    pub fn complete(json: &str) {
214        #[derive(Deserialize)]
215        struct Reply {
216            id: String,
217            #[serde(default)]
218            value: Value,
219            error: Option<String>,
220        }
221        let Ok(reply) = serde_json::from_str::<Reply>(json) else {
222            return;
223        };
224        let pending = hub().lock().unwrap().pending.remove(&reply.id);
225        if let Some((_, _, state)) = pending {
226            finish(
227                &state,
228                reply.error.map_or(Ok(reply.value), |e| Err(Error(e))),
229            );
230        }
231    }
232}
233#[cfg(feature = "boa")]
234mod boa {
235    use super::*;
236    use boa_engine::{
237        Context as JsContext, Source, builtins::promise::PromiseState, object::builtins::JsPromise,
238    };
239    pub fn worker() -> std::result::Result<std::sync::mpsc::Sender<(String, Shared)>, Error> {
240        let (tx, rx) = std::sync::mpsc::channel::<(String, Shared)>();
241        std::thread::Builder::new()
242            .name("bern-js".into())
243            .spawn(move || {
244                let mut context = JsContext::default();
245                context
246                    .runtime_limits_mut()
247                    .set_loop_iteration_limit(1_000_000);
248                context.runtime_limits_mut().set_recursion_limit(512);
249                for (source, state) in rx {
250                    let result = evaluate(&source, &mut context).map_err(Error);
251                    finish(&state, result);
252                }
253            })
254            .map_err(|e| Error(e.to_string()))?;
255        Ok(tx)
256    }
257    fn evaluate(source: &str, context: &mut JsContext) -> std::result::Result<Value, String> {
258        let value = context
259            .eval(Source::from_bytes(source))
260            .map_err(|e| e.to_string())?;
261        context.run_jobs().map_err(|e| e.to_string())?;
262        let value = if let Some(object) = value.as_object() {
263            if let Ok(promise) = JsPromise::from_object(object.clone()) {
264                match promise.state() {
265                    PromiseState::Fulfilled(value) => value,
266                    PromiseState::Rejected(error) => {
267                        return Err(error
268                            .to_string(context)
269                            .map(|s| s.to_std_string_escaped())
270                            .unwrap_or_else(|e| e.to_string()));
271                    }
272                    PromiseState::Pending => {
273                        return Err(
274                            "Promise did not settle; no external async host is installed".into(),
275                        );
276                    }
277                }
278            } else {
279                value
280            }
281        } else {
282            value
283        };
284        if value.is_undefined() {
285            return Ok(Value::Null);
286        }
287        value
288            .to_json(context)
289            .map(|v| v.unwrap_or(Value::Null))
290            .map_err(|e| e.to_string())
291    }
292}
293
294/// HTML runtime shared by native WebView adapters.
295pub const HOST_RUNTIME: &str = include_str!("runtime.js");