-
Notifications
You must be signed in to change notification settings - Fork 1
ES Modules
Roger Johansson edited this page Jan 14, 2026
·
1 revision
How the ES Module system works in Asynkron.JsEngine.
flowchart TB
subgraph Loading["Module Loading"]
Source["Source Code"]
Parse["Parse"]
AST["Module AST"]
end
subgraph Registration["Registration"]
Registry["Module Registry"]
Entry["ModuleEntry"]
end
subgraph Instantiation["Instantiation"]
Deps["Resolve Dependencies"]
Bindings["Create Bindings"]
Namespace["Module Namespace"]
end
subgraph Evaluation["Evaluation"]
Body["Execute Body"]
Exports["Populate Exports"]
end
Source --> Parse --> AST
AST --> Registry
Registry --> Entry
Entry --> Deps --> Bindings --> Namespace
Namespace --> Body --> Exports
Each module is tracked as a ModuleEntry:
private sealed class ModuleEntry
{
internal string Path { get; } // Resolved module path
internal ProgramNode Program { get; } // Parsed AST
internal JsEnvironment Environment { get; }// Module scope
internal JsObject Exports { get; } // Live exports object
// State tracking
internal bool Instantiating { get; set; }
internal bool Instantiated { get; set; }
internal bool Evaluating { get; set; }
internal bool Evaluated { get; set; }
// Async module support
internal bool IsAsync { get; set; }
internal bool HasAsyncDependency { get; set; }
internal Task<object?>? EvaluationTask { get; set; }
// Namespace objects
internal ModuleNamespace? Namespace { get; set; }
internal ModuleNamespace? DeferredNamespace { get; set; }
// import.meta support
internal JsObject? ImportMeta { get; set; }
}Modules are cached by resolved path:
private readonly Dictionary<string, ModuleEntry> _moduleRegistry = new(StringComparer.Ordinal);
private ModuleEntry LoadModule(string modulePath, string? referrerPath = null, ...)
{
var resolvedPath = NormalizeModulePath(modulePath, referrerPath);
// Return cached if already loaded
if (_moduleRegistry.TryGetValue(resolvedPath, out var cachedEntry))
{
EnsureModuleInstantiated(cachedEntry, phase);
return cachedEntry;
}
// Load and register new module
var entry = LoadAndRegisterModule(resolvedPath, referrerPath);
EnsureModuleInstantiated(entry, phase);
return entry;
}// Simple: path -> source
engine.SetModuleLoader(path =>
{
return File.ReadAllText(Path.Combine(baseDir, path));
});
// Advanced: with referrer context
engine.SetModuleLoader((path, referrer) =>
{
var resolved = ResolveRelative(path, referrer);
return File.ReadAllText(resolved);
});import { foo, bar } from './module.js';
import * as utils from './utils.js';
import defaultExport from './default.js';Resolved at instantiation time, before evaluation.
const module = await import('./dynamic.js');Loads and evaluates at runtime, returns a Promise.
public enum ImportPhase
{
Module, // Standard import - full instantiation and evaluation
Defer, // Deferred import - create namespace but don't evaluate
Source // Source phase - for import source proposal
}export const PI = 3.14;
export function double(x) { return x * 2; }
export { localVar as exportedName };export default function() { ... }
export default class { ... }
export default expression;export { foo } from './other.js';
export * from './another.js';
export * as namespace from './module.js';flowchart TB
Start["EnsureModuleInstantiated"] --> Check{Instantiated?}
Check -->|Yes| Done((Done))
Check -->|No| Mark["Mark Instantiating"]
Mark --> Deps["Load Dependencies"]
Deps --> Bindings["Create Import Bindings"]
Bindings --> Exports["Initialize Export Names"]
Exports --> Complete["Mark Instantiated"]
Complete --> Done
private void CreateImportBinding(
JsEnvironment moduleEnv,
ModuleEntry importedModule,
ImportPhase phase,
Symbol localName,
Symbol? importedName)
{
if (importedName is null)
{
// import * as ns from './mod.js'
var ns = GetModuleNamespace(importedModule, phase);
moduleEnv.DefineJsValue(localName, (JsValue)ns);
}
else
{
// import { x } from './mod.js'
// Create live binding to exported variable
var binding = new LiveImportBinding(importedModule, importedName);
moduleEnv.DefineSpecialBinding(localName, binding);
}
}Export bindings are live - they reflect the current value in the exporting module:
internal sealed class LiveImportBinding : ISpecialBinding
{
private readonly ModuleEntry _sourceModule;
private readonly Symbol _exportName;
public JsValue GetJsValue()
{
// Resolve export each time - reflects current value
var resolution = ResolveExport(_sourceModule, _exportName);
return resolution.Module.Environment.GetBindingValue(resolution.BindingName);
}
}private ExportResolution ResolveExport(ModuleEntry module, Symbol exportName, ...)
{
// Check direct exports
foreach (var statement in module.Program.Body)
{
switch (statement)
{
case ExportDeclarationStatement { Declaration: VariableDeclaration decl }:
// export const x = ...
if (DeclaresName(decl, exportName))
return new ExportResolution(module, exportName);
break;
case ExportFromStatement { FromModule: var from, Specifiers: var specs }:
// export { x } from './other.js'
var sourceModule = LoadModule(from, module.Path);
return ResolveExport(sourceModule, exportName);
case ExportAllStatement { ModulePath: var path }:
// export * from './other.js'
var starModule = LoadModule(path, module.Path);
var starResolution = ResolveExport(starModule, exportName);
if (starResolution.IsResolved)
return starResolution;
break;
}
}
return ExportResolution.NotFound;
}sequenceDiagram
participant Main as Main Module
participant DepA as Dependency A
participant DepB as Dependency B
Main->>DepA: Load & Instantiate
Main->>DepB: Load & Instantiate
DepA->>DepA: Evaluate Body
DepB->>DepB: Evaluate Body
Main->>Main: Evaluate Body
Main-->>Main: Return exports
- All dependencies are instantiated first
- Dependencies are evaluated in depth-first order
- Circular dependencies handled via live bindings
- Parent module evaluates after all dependencies
Modules can use await at the top level:
// async-module.js
const data = await fetchData();
export const processedData = process(data);private async Task<object?> EvaluateModuleBodyWithTopLevelAwait(ModuleEntry entry)
{
var runner = new AsyncModuleBodyRunner(this, entry);
entry.AsyncBodyRunner = runner;
// Start execution
var result = await runner.RunAsync();
entry.Evaluated = true;
return result;
}private async Task EvaluateModuleDependenciesAsync(ModuleEntry entry)
{
foreach (var dependency in GetModuleDependencies(entry))
{
if (dependency.IsAsync || dependency.HasAsyncDependency)
{
await EnsureModuleEvaluatedAsync(dependency);
}
else
{
EnsureModuleEvaluated(dependency);
}
}
}Provides access to all exports of a module:
internal sealed class ModuleNamespace : IJsPropertyAccessor
{
private readonly ModuleEntry _module;
private readonly Dictionary<Symbol, Lazy<JsValue>> _exports;
public bool TryGetProperty(string name, out JsValue value)
{
var symbol = Symbol.Intern(name);
if (_exports.TryGetValue(symbol, out var lazy))
{
value = lazy.Value; // Resolve live binding
return true;
}
value = JsValue.Undefined;
return false;
}
}Each module has access to import.meta:
console.log(import.meta.url); // Module's URL/pathprivate void EnsureModuleImportMeta(ModuleEntry entry)
{
if (entry.ImportMeta is not null) return;
var meta = new JsObject();
meta.DefineProperty("url", new PropertyDescriptor
{
Value = (JsValue)entry.Path,
Writable = false,
Enumerable = true,
Configurable = false
});
entry.ImportMeta = meta;
}Import JSON files as modules:
import data from './config.json' with { type: 'json' };private ModuleEntry CreateJsonModule(string source, string resolvedPath)
{
var parsed = JsonParse(source);
var exports = new JsObject();
exports.DefineProperty("default", new PropertyDescriptor { Value = parsed });
var entry = CreateModuleEntry(emptyProgram, moduleEnv, exports, resolvedPath);
entry.Evaluated = true; // JSON modules are immediately "evaluated"
return entry;
}Handled via live bindings and evaluation order:
flowchart LR
A["Module A"] -->|"imports"| B["Module B"]
B -->|"imports"| A
Note["Live bindings allow<br/>circular references"]
// a.js
import { b } from './b.js';
export const a = 'A';
console.log(b); // Works - b is resolved when accessed
// b.js
import { a } from './a.js';
export const b = 'B';
console.log(a); // Works - a is resolved when accessed// Module not found
if (_moduleLoader is null && !File.Exists(resolvedPath))
throw new ThrowSignal(CreateTypeError($"Cannot find module '{modulePath}'"));
// Circular instantiation
if (entry.Instantiating)
throw new ThrowSignal(CreateTypeError($"Circular dependency during instantiation"));
// Export not found
if (resolution.Kind == ExportResolutionKind.NotFound)
throw new ThrowSignal(CreateSyntaxError($"'{exportName}' is not exported from module"));
// Ambiguous export (multiple star exports provide same name)
if (resolution.Kind == ExportResolutionKind.Ambiguous)
throw new ThrowSignal(CreateSyntaxError($"Ambiguous export '{exportName}'"));- Host Integration API - SetModuleLoader
- Promise & Microtasks - Async module evaluation
- Scope Analysis - Module scope handling
- JsEnvironment & Slots - Module environment