Skip to main content

Adding a JavaScript interpreter to your Rust project

· 12 min read

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.