Recursive AI

Run JavaScript ES modules in C#

OneEcma is a free, open-source JavaScript interpreter for .NET from Recursive AI. MIT or Apache-2.0, no native dependencies.

ES modules share JavaScript values with import and export. This example registers their source in memory; it does not load files or URLs.

Terminal
dotnet add package RecursiveAI.OneEcma

Requires .NET 10 (net10.0); .NET 9 and earlier aren’t supported.

C#
using RecursiveAI.OneEcma;
 
var host = new InMemoryModuleHost()
    .Add("lib/util.js", "export const twice = x => x * 2;")
    .Add("main.js", "import { twice } from './lib/util.js'; export const answer = twice(21);");
var engine = new ScriptEngine(new ScriptEngineOptions { Host = host });
var exports = engine.EvaluateModule("main.js");
Console.WriteLine(engine.GetProperty(exports.AsObject!, "answer").AsNumber);
Text
42

Both module sources are inline: lib/util.js exports twice; main.js imports it and exports answer. An unresolved import fails rather than fetching a file or URL automatically.

Unzip the example, then dotnet run.

Variations

To invoke an exported function from C#, replace the module setup and evaluation with:

C#
var host = new InMemoryModuleHost()
    .Add("lib/util.js", "export const twice = x => x * 2;")
    .Add("main.js", "export { twice } from './lib/util.js';");
var engine = new ScriptEngine(new ScriptEngineOptions { Host = host });
var exports = engine.EvaluateModule("main.js");
var twice = engine.GetProperty(exports.AsObject!, "twice");
Console.WriteLine(engine.Call(twice, default,
    [JsValue.FromNumber(9)]).AsNumber);
Text
18

Read your own JavaScript files. Save the two source strings from the snippet at the top as lib/util.js and main.js under your working directory. Register their contents explicitly:

C#
using RecursiveAI.OneEcma;
 
var host = new InMemoryModuleHost()
    .Add("lib/util.js", File.ReadAllText("lib/util.js"))
    .Add("main.js", File.ReadAllText("main.js"));
var engine = new ScriptEngine(new ScriptEngineOptions { Host = host });
var exports = engine.EvaluateModule("main.js");
Console.WriteLine(engine.GetProperty(exports.AsObject!, "answer").AsNumber);
Text
42

Only C# reads these two files; relative JavaScript imports still resolve through the registered module IDs, not arbitrary filesystem paths. The host resolution contract describes ResolveModule for a custom loader.

The short answer

Register source strings in InMemoryModuleHost, pass that host through ScriptEngineOptions, and call EvaluateModule with the entry module’s ID. The returned JsValue is the module namespace. Read an exported value with GetProperty(namespace.AsObject!, name) or retrieve an exported function and invoke it with Call.

What are ES modules in JavaScript?

ES modules separate source into units with explicit imports and exports. In this example, one module owns a reusable calculation while a second imports it and publishes the result. The names lib/util.js and main.js are identifiers in the host’s dictionary, not files created by the example.

The module source is ordinary JavaScript: export const twice = x => x * 2; exports a function, and import { twice } from './lib/util.js' imports that named function. Calling it with 21 produces the exported answer value, 42. C# does not need to rewrite the import into a global variable or concatenate the modules into a single script.

The JavaScript module guide describes the language’s import and export syntax. The host controls how source is found; language syntax by itself does not supply a browser or server runtime inside your .NET application.

How do I run a JavaScript module from C#?

Use EvaluateModule, not the plain script evaluation entry point, for source containing module imports and exports. Supply a host whose ResolveModule method can return a module ID and its source. InMemoryModuleHost provides that implementation for strings registered with Add.

Register every dependency before evaluating the entry module. The entry is resolved through the host too, so it needs its own registration. A relative import beginning with ./ or ../ is resolved against the importing module’s slash-separated ID. In the example, main.js plus ./lib/util.js resolves to lib/util.js. IDs use ordinal string comparison, so keep their spelling and case consistent.

The OneECMA host API documents this boundary. For an application-defined loader, implement the host resolution contract. The recipe does not grant file or network access, search installed packages, or infer a package installation step from an import name.

How do I import a JavaScript function?

Import it in JavaScript when another module needs to use it. Export or re-export it from your entry module when C# needs to call it. The variation re-exports twice and then retrieves that function from the namespace object. engine.Call receives the function, a receiver value and a list of JavaScript argument values.

JsValue.FromNumber(9) supplies the numeric argument; the returned value’s AsNumber reads the result, 18. This keeps the boundary explicit rather than assuming a JavaScript function is a C# delegate. If you need a JavaScript string or object instead, use the corresponding value representation and inspect the appropriate result type.

Keep module loading separate from application permissions. Registering trusted source strings is enough for this small calculation. If you later replace the in-memory host with a loader, decide which IDs it accepts and where it may read before exposing it to scripts. Relative import syntax is a resolution rule, not a permission to read arbitrary paths.

All oneecma recipes