DEV Community

loading
loading

Posted on

Building a JavaScript Runtime in Rust powered by the Nova engine

Ever wondered how JavaScript runtimes like Node.js or Deno are built? Or maybe you've thought about creating your own JavaScript runtime with custom features and performance tweaks? If so, you're in the right place! In this tutorial, we'll dive into building a JavaScript runtime using Rust and the andromeda_runtime crate.

Introduction

Rust is a systems programming language that guarantees memory safety without the need for a garbage collector, making it an excellent choice for high-performance applications. By leveraging Rust's capabilities along with the andromeda_runtime crate, we can create a custom JavaScript runtime that's both fast and secure.

The andromeda_runtime crate provides a solid foundation for executing JavaScript code. It comes with recommended extensions, built-in functions, and an event loop handler, all of which simplify the process of setting up a runtime.

Prerequisites

Before we get started, make sure you have the following:

  • Rust: Ensure you have Rust installed. If not, you can download it from rust-lang.org.
  • Cargo: Cargo is Rust's package manager and comes bundled with Rust installations.
  • Basic Knowledge of Rust: Familiarity with Rust's syntax and concepts will be helpful.
  • JavaScript/TypeScript: A basic understanding of JavaScript or TypeScript.

Setting Up the Project

Let's kick things off by creating a new Rust project:

cargo new jsruntime
cd jsruntime
Enter fullscreen mode Exit fullscreen mode

Next, open Cargo.toml and add the necessary dependencies:

[dependencies]
andromeda_core = { git = "https://github.com/tryandromeda/andromeda" }
andromeda_runtime = { git = "https://github.com/tryandromeda/andromeda" }
clap = { version = "4.5.16", features = ["derive"] }
tokio = { version = "1.39.0", features = ["rt", "sync", "time"] }
Enter fullscreen mode Exit fullscreen mode

1. Importing Dependencies

First, we'll import the necessary crates and modules:

use andromeda_core::{Runtime, RuntimeConfig};
use andromeda_runtime::{
    recommended_builtins, recommended_eventloop_handler, recommended_extensions,
};
use clap::{Parser as ClapParser, Subcommand};
Enter fullscreen mode Exit fullscreen mode

Here's what each of these imports does:

  • andromeda_core: Contains the core components for building our runtime.
  • andromeda_runtime: Provides recommended built-ins, extensions, and the event loop handler we'll use.
  • clap: A crate for parsing command-line arguments, which we'll use to build our CLI.

2. Defining the Command-Line Interface

Next, let's define a CLI using clap:

#[derive(Debug, ClapParser)]
#[command(name = "jsruntime")]
#[command(
    about = "JavaScript Runtime built for a blog post",
    long_about = "JS/TS Runtime in Rust powered by Nova built for a blog post"
)]
struct Cli {
    #[command(subcommand)]
    command: Command,
}

#[derive(Debug, Subcommand)]
enum Command {
    /// Runs a file or files
    Run {
        #[arg(short, long)]
        verbose: bool,

        #[arg(short, long)]
        no_strict: bool,

        /// The files to run
        #[arg(required = true)]
        paths: Vec<String>,
    },
}
Enter fullscreen mode Exit fullscreen mode

Here's what's happening:

  • Cli Struct: This struct represents our command-line interface, including any subcommands and arguments.
  • Command Enum: This enum defines the available subcommands—in this case, only Run.

3. The Main Function

Our main function orchestrates the runtime execution:

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let args = Cli::parse();

    let rt = tokio::runtime::Builder::new_current_thread()
        .enable_time()
        .build()
        .unwrap();

    // Run the JSRuntime in a secondary blocking thread so tokio tasks can still run
    let runtime_thread = rt.spawn_blocking(|| match args.command {
        Command::Run {
            verbose,
            no_strict,
            paths,
        } => {
            let mut runtime = Runtime::new(RuntimeConfig {
                no_strict,
                paths,
                verbose,
                extensions: recommended_extensions(),
                builtins: recommended_builtins(),
                eventloop_handler: recommended_eventloop_handler,
            });
            let runtime_result = runtime.run();

            match runtime_result {
                Ok(result) => {
                    if verbose {
                        println!("{:?}", result);
                    }
                }
                Err(error) => runtime.agent.run_in_realm(&runtime.realm_root, |agent| {
                    eprintln!(
                        "Uncaught exception: {}",
                        error.value().string_repr(agent).as_str(agent)
                    );
                    std::process::exit(1);
                }),
            }
        }
    });

    rt.block_on(runtime_thread)
        .expect("An error occurred while running the JS runtime.");

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

