Modules

A runtime runs ES modules as well as scripts. The embedder compiles a main module, and a module loader that the embedder writes supplies every module it imports. This page covers both, plus JSON modules, import attributes, import.meta and dynamic import().

Compiling and running a module

Context::compile_module_with_filename compiles module source under a name. Context::execute_module links the module and what it imports, then evaluates it. The result is a promise that settles when evaluation finishes. Top-level await is allowed, so the promise can stay pending until the job queue runs.

examples/modules.rsrust
let runtime = Runtime::new();
let mut context = runtime.context();

let module = context
    .compile_module_with_filename(
        "await Promise.resolve(); globalThis.answer = 6 * 7;",
        "main.mjs",
    )
    .unwrap();
assert_eq!(module.name(), "main.mjs");

// The result is the promise of the module's evaluation.
let Value::Object(promise) = context.execute_module(&module).unwrap() else {
    panic!("expected a promise");
};
runtime.run_jobs().unwrap();

let snapshot = runtime.promise_snapshot(&promise).unwrap().unwrap();
assert_eq!(snapshot.state(), PromiseState::Fulfilled);
assert_eq!(context.eval("answer").unwrap(), Value::Int(42));

The compile methods differ only in their input:

Method Input
compile_module(source) &str, named <input>
compile_module_bytes(source) &[u8], named <input>
compile_module_with_filename(source, filename) &str
compile_module_bytes_with_filename(source, filename) &[u8]
compile_module_bytes_with_options(source, &options) &[u8], named options.filename
compile_module_bytes_with_import_meta(source, filename, import_meta) &[u8], with initial import.meta properties

Each one returns a ModuleBytecodeRef, whose name() is the filename. The filename is the module's name: other modules import it under that name, stack traces show it, and relative specifiers in it resolve against its directory. Compiling a second module under the same name replaces the first for later imports.

A module has no completion value. To get results out, have the module store them on globalThis, or import it with import() from a script and read the namespace object, as shown under Dynamic import.

Errors

A module fails in one of three places:

examples/modules.rsrust
// A syntax error is thrown by the compile call.
let error = context.compile_module_with_filename("export {", "bad.mjs");
assert!(error.is_err());
let exception = context.take_exception().unwrap().unwrap();
assert!(
    runtime
        .print_value(&exception)
        .unwrap()
        .starts_with("SyntaxError")
);

// An error while the module runs rejects the evaluation promise.
let module = context
    .compile_module_with_filename("throw new RangeError('boom');", "throws.mjs")
    .unwrap();
let Value::Object(promise) = context.execute_module(&module).unwrap() else {
    panic!("expected a promise");
};
runtime.run_jobs().unwrap();
let snapshot = runtime.promise_snapshot(&promise).unwrap().unwrap();
assert_eq!(snapshot.state(), PromiseState::Rejected);
let reason = runtime.print_value(snapshot.result()).unwrap();
assert!(reason.starts_with("RangeError: boom"));
examples/modules.rsrust
// A module that cannot be loaded fails execute_module itself.
let module = context
    .compile_module_with_filename("import './gone.mjs';", "main.mjs")
    .unwrap();
assert!(matches!(
    context.execute_module(&module),
    Err(RuntimeError::Exception)
));
let exception = context.take_exception().unwrap().unwrap();
assert!(
    runtime
        .print_value(&exception)
        .unwrap()
        .starts_with("ReferenceError: could not load module filename 'gone.mjs'")
);

Module loaders

An import of a module the embedder did not compile goes to the runtime's module loader. Runtime::set_module_loader installs it. Without a loader, such an import throws ReferenceError: could not load module filename '…'.

A loader implements the ModuleLoader trait:

Each normalized name is loaded and evaluated once per runtime. Later imports of the same name share the module.

ModuleSource has four forms:

Variant Module
Source { text, import_meta } JavaScript source, with initial import.meta properties
Json(bytes) a JSON module: its default export is the parsed value
Json5(bytes) a JSON5 module
Compiled(module) a CompiledModule that Scope::compile_module already compiled

ModuleSource::source(text) builds Source with an empty import.meta.

This loader serves modules from a map and treats .json names and with { type: "json" } imports as JSON:

examples/modules.rsrust
/// Modules from an in-memory map of normalized names to source text.
struct MemoryLoader {
    files: HashMap<String, String>,
}

impl ModuleLoader for MemoryLoader {
    // `normalize` keeps its default: a specifier starting with `.` resolves
    // against the importing module's directory, anything else is kept as is.

    fn load(
        &mut self,
        scope: &mut Scope<'_>,
        name: &str,
        attributes: &ModuleImportAttributes,
    ) -> Result<ModuleSource, Value> {
        let Some(text) = self.files.get(name) else {
            let message = format!("could not load module filename '{name}'");
            return Err(scope
                .new_native_error(NativeErrorKind::Reference, &message)
                .unwrap_or(Value::Undefined));
        };
        if name.ends_with(".json") || import_type_is(attributes, "json") {
            return Ok(ModuleSource::Json(text.clone().into_bytes()));
        }
        let url = JsString::try_from_utf8(&format!("mem:///{name}")).unwrap();
        Ok(ModuleSource::Source {
            text: text.clone().into_bytes(),
            import_meta: vec![("url".to_owned(), Value::String(url))],
        })
    }
}

