Skip to main content

xript_runtime/
sandbox.rs

1use rquickjs::function::Rest;
2use rquickjs::promise::{Promise, PromiseState};
3use rquickjs::{Context, Ctx, Function, Object, Runtime, Value};
4use std::collections::{HashMap, HashSet};
5use std::future::Future;
6use std::pin::Pin;
7use std::sync::atomic::{AtomicBool, Ordering};
8use std::sync::Arc;
9use std::time::{Duration, Instant};
10
11use crate::error::{Result, XriptError};
12use crate::manifest::{Binding, Manifest, NamespaceBinding};
13
14pub type HostFn =
15    Arc<dyn Fn(&[serde_json::Value]) -> std::result::Result<serde_json::Value, String> + Send + Sync>;
16
17pub type AsyncHostFn = Arc<
18    dyn Fn(
19            &[serde_json::Value],
20        ) -> Pin<
21            Box<dyn Future<Output = std::result::Result<serde_json::Value, String>> + Send>,
22        > + Send
23        + Sync,
24>;
25
26pub struct HostBindings {
27    bindings: HashMap<String, HostBinding>,
28}
29
30enum HostBinding {
31    Function(HostFn),
32    AsyncFunction(AsyncHostFn),
33    Namespace(HashMap<String, HostFn>),
34    AsyncNamespace(HashMap<String, AsyncHostFn>),
35}
36
37impl HostBindings {
38    pub fn new() -> Self {
39        Self {
40            bindings: HashMap::new(),
41        }
42    }
43
44    pub fn add_function<F>(&mut self, name: impl Into<String>, f: F)
45    where
46        F: Fn(&[serde_json::Value]) -> std::result::Result<serde_json::Value, String>
47            + Send
48            + Sync
49            + 'static,
50    {
51        self.bindings
52            .insert(name.into(), HostBinding::Function(Arc::new(f)));
53    }
54
55    pub fn add_namespace(&mut self, name: impl Into<String>, members: HashMap<String, HostFn>) {
56        self.bindings
57            .insert(name.into(), HostBinding::Namespace(members));
58    }
59
60    pub fn add_async_function<F, Fut>(&mut self, name: impl Into<String>, f: F)
61    where
62        F: Fn(&[serde_json::Value]) -> Fut + Send + Sync + 'static,
63        Fut: Future<Output = std::result::Result<serde_json::Value, String>> + Send + 'static,
64    {
65        self.bindings.insert(
66            name.into(),
67            HostBinding::AsyncFunction(Arc::new(move |args| Box::pin(f(args)))),
68        );
69    }
70
71    pub fn add_async_namespace(
72        &mut self,
73        name: impl Into<String>,
74        members: HashMap<String, AsyncHostFn>,
75    ) {
76        self.bindings
77            .insert(name.into(), HostBinding::AsyncNamespace(members));
78    }
79}
80
81impl Default for HostBindings {
82    fn default() -> Self {
83        Self::new()
84    }
85}
86
87pub struct ConsoleHandler {
88    pub log: Box<dyn Fn(&str) + Send + Sync>,
89    pub warn: Box<dyn Fn(&str) + Send + Sync>,
90    pub error: Box<dyn Fn(&str) + Send + Sync>,
91}
92
93impl Default for ConsoleHandler {
94    fn default() -> Self {
95        Self {
96            log: Box::new(|_| {}),
97            warn: Box::new(|_| {}),
98            error: Box::new(|_| {}),
99        }
100    }
101}
102
103pub struct RuntimeOptions {
104    pub host_bindings: HostBindings,
105    pub capabilities: Vec<String>,
106    pub console: ConsoleHandler,
107}
108
109#[derive(Debug)]
110pub struct ExecutionResult {
111    pub value: serde_json::Value,
112    pub duration_ms: f64,
113}
114
115pub struct XriptRuntime {
116    rt: Runtime,
117    ctx: Context,
118    manifest: Manifest,
119}
120
121impl std::fmt::Debug for XriptRuntime {
122    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123        f.debug_struct("XriptRuntime")
124            .field("manifest_name", &self.manifest.name)
125            .finish_non_exhaustive()
126    }
127}
128
129impl XriptRuntime {
130    pub fn new(manifest: Manifest, options: RuntimeOptions) -> Result<Self> {
131        crate::manifest::validate_structure(&manifest)?;
132
133        let rt = Runtime::new().map_err(|e| XriptError::Engine(e.to_string()))?;
134
135        if let Some(ref limits) = manifest.limits {
136            if let Some(memory_mb) = limits.memory_mb {
137                rt.set_memory_limit(memory_mb as usize * 1024 * 1024);
138            }
139            if let Some(stack) = limits.max_stack_depth {
140                rt.set_max_stack_size(stack * 1024);
141            }
142        }
143
144        let ctx = Context::full(&rt).map_err(|e| XriptError::Engine(e.to_string()))?;
145
146        let granted: HashSet<String> = options.capabilities.into_iter().collect();
147
148        ctx.with(|ctx| -> Result<()> {
149            remove_dangerous_globals(&ctx)?;
150            register_console(&ctx, options.console)?;
151            register_bindings(&ctx, &manifest, options.host_bindings, &granted)?;
152            register_hooks(&ctx, &manifest, &granted)?;
153            register_fragment_hooks(&ctx)?;
154            Ok(())
155        })?;
156
157        Ok(Self { rt, ctx, manifest })
158    }
159
160    pub fn execute(&self, code: &str) -> Result<ExecutionResult> {
161        let timeout_ms = self
162            .manifest
163            .limits
164            .as_ref()
165            .and_then(|l| l.timeout_ms)
166            .unwrap_or(5000);
167
168        let interrupted = Arc::new(AtomicBool::new(false));
169        let interrupted_clone = interrupted.clone();
170        let start = Instant::now();
171        let deadline = start + Duration::from_millis(timeout_ms);
172
173        self.rt
174            .set_interrupt_handler(Some(Box::new(move || {
175                if Instant::now() >= deadline {
176                    interrupted_clone.store(true, Ordering::Relaxed);
177                    true
178                } else {
179                    false
180                }
181            }) as Box<dyn FnMut() -> bool + Send>));
182
183        let result = self.ctx.with(|ctx| {
184            let res: std::result::Result<Value, _> = ctx.eval(code);
185            match res {
186                Ok(val) => {
187                    while ctx.execute_pending_job() {}
188                    let resolved = resolve_if_promise(&ctx, val);
189                    let json = js_value_to_json(&ctx, &resolved);
190                    Ok(json)
191                }
192                Err(_) => {
193                    if interrupted.load(Ordering::Relaxed) {
194                        Err(XriptError::ExecutionLimit {
195                            limit: "timeout_ms".into(),
196                        })
197                    } else {
198                        let msg: std::result::Result<String, _> =
199                            ctx.eval("(() => { try { throw undefined; } catch(e) { return String(e); } })()");
200                        let error_msg = msg.unwrap_or_else(|_| "unknown script error".into());
201                        Err(XriptError::Script(error_msg))
202                    }
203                }
204            }
205        });
206
207        self.rt
208            .set_interrupt_handler(None::<Box<dyn FnMut() -> bool + Send>>);
209
210        let duration_ms = start.elapsed().as_secs_f64() * 1000.0;
211
212        result.map(|value| ExecutionResult { value, duration_ms })
213    }
214
215    pub fn manifest(&self) -> &Manifest {
216        &self.manifest
217    }
218
219    pub fn load_mod(
220        &self,
221        mod_manifest_json: &str,
222        fragment_sources: HashMap<String, String>,
223        granted_capabilities: &HashSet<String>,
224        entry_source: Option<&str>,
225    ) -> Result<crate::fragment::ModInstance> {
226        let mod_instance = crate::fragment::load_mod(
227            mod_manifest_json,
228            &self.manifest,
229            granted_capabilities,
230            &fragment_sources,
231        )?;
232
233        if let Some(source) = entry_source {
234            let mod_name = mod_instance.name.clone();
235            self.ctx.with(|ctx| {
236                let res: std::result::Result<Value, _> = ctx.eval(source);
237                if let Err(_) = res {
238                    let msg: std::result::Result<String, _> =
239                        ctx.eval("(() => { try { throw undefined; } catch(e) { return String(e); } })()");
240                    let error_msg = msg.unwrap_or_else(|_| "unknown entry script error".into());
241                    return Err(XriptError::ModEntry {
242                        mod_name,
243                        message: error_msg,
244                    });
245                }
246                Ok(())
247            })?;
248        }
249
250        Ok(mod_instance)
251    }
252
253    pub fn fire_fragment_hook(
254        &self,
255        fragment_id: &str,
256        lifecycle: &str,
257        bindings: Option<&serde_json::Value>,
258    ) -> Result<Vec<serde_json::Value>> {
259        let bindings_json = match bindings {
260            Some(b) => serde_json::to_string(b).unwrap_or("{}".into()),
261            None => "{}".into(),
262        };
263
264        let code = format!(
265            r#"(function() {{
266                var handlers = globalThis.__xript_fragment_handlers || {{}};
267                var key = "fragment:{lifecycle}:{fid}";
268                var list = handlers[key] || [];
269                var results = [];
270                var bindingsObj = JSON.parse('{bindings_json}');
271                for (var i = 0; i < list.length; i++) {{
272                    var ops = [];
273                    var proxy = {{
274                        toggle: function(sel, cond) {{ ops.push({{ op: "toggle", selector: sel, value: !!cond }}); }},
275                        addClass: function(sel, cls) {{ ops.push({{ op: "addClass", selector: sel, value: cls }}); }},
276                        removeClass: function(sel, cls) {{ ops.push({{ op: "removeClass", selector: sel, value: cls }}); }},
277                        setText: function(sel, txt) {{ ops.push({{ op: "setText", selector: sel, value: txt }}); }},
278                        setAttr: function(sel, attr, val) {{ ops.push({{ op: "setAttr", selector: sel, attr: attr, value: val }}); }},
279                        replaceChildren: function(sel, html) {{ ops.push({{ op: "replaceChildren", selector: sel, value: html }}); }}
280                    }};
281                    list[i](bindingsObj, proxy);
282                    results.push(ops);
283                }}
284                return JSON.stringify(results);
285            }})()"#,
286            lifecycle = lifecycle,
287            fid = fragment_id,
288            bindings_json = bindings_json.replace('\'', "\\'"),
289        );
290
291        let result = self.execute(&code)?;
292        match serde_json::from_str::<Vec<serde_json::Value>>(
293            result.value.as_str().unwrap_or("[]"),
294        ) {
295            Ok(ops) => Ok(ops),
296            Err(_) => Ok(vec![]),
297        }
298    }
299}
300
301fn remove_dangerous_globals(ctx: &Ctx<'_>) -> Result<()> {
302    let script = r#"
303        delete globalThis.eval;
304        if (typeof globalThis.Function !== 'undefined') {
305            Object.defineProperty(globalThis, 'Function', {
306                get: function() { throw new Error("Function constructor is not permitted. Dynamic code generation is disabled in xript."); },
307                configurable: false
308            });
309        }
310    "#;
311    ctx.eval::<(), _>(script)
312        .map_err(|e| XriptError::Engine(e.to_string()))?;
313    Ok(())
314}
315
316fn register_console(ctx: &Ctx<'_>, console: ConsoleHandler) -> Result<()> {
317    let console_obj = Object::new(ctx.clone()).map_err(|e| XriptError::Engine(e.to_string()))?;
318
319    let log = Arc::new(console.log);
320    let log_clone = log.clone();
321    let log_fn = Function::new(ctx.clone(), move |args: Rest<String>| {
322        let msg = args.0.join(" ");
323        log_clone(&msg);
324    })
325    .map_err(|e| XriptError::Engine(e.to_string()))?;
326
327    let warn = Arc::new(console.warn);
328    let warn_clone = warn.clone();
329    let warn_fn = Function::new(ctx.clone(), move |args: Rest<String>| {
330        let msg = args.0.join(" ");
331        warn_clone(&msg);
332    })
333    .map_err(|e| XriptError::Engine(e.to_string()))?;
334
335    let error = Arc::new(console.error);
336    let error_clone = error.clone();
337    let error_fn = Function::new(ctx.clone(), move |args: Rest<String>| {
338        let msg = args.0.join(" ");
339        error_clone(&msg);
340    })
341    .map_err(|e| XriptError::Engine(e.to_string()))?;
342
343    console_obj
344        .set("log", log_fn)
345        .map_err(|e| XriptError::Engine(e.to_string()))?;
346    console_obj
347        .set("warn", warn_fn)
348        .map_err(|e| XriptError::Engine(e.to_string()))?;
349    console_obj
350        .set("error", error_fn)
351        .map_err(|e| XriptError::Engine(e.to_string()))?;
352
353    ctx.globals()
354        .set("console", console_obj)
355        .map_err(|e| XriptError::Engine(e.to_string()))?;
356
357    Ok(())
358}
359
360fn make_throwing_function<'js>(ctx: &Ctx<'js>, message: &str) -> Result<Function<'js>> {
361    let escaped = message.replace('\\', "\\\\").replace('"', "\\\"");
362    let script = format!("(function() {{ throw new Error(\"{}\"); }})", escaped);
363    ctx.eval::<Function, _>(script.as_str())
364        .map_err(|e| XriptError::Engine(e.to_string()))
365}
366
367fn register_bindings(
368    ctx: &Ctx<'_>,
369    manifest: &Manifest,
370    host_bindings: HostBindings,
371    granted: &HashSet<String>,
372) -> Result<()> {
373    let Some(ref bindings) = manifest.bindings else {
374        return Ok(());
375    };
376
377    for (name, binding) in bindings {
378        match binding {
379            Binding::Function(func_def) => {
380                if let Some(ref cap) = func_def.capability {
381                    if !granted.contains(cap) {
382                        let msg = format!(
383                            "{}() requires the \"{}\" capability, which hasn't been granted to this script",
384                            name, cap
385                        );
386                        let deny_fn = make_throwing_function(ctx, &msg)?;
387                        ctx.globals()
388                            .set(name.as_str(), deny_fn)
389                            .map_err(|e| XriptError::Engine(e.to_string()))?;
390                        continue;
391                    }
392                }
393
394                match host_bindings.bindings.get(name) {
395                    Some(HostBinding::Function(f)) => {
396                        let js_fn = create_host_function(ctx, name, f.clone())?;
397                        ctx.globals()
398                            .set(name.as_str(), js_fn)
399                            .map_err(|e| XriptError::Engine(e.to_string()))?;
400                    }
401                    Some(HostBinding::AsyncFunction(f)) => {
402                        let js_fn = create_async_host_function(ctx, name, f.clone())?;
403                        ctx.globals()
404                            .set(name.as_str(), js_fn)
405                            .map_err(|e| XriptError::Engine(e.to_string()))?;
406                    }
407                    _ => {
408                        let msg = format!("host binding '{}' is not provided", name);
409                        let missing_fn = make_throwing_function(ctx, &msg)?;
410                        ctx.globals()
411                            .set(name.as_str(), missing_fn)
412                            .map_err(|e| XriptError::Engine(e.to_string()))?;
413                    }
414                }
415            }
416            Binding::Namespace(ns_def) => {
417                register_namespace_binding(ctx, name, ns_def, &host_bindings, granted)?;
418            }
419        }
420    }
421
422    Ok(())
423}
424
425fn register_namespace_binding(
426    ctx: &Ctx<'_>,
427    name: &str,
428    ns_def: &NamespaceBinding,
429    host_bindings: &HostBindings,
430    granted: &HashSet<String>,
431) -> Result<()> {
432    let ns_obj = Object::new(ctx.clone()).map_err(|e| XriptError::Engine(e.to_string()))?;
433
434    let host_ns = match host_bindings.bindings.get(name) {
435        Some(HostBinding::Namespace(members)) => Some(members),
436        _ => None,
437    };
438
439    let host_async_ns = match host_bindings.bindings.get(name) {
440        Some(HostBinding::AsyncNamespace(members)) => Some(members),
441        _ => None,
442    };
443
444    for (member_name, member_binding) in &ns_def.members {
445        if let Binding::Function(func_def) = member_binding {
446            let full_name = format!("{}.{}", name, member_name);
447
448            if let Some(ref cap) = func_def.capability {
449                if !granted.contains(cap) {
450                    let msg = format!(
451                        "{}() requires the \"{}\" capability, which hasn't been granted to this script",
452                        full_name, cap
453                    );
454                    let deny_fn = make_throwing_function(ctx, &msg)?;
455                    ns_obj
456                        .set(member_name.as_str(), deny_fn)
457                        .map_err(|e| XriptError::Engine(e.to_string()))?;
458                    continue;
459                }
460            }
461
462            if let Some(host_members) = host_ns {
463                if let Some(f) = host_members.get(member_name) {
464                    let js_fn = create_host_function(ctx, &full_name, f.clone())?;
465                    ns_obj
466                        .set(member_name.as_str(), js_fn)
467                        .map_err(|e| XriptError::Engine(e.to_string()))?;
468                    continue;
469                }
470            }
471
472            if let Some(host_members) = host_async_ns {
473                if let Some(f) = host_members.get(member_name) {
474                    let js_fn = create_async_host_function(ctx, &full_name, f.clone())?;
475                    ns_obj
476                        .set(member_name.as_str(), js_fn)
477                        .map_err(|e| XriptError::Engine(e.to_string()))?;
478                    continue;
479                }
480            }
481
482            let msg = format!("host binding '{}' is not provided", full_name);
483            let missing_fn = make_throwing_function(ctx, &msg)?;
484            ns_obj
485                .set(member_name.as_str(), missing_fn)
486                .map_err(|e| XriptError::Engine(e.to_string()))?;
487        }
488    }
489
490    ctx.globals()
491        .set(name, ns_obj)
492        .map_err(|e| XriptError::Engine(e.to_string()))?;
493
494    let freeze_script = format!(
495        "Object.freeze(globalThis['{}'])",
496        name.replace('\'', "\\'")
497    );
498    ctx.eval::<(), _>(freeze_script.as_str())
499        .map_err(|e| XriptError::Engine(e.to_string()))?;
500
501    Ok(())
502}
503
504fn register_hooks(ctx: &Ctx<'_>, manifest: &Manifest, granted: &HashSet<String>) -> Result<()> {
505    let Some(ref hooks) = manifest.hooks else {
506        return Ok(());
507    };
508
509    if hooks.is_empty() {
510        return Ok(());
511    }
512
513    let mut hook_setup = String::from("globalThis.__xript_hooks = {};\n");
514    hook_setup.push_str("globalThis.hooks = {};\n");
515
516    for (hook_name, hook_def) in hooks {
517        hook_setup.push_str(&format!(
518            "globalThis.__xript_hooks['{}'] = [];\n",
519            hook_name
520        ));
521
522        if let Some(ref phases) = hook_def.phases {
523            if !phases.is_empty() {
524                hook_setup.push_str(&format!("globalThis.hooks['{}'] = {{}};\n", hook_name));
525                for phase in phases {
526                    let registration = if let Some(ref cap) = hook_def.capability {
527                        if !granted.contains(cap) {
528                            format!(
529                                "globalThis.hooks['{hook}']['{phase}'] = function() {{ throw new Error(\"{hook}.{phase}() requires the \\\"{cap}\\\" capability\"); }};",
530                                hook = hook_name, phase = phase, cap = cap
531                            )
532                        } else {
533                            format!(
534                                "globalThis.hooks['{hook}']['{phase}'] = function(handler) {{ globalThis.__xript_hooks['{hook}'].push({{ phase: '{phase}', handler: handler }}); }};",
535                                hook = hook_name, phase = phase
536                            )
537                        }
538                    } else {
539                        format!(
540                            "globalThis.hooks['{hook}']['{phase}'] = function(handler) {{ globalThis.__xript_hooks['{hook}'].push({{ phase: '{phase}', handler: handler }}); }};",
541                            hook = hook_name, phase = phase
542                        )
543                    };
544                    hook_setup.push_str(&registration);
545                    hook_setup.push('\n');
546                }
547            }
548        } else {
549            let registration = if let Some(ref cap) = hook_def.capability {
550                if !granted.contains(cap) {
551                    format!(
552                        "globalThis.hooks['{hook}'] = function() {{ throw new Error(\"{hook}() requires the \\\"{cap}\\\" capability\"); }};",
553                        hook = hook_name, cap = cap
554                    )
555                } else {
556                    format!(
557                        "globalThis.hooks['{hook}'] = function(handler) {{ globalThis.__xript_hooks['{hook}'].push({{ handler: handler }}); }};",
558                        hook = hook_name
559                    )
560                }
561            } else {
562                format!(
563                    "globalThis.hooks['{hook}'] = function(handler) {{ globalThis.__xript_hooks['{hook}'].push({{ handler: handler }}); }};",
564                    hook = hook_name
565                )
566            };
567            hook_setup.push_str(&registration);
568            hook_setup.push('\n');
569        }
570    }
571
572    hook_setup.push_str("Object.freeze(globalThis.hooks);\n");
573
574    ctx.eval::<(), _>(hook_setup.as_str())
575        .map_err(|e| XriptError::Engine(e.to_string()))?;
576
577    Ok(())
578}
579
580fn register_fragment_hooks(ctx: &Ctx<'_>) -> Result<()> {
581    let script = r#"
582        globalThis.__xript_fragment_handlers = {};
583
584        var existingHooks = {};
585        if (typeof globalThis.hooks === 'object' && globalThis.hooks !== null) {
586            var hookKeys = Object.getOwnPropertyNames(globalThis.hooks);
587            for (var i = 0; i < hookKeys.length; i++) {
588                existingHooks[hookKeys[i]] = globalThis.hooks[hookKeys[i]];
589            }
590        }
591
592        var fragmentNs = {};
593        var lifecycles = ['mount', 'unmount', 'update', 'suspend', 'resume'];
594        for (var j = 0; j < lifecycles.length; j++) {
595            (function(lifecycle) {
596                fragmentNs[lifecycle] = function(fragmentId, handler) {
597                    var key = "fragment:" + lifecycle + ":" + fragmentId;
598                    if (!globalThis.__xript_fragment_handlers[key]) {
599                        globalThis.__xript_fragment_handlers[key] = [];
600                    }
601                    globalThis.__xript_fragment_handlers[key].push(handler);
602                };
603            })(lifecycles[j]);
604        }
605        Object.freeze(fragmentNs);
606        existingHooks.fragment = fragmentNs;
607
608        globalThis.hooks = existingHooks;
609        Object.freeze(globalThis.hooks);
610    "#;
611
612    ctx.eval::<(), _>(script)
613        .map_err(|e| XriptError::Engine(e.to_string()))?;
614
615    Ok(())
616}
617
618fn create_host_function<'js>(
619    ctx: &Ctx<'js>,
620    name: &str,
621    f: HostFn,
622) -> Result<Function<'js>> {
623    let bridge_fn = Function::new(ctx.clone(), move |args_json: String| -> String {
624        let args: Vec<serde_json::Value> = match serde_json::from_str(&args_json) {
625            Ok(a) => a,
626            Err(e) => {
627                let err = serde_json::json!({"__xript_err": format!("invalid args: {}", e)});
628                return serde_json::to_string(&err).unwrap();
629            }
630        };
631        match f(&args) {
632            Ok(result) => {
633                let wrapped = serde_json::json!({"__xript_ok": result});
634                serde_json::to_string(&wrapped).unwrap_or("{\"__xript_ok\":null}".into())
635            }
636            Err(msg) => {
637                let err = serde_json::json!({"__xript_err": msg});
638                serde_json::to_string(&err).unwrap()
639            }
640        }
641    })
642    .map_err(|e| {
643        XriptError::Engine(format!("failed to create host function '{}': {}", name, e))
644    })?;
645
646    ctx.globals()
647        .set("__xript_tmp_bridge", bridge_fn)
648        .map_err(|e| XriptError::Engine(e.to_string()))?;
649
650    let wrapper: Function = ctx.eval(
651        "(function(bridge) { return function() { var args = Array.prototype.slice.call(arguments); var raw = bridge(JSON.stringify(args)); var envelope = JSON.parse(raw); if (envelope.__xript_err !== undefined) { throw new Error(envelope.__xript_err); } return envelope.__xript_ok; }; })(__xript_tmp_bridge)",
652    )
653    .map_err(|e| XriptError::Engine(e.to_string()))?;
654
655    ctx.eval::<(), _>("delete globalThis.__xript_tmp_bridge")
656        .map_err(|e| XriptError::Engine(e.to_string()))?;
657
658    Ok(wrapper)
659}
660
661fn create_async_host_function<'js>(
662    ctx: &Ctx<'js>,
663    name: &str,
664    f: AsyncHostFn,
665) -> Result<Function<'js>> {
666    let bridge_fn = Function::new(ctx.clone(), move |args_json: String| -> String {
667        let args: Vec<serde_json::Value> = match serde_json::from_str(&args_json) {
668            Ok(a) => a,
669            Err(e) => {
670                let err = serde_json::json!({"__xript_err": format!("invalid args: {}", e)});
671                return serde_json::to_string(&err).unwrap();
672            }
673        };
674        match pollster::block_on(f(&args)) {
675            Ok(result) => {
676                let wrapped = serde_json::json!({"__xript_ok": result});
677                serde_json::to_string(&wrapped).unwrap_or("{\"__xript_ok\":null}".into())
678            }
679            Err(msg) => {
680                let err = serde_json::json!({"__xript_err": msg});
681                serde_json::to_string(&err).unwrap()
682            }
683        }
684    })
685    .map_err(|e| {
686        XriptError::Engine(format!(
687            "failed to create async host function '{}': {}",
688            name, e
689        ))
690    })?;
691
692    ctx.globals()
693        .set("__xript_tmp_bridge", bridge_fn)
694        .map_err(|e| XriptError::Engine(e.to_string()))?;
695
696    let wrapper: Function = ctx.eval(
697        "(function(bridge) { return function() { var args = Array.prototype.slice.call(arguments); var raw = bridge(JSON.stringify(args)); var envelope = JSON.parse(raw); if (envelope.__xript_err !== undefined) { return Promise.reject(new Error(envelope.__xript_err)); } return Promise.resolve(envelope.__xript_ok); }; })(__xript_tmp_bridge)",
698    )
699    .map_err(|e| XriptError::Engine(e.to_string()))?;
700
701    ctx.eval::<(), _>("delete globalThis.__xript_tmp_bridge")
702        .map_err(|e| XriptError::Engine(e.to_string()))?;
703
704    Ok(wrapper)
705}
706
707fn resolve_if_promise<'js>(ctx: &Ctx<'js>, val: Value<'js>) -> Value<'js> {
708    if let Ok(promise) = Promise::from_value(val.clone()) {
709        loop {
710            match promise.state() {
711                PromiseState::Pending => {
712                    if !ctx.execute_pending_job() {
713                        break val;
714                    }
715                }
716                PromiseState::Resolved => {
717                    break promise.result::<Value>().unwrap_or(Ok(val)).unwrap_or_else(|_| Value::new_undefined(ctx.clone()));
718                }
719                PromiseState::Rejected => {
720                    break promise.result::<Value>().unwrap_or(Ok(val)).unwrap_or_else(|_| Value::new_undefined(ctx.clone()));
721                }
722            }
723        }
724    } else {
725        val
726    }
727}
728
729fn js_value_to_json(ctx: &Ctx<'_>, val: &Value<'_>) -> serde_json::Value {
730    if val.is_undefined() || val.is_null() {
731        return serde_json::Value::Null;
732    }
733
734    if let Some(b) = val.as_bool() {
735        return serde_json::Value::Bool(b);
736    }
737
738    if let Some(n) = val.as_int() {
739        return serde_json::json!(n);
740    }
741
742    if let Some(n) = val.as_float() {
743        if n.is_finite() {
744            return serde_json::json!(n);
745        }
746        return serde_json::Value::Null;
747    }
748
749    if let Some(s) = val.as_string() {
750        if let Ok(s) = s.to_string() {
751            return serde_json::Value::String(s);
752        }
753    }
754
755    let stringify_result: std::result::Result<String, _> = ctx.eval(
756        "((v) => JSON.stringify(v))",
757    );
758    if let Ok(stringify_fn_str) = stringify_result {
759        if let Ok(v) = serde_json::from_str::<serde_json::Value>(&stringify_fn_str) {
760            return v;
761        }
762    }
763
764    serde_json::Value::Null
765}