Skip to content

ES Modules

Roger Johansson edited this page Jan 14, 2026 · 1 revision

ES Modules

How the ES Module system works in Asynkron.JsEngine.


Overview

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
Loading

Module Entry

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; }
}

Module Registry

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;
}

Custom Module Loader

// 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 Types

Static Import

import { foo, bar } from './module.js';
import * as utils from './utils.js';
import defaultExport from './default.js';

Resolved at instantiation time, before evaluation.

Dynamic Import

const module = await import('./dynamic.js');

Loads and evaluates at runtime, returns a Promise.

Import Phases

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 Types

Named Exports

export const PI = 3.14;
export function double(x) { return x * 2; }
export { localVar as exportedName };

Default Export

export default function() { ... }
export default class { ... }
export default expression;

Re-exports

export { foo } from './other.js';
export * from './another.js';
export * as namespace from './module.js';

Module Instantiation

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
Loading

Import Binding Resolution

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);
    }
}

Live Bindings

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);
    }
}

Export Resolution

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;
}

Module Evaluation

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
Loading

Evaluation Order

  1. All dependencies are instantiated first
  2. Dependencies are evaluated in depth-first order
  3. Circular dependencies handled via live bindings
  4. Parent module evaluates after all dependencies

Async Modules (Top-Level Await)

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;
}

Async Dependency Handling

private async Task EvaluateModuleDependenciesAsync(ModuleEntry entry)
{
    foreach (var dependency in GetModuleDependencies(entry))
    {
        if (dependency.IsAsync || dependency.HasAsyncDependency)
        {
            await EnsureModuleEvaluatedAsync(dependency);
        }
        else
        {
            EnsureModuleEvaluated(dependency);
        }
    }
}

Module Namespace Object

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;
    }
}

import.meta

Each module has access to import.meta:

console.log(import.meta.url);  // Module's URL/path
private 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;
}

JSON Modules

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;
}

Circular Dependencies

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"]
Loading
// 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

Error Handling

// 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}'"));

See Also

Clone this wiki locally