/// Whether the import has `with { type: expected }`.
fn import_type_is(attributes: &ModuleImportAttributes, expected: &str) -> bool {
    attributes.effective().is_some_and(|attributes| {
        attributes.iter().any(|attribute| {
            attribute.key.to_utf8_lossy() == "type" && attribute.value.to_utf8_lossy() == expected
        })
    })
}

Installing it and importing through it:

examples/modules.rsrust
let files = HashMap::from([
    (
        "lib/math.mjs".to_owned(),
        "export const square = (x) => x * x; export const where = import.meta.url;".to_owned(),
    ),
    ("lib/config.json".to_owned(), r#"{ "size": 9 }"#.to_owned()),
]);
let runtime = Runtime::new();
runtime.set_module_loader(MemoryLoader { files }).unwrap();
let mut context = runtime.context();

let module = context
    .compile_module_with_filename(
        r#"
        import { square, where } from "./math.mjs";
        import config from "./config.json" with { type: "json" };
        globalThis.result = square(config.size);
        globalThis.where = where;
        "#,
        "lib/main.mjs",
    )
    .unwrap();
context.execute_module(&module).unwrap();
runtime.run_jobs().unwrap();

assert_eq!(global(&mut context, "result"), Value::Int(81));
assert_eq!(text(&global(&mut context, "where")), "mem:///lib/math.mjs");

Modules compiled through Context::compile_module* or returned by a loader stay alive as long as the runtime.

The Scope

Both loader methods receive a &mut Scope<'_>, which gives access to the runtime while the callback runs. With it a loader can:

A loader throws by returning Err(value). The value becomes the exception of the import that triggered the load, or the rejection reason of the import() promise.

During a callback the runtime is already in use. Calling any Runtime or Context method from inside the callback returns Err(RuntimeError::Reentered); the Scope is the only way in. Values made through the scope are ordinary handles and outlive the callback.

This loader overrides normalize to accept only std: specifiers and compiles each module through the scope:

examples/modules.rsrust
/// A loader that maps `std:NAME` to one built-in module and compiles it
/// through the scope.
struct StdLoader;

impl ModuleLoader for StdLoader {
    fn normalize(
        &mut self,
        scope: &mut Scope<'_>,
        _referrer: &str,
        specifier: &str,
        _attributes: &ModuleImportAttributes,
    ) -> Result<String, Value> {
        match specifier.strip_prefix("std:") {
            Some(name) => Ok(format!("<std>/{name}.mjs")),
            None => Err(scope
                .new_native_error(NativeErrorKind::Type, "only std: modules")
                .unwrap_or(Value::Undefined)),
        }
    }

    fn load(
        &mut self,
        scope: &mut Scope<'_>,
        name: &str,
        _attributes: &ModuleImportAttributes,
    ) -> Result<ModuleSource, Value> {
        let text = format!(
            "globalThis.evaluations = (globalThis.evaluations ?? 0) + 1;
             export const name = {name:?};"
        );
        let module = scope.compile_module(name, text.as_bytes(), Vec::new())?;
        Ok(ModuleSource::Compiled(module))
    }
}

Import attributes

normalize and load both receive the import's attributes as ModuleImportAttributes:

ModuleImportAttributes::effective() returns None for an absent or empty clause and the entries otherwise, matching what QuickJS passes to its loader. The engine does not interpret attributes. The loader decides which keys and values it accepts and what each one means. The qjs command, for example, accepts only type, loads type: "json" as JSON and type: "json5" as JSON5, and throws a TypeError for any other key.

import.meta

A module's import.meta object starts with the properties its source supplies: import_meta in ModuleSource::Source, in Scope::compile_module, or in Context::compile_module_bytes_with_import_meta. Each property is a (String, Value) pair. The qjs command sets url to the module's file:// URL and main to true for the main module and false for imported ones.

examples/modules.rsrust
let module = context
    .compile_module_bytes_with_import_meta(
        b"globalThis.meta = [import.meta.url, import.meta.main].join(' ');",
        "app.mjs",
        vec![
            (
                "url".to_owned(),
                Value::String(JsString::from_static("file:///app.mjs")),
            ),
            ("main".to_owned(), Value::Bool(true)),
        ],
    )
    .unwrap();
context.execute_module(&module).unwrap();
runtime.run_jobs().unwrap();
assert_eq!(text(&global(&mut context, "meta")), "file:///app.mjs true");

Dynamic import

import(specifier) works in modules and in scripts. It goes through the same loader. It returns a promise that the job queue settles: with the module's namespace object once it has loaded and evaluated, or with the error the loader or the module threw.

examples/modules.rsrust
context
    .eval(
        "import('greet.mjs').then((m) => { globalThis.greeting = m.default('module'); });
         import('nope.mjs').catch((e) => { globalThis.failure = e.message; });",
    )
    .unwrap();
runtime.run_jobs().unwrap();

assert_eq!(text(&global(&mut context, "greeting")), "hello, module");
assert_eq!(
    text(&global(&mut context, "failure")),
    "could not load module filename 'nope.mjs'"
);

Choosing script or module

quickjs_detect_module_bytes(source) applies QuickJS's heuristic for source that names no goal: it returns true when the first token after an optional #! line and comments is export, or import not followed by . or (. The qjs command runs a file as a module when its name ends in .mjs or this function returns true.

The complete program is examples/modules.rs; the loader the qjs command uses is FileModuleLoader in apps/cli/src/main.rs.

shell
cargo run --quiet --example modules

Where to go next