Create Your Own JavaScript Runtime
Reuben Tier
Posted on September 1, 2022
This post was originally posted in JavaScript in Plain English on Medium. You can find future tutorials over on my Medium series
Whether it be a browser runtime or a server-side runtime like Node.js, we all use some sort of runtime to run our JavaScript code. Today, we’ll create a basic JavaScript runtime of our own, using the V8 JavaScript engine.
What is a JavaScript runtime?
A JavaScript runtime is simply an environment that extends a JavaScript engine by providing useful APIs and allowing the program to interact with the world outside its container. This differs from an engine, which simply parses the code and executes it inside a contained environment.
As I mentioned earlier, V8 is a JavaScript engine, meaning it handles the parsing and execution of JavaScript source code. Node.js and Chrome (both powered by v8), provide objects and APIs that allow the code to interact with things like the file system (via node:fs
), or the window object (in Chrome).
Setup
In this tutorial, we’ll be using Rust to create our runtime. We’ll use the V8 bindings maintained by the Deno team. Because creating a runtime is a complex process, today we’ll start simple, by implementing a REPL (read-evaluate-print loop). A prompt that runs JavaScript one input line at a time.
To get started, create a new project with cargo init. Then, add some dependencies to the Cargo.toml file. The v8 package contains the bindings to the V8 JavaScript engine, and clap is a popular library for handling command line arguments.
[dependencies]
v8 = "0.48.0"
clap = "3.2.16"
Managing command inputs
When using our runtime, we probably want to provide it with some command line arguments, such as what file to run, or any flags that modify behaviour. Open src/main.rs
and in our main
function, replace the println
call with some code defining our sub-commands and input parameters. If no sub-command is provided, we’ll do the same thing Node.js does, and throw the user into a REPL. We’ll also create one sub-command run
which we will implement in a later tutorial. run, once implemented, will allow the user to run a JavaScript file (with any other parameters we define).
use clap::{Command, arg};
fn main() {
let cmd = clap::Command::new("myruntime")
.bin_name("myruntime")
.subcommand_required(false)
.subcommand(
Command::new("run")
.about("Run a file")
.arg(arg!(<FILE> "The file to run"))
.arg_required_else_help(true),
);
}
Now, we’ll match the arguments against this schema, and handle the responses accordingly.
...
let matches = cmd.get_matches();
match matches.subcommand() {
Some(("run", _matches)) => unimplemented!(),
_ => {
unimplemented!("Implement this in the next step")
},
};
We only have two possibilities for now. The first is run
which we will not be implementing today, and the second is no sub-command, which will open our REPL. Before we implement the REPL, we first need to create our JavaScript environment.
Initializing V8 & creating an engine instance
Before we can do anything with V8, we must first initialise it. Then, we need to create an isolate. An all-encompassing object that represents a single instance of the JavaScript engine.
Add a use
statement at the top of the file to include the v8
crate. Next, let’s return to that slice of unimplemented code for our REPL and initialise V8, as well as create an isolate and wrap it in a HandleScope
.
use v8;
...
_ => {
let platform = v8::new_default_platform(0, false).make_shared();
v8::V8::initialize_platform(platform);
v8::V8::initialize();
let isolate = &mut v8::Isolate::new(v8::CreateParams::default());
let handle_scope = &mut v8::HandleScope::new(isolate);
},
Creating the REPL
To help manage our code, we’ll create our runtime inside a struct
. When a new instance is created, we’ll create a Context
. The Context
allows a set of global and builtin objects to exist inside a “context”. Speaking of global objects, we’ll create an object template called global for use in a later tutorial. This object allows us to bind our own global functions, but for now, we’ll just use it to create the context.
struct Runtime<'s, 'i> {
context_scope: v8::ContextScope<'i, v8::HandleScope<'s>>,
}
impl<'s, 'i> Runtime<'s, 'i>
where
's: 'i,
{
pub fn new(
isolate_scope: &'i mut v8::HandleScope<'s, ()>,
) -> Self {
let global = v8::ObjectTemplate::new(isolate_scope);
let context = v8::Context::new_from_template(isolate_scope, global);
let context_scope = v8::ContextScope::new(isolate_scope, context);
Runtime { context_scope }
}
}
Next, let’s define a method inside Runtime
responsible for handling the REPL, and only the REPL. Using a loop
, we’ll grab the input on each iteration, then run that if successful. We’ll also need to import some things from std::io
at the top of the file.
use std::io::{self, Write};
...
pub fn repl(&mut self) {
println!("My Runtime REPL (V8 {})", v8::V8::get_version());
loop {
print!("> ");
io::stdout().flush().unwrap();
let mut buf = String::new();
match io::stdin().read_line(&mut buf) {
Ok(n) => {
if n == 0 {
println!();
return;
}
// prints the input (you'll replace this in the next step)
println!("input: {}", &buf);
}
Err(error) => println!("error: {}", error),
}
}
}
Now let’s return our REPL command in main
, create a runtime instance, and initialize the REPL.
...
let mut runtime = Runtime::new(handle_scope);
runtime.repl();
Running the code
Our run
method will take the code, as well as a filename (which for the REPL we will simply use (shell)
) for error handling. We create a new scope to handle the execution of the script and wrap this in a TryCatch
scope for a better error handle (which we will implement in a future tutorial). Next, we initialise the script and create an origin object, which defines where this script originated from (in a file).
fn run(
&mut self,
script: &str,
filename: &str,
) -> Option<String> {
let scope = &mut v8::HandleScope::new(&mut self.context_scope);
let mut scope = v8::TryCatch::new(scope);
let filename = v8::String::new(&mut scope, filename).unwrap();
let undefined = v8::undefined(&mut scope);
let script = v8::String::new(&mut scope, script).unwrap();
let origin = v8::ScriptOrigin::new(
&mut scope,
filename.into(),
0,
0,
false,
0,
undefined.into(),
false,
false,
false,
);
}
Now, continuing run
, we compile the script, catch any errors, and print that an error occurred. We then run the script, again catching any errors and logging if an error did occur. We then return the result of the script (or None
if an error occurred).
...
let script = if let Some(script) = v8::Script::compile(&mut scope, script, Some(&origin)) {
script
} else {
assert!(scope.has_caught());
eprintln!("An error occurred when compiling the JavaScript!");
return None;
};
if let Some(result) = script.run(&mut scope) {
return Some(result.to_string(&mut scope).unwrap().to_rust_string_lossy(&mut scope));
} else {
assert!(scope.has_caught());
eprintln!("An error occurred when running the JavaScript!");
return None;
}
Return to these two lines in our repl
method.
// prints the input (you'll replace this in the next step)
println!("input: {}", &buf);
We can now implement our run
method. Replace the println
with an if
statement to run the script, and print the result.
if let Some(result) = self.run(&buf, "(shell)") {
println!("{}", result);
}
Conclusion
Congratulations! You’ve made the first step in creating your own JavaScript runtime using the V8 engine. The completed code from this tutorial can be found on GitHub, and I’ve listed some wonderful resources that made the tutorial possible below.
Next time, we’ll tackle error handling using some of the code we’ve already put in place (such as the TryCatch
scope).
Resources
Posted on September 1, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.