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.
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:
- A syntax error makes the compile call return
Err(RuntimeError::Exception), with aSyntaxErrorpending on the context. - An import that cannot be loaded or linked makes
execute_modulereturnErr(RuntimeError::Exception), with the loader's error pending. - An error thrown while the module runs rejects the evaluation promise.
// 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"));// 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:
normalize(scope, referrer, specifier, attributes)turns a specifier into a module name.referreris the name of the importing module or script. The default follows QuickJS: a specifier starting with.resolves against the referrer's directory (./math.mjsimported bylib/main.mjsislib/math.mjs), and any other specifier is the name unchanged.load(scope, name, attributes)returns the module with that normalized name as aModuleSource.
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:
/// 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:
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:
- create an error to throw with
Scope::new_native_error(kind, message); - create an object with
Scope::new_object(); - compile module source itself with
Scope::compile_module(name, text, import_meta), which returns aCompiledModuleor theSyntaxErrorto throw.
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:
/// 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:
Absentwhen the import has nowithclause;Present(attributes)when it has one, possibly empty. EachModuleImportAttributehas akeyand avalue, bothJsString.
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.
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.
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.
cargo run --quiet --example modulesWhere to go next
- Promises and jobs: run the jobs that settle module evaluation and
import(). - Host services: output, clocks and files for the code your modules run.
- Errors and limits: exceptions,
RuntimeErrorand resource limits.