Skip to main content

brep_script_core/
lib.rs

1//! The interpreter core: evaluate one script, call one global function with
2//! one JSON argument, and hand back its JSON result or its refusal.
3//!
4//! This crate depends on Boa and `serde_json` and nothing else — no file system,
5//! no network, no store. That is deliberate: the operator chose Boa so the same
6//! interpreter runs inside the CAD app, including its wasm build
7//! (plm-cad-integration-todo §3 S11). It was the PLM's `scripting::engine` module
8//! and is still reached under that name there. Everything that reaches outside the
9//! interpreter is installed by the caller through [`call`]'s `install` argument:
10//! the server's bindings are in `BREP_plm/src/scripting/host.rs`, the CAD app's in
11//! `BREP_app/src/scripting.rs`.
12//!
13//! # A fresh interpreter per call
14//!
15//! Every call builds a new [`Context`], evaluates the file, and drops it. No
16//! state leaks from one hook call into the next, a script edited on disk is
17//! simply the script the next call evaluates, and a `Context` (which is not
18//! `Send`) never has to cross a thread.
19//!
20//! # What is bounded, honestly
21//!
22//! Boa's [`RuntimeLimits`](boa_engine::vm::RuntimeLimits) bound three things,
23//! and they are set here:
24//!
25//! * each CALL FRAME may run at most [`LOOP_ITERATION_LIMIT`] loop iterations
26//!   in total (Boa counts per frame, not per loop) — an infinite `while (true)`
27//!   throws instead of hanging, but a loop that calls a function whose own
28//!   loops stay under the limit is not bounded in total;
29//! * call depth is at most [`RECURSION_LIMIT`] — runaway recursion throws;
30//! * the VM stack size.
31//!
32//! There is NO wall-clock limit: Boa cannot pre-empt a running script. A script
33//! of many individually-bounded loops can still run long, and a script blocked
34//! inside a host function (an HTTP call, a child process) is bounded only by
35//! that host function's own timeout.
36
37use std::cell::RefCell;
38use std::rc::Rc;
39
40use boa_engine::object::ObjectInitializer;
41use boa_engine::property::Attribute;
42use boa_engine::{js_string, Context, JsError, JsResult, JsString, JsValue, NativeFunction, Source};
43use serde_json::Value;
44
45/// Boa itself, so a host names the same engine's types (`Context`, `JsValue`,
46/// `ObjectInitializer`) its bindings are built from without a Boa line of its
47/// own that could drift to another version.
48pub use boa_engine;
49
50/// The most loop iterations any one call frame may run before it throws.
51pub const LOOP_ITERATION_LIMIT: u64 = 10_000_000;
52
53/// The deepest call stack a script may build before it throws.
54pub const RECURSION_LIMIT: usize = 512;
55
56/// Lines a script wrote through `console.log`, kept for the caller — the test
57/// run shows them, and a failing hook's log goes to the server log.
58pub type Logs = Rc<RefCell<Vec<String>>>;
59
60/// A call that returned.
61#[derive(Debug, Clone)]
62pub struct Success {
63    /// What the function returned, as JSON. `undefined` reads as `null`.
64    pub value: Value,
65    pub logs: Vec<String>,
66}
67
68/// A call that threw, failed to parse, or named a function the file does not
69/// define. `message` is what a person reads: for `throw new Error("x")` it is
70/// `x`, for `throw "x"` it is `x`.
71#[derive(Debug, Clone)]
72pub struct Failure {
73    pub message: String,
74    pub logs: Vec<String>,
75}
76
77/// Evaluate `source` (named `file` in messages), then call the global
78/// function `function` with `input` as its single argument.
79///
80/// `install` runs after `console` is defined and before the file is
81/// evaluated, and is where a caller adds its host objects.
82pub fn call(
83    source: &str,
84    file: &str,
85    function: &str,
86    input: &Value,
87    install: &dyn Fn(&mut Context, &Logs) -> JsResult<()>,
88) -> Result<Success, Failure> {
89    let mut context = Context::default();
90    let limits = context.runtime_limits_mut();
91    limits.set_loop_iteration_limit(LOOP_ITERATION_LIMIT);
92    limits.set_recursion_limit(RECURSION_LIMIT);
93
94    let logs: Logs = Rc::new(RefCell::new(Vec::new()));
95    let fail = |message: String, logs: &Logs| Failure {
96        message,
97        logs: logs.borrow().clone(),
98    };
99
100    if let Err(error) = install_console(&mut context, &logs).and_then(|()| install(&mut context, &logs)) {
101        return Err(fail(format!("setting up the script host failed: {}", describe(error, &mut context)), &logs));
102    }
103
104    let path = std::path::Path::new(file);
105    if let Err(error) = context.eval(Source::from_bytes(source).with_path(path)) {
106        let message = describe(error, &mut context);
107        return Err(fail(format!("{file}: {message}"), &logs));
108    }
109
110    let global = context.global_object();
111    let callee = match global.get(JsString::from(function), &mut context) {
112        Ok(value) => value,
113        Err(error) => return Err(fail(describe(error, &mut context), &logs)),
114    };
115    let Some(callee) = callee.as_callable() else {
116        return Err(fail(format!("{file} defines no function {function}()"), &logs));
117    };
118
119    let argument = match JsValue::from_json(input, &mut context) {
120        Ok(value) => value,
121        Err(error) => return Err(fail(describe(error, &mut context), &logs)),
122    };
123    let returned = match callee.call(&JsValue::undefined(), &[argument], &mut context) {
124        Ok(value) => value,
125        Err(error) => return Err(fail(describe(error, &mut context), &logs)),
126    };
127    // A script may have queued promise jobs; run them so their side effects
128    // (a log line, a write) are not silently dropped.
129    let _ = context.run_jobs();
130
131    let value = match returned.to_json(&mut context) {
132        Ok(json) => json.unwrap_or(Value::Null),
133        Err(error) => {
134            return Err(fail(
135                format!("{function}() returned a value that is not JSON: {}", describe(error, &mut context)),
136                &logs,
137            ))
138        }
139    };
140    let logs = logs.borrow().clone();
141    Ok(Success { value, logs })
142}
143
144/// The message a person should read for `error`.
145pub fn describe(error: JsError, context: &mut Context) -> String {
146    if let Some(value) = error.as_opaque() {
147        if let Some(text) = value.as_string() {
148            return text.to_std_string_escaped();
149        }
150    }
151    match error.try_native(context) {
152        Ok(native) if !native.message().is_empty() => native.message().to_string(),
153        Ok(native) => native.to_string(),
154        Err(_) => {
155            // A thrown plain object: show it as JSON if it is JSON.
156            if let Some(value) = error.as_opaque() {
157                if let Ok(Some(json)) = value.to_json(context) {
158                    return json.to_string();
159                }
160            }
161            error.to_string()
162        }
163    }
164}
165
166/// One argument as the text a log line shows: strings verbatim, everything
167/// else as JSON.
168pub fn text_of(value: &JsValue, context: &mut Context) -> String {
169    if let Some(text) = value.as_string() {
170        return text.to_std_string_escaped();
171    }
172    match value.to_json(context) {
173        Ok(Some(json)) => json.to_string(),
174        Ok(None) => "undefined".to_string(),
175        Err(_) => value.display().to_string(),
176    }
177}
178
179/// `console.log / info / warn / error`, all writing into `logs`.
180fn install_console(context: &mut Context, logs: &Logs) -> JsResult<()> {
181    let make = |prefix: &'static str| {
182        let logs = logs.clone();
183        // SAFETY: the closure captures an `Rc<RefCell<Vec<String>>>`, which
184        // holds no garbage-collected value, so the collector has nothing in it
185        // to trace.
186        unsafe {
187            NativeFunction::from_closure(move |_, args, context| {
188                let line = args
189                    .iter()
190                    .map(|value| text_of(value, context))
191                    .collect::<Vec<_>>()
192                    .join(" ");
193                logs.borrow_mut().push(format!("{prefix}{line}"));
194                Ok(JsValue::undefined())
195            })
196        }
197    };
198    let console = ObjectInitializer::new(context)
199        .function(make(""), js_string!("log"), 0)
200        .function(make(""), js_string!("info"), 0)
201        .function(make("warn: "), js_string!("warn"), 0)
202        .function(make("error: "), js_string!("error"), 0)
203        .build();
204    context.register_global_property(js_string!("console"), console, Attribute::all())
205}
206
207
208pub mod modules;
209/// The code-mode worker process (a host binary started with its worker flag).
210pub mod worker;