Let's break down what's happening in this function:

a. Parsing Arguments

We start by parsing the command-line arguments using clap:

let args = Cli::parse();
Enter fullscreen mode Exit fullscreen mode

b. Setting Up the Tokio Runtime

We create a new Tokio runtime. Tokio is an asynchronous runtime for Rust that allows us to handle asynchronous operations efficiently.

let rt = tokio::runtime::Builder::new_current_thread()
    .enable_time()
    .build()
    .unwrap();
Enter fullscreen mode Exit fullscreen mode

c. Spawning a Blocking Task

We spawn a blocking task to run our JavaScript code. This ensures our runtime doesn't block other asynchronous tasks.

let runtime_thread = rt.spawn_blocking(|| match args.command { /* ... */ });
Enter fullscreen mode Exit fullscreen mode

d. Handling the Run Command

Within the spawned task, we match the command:

Command::Run {
    verbose,
    no_strict,
    paths,
} => {
    let mut runtime = Runtime::new(RuntimeConfig { /* ... */ });
    let runtime_result = runtime.run();

    match runtime_result {
        Ok(result) => { /* ... */ }
        Err(error) => { /* ... */ }
    }
}
Enter fullscreen mode Exit fullscreen mode

Here's what's going on:

  • Creating the Runtime: We instantiate a new Runtime with the provided configuration.
  • Running the Runtime: We call runtime.run() to execute the JavaScript code.
  • Handling Results: If the execution is successful, we print the result if verbose is enabled. If there's an error, we print the uncaught exception.

e. Blocking on the Runtime Thread

Finally, we wait for the spawned task to complete:

rt.block_on(runtime_thread)
    .expect("An error occurred while running the JS runtime.");
Enter fullscreen mode Exit fullscreen mode

Customizing the Runtime

One of the great benefits of building your own runtime is the ability to customize it to your needs. Let's explore some ways you can tailor the runtime.

1. Adding Custom Extensions

Extensions add extra functionality to the runtime. You can add your own custom extensions or modify existing ones.

let custom_extensions = vec![
    // Your custom extensions here
];

let mut runtime = Runtime::new(RuntimeConfig {
    // ...
    extensions: custom_extensions,
    // ...
});
Enter fullscreen mode Exit fullscreen mode

2. Modifying Built-ins

Built-in functions are essential for interacting with the runtime environment. You can add or customize built-in functions to suit your requirements.

let custom_builtins = vec![
    // Your custom built-in functions here
];

let mut runtime = Runtime::new(RuntimeConfig {
    // ...
    builtins: custom_builtins,
    // ...
});
Enter fullscreen mode Exit fullscreen mode

3. Custom Event Loop Handler

If you need custom event loop behavior, you can provide your own event loop handler.

fn custom_eventloop_handler() {
    // Your custom event loop logic here
}

let mut runtime = Runtime::new(RuntimeConfig {
    // ...
    eventloop_handler: custom_eventloop_handler,
    // ...
});
Enter fullscreen mode Exit fullscreen mode

Running the Runtime

To test your runtime, let's create a simple JavaScript file, test.js:

console.log("Hello from the JS Runtime!");
Enter fullscreen mode Exit fullscreen mode

Then, run the runtime using:

cargo run run test.js
Enter fullscreen mode Exit fullscreen mode

If everything is set up correctly, you should see:

Hello from the JS Runtime!
Enter fullscreen mode Exit fullscreen mode

Conclusion

And there you have it! You've built a simple JavaScript runtime using Rust. Not only have you demystified how runtimes like Node.js and Deno work, but you've also set the foundation for endless possibilities in customizing and extending your own runtime.

Remember, this is just the beginning. Keep experimenting, add new features, and most importantly, have fun along the way!

Happy coding! 🚀

References

Top comments (0)