Blob: docs/reference/detail/new-module-registry.md
New Module Registry (jsg::modules::ModuleRegistry)
Reference for reasoning about the new module registry implementation, how it interfaces with V8's module APIs, and how modules flow from config through compilation to evaluation. For V8 module internals, see v8-module-internals.md. For the legacy implementation, see legacy-module-registry.md.
Source Files
| File | Role |
|---|---|
src/workerd/jsg/modules-new.h |
Module, ModuleBundle, ModuleRegistry classes; ResolveContext; builder APIs; IsolateModuleRegistry (fwd) |
src/workerd/jsg/modules-new.c++ |
All implementation: EsModule, SyntheticModule, IsolateModuleRegistry, V8 callbacks, bundle builders |
src/workerd/jsg/observer.h |
ResolveObserver — metrics/tracing hooks for module resolution |
src/workerd/jsg/url.h |
jsg::Url — WHATWG URL wrapper (ada-url); used as specifier keys throughout |
src/workerd/jsg/resource.h:1913–22 |
Context creation: picks legacy vs new registry based on NewContextOptions |
src/workerd/jsg/jsg.c++:357–395 |
Lock::resolveModule / Lock::resolveInternalModule dispatches between legacy and new |
src/workerd/jsg/setup.h:291–297 |
IsolateBase::isUsingNewModuleRegistry() flag |
src/workerd/io/worker-modules.h |
newWorkerModuleRegistry<TypeWrapper> — creates ModuleRegistry from worker config |
src/workerd/api/modules.h |
registerModules (legacy), registerBuiltinModules (new) — registers all built-in API modules |
src/workerd/server/workerd-api.c++ |
WorkerdApi::newWorkerdModuleRegistry — workerd-specific new registry construction |
Design Goals and Key Differences from Legacy
The new module registry was designed to address several limitations of the legacy
ModuleRegistryImpl<TypeWrapper>:
Thread-safe and shareable across isolate replicas. The
ModuleRegistryiskj::AtomicRefcountedand protected bykj::MutexGuarded. MultipleWorker::Isolatereplicas can share a singleModuleRegistryinstance. The legacy registry was per-isolate with no sharing.URL-based specifiers. All specifiers are handled as
jsg::Urlobjects (WHATWG-compliant, backed by ada-url). The legacy registry usedkj::Path. This enables proper protocol-based dispatch (node:,cloudflare:,file:) and correct relative resolution.Fully lazy compilation and evaluation. No modules are compiled during
ModuleRegistryconstruction. Both ESM compilation and synthetic module evaluation happen on first import. The legacy registry eagerly compiled all worker bundle ESM modules at startup.Cross-isolate compile cache. ESM modules cache their V8 compiled bytecode in a
kj::MutexGuardedfield on theModuleobject itself. When a second isolate imports the same module, the cached bytecode is consumed, skipping recompilation. The legacy registry had no compile cache sharing.Separation of definition from instantiation.
Moduleobjects are isolate-independent definitions.IsolateModuleRegistryholds the per-isolate V8 handles. This clean split enables the sharing model.import.metasupport. The new registry providesimport.meta.url,import.meta.main, andimport.meta.resolve()on all ESM modules. The legacy registry had noimport.metasupport.Modular bundle architecture. The
ModuleBundleabstraction allows composing registries from multiple independent sources (worker bundle, builtins, internal-only, fallback) without coupling them.
Architecture Overview
The system has a two-layer design: a shared, isolate-independent layer
(ModuleRegistry + ModuleBundle + Module) and a per-isolate layer
(IsolateModuleRegistry).
Shared Layer (owned by Worker::Script, thread-safe)
ModuleRegistry (kj::AtomicRefcounted)
|-- observer: const ResolveObserver& (metrics hooks)
|-- bundleBase: const jsg::Url& (e.g. "file:///bundle/")
|-- schemaLoader: capnp::SchemaLoader (for capnp modules)
|-- maybeEvalCallback: EvalCallback (ensures eval outside IoContext)
+-- impl: kj::MutexGuarded<Impl>
+-- bundles: FixedArray<Array<Own<ModuleBundle>>, 4>
[kBundle] -> worker bundle modules
[kBuiltin] -> runtime builtins importable by user code
[kBuiltinOnly] -> internal-only modules
[kFallback] -> fallback service (local dev only)Per-Isolate Layer (owned by JsContext, destroyed with context)
IsolateModuleRegistry
|-- inner: const ModuleRegistry& (back-reference to shared registry)
|-- observer: const CompilationObserver& (compilation metrics)
+-- lookupCache: kj::Table<Entry> (triple-indexed)
Each Entry:
|-- key: HashableV8Ref<v8::Module> (V8 module handle)
|-- context: SpecifierContext (type + URL)
+-- module: const Module& (back-ref to definition)
Indices:
|-- HashIndex<EntryCallbacks> -- lookup by v8::Module identity
|-- HashIndex<ContextCallbacks> -- lookup by (type, URL)
+-- HashIndex<UrlCallbacks> -- lookup by URL aloneModule Types
Module::Type enum
| Type | Value | Purpose |
|---|---|---|
BUNDLE |
0 | User worker modules (from config) |
BUILTIN |
1 | Runtime-provided, importable by user code (e.g. node:buffer) |
BUILTIN_ONLY |
2 | Runtime-internal, only importable by other builtins |
FALLBACK |
3 | Dynamically resolved from fallback service (local dev only) |
Module::Flags bitmask
| Flag | Bit | Meaning |
|---|---|---|
NONE |
0x00 | No flags |
MAIN |
0x01 | import.meta.main = true (worker entrypoint) |
ESM |
0x02 | ECMAScript module (vs synthetic) |
EVAL |
0x04 | Requires evaluation outside IoContext (deferred to EvalCallback) |
WASM |
0x08 | WebAssembly module |
Module::ContentType enum
Used to validate import attributes (e.g. with { type: 'json' }). Set at
module construction time.
| ContentType | Meaning |
|---|---|
NONE |
No specific content type (ESM, CJS, builtin objects) |
JSON |
JSON module |
TEXT |
Text module |
DATA |
Data/binary module |
WASM |
WebAssembly module |
Module Subclasses
Module is an abstract base class. Two concrete implementations exist
(both file-private in modules-new.c++):
EsModule — ECMAScript Modules
EsModule extends Module {
source: kj::ArrayPtr<const char> // Raw source text
cachedData: MutexGuarded<Maybe<Own<CachedData>>> // Cross-isolate compile cache
}getDescriptor(): Compiles the source viav8::ScriptCompiler::CompileModule. Checks thecachedDatashared cache first (shared lock for reading, exclusive lock for writing). On cache miss, generates and stores bytecode after compilation.evaluate(): Callsv8::Module::Evaluate(). For modules withFlags::EVAL, delegates to theEvaluatorto run evaluation outside the IoContext.- Always has
Flags::ESM | Flags::EVALset.
Compile Cache Flow
First isolate compiles module:
1. lockShared(cachedData) -> empty -> no cached data
2. CompileModule(source) -> v8::Module
3. lockExclusive(cachedData) -> still empty?
-> CreateCodeCache(module) -> store Own<CachedData>
Second isolate compiles same module:
1. lockShared(cachedData) -> has data -> copy bytes
-> kConsumeCodeCache -> CompileModule(source, cache)
2. No exclusive lock needed (cache already populated)SyntheticModule — All Non-ESM Modules
SyntheticModule extends Module {
callback: EvaluateCallback // Sets exports during evaluation
namedExports: Array<String> // Declared export names (besides "default")
}getDescriptor(): Callsv8::Module::CreateSyntheticModulewith the declared export names (always includes"default"plus anynamedExports).evaluate(): Ensures the module is instantiated, optionally delegates to theEvaluator(forFlags::EVALmodules), then callsmodule->Evaluate()to enter V8's status machine. V8 calls back intoevaluationStepswhich callsactuallyEvaluate().actuallyEvaluate(): Creates a resolved Promise, calls thecallbackwhich uses theModuleNamespacehelper to set exports viaSetSyntheticModuleExport. This is always invoked via V8's evaluation steps callback, never called directly from external code.
Static evaluationSteps Callback
All synthetic modules share a single static evaluationSteps function, registered
as the V8 SyntheticModuleEvaluationSteps. When V8 calls it:
- Gets the
IsolateModuleRegistryfrom context embedder data. - Looks up the
Entryby v8::Module identity (hash-indexed, O(1) — unlike the legacy registry's O(n) linear scan). - Calls
actuallyEvaluate()on the found module — notevaluate(), to avoid reentry intomodule->Evaluate().
This design ensures V8 always manages the status transitions (kEvaluating →
kEvaluated or kErrored) regardless of whether evaluation was initiated by
V8 (static import) or by our code (dynamic import, require). The evaluate()
method is the external entry point that goes through V8; evaluationSteps is
V8's callback entry point that goes directly to the work.
ModuleBundle — Sources of Modules
A ModuleBundle is an immutable collection of module definitions. The
ModuleRegistry composes multiple bundles and searches them in priority order.
StaticModuleBundle (file-private)
The default bundle type, created by Builder::finish(). Contains:
modules:HashMap<Url, ResolveCallback>— URL to factoryaliases:HashMap<Url, Url>— alias to targetcache:HashMap<Url, Own<Module>>— resolved modules (populated on first lookup)
Each ResolveCallback is a factory (ResolveContext) -> Maybe<OneOf<String, Own<Module>>>.
It returns either a Module (resolved), a redirect string, or kj::none (not found).
FallbackModuleBundle (file-private)
Used only for local development (workerd binary). Contains a single
ResolveCallback that calls an external service. Caches results in internal
storage and aliases maps.
BundleBuilder — Building Worker Bundle Modules
BundleBuilder creates BUNDLE-type modules. Module names are processed relative
to the bundleBase URL (typically file:///bundle/):
BundleBuilder builder(bundleBase);
builder.addEsmModule("index.js", source, Flags::ESM | Flags::MAIN);
builder.addSyntheticModule("data.json", Module::newJsonModuleHandler(jsonData));
builder.addWasmModule("module.wasm", wasmBytes);
auto bundle = builder.finish();Name normalization (normalizeModuleName):
- Parse name as URL relative to
bundleBase. - Normalize path (remove
./.., normalize percent-encoding). - Strip fragments and query parameters.
- Validate: must be subordinate to
bundleBase, nocloudflare:orworkerd:protocol.
BuiltinBuilder — Building Runtime Modules
BuiltinBuilder creates BUILTIN or BUILTIN_ONLY modules. Specifiers must be
absolute URLs with non-file: protocols:
BuiltinBuilder builder(BuiltinBuilder::Type::BUILTIN);
builder.addEsm("node:buffer"_url, NODE_BUFFER_SOURCE);
builder.addSynthetic("node:path"_url, pathCallback);
builder.addObject<CryptoModule, TypeWrapper>("node:crypto"_url);
auto bundle = builder.finish();Resolution Flow
Lookup Priority by Context Type
When ModuleRegistry::lookup() is called, it acquires an exclusive lock on the
MutexGuarded<Impl> and searches bundles in order:
ResolveContext::Type |
Search order | Use case |
|---|---|---|
BUNDLE |
kBundle -> kBuiltin -> kFallback | User code imports |
BUILTIN |
kBuiltin -> kBuiltinOnly | Builtin code importing other builtins |
BUILTIN_ONLY |
kBuiltinOnly only | Internal modules importing other internals |
PUBLIC_BUILTIN |
kBuiltin only | process.getBuiltinModule() and similar |
Complete Resolution Sequence (Static Import)
User code: import { foo } from './bar.js'
1. V8 calls resolveModuleCallback<false>(context, specifier, attrs, referrer)
2. Look up referrer in IsolateModuleRegistry by v8::Module identity
-> Determines referrer's type (BUNDLE/BUILTIN/BUILTIN_ONLY)
-> Gets referrer's URL for relative resolution
3. Normalize specifier:
a. If Node.js compat enabled, check for bare node specifier -> prefix "node:"
b. Check for node:process redirect (v1 vs v2)
c. Resolve specifier relative to referrer URL -> absolute URL
d. Normalize path (percent-encoding normalization)
4. Build ResolveContext { type, source=STATIC_IMPORT, normalizedSpecifier, referrer }
5. IsolateModuleRegistry::resolve(js, context)
a. Check lookupCache by (type, URL) -> cache hit? Return v8::Module handle
b. Cache miss -> call resolveWithCaching(js, context)
i. Strip query params and fragments from specifier
ii. Call inner.lookup(innerContext) [acquires MutexGuarded exclusive lock]
iii. ModuleRegistry::lookupImpl searches bundles in priority order
iv. Each bundle's lookup() checks aliases, cache, then resolve callbacks
v. If redirect (alias): recurse once with new specifier (max depth 1)
vi. If Module found: call module.getDescriptor(js, observer) -> v8::Module
vii. Insert Entry{v8Module, context, module} into lookupCache
c. Return v8::Module handle from the new Entry
6. V8 receives the v8::Module and continues PrepareInstantiateDynamic Import Flow
User code: const mod = await import('./bar.js')
1. V8 calls dynamicImport(context, host_options, resource_name, specifier, attrs)
2. Reject any import attributes (not yet supported)
3. Parse referrer from resource_name (or fall back to bundleBase)
4. Normalize specifier (same as static: node: prefix, process redirect, URL resolve)
5. IsolateModuleRegistry::dynamicResolve(js, normalizedSpec, referrer, rawSpec, sourcePhase)
a. Look up referrer in lookupCache -> get referrer's module type
b. Build ResolveContext { type from referrer, source=DYNAMIC_IMPORT, ... }
c. Check lookupCache or call resolveWithCaching (same as static)
d. Call module.evaluate(js, v8Module, observer, maybeEvaluate) -> Promise
e. Chain: .then(namespace -> resolve Promise with module namespace)
6. Return Promise to V8require() Flow
CJS code: const foo = require('./bar.js')
1. IsolateModuleRegistry::require(js, context, option)
2. Check for node:process redirect
3. Resolve module (same cache + lookup path as static import)
4. Evaluate the module:
a. If status == kErrored -> throw the stored exception
b. If ESM and status == kEvaluating -> throw circular dependency error
c. If status == kEvaluated or kEvaluating -> return namespace directly
(allows CJS circular deps with incomplete exports, same as Node.js)
d. Otherwise: call module.evaluate() -> Promise
e. Run microtasks
f. Check promise state:
- kFulfilled -> return module namespace
- kRejected -> throw rejection reason
- kPending -> throw "top-level await" error
5. Return the module namespace as v8::ObjectEvaluation and the Evaluator Pattern
Module evaluation has a key constraint: it must occur outside any
IoContext. The Module::Evaluator pattern enforces this.
The EvalCallback
Set during registry construction via Builder::setEvalCallback. The standard
implementation (from worker-modules.h) creates a SuppressIoContextScope:
builder.setEvalCallback([](Lock& js, const auto& module, auto v8Module,
const auto& observer) -> Promise<Value> {
return js.tryOrReject<Value>([&] {
SuppressIoContextScope suppressIoContextScope;
KJ_DASSERT(!IoContext::hasCurrent());
return check(v8Module->Evaluate(js.v8Context()));
});
});How it flows
module.evaluate(js, v8Module, observer, evaluator)is called.- For ESM modules (
Flags::EVALalways set):- The
evaluator(js, module, v8Module, observer)is invoked. - The evaluator calls
ModuleRegistry::evaluateImplwhich invokes theEvalCallback(wrapping evaluation inSuppressIoContextScope). - The
EvalCallbackcallsv8Module->Evaluate(). V8 evaluates the source text directly.
- The
- For Synthetic modules (dynamic import and require paths):
- If
Flags::EVALis set, the evaluator is invoked first (same as ESM). - Otherwise,
module->Evaluate()is called directly. - V8 sets status to
kEvaluatingand calls theevaluationStepscallback. evaluationStepscallsactuallyEvaluate()which runs the callback to set exports.- V8 receives the result and sets status to
kEvaluatedorkErrored.
- If
- For static imports of synthetic modules, V8 drives
Module::Evaluate()itself, which callsevaluationSteps→actuallyEvaluate(). The same code path executes; only the initial caller differs.
import.meta Support
The importMeta callback is registered on the isolate during
IsolateModuleRegistry construction. When V8 creates an import.meta object:
- Looks up the module in the
IsolateModuleRegistryby v8::Module handle. - Sets three properties via
CreateDataProperty:import.meta.main—trueifModule::Flags::MAINis setimport.meta.url— the module's URL (e.g."file:///bundle/index.js")import.meta.resolve(specifier)— resolvesspecifierrelative toimport.meta.urlusing WHATWG URL resolution. Returns the resolved URL string, ornullif unparseable. Does not check if the resolved URL matches a module in the registry.
The ModuleNamespace Helper
Module::ModuleNamespace wraps a v8::Local<v8::Module> and provides a safe
interface for synthetic module evaluation callbacks:
class ModuleNamespace {
bool set(Lock& js, kj::StringPtr name, JsValue value); // Set a named export
bool setDefault(Lock& js, JsValue value); // Set the "default" export
kj::ArrayPtr<const kj::StringPtr> getNamedExports(); // Declared named exports
};Internally calls v8::Module::SetSyntheticModuleExport. Returns false if the
export cannot be set (export name not declared, or V8 error). Validates that
name is in the declared exports set.
Common Synthetic Module Handlers
Static factory methods on Module create EvaluateCallback closures for common
module types:
| Handler | Created by | Export behavior |
|---|---|---|
newTextModuleHandler |
Module::newTextModuleHandler |
default = JS string from char data |
newDataModuleHandler |
Module::newDataModuleHandler |
default = ArrayBuffer (copied from byte data) |
newJsonModuleHandler |
Module::newJsonModuleHandler |
default = JSON.parse(data) |
newWasmModuleHandler |
Module::newWasmModuleHandler |
default = WebAssembly.Module (compiled Wasm) |
The Wasm handler has its own cross-isolate cache: a MutexGuarded<Maybe<CompiledWasmModule>>
that stores the compiled module for reuse across isolates (similar to the ESM
compile cache).
CJS-Style Module Handler
Module::newCjsStyleModuleHandler<T, TypeWrapper> is a template that creates a
handler for CommonJS-style modules:
- Allocates a JSG resource object of type
T(e.g.CommonJsModuleContext). - Compiles the source as a function via
ScriptCompiler::CompileFunctionwith the JSG object as extension object (providingmodule,exports,require). - Calls the compiled function.
- Extracts
exportsfrom the JSG object and sets them on the module namespace.
Re-evaluation is prevented by V8's module status machine (kEvaluated prevents
re-entry), not by application-level guards.
Specifier Processing
Bundle Module Names
Worker bundle module names (from config) are processed through processModuleName:
- Normalize: resolve relative to
bundleBase, remove./.., normalize percent-encoding, strip query/fragment. - Validate URL structure: must be subordinate to
bundleBasepath. - Protocol restrictions:
cloudflare:,workerd:, anddata:protocols are forbidden in bundle module names (reserved for runtime use).
Result: a file:///bundle/<path> URL for file-protocol modules, or a fully
qualified URL for other protocols.
Builtin Module Specifiers
Builtin modules use non-file: protocols (enforced by ensureIsNotBundleSpecifier):
node:buffer,node:crypto, etc.cloudflare:sockets,cloudflare:workers, etc.node-internal:*(internal-only modules)workerd:*(internal-only modules)
These are parsed as absolute URLs where the protocol is the scheme and the pathname is the module identifier.
Node.js Compatibility Handling
When Node.js compat is enabled (isNodeJsCompatEnabled(js)):
- Bare specifiers like
"buffer"are prefixed to"node:buffer"viacheckNodeSpecifier. "node:process"is redirected to either"node-internal:public_process"or"node-internal:legacy_process"based on theenable_nodejs_process_v2flag.
IsolateModuleRegistry — Per-Isolate Cache
The IsolateModuleRegistry binds the shared ModuleRegistry to a specific
V8 isolate. It is stored in context embedder data at
ContextPointerSlot::MODULE_REGISTRY and retrieved via
IsolateModuleRegistry::from(isolate).
Lookup Cache
The cache is a kj::Table<Entry> with three hash indices:
| Index | Key type | Used by |
|---|---|---|
EntryCallbacks |
HashableV8Ref<v8::Module> |
Reverse lookup (V8 handle to Entry) |
ContextCallbacks |
SpecifierContext (type+URL) |
Primary resolution (specifier to Entry) |
UrlCallbacks |
Url |
Referrer lookup for dynamic imports |
This triple-indexing means:
- Resolving by specifier is O(1) hash lookup (vs legacy's O(n) scan for some paths).
- Reverse lookup (v8::Module to Module definition) is O(1) hash lookup (vs legacy's O(n) linear scan over all entries).
- Referrer lookup (by URL alone, ignoring type) is O(1).
Cache Population
On first resolution of a specifier:
resolveWithCachingstrips query params and fragments from the specifier.- Calls
inner.lookup()on the sharedModuleRegistry(acquires exclusive lock). - If found, calls
module.getDescriptor(js, observer)to create the v8::Module. - Inserts a new
Entryinto thelookupCache. - Subsequent lookups for the same specifier hit the cache directly.
V8 Callback Registration
During IsolateModuleRegistry construction, three callbacks are registered:
setAlignedPointerInEmbedderData(context, MODULE_REGISTRY, this);
isolate->SetHostImportModuleDynamicallyCallback(&dynamicImport);
isolate->SetHostImportModuleWithPhaseDynamicallyCallback(&dynamicImportWithPhase);
isolate->SetHostInitializeImportMetaObjectCallback(&importMeta);The resolveModuleCallback<false> and resolveModuleCallback<true> are passed
to InstantiateModule during each module instantiation (not set globally on the
isolate).
Source Phase Imports
Source phase imports (import source x from "mod") are handled through:
resolveModuleCallback<true>— returnsv8::Object(notv8::Module)dynamicImportWithPhase— wrapsdynamicImportModuleCallbackwithSourcePhase::YES
Only WebAssembly modules support source phase imports. The resolved Wasm
module is evaluated, and the WebAssembly.Module object (the compiled Wasm,
not an instance) is extracted from the module's default export and returned.
All other module types throw SyntaxError.
Registry Setup and Population
1. Construction (newWorkerModuleRegistry<TypeWrapper> in worker-modules.h)
The main entry point. Called during Worker::Script construction:
newWorkerModuleRegistry<TypeWrapper>(resolveObserver, maybeSource, flags, bundleBase, setupForApi)
|
|-- Create ModuleRegistry::Builder
|
|-- Set EvalCallback (SuppressIoContextScope wrapper)
|
|-- registerBuiltinModules<TypeWrapper>(builder, featureFlags)
| |-- Node.js compat modules (internal + external bundles)
| |-- Sockets, Base64, MessageChannel, RPC modules
| |-- Unsafe modules (if enabled)
| |-- RTTI module (if enabled)
| |-- Cloudflare bundle (from CLOUDFLARE_BUNDLE capnp)
| |-- Workers module, Tracing module
| +-- Internal-only: EnvModule, FileSystemModule
|
|-- Register worker bundle modules (if any)
| |-- Load capnp schemas into SchemaLoader
| |-- Create BundleBuilder(bundleBase)
| |-- For each module in source:
| | |-- EsModule -> addEsmModule (first gets MAIN flag)
| | |-- TextModule -> addSyntheticModule(newTextModuleHandler)
| | |-- DataModule -> addSyntheticModule(newDataModuleHandler)
| | |-- WasmModule -> addWasmModule
| | |-- JsonModule -> addSyntheticModule(newJsonModuleHandler)
| | |-- CommonJsModule -> addSyntheticModule(newCjsStyleModuleHandler)
| | +-- CapnpModule -> addSyntheticModule(capnp handler with lazy export)
| +-- builder.add(bundleBuilder.finish())
|
|-- setupForApi(builder, isPythonWorker) // API-specific extensions
|
+-- builder.finish() -> kj::Arc<ModuleRegistry>2. Attachment to Isolate (resource.h:1913-1922)
During V8 context creation (JsContext construction):
if (options.newModuleRegistry) {
return newModuleRegistry.attachToIsolate(js, compilationObserver);
} else {
return ModuleRegistryImpl<TypeWrapper>::install(isolate, context, compilationObserver);
}attachToIsolate creates an IsolateModuleRegistry, stores it in embedder data,
and registers V8 callbacks. Returns kj::Own<void> whose destructor cleans up.
3. Dispatch Between Legacy and New (jsg.c++)
Lock::resolveModule and Lock::resolveInternalModule check
IsolateBase::isUsingNewModuleRegistry() and dispatch accordingly:
if (isolate.isUsingNewModuleRegistry()) {
return modules::ModuleRegistry::tryResolveModuleNamespace(js, specifier, ...);
} else {
// legacy ModuleRegistry path
}Resolution Priority and Isolation Rules
Referrer-Based Context Determination
When a V8 resolve callback fires, the referrer module's type determines the
ResolveContext::Type:
| Referrer Module Type | Resolve Context Type | Can see |
|---|---|---|
BUNDLE |
BUNDLE |
Bundle, Builtin, Fallback |
BUILTIN |
BUILTIN |
Builtin, BuiltinOnly |
BUILTIN_ONLY |
BUILTIN_ONLY |
BuiltinOnly only |
FALLBACK |
BUNDLE |
Bundle, Builtin, Fallback |
This means:
- User code can import builtins (e.g.
import { Buffer } from 'node:buffer'). - User code cannot import internal-only modules.
- Builtin code can import internal-only modules.
- Builtin code cannot import user bundle modules.
- User bundle modules with the same specifier as a builtin shadow the builtin (Bundle is searched before Builtin in BUNDLE resolution).
Alias Handling
Aliases are single-level redirects. When a bundle lookup returns a string
(redirect) instead of a Module:
- The string is parsed as a URL (relative to
bundleBaseif needed). lookupImplrecurses with the new specifier, but with arecursed=trueflag that prevents further recursion. Only one level of aliasing is supported.
Error Propagation for Errored Dependencies
V8's InnerModuleEvaluation only recurses into SourceTextModule dependencies
when checking for kErrored status — it skips SyntheticModule dependencies
entirely. This means if a synthetic module is kErrored, an ESM that imports
it would evaluate successfully with undefined export values instead of
propagating the error.
To work around this, resolveModuleCallback checks the resolved module's
status after resolution. If the module is kErrored, the callback throws the
module's cached exception instead of returning the handle to V8. This prevents
V8 from instantiating an ESM graph containing errored synthetic dependencies.
Similarly, dynamicResolve checks for kErrored before calling evaluate(),
returning a rejected promise with the cached exception.
Thread Safety Model
Shared State (ModuleRegistry, ModuleBundle, Module)
ModuleRegistry::lookup()acquiresimpl.lockExclusive()for every call. This serializes all resolution across all isolate replicas.ModuleBundleandModuleobjects are immutable after construction, with two exceptions:EsModule::cachedDatauseskj::MutexGuarded(shared lock for reads, exclusive for writes).newWasmModuleHandlersimilarly useskj::MutexGuardedfor its compiled Wasm cache.StaticModuleBundle::cacheandFallbackModuleBundle::storage/aliasesare mutated during lookup, but are protected by the registry's exclusive lock.
Per-Isolate State (IsolateModuleRegistry)
IsolateModuleRegistryis created and used only within a single V8 isolate.- The
lookupCacheis not thread-safe on its own; safety comes from V8's single-threaded execution model (only one thread holds the isolate lock). - V8 callbacks (
resolveModuleCallback,dynamicImport,importMeta) always run while holding the isolate lock.
Key Characteristics
Fully lazy. No module is compiled until first import. No module is evaluated until first use. Unreferenced modules cost nothing.
URL-based specifiers. All module identity and resolution uses WHATWG URLs. Relative resolution uses standard URL semantics via ada-url.
O(1) lookups. Both forward (specifier to module) and reverse (v8::Module to definition) lookups are hash-indexed. The legacy registry's O(n) reverse scan is eliminated.
Cross-isolate caching. ESM compile cache and Wasm compiled modules are shared across isolate replicas, reducing startup time for subsequent replicas.
Compile cache is transparent. V8 bytecode caching is handled entirely within
EsModule::getDescriptor(). Callers never see compile cache details.Evaluation callbacks are idempotent. Synthetic module
EvaluateCallbackfunctions can be called multiple times from multiple isolates. They create fresh JS objects each time.Import attributes are validated. The
typeimport attribute is supported for both static and dynamic imports. EachModulehas aContentType(NONE,JSON,TEXT,DATA,WASM) set at construction time. Supported type values and their corresponding TC39 proposals:type: 'json'— JSON Modules (TC39 Stage 4, finished) →ContentType::JSON. This is the only type currently enabled.type: 'text'— Import Text (TC39 Stage 3) →ContentType::TEXT. Recognized but rejected with "not yet supported" pending Stage 4.type: 'bytes'— Import Bytes (TC39 Stage 2.7) →ContentType::DATA. Recognized but rejected with "not yet supported". The proposal requiresUint8Arraybacked by an immutableArrayBuffer, which is not yet implemented. Data modules currently expose mutableArrayBuffers. All three types are parsed inparseImportAttributes. Onlyjsonpasses validation invalidateImportType;textandbytesare rejected there with specific error messages. TheContentTypeenum and module tagging are fully plumbed for all three, ready to enable when the proposals are ready. When a type attribute is specified, the resolved module's content type must match or aTypeErroris thrown. Unrecognized attribute keys (anything other thantype) and unsupported type values are rejected withTypeError. Attribute parsing is handled byparseImportAttributes()which reads V8'sFixedArrayof key-value- location triples. Validation is handled byvalidateImportType()which compares the declared type againstModule::contentType(). Both static imports (resolveModuleCallback) and dynamic imports (dynamicImportModuleCallback→dynamicResolve) use these helpers.
require()return value semantics (UNWRAP_DEFAULT). When callers use theUNWRAP_DEFAULTrequire option (used bycreateRequireandCommonJsModuleContext::require), the return value depends on the module type:- User bundle ESM: returns the module namespace (matching Node.js
require(esm)behavior), unless the module exports__cjsUnwrapDefaultas truthy (a convention used by bundlers like esbuild when transpiling CJS to ESM), in which case thedefaultexport is returned. - Builtin ESM (
node:assert,node:buffer, etc.): returns thedefaultexport, because workerd implements builtins as ESM that wrap CJS-style APIs in a default export. - Synthetic modules (CJS, JSON, Text, Data, WASM): returns the
defaultexport, which is where the module's value lives (module.exportsfor CJS, parsed value for JSON, etc.). WithoutUNWRAP_DEFAULT,require()always returns the full module namespace.
- User bundle ESM: returns the module namespace (matching Node.js
Python modules are not yet supported. The new registry currently throws
KJ_FAIL_ASSERTforPythonModulecontent. Python support remains on the legacy registry path.Top-level await handling. Same as legacy: after
Evaluate, microtasks are drained. If the returned Promise is still pending, a "top-level await" error is thrown. Workers must fully initialize synchronously.