Skip to main content

foundry_runtime/
js.rs

1use boa_engine::property::Attribute;
2use boa_engine::NativeFunction;
3use boa_engine::{Context, JsArgs, JsValue, Source};
4use std::cell::RefCell;
5use std::rc::Rc;
6
7use crate::scene::*;
8
9#[derive(Debug, Clone)]
10pub enum DomMutation {
11    SetTextContent(String, String),
12    SetStyle(String, String, String),
13    AddClass(String, String),
14    RemoveClass(String, String),
15}
16
17/// In-memory localStorage backed by a JSON file for persistence.
18#[derive(Debug, Clone, Default)]
19pub struct LocalStorage {
20    data: std::collections::HashMap<String, String>,
21    file_path: Option<std::path::PathBuf>,
22}
23
24impl LocalStorage {
25    pub fn new(file_path: Option<std::path::PathBuf>) -> Self {
26        let mut storage = Self {
27            data: std::collections::HashMap::new(),
28            file_path: file_path.clone(),
29        };
30        if let Some(path) = &file_path {
31            if let Ok(content) = std::fs::read_to_string(path) {
32                if let Ok(map) =
33                    serde_json::from_str::<std::collections::HashMap<String, String>>(&content)
34                {
35                    storage.data = map;
36                }
37            }
38        }
39        storage
40    }
41
42    pub fn get_item(&self, key: &str) -> Option<&String> {
43        self.data.get(key)
44    }
45
46    pub fn set_item(&mut self, key: String, value: String) {
47        self.data.insert(key, value);
48        self.persist();
49    }
50
51    pub fn remove_item(&mut self, key: &str) {
52        self.data.remove(key);
53        self.persist();
54    }
55
56    pub fn clear(&mut self) {
57        self.data.clear();
58        self.persist();
59    }
60
61    pub fn length(&self) -> usize {
62        self.data.len()
63    }
64
65    fn persist(&self) {
66        if let Some(path) = &self.file_path {
67            if let Ok(json) = serde_json::to_string_pretty(&self.data) {
68                // Atomic write: write to temp file, then rename.
69                // Prevents data loss on crash or disk-full mid-write.
70                let tmp = path.with_extension("json.tmp");
71                if std::fs::write(&tmp, &json).is_ok() {
72                    let _ = std::fs::rename(&tmp, path);
73                }
74            }
75        }
76    }
77}
78
79pub struct JsEngine {
80    context: Context,
81    mutations: Rc<RefCell<Vec<DomMutation>>>,
82    storage: Rc<RefCell<LocalStorage>>,
83}
84
85impl Default for JsEngine {
86    fn default() -> Self {
87        Self::new()
88    }
89}
90
91impl JsEngine {
92    pub fn new() -> Self {
93        Self::with_storage_path(None)
94    }
95
96    pub fn with_storage_path(storage_path: Option<std::path::PathBuf>) -> Self {
97        let context = Context::default();
98        let mutations = Rc::new(RefCell::new(Vec::new()));
99        let storage = Rc::new(RefCell::new(LocalStorage::new(storage_path)));
100
101        let mut engine = Self {
102            context,
103            mutations,
104            storage,
105        };
106        engine.register_dom_api();
107        engine.register_storage_api();
108        engine.register_fetch_api();
109        engine
110    }
111
112    fn register_dom_api(&mut self) {
113        let mutations = self.mutations.clone();
114
115        // SAFETY: from_closure requires the closure is valid for 'static.
116        // The closure captures only Rc<RefCell<Vec<DomMutation>>> (no borrowed refs),
117        // so it satisfies the 'static bound.
118        let get_element = unsafe {
119            NativeFunction::from_closure(move |_, args, ctx| {
120                let id = args
121                    .get_or_undefined(0)
122                    .to_string(ctx)?
123                    .to_std_string_escaped();
124
125                let m = mutations.clone();
126                let element_id = id.clone();
127
128                let obj = boa_engine::JsObject::with_null_proto();
129
130                // setTextContent(text)
131                {
132                    let m = m.clone();
133                    let eid = element_id.clone();
134                    let func = NativeFunction::from_closure(move |_, args, ctx| {
135                        let text = args
136                            .get_or_undefined(0)
137                            .to_string(ctx)?
138                            .to_std_string_escaped();
139                        m.borrow_mut()
140                            .push(DomMutation::SetTextContent(eid.clone(), text));
141                        Ok(JsValue::undefined())
142                    });
143                    let js_func = func.to_js_function(ctx.realm());
144                    obj.set(
145                        boa_engine::js_string!("setTextContent"),
146                        js_func,
147                        false,
148                        ctx,
149                    )?;
150                }
151
152                // setStyle(property, value)
153                {
154                    let m = m.clone();
155                    let eid = element_id.clone();
156                    let func = NativeFunction::from_closure(move |_, args, ctx| {
157                        let prop = args
158                            .get_or_undefined(0)
159                            .to_string(ctx)?
160                            .to_std_string_escaped();
161                        let val = args
162                            .get_or_undefined(1)
163                            .to_string(ctx)?
164                            .to_std_string_escaped();
165                        m.borrow_mut()
166                            .push(DomMutation::SetStyle(eid.clone(), prop, val));
167                        Ok(JsValue::undefined())
168                    });
169                    let js_func = func.to_js_function(ctx.realm());
170                    obj.set(boa_engine::js_string!("setStyle"), js_func, false, ctx)?;
171                }
172
173                // addClass(className)
174                {
175                    let m = m.clone();
176                    let eid = element_id.clone();
177                    let func = NativeFunction::from_closure(move |_, args, ctx| {
178                        let class = args
179                            .get_or_undefined(0)
180                            .to_string(ctx)?
181                            .to_std_string_escaped();
182                        m.borrow_mut()
183                            .push(DomMutation::AddClass(eid.clone(), class));
184                        Ok(JsValue::undefined())
185                    });
186                    let js_func = func.to_js_function(ctx.realm());
187                    obj.set(boa_engine::js_string!("addClass"), js_func, false, ctx)?;
188                }
189
190                // removeClass(className)
191                {
192                    let m = m.clone();
193                    let eid = element_id.clone();
194                    let func = NativeFunction::from_closure(move |_, args, ctx| {
195                        let class = args
196                            .get_or_undefined(0)
197                            .to_string(ctx)?
198                            .to_std_string_escaped();
199                        m.borrow_mut()
200                            .push(DomMutation::RemoveClass(eid.clone(), class));
201                        Ok(JsValue::undefined())
202                    });
203                    let js_func = func.to_js_function(ctx.realm());
204                    obj.set(boa_engine::js_string!("removeClass"), js_func, false, ctx)?;
205                }
206
207                obj.set(
208                    boa_engine::js_string!("id"),
209                    JsValue::from(boa_engine::js_string!(element_id.as_str())),
210                    false,
211                    ctx,
212                )?;
213
214                Ok(JsValue::from(obj))
215            })
216        };
217
218        let document = boa_engine::JsObject::with_null_proto();
219        let get_elem_func = get_element.to_js_function(self.context.realm());
220        document
221            .set(
222                boa_engine::js_string!("getElementById"),
223                get_elem_func,
224                false,
225                &mut self.context,
226            )
227            .ok();
228
229        self.context
230            .register_global_property(
231                boa_engine::js_string!("document"),
232                JsValue::from(document),
233                Attribute::all(),
234            )
235            .ok();
236
237        // console.log
238        let console = boa_engine::JsObject::with_null_proto();
239        let log_fn = NativeFunction::from_fn_ptr(|_, args, ctx| {
240            let msg = args
241                .get_or_undefined(0)
242                .to_string(ctx)?
243                .to_std_string_escaped();
244            println!("[foundry:js] {}", msg);
245            Ok(JsValue::undefined())
246        });
247        let log_js_func = log_fn.to_js_function(self.context.realm());
248        console
249            .set(
250                boa_engine::js_string!("log"),
251                log_js_func,
252                false,
253                &mut self.context,
254            )
255            .ok();
256
257        self.context
258            .register_global_property(
259                boa_engine::js_string!("console"),
260                JsValue::from(console),
261                Attribute::all(),
262            )
263            .ok();
264    }
265
266    fn register_storage_api(&mut self) {
267        let storage = self.storage.clone();
268        let ls = boa_engine::JsObject::with_null_proto();
269
270        // getItem(key) -> string | null
271        {
272            let s = storage.clone();
273            // SAFETY: captures Rc<RefCell<LocalStorage>> only; Rc<T>: 'static when T: 'static.
274            let func = unsafe {
275                NativeFunction::from_closure(move |_, args, ctx| {
276                    let key = args
277                        .get_or_undefined(0)
278                        .to_string(ctx)?
279                        .to_std_string_escaped();
280                    let val = s.borrow().get_item(&key).cloned();
281                    match val {
282                        Some(v) => Ok(JsValue::from(boa_engine::js_string!(v.as_str()))),
283                        None => Ok(JsValue::null()),
284                    }
285                })
286            };
287            let js_func = func.to_js_function(self.context.realm());
288            ls.set(
289                boa_engine::js_string!("getItem"),
290                js_func,
291                false,
292                &mut self.context,
293            )
294            .ok();
295        }
296
297        // setItem(key, value)
298        {
299            let s = storage.clone();
300            // SAFETY: captures Rc<RefCell<LocalStorage>> only; Rc<T>: 'static when T: 'static.
301            let func = unsafe {
302                NativeFunction::from_closure(move |_, args, ctx| {
303                    let key = args
304                        .get_or_undefined(0)
305                        .to_string(ctx)?
306                        .to_std_string_escaped();
307                    let val = args
308                        .get_or_undefined(1)
309                        .to_string(ctx)?
310                        .to_std_string_escaped();
311                    s.borrow_mut().set_item(key, val);
312                    Ok(JsValue::undefined())
313                })
314            };
315            let js_func = func.to_js_function(self.context.realm());
316            ls.set(
317                boa_engine::js_string!("setItem"),
318                js_func,
319                false,
320                &mut self.context,
321            )
322            .ok();
323        }
324
325        // removeItem(key)
326        {
327            let s = storage.clone();
328            // SAFETY: captures Rc<RefCell<LocalStorage>> only; Rc<T>: 'static when T: 'static.
329            let func = unsafe {
330                NativeFunction::from_closure(move |_, args, ctx| {
331                    let key = args
332                        .get_or_undefined(0)
333                        .to_string(ctx)?
334                        .to_std_string_escaped();
335                    s.borrow_mut().remove_item(&key);
336                    Ok(JsValue::undefined())
337                })
338            };
339            let js_func = func.to_js_function(self.context.realm());
340            ls.set(
341                boa_engine::js_string!("removeItem"),
342                js_func,
343                false,
344                &mut self.context,
345            )
346            .ok();
347        }
348
349        // clear()
350        {
351            let s = storage.clone();
352            // SAFETY: captures Rc<RefCell<LocalStorage>> only; Rc<T>: 'static when T: 'static.
353            let func = unsafe {
354                NativeFunction::from_closure(move |_, _args, _ctx| {
355                    s.borrow_mut().clear();
356                    Ok(JsValue::undefined())
357                })
358            };
359            let js_func = func.to_js_function(self.context.realm());
360            ls.set(
361                boa_engine::js_string!("clear"),
362                js_func,
363                false,
364                &mut self.context,
365            )
366            .ok();
367        }
368
369        self.context
370            .register_global_property(
371                boa_engine::js_string!("localStorage"),
372                JsValue::from(ls),
373                Attribute::all(),
374            )
375            .ok();
376    }
377
378    fn register_fetch_api(&mut self) {
379        // Synchronous fetch(url) -> { ok: bool, status: number, text: string }
380        // Blocking with 5s connect / 10s read timeout to avoid freezing the UI.
381        // Body capped at 4 MB to prevent unbounded allocation from remote.
382        let func = NativeFunction::from_fn_ptr(|_, args, ctx| {
383            let url = args
384                .get_or_undefined(0)
385                .to_string(ctx)?
386                .to_std_string_escaped();
387
388            let result = boa_engine::JsObject::with_null_proto();
389
390            let agent = ureq::Agent::new_with_config(
391                ureq::config::Config::builder()
392                    .timeout_connect(Some(std::time::Duration::from_secs(5)))
393                    .timeout_recv_body(Some(std::time::Duration::from_secs(10)))
394                    .build(),
395            );
396
397            match agent.get(&url).call() {
398                Ok(response) => {
399                    let status = response.status().as_u16();
400                    // Cap body at 4 MB to prevent unbounded allocation
401                    let body = response
402                        .into_body()
403                        .with_config()
404                        .limit(4 * 1024 * 1024)
405                        .read_to_string()
406                        .unwrap_or_default();
407                    result
408                        .set(
409                            boa_engine::js_string!("ok"),
410                            JsValue::from((200..300).contains(&status)),
411                            false,
412                            ctx,
413                        )
414                        .ok();
415                    result
416                        .set(
417                            boa_engine::js_string!("status"),
418                            JsValue::from(status as i32),
419                            false,
420                            ctx,
421                        )
422                        .ok();
423                    result
424                        .set(
425                            boa_engine::js_string!("text"),
426                            JsValue::from(boa_engine::js_string!(body.as_str())),
427                            false,
428                            ctx,
429                        )
430                        .ok();
431                }
432                Err(e) => {
433                    result
434                        .set(
435                            boa_engine::js_string!("ok"),
436                            JsValue::from(false),
437                            false,
438                            ctx,
439                        )
440                        .ok();
441                    result
442                        .set(
443                            boa_engine::js_string!("status"),
444                            JsValue::from(0),
445                            false,
446                            ctx,
447                        )
448                        .ok();
449                    let err_msg = e.to_string();
450                    result
451                        .set(
452                            boa_engine::js_string!("text"),
453                            JsValue::from(boa_engine::js_string!(err_msg.as_str())),
454                            false,
455                            ctx,
456                        )
457                        .ok();
458                }
459            }
460
461            Ok(JsValue::from(result))
462        });
463
464        let js_func = func.to_js_function(self.context.realm());
465        self.context
466            .register_global_property(
467                boa_engine::js_string!("fetch"),
468                JsValue::from(js_func),
469                Attribute::all(),
470            )
471            .ok();
472    }
473
474    pub fn execute(&mut self, code: &str) -> Result<(), String> {
475        self.mutations.borrow_mut().clear();
476        // boa_engine::Context::eval is the official API for JS execution
477        self.context
478            .eval(Source::from_bytes(code))
479            .map_err(|e| format!("JS error: {}", e))?;
480        Ok(())
481    }
482
483    pub fn take_mutations(&self) -> Vec<DomMutation> {
484        self.mutations.borrow_mut().drain(..).collect()
485    }
486
487    pub fn apply_mutations(&self, scene: &mut SceneGraph, mutations: &[DomMutation]) {
488        for mutation in mutations {
489            match mutation {
490                DomMutation::SetTextContent(element_id, text) => {
491                    if let Some(node_id) = scene.find_by_element_id(element_id) {
492                        let children: Vec<NodeId> = scene.get(node_id).children.clone();
493                        let text_child = children
494                            .iter()
495                            .find(|&&c| scene.get(c).kind == ElementKind::Text);
496                        if let Some(&child_id) = text_child {
497                            scene.get_mut(child_id).text_content = Some(text.clone());
498                            scene.get_mut(child_id).dirty = true;
499                        } else {
500                            let child = scene.add_node(ElementKind::Text, "text".to_string());
501                            scene.get_mut(child).text_content = Some(text.clone());
502                            scene.add_child(node_id, child);
503                        }
504                        scene.mark_dirty_recursive(node_id);
505                    }
506                }
507                DomMutation::SetStyle(element_id, prop, val) => {
508                    if let Some(node_id) = scene.find_by_element_id(element_id) {
509                        crate::css::apply_property(&mut scene.get_mut(node_id).style, prop, val);
510                        scene.mark_dirty_recursive(node_id);
511                    }
512                }
513                DomMutation::AddClass(element_id, class) => {
514                    if let Some(node_id) = scene.find_by_element_id(element_id) {
515                        let node = scene.get_mut(node_id);
516                        if !node.classes.contains(class) {
517                            node.classes.push(class.clone());
518                            node.dirty = true;
519                        }
520                    }
521                }
522                DomMutation::RemoveClass(element_id, class) => {
523                    if let Some(node_id) = scene.find_by_element_id(element_id) {
524                        let node = scene.get_mut(node_id);
525                        node.classes.retain(|c| c != class);
526                        node.dirty = true;
527                    }
528                }
529            }
530        }
531    }
532}