Adding a JavaScript interpreter to your Rust project
Introduction
When we develop tools for our users, we sometimes want to give them some form of control over how they work. This is common in games, where we can add scripting for our users to be able to create extensions, or even for business tools, where we allow our customer to change or extend the behaviour of our platform. For those cases, using Rust, a compiled, type safe language can be a challenge, since once a program has been compiled, it's tricky to change or extend it at runtime. Furthermore, many of our users will prefer to use a more common scripting language, such as JavaScript.
This is where Boa enters the scene. Boa is a Javascript engine fully written in Rust. Currently, it can be used in places where you need most of the JavaScript language to work, even though, we would advise to wait to get all our known blocker bugs solved before using this for critical workloads. You can check how conformant we are with the official ECMAScript specification here.
And, before going further, we would like to mention that you can contribute to Boa by solving one of the issues where we need special help, and we now also accept financial contributions in our OpenCollective page.
Note: You can see more examples of integrating Boa in our repository.
Starting from scratch
Let's start a new project running cargo new my_project
, and then add boa_engine
as one
of our dependencies by running cargo add boa_engine -F console
in our newly created my_project
directory.
Let's start by adding the minimal code needed to get a JavaScript interpreter working in our
src/main.rs
file:
use boa_engine::Context;
fn main() {
let js_code = "console.log('Hello World from a JS code string!')";
// Instantiate the execution context
let mut context = Context::default();
// Parse the source code
match context.eval(js_code) {
Ok(res) => {
println!("{}", res.to_string(&mut context).unwrap());
}
Err(e) => {
// Pretty print the error
eprintln!("Uncaught {}", e.display());
}
};
}
As you can see in this example, when working with Boa, you will have to use a Context
,
which will be in charge of initializing all the internals and built-in objects (such as Date
,
Promise
and so on). The Context
in Boa is also your go-to place for configuring your interpreter
as you wish. You can add custom global functions, objects, and anything you might imagine. It's also
one of the arguments you will receive if you create a Rust function and expose it to JavaScript, and
with it, you will be able to throw errors, modify the global object and return values to JavaScript.
Talking about values, Boa comes with its built-in JsValue
type. This enumeration
represents any JavaScript value that can, for example, be assigned to a variable. And, before you
ask, you can convert it to and from a serde_json::Value
, of course, by using the
JsValue::from_json()
and JsValue::to_json()
methods.
As you can see in those methods, or in the Context::eval()
that we used earlier, you will
receive a JsResult
as a response. This result type will contain a JsValue
as its error variant,
which means you can return the error back to JavaScript for it to handle it. A JsValue
, internally,
is a garbage-collected JavaScript value. But, isn't Rust one of the few non-garbage collected
languages? Wasn't that a good thing?
The answer is yes, of course, but JavaScript requires a garbage collector. This garbage collector
makes sure that all values are freed when they are no longer needed. It also makes a JsValue
extremely cheap to clone, independently of its contents.
If you run this example code with cargo run
, you will notice that it will print the message sent
to console.log()
, and it will also print undefined
at the end. This last undefined
is part of
the Ok(res)
branch in the match
, which prints the result of the execution. In this case, the
result of the execution is the result of the last statement, which is the console.log()
, and this
statement returns undefined
.
But, what can you do with Boa?
Let's start with the basics. Of course, you can execute JavaScript code. This code can be any
string or directly a byte vector (so you can load files and use them directly). You can use
Context::eval()
in both cases, as you saw before, and you can also use
Context::parse()
, which will give you a StatementList
that
you can use multiple times in Context::compile()
, so that you don't need to
parse the same code more than once. The compiled source code can also be executed multiple times,
since it's CodeBlock
is garbage collected, and therefore it can be cheaply cloned.
In order to execute a code block you will need to use Context::execute()
.
This in itself is good enough to provide a simple scripting API for your project, but where Boa
really shines is in the ability to inter-operate Rust and JavaScript. Let's start with a simple
example: exposing a Rust function to JavaScript. A JavaScript-compatible Rust function must have
the NativeFunctionSignature
signature:
use boa_engine::{builtins::JsArgs, Context, JsResult, JsValue};
/// Says "hello" using the first argument.
fn say_hello(_this: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult<JsValue> {
let name = args.get_or_undefined(0);
if name.is_undefined() {
println!("Hello World!");
} else {
println!("Hello {}!", name.to_string(context)?);
}
Ok(JsValue::undefined())
}
The JsArgs
trait allows you to retrieve a value if the function received it, or set it
to the undefined
value, if not. Then, in this case, it will convert the name to a JsString
before printing it, since we might be receiving an object, a symbol, a boolean... one of the perks
of dynamic typing. This will then print the result in th standard output using the common
println!()
macro in Rust. It will just return an undefined value.
You can register this function in the context by adding this line after the context creation (and
before executing any JS) in the main()
function:
context.register_global_builtin_function("say_hello", 1, say_hello);
This will register it as a global function, with the say_hello()
name, and with a length
of 1
(which indicates the number of arguments that it receives by default). You can then try it out by
modifying the JavaScript string:
let js_code = r#"say_hello("Rust");"#;
The r#"..."#
syntax is a Rust raw string literal.
You can also add any JsValue
as a property to the global object by using the
Context::register_global_property()
function:
use boa_engine::property::Attribute;
context.register_global_property("MY_PROJECT_VERSION", "1.0.0", Attribute::all());
And you can use it in JavaScript:
say_hello(MY_PROJECT_VERSION);
The Attribute
of a property indicates if it will be writable (it can be set and
modified), enumerable (it can be used in for..in
statements) and configurable (its attributes or
type of property can be modified).
Integrating a full Rust data structure
Sometimes, adding a function or a single JsValue
to the global scope of your JavaScript context
is not enough, and you want to enable the full power of Rust with its structures to handle more
complex scenarios. This can be achieved using the Class
trait. This has to be combined
with two other traits, that make any Rust object be garbage-collected: Trace
and
Finalize
, in the boa_gc
crate. Luckily those two traits can be derived.
Let's start by implementing a Person
type, that will showcase the potential of this API. Let's
run cargo add gc boa_gc
and add some code:
use boa_gc::{Finalize, Trace};
#[derive(Debug, Trace, Finalize)]
struct Person {
/// The name of the person.
name: String,
/// The age of the person.
age: u8,
}
Then, we will move the say_hello()
function to be a static method of Person
:
impl Person {
/// Says "hello" using the name and the age of a `Person`.
fn say_hello(this: &JsValue, _args: &[JsValue], context: &mut Context) -> JsResult<JsValue> {
let this = this
.as_object()
.and_then(|obj| obj.downcast_ref::<Self>())
.ok_or_else(|| context.construct_type_error("`this` is not a `Person` object"))?;
println!("Hello {}-year-old {}!", this.age, this.name);
Ok(JsValue::undefined())
}
}
As you can see, this now uses the this
parameter of the say_hello()
function, which should be a
Person
, but in JavaScript you can assign methods of some objects to others, so we must make sure
that on this invocation, we are indeed working with a Person
, and return a TypeError
if not.
Now, let's implement the Class
trait. This trait requires a NAME
constant, which will
be the name of the global object property, and a LENGTH
for the constructor (the number of
arguments, by default 0
). Then, it needs a constructor()
function, which is a native function
that will be called when we do a new Person()
, and an init()
function, which will be called by
the Context
when registering the function in the global scope. It will receive a
ClassBuilder
, which allows you to add a method (both, static and prototype
), a
property
, also for both cases, accessor properties (to use get
and set
) and property
descriptors. You can also get a reference to the Context
with the ClasBuilder::context()
method,
in case you want to do anything fancier.
In this case, the constructor will take care of constructing the Rust Person
data structure with
the two arguments it receives, and then register the say_hello()
method:
use boa_engine::{
builtins::JsArgs,
class::{Class, ClassBuilder},
};
impl Class for Person {
const NAME: &'static str = "Person";
const LENGTH: usize = 2;
// This is what is called when we construct a `Person` with the expression `new Person()`.
fn constructor(_this: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult<Self> {
let name = args.get_or_undefined(0).to_string(context)?;
let age = args.get_or_undefined(1).to_u32(context)?;
if !(0..=150).contains(&age) {
context.throw_range_error(format!("invalid age `{age}`. Must be between 0 and 150"))?;
}
let age = u8::try_from(age).expect("we already checked that it was in range");
let person = Person {
name: name.to_string(),
age,
};
Ok(person)
}
/// Here is where the class is initialized, to be inserted into the global object.
fn init(class: &mut ClassBuilder) -> JsResult<()> {
class.method("say_hello", 0, Self::say_hello);
Ok(())
}
}
In order to register the class, you will need to use the
Context::register_global_class()
method:
context
.register_global_class::<Person>()
.expect("could not register class");
You can now adapt the JavaScript code:
let person = new Person("John", 28);
person.say_hello();
If you want to access the global object from Rust, you can use Context::global_object()
, which
will return a JsObject
. In this object, you can use the
JsObject::get()
function to retrieve any property of the global object, such as
the MY_PROJECT_VERSION
that you defined earlier, or any intrinsic, such as the Date
object.
We are now in the process of creating Rust wrappers for all JavaScript intrinsics (#2098).
For example, you can create a JsArray
from a JsObject
to make it much easier to manipulate a
JavaScript array from Rust. In the following example, you'll create a new reverseAppend()
global
function that will receive an array, reverse it, and then append the "My Project" string to it.
It will then get the MY_PROJECT_VERSION
from the global object, and append it to the array.
use boa_engine::{
builtins::JsArgs, object::JsArray, property::Attribute, Context, JsResult, JsValue,
};
/// Reverses an array and appends the `"My Project"` string and the `MY_PROJECT_VERSION` global
/// property to it.
fn reverse_append(_this: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult<JsValue> {
let arr = args
.get_or_undefined(0)
.as_object()
.ok_or_else(|| context.construct_type_error("argument must be an array"))?;
let arr = JsArray::from_object(arr.clone(), context)?;
let reverse = arr.reverse(context)?;
reverse.push("My Project", context)?;
let global_object = context.global_object().clone();
let version = global_object
.get("MY_PROJECT_VERSION", context)
.unwrap_or_default();
reverse.push(version, context)?;
Ok((*reverse).clone().into())
}
fn main() {
let js_code = r#"
let arr = ['a', 2, 5.4, "Hello"];
reverseAppend(arr);
"#;
// Instantiate the execution context
let mut context = Context::default();
context.register_global_property("MY_PROJECT_VERSION", "1.0.0", Attribute::all());
context.register_global_builtin_function("reverseAppend", 1, reverse_append);
// Parse the source code
match context.eval(js_code) {
Ok(res) => {
println!("{}", res.to_string(&mut context).unwrap());
}
Err(e) => {
// Pretty print the error
eprintln!("Uncaught {}", e.display());
}
};
}
We are looking for contributors to implement the rest of the wrappers, and of course, we offer mentoring!
What's coming next?
Boa's development is ongoing non-stop. The next version, v0.17, is already looking pretty nice,
with some great enhancements. For example, @jedel1043 has created new
"lazy" errors, that are much easier to create and throw, since they don't need a
Context
, and also enhance the performance. @nekevss has implemented a
new wrapper for RegExp
, and @anuvratsingh is working on a Date
wrapper. @razican is working on a JavaScript to Rust
conversion trait and derive, that will allow you to convert a JsValue
to a Rust
structure and back really easily:
use boa_derive::TryFromJs;
use boa_engine::{value::TryFromJs, Context, JsResult, JsValue};
#[derive(Debug, TryFromJs)]
#[allow(dead_code)]
struct TestStruct {
inner: bool,
hello: String,
#[boa(from_js_with = "lossy_conversion", hello = "myfriend")]
my_float: i16,
}
fn main() {
let js = r#"
let x = {
inner: false,
hello: "World",
my_float: 2.9,
};
x;
"#;
let mut context = Context::default();
let res = context.eval(js).unwrap();
let str = TestStruct::try_from_js(&res, &mut context)
.map_err(|e| e.display().to_string())
.unwrap();
println!("{str:?}");
}
/// Converts the value lossly
fn lossy_conversion(value: &JsValue, context: &mut Context) -> JsResult<i16> {
match value {
JsValue::Rational(r) => Ok(r.round() as i16),
JsValue::Integer(i) => Ok(*i as i16),
_ => context.throw_type_error("cannot convert value to an i16"),
}
}
We love contributions, whether it's a documentation enhancement, fixing or implementing the ECMAScript specification, adding new functionality / APIs or enhancing performance, we would love to get new contributors on board! We are also looking for financial contributors, so feel free to join our OpenCollective.