DEV Community

Reuben Tier
Reuben Tier

Posted on • Originally published at Medium

Create Your Own JavaScript Runtime

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"
Enter fullscreen mode Exit fullscreen mode

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),
  );
}
Enter fullscreen mode Exit fullscreen mode

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")
    },
  };
Enter fullscreen mode Exit fullscreen mode

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);
    },
Enter fullscreen mode Exit fullscreen mode

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 }
  }
}
Enter fullscreen mode Exit fullscreen mode

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),
      }
    }
  }
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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,
    );
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
Enter fullscreen mode Exit fullscreen mode

Return to these two lines in our repl method.

          // prints the input (you'll replace this in the next step)
          println!("input: {}", &buf);
Enter fullscreen mode Exit fullscreen mode

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);
          }
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)