Skip to main content
Roll your own javascript runtime, pt3.

Roll your own JavaScript runtime, pt. 3

This post is a continuation of Roll your own JavaScript runtime and Roll your own JavaScript runtime, pt. 2.

We’ve been delighted by the positive response to this series on rolling your own custom JavaScript runtime. One area that some expressed interest in is how to use snapshots to get faster startup times. Snapshots can provide improved performance at a (typically) negligible increase in filesize.

In this blog post, we’ll build on the first and second part by creating a snapshot of runtime.js in a build script, then loading that snapshot in main.rs to speed up start time for our custom runtime.

Screenshot of the video walkthrough from Andy and Leo

Watch the video demo or view source code here.

Getting setup

If you followed the first and second blog post, your project should have three files:

  • example.ts: the JavaScript file we intend to execute with the custom runtime
  • src/main.rs: the asynchronous Rust function that creates an instance of JsRuntime, which is responsible for JavaScript execution
  • src/runtime.js: the runtime interface that defines and provides the API that will interop with the JsRuntime from main.rs

Let’s write a build.rs file that will create a snapshot of the custom runtime, runjs.

Creating a snapshot in build.rs

Before create a build.rs file, let’s first add deno_core as a build dependency in Cargo.toml:

[package]
name = "runjs"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
deno_ast = { version = "0.22.0", features = ["transpiling"] }
deno_core = "0.174.0"
reqwest = "0.11.14"
tokio = { version = "1.25.0", features = ["full"] }

+ [build-dependencies]
+ deno_core = "0.174.0"

Next, let’s create a build.rs file in the root of your project. In this file, we’ll have to do the following steps:

  • Create a small extension of src/runtime.js
  • Build a file path to the snapshot
  • Create the snapshot

Putting the above steps into code, your build.rs script should look like:

use std::env;
use std::path::PathBuf;
use deno_core::{Extension, include_js_files};

fn main() {
  // Create the runjs extension.
  let runjs_extension = Extensions::builder("runjs")
    .esm(include_js_files!(
      "src/runtime.js",
    ))
    .build();

  // Build the file path to the snapshot.
  let o = PathBuf::from(env::var_os("OUT_DIR").unwrap());
  let snapshot_path = o.join("RUNJS_SNAPSHOT.bin");

  // Create the snapshot.
  deno_core::snapshot_util::create_snapshot(deno_core::snapshot_util::CreateSnapshotOptions {
    cargo_manifest_dir: env!("CARGO_MANIFEST_DIR"),
    snapshot_path: snapshot_path,
    startup_snapshot: None,
    extensions: vec!(runjs_extension),
    compression_cb, None,
    Snapshot_module_load_cb: None,
  })
}

The main function is create_snapshot, which accepts several options. Let’s go over them in the next section.

Diving into CreateSnapshotOptions

The create_snapshot function is a nice abstraction layer that uses an options struct to determine how the snapshot is created. Let’s go over the different fields that are available in CreateSnapshotOptions.

pub struct CreateSnapshotOptions {
  pub cargo_manifest_dir: &'static str,
  pub snapshot_path: PathBuf,
  pub startup_snapshot: Option<Snapshot>,
  pub extensions: Vec<Extensions>,
  pub compression_cb: Option<Box<CompressionCb>>,
  pub snapshot_module_load_cb: Option<ExtModuleLoaderCb>,
}
  • cargo_manifest_dir: the directory in which Cargo will be compiling everything into. This should always be the CARGO_MANIFEST_DIR environment variable.
  • snapshot_path: the path where we write the snapshot to.
  • startup_snapshot: you can pass in a snapshot, if you want to build a snapshot from another snapshot.
  • extensions: any extensions that you would like to add on top of the snapshot
  • compression_cb: you can set which compression method to use to further reduce the filesize.
  • snapshot_module_load_cb: you can pass a module loader that will transform files included in the snapshot. For example, you can transpile TypeScript to JavaScript.

In this example, we only use:

  • snapshot_path: we define the snapshot path by resolving OUT_DIR and RUNJS_SNAPSHOT.bin.
  • extensions: we pass the runjs_extension, which is built from src/runtime.js.

Loading the snapshot in main.rs

Currently, the main.rs file’s run_js function loads the runjs_extension. We’ll modify this function to instead load the snapshot we created in build.rs:

- use deno_core::include_js_files;
+ use deno_core::Snapshot;

// Other stuff…

+ static RUNTIME_SNAPSHOT: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/RUNJS_SNAPSHOT.bin"));

async fn run_js(file_path: &str) -> Result<(), AnyError> {
    let main_module = deno_core::resolve_path(file_path)?;
    let runjs_extension = Extension::builder("runjs")
-         .esm(include_js_files!(
-             "runtime.js",
-         ))
        .ops(vec![
            op_read_file::decl(),
            op_write_file::decl(),
            op_remove_file::decl(),
            op_fetch::decl(),
            op_set_timeout::decl(),
        ])
        .build();
    let mut js_runtime = deno_core::JsRuntime::new(deno_core::RuntimeOptions {
        module_loader: Some(Rc::new(TsModuleLoader)),
+      startup_snapshot: Some(Snapshot::Static(RUNTIME_SNAPSHOT)),
        extensions: vec![runjs_extension],
        ..Default::default()
    });

    let mod_id = js_runtime.load_main_module(&main_module, None).await?;
    let result = js_runtime.mod_evaluate(mod_id);
    js_runtime.run_event_loop(false).await?;
    result.await?
}

We’ll remove the .esm() function, since that was moved to build.rs script. Then, we’ll add a line to load the snapshot.

Finally, to load the snapshot, we’ll add startup_snapshot in RuntimeOptions that points to the RUNTIME_SNAPSHOT, which is defined above run_js as a static slice of bytes of the snapshot we created in build.rs.

And that’s it! Let’s try running with:

cargo run -- example.ts

It should work!

What’s next?

Snapshotting is an excellent tool to help improve startup speeds for a custom runtime. This is an extremely simple example, but we hope that it sheds some light into how Deno uses snapshots to optimize performance.

Through this series, we’ve shown how to build your own custom JavaScript runtime, add APIs like fetch, and now speed up startup times via snapshotting. We love hearing from you so if there’s anything you want us to cover, please let us know on Twitter, YouTube, or Discord.

Don’t miss any updates — follow us on Twitter.