Deno Module Loading and Resolution Process: How the Runtime Resolves Imports
Deno's module loading and resolution process operates through three distinct phases—resolution, graph building, and source loading—implemented primarily in cli/module_loader.rs to convert import specifiers into executable V8 bytecode while enforcing security rules for JSR, npm, and remote modules.
When you execute deno run or trigger a dynamic import, Deno initiates a sophisticated pipeline that transforms raw import strings into compiled JavaScript. Unlike Node.js, Deno uses explicit URL-based resolution and maintains a complete dependency graph before execution. This article examines the exact source code paths in the denoland/deno repository that handle specifier resolution, dependency analysis, and source code retrieval.
The Three-Phase Module Loading Pipeline
Deno processes every module request through a strict sequence implemented in cli/module_loader.rs. Each phase handles a specific concern: converting strings to URLs, ensuring dependencies are available, and retrieving the actual source code.
Phase 1: Specifier Resolution via CliModuleLoader::inner_resolve
The resolution phase transforms a raw import specifier into an absolute ModuleSpecifier URL. This process starts in CliModuleLoader::inner_resolve (lines 994‑1020), which coordinates several critical subsystems:
fn inner_resolve(
&self,
raw_specifier: &str,
raw_referrer: &str,
kind: deno_core::ResolutionKind,
is_import_meta: bool,
) -> Result<ModuleSpecifier, ModuleLoaderError> {
// ① Resolve the referrer (file, REPL, cwd …)
let referrer = self.resolve_referrer(raw_referrer)?;
// ② Ask the Deno resolver (graph‑aware) to resolve the specifier.
let result = self.shared.resolver.resolve_with_graph(
graph.as_ref(),
raw_specifier,
&referrer,
deno_graph::Position::zeroed(),
ResolveWithGraphOptions {
mode: ResolutionMode::Import,
kind: NodeResolutionKind::Execution,
// Keep npm specifiers untouched for dynamic imports so they can be installed later.
maintain_npm_specifiers: matches!(kind, deno_core::ResolutionKind::DynamicImport) && !is_import_meta,
},
);
…
}
Referrer handling occurs first via resolve_referrer (lines 842‑862), which converts relative paths, absolute URLs, or REPL placeholders into a valid ModuleSpecifier. The graph-based resolution then delegates to self.shared.resolver, an instance of CliResolver that traverses import maps, npm registries, and JSR packages while applying Node-style resolution rules.
A critical security check happens via ensure_not_jsr_non_jsr_remote_import (lines 815‑834), which forbids JSR packages from importing arbitrary remote modules to prevent supply-chain attacks. The result is a fully-qualified URL such as file:///home/user/project/mod.ts or https://deno.land/std@0.224.0/fs/mod.ts.
Phase 2: Module Graph Preparation with ModuleLoadPreparer::prepare_module_load
Before executing code, Deno guarantees that the entire dependency tree is available and valid. This happens in ModuleLoadPreparer::prepare_module_load (lines 176‑209):
pub async fn prepare_module_load(
&self,
graph: &mut ModuleGraph,
roots: &[ModuleSpecifier],
options: PrepareModuleLoadOptions<'_>,
) -> Result<(), PrepareModuleLoadError> {
// Build a `GraphLoader` that knows about permissions, file overrides, etc.
let mut loader = self.module_graph_builder.create_graph_loader_with_permissions(...);
// Populate the graph (including npm resolution, type‑checking, lockfile handling)
self.module_graph_builder.build_graph_roots_with_npm_resolution(
graph,
roots.to_vec(),
BuildGraphWithNpmOptions { … },
).await?;
// Optional type‑checking (if `--check` is enabled)
if self.options.type_check_mode().is_true() && !has_type_checked {
self.type_checker.check(...)?;
}
}
This phase constructs or updates the ModuleGraph, fetching remote sources, analyzing static dependencies, and resolving npm packages via CliNpmResolver. The system supports file overrides for scenarios like deno eval where source code is passed via --code rather than read from disk. If type checking is enabled, the graph is validated before execution, and the lockfile is updated to capture exact dependency versions.
Phase 3: Source Loading via CliModuleLoaderInner::load_inner
The final phase retrieves the actual bytecode or source text. CliModuleLoaderInner::load_inner (lines 882‑927) handles cache lookups, source map stripping, and V8 code caching:
async fn load_inner(
&self,
specifier: &ModuleSpecifier,
maybe_referrer: Option<&ModuleSpecifier>,
requested_module_type: &RequestedModuleType,
) -> Result<ModuleSource, ModuleLoaderError> {
// ① Ask the `deno_resolver` loader for the prepared module or asset.
let code_source = self.load_code_source(specifier, maybe_referrer, requested_module_type).await?;
// ② Strip source‑maps unless we are debugging or loading a Wasm module.
let code = if self.shared.is_inspecting || code_source.module_type == ModuleType::Wasm {
code_source.code
} else {
code_without_source_map(code_source.code)
};
// ③ V8 code‑cache lookup (only for JavaScript modules).
let code_cache = if code_source.module_type == ModuleType::JavaScript {
self.shared.code_cache.as_ref().map(|cache| {
let hash = FastInsecureHasher::new_deno_versioned().write_hashable(&code).finish();
let data = cache.get_sync(...).map(Cow::from);
SourceCodeCacheInfo { hash, data }
})
} else {
None
};
// ④ Return a `ModuleSource` that the runtime will compile.
Ok(ModuleSource::new_with_redirect(
code_source.module_type,
code,
specifier,
&code_source.found_url,
code_cache,
))
}
The loader checks the V8 code cache for precompiled bytecode to skip parsing overhead. It also performs dynamic import reload checks via maybe_reload_dynamic, which compares file system timestamps to rebuild subgraphs when files change during development.
Core Components and Integration Points
Several architectural components wire these phases together:
CliModuleLoaderFactory(lines 92‑125): InstantiatesCliModuleLoaderfor the main thread or workers, connecting the resolver, emitter, and code cache.CliResolver: The high-level resolver defined incli/resolver.rsthat coordinates import maps, npm, and JSR registries.ModuleLoadertrait: The interface fromdeno_corethat theJsRuntimecalls forresolve,load, andprepare_loadoperations.EszipModuleLoader(lines 1514‑1550): Enables loading from pre-packed ESZIP archives when usingdeno run --unstable-eszip.
Practical Examples of Module Resolution
Static Import Resolution
// file: main.ts
import { serve } from "https://deno.land/std@0.224.0/http/server.ts";
serve((req) => new Response("Hello Deno!"));
When you run deno run main.ts, the runtime immediately triggers inner_resolve to convert the HTTPS specifier, builds the module graph to fetch server.ts and its dependencies, then loads the cached source through load_inner.
Dynamic Import with File Watching
// file: dynamic.ts
if (await someCondition()) {
const { hello } = await import("./hello.ts");
hello();
}
Dynamic imports set is_dynamic_import = true, which preserves npm specifiers for lazy installation. On subsequent executions, maybe_reload_dynamic checks if ./hello.ts has changed on disk and rebuilds only the affected subgraph.
Programmatic Resolution with import.meta.resolve
// file: meta.ts
const url = import.meta.resolve("./util.ts");
console.log(url); // → file:///absolute/path/util.ts
The runtime calls CliModuleLoader::import_meta_resolve, which forwards to inner_resolve with is_import_meta = true. This flag suppresses the JSR remote-import security check, allowing resolution of any valid specifier for informational purposes.
Summary
- Resolution Phase:
CliModuleLoader::inner_resolveconverts raw specifiers to absolute URLs viaCliResolver, enforcing JSR security rules and handling import maps, npm, and relative paths. - Graph Phase:
ModuleLoadPreparer::prepare_module_loadbuilds theModuleGraph, fetches dependencies, resolves npm packages, and optionally type-checks the entire tree. - Loading Phase:
CliModuleLoaderInner::load_innerretrieves source code from cache or disk, strips source maps (unless debugging), and checks the V8 code cache for precompiled bytecode. - Security: The
ensure_not_jsr_non_jsr_remote_importcheck prevents JSR packages from importing arbitrary remote URLs. - Performance: Dynamic imports support lazy npm installation, and the V8 code cache avoids re-parsing unchanged JavaScript.
Frequently Asked Questions
How does Deno resolve relative versus absolute specifiers?
Deno uses the resolve_referrer function (lines 842‑862) to establish a base URL for resolution. Relative specifiers like ./utils.ts resolve against the referrer's URL, while absolute specifiers (file, HTTPS, JSR, or npm) are validated directly. The CliResolver handles protocol-specific logic for npm (npm:package) and JSR (jsr:@scope/package) specifiers.
What is the difference between static and dynamic import resolution?
Static imports are resolved during the graph-building phase before execution, allowing Deno to fetch all dependencies upfront and perform type checking. Dynamic imports (via import()) trigger prepare_load with ResolutionKind::DynamicImport, which sets maintain_npm_specifiers: true to defer npm package installation until the import actually executes. Dynamic imports also support maybe_reload_dynamic to detect file changes during development.
How does Deno handle npm package resolution without a node_modules folder?
The CliNpmResolver (invoked during build_graph_roots_with_npm_resolution) manages npm packages through Deno's global cache. When the graph builder encounters an npm specifier, it resolves the package version, potentially triggering an installation if the package is missing. The source files are then treated as virtual file system entries, loaded through the same load_inner mechanism as standard modules.
What role does the ModuleGraph play in Deno's module system?
The ModuleGraph serves as the authoritative dependency manifest, stored in an Arc<ModuleGraph> within ModuleGraphContainer. It tracks all static dependencies, their URLs, and content hashes before any code executes. This enables Deno to perform ahead-of-time type checking, validate lockfiles for reproducible builds, and optimize loading by knowing the complete dependency tree upfront. Workers receive isolated WorkerModuleGraphContainer instances to prevent cross-contamination of module state.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →