Recursive AI

Call C# from JavaScript

All oneecma recipes

Give scripts C# functions with OneEcma’s host API: plain functions, .NET exceptions that scripts can catch, tasks that become promises, and script values read back in C#. No generator needed.

C#
using RecursiveAI.OneEcma;
using RecursiveAI.OneEcma.Diagnostics;
 
var stock = new Dictionary<string, int> { ["pen"] = 12, ["ink"] = 0 };
 
var engine = new ScriptEngine(new ScriptEngineOptions
{
    // Host setup goes in Configure: a failed run is replayed to explain it, and the replay rebuilds this world.
    Configure = e =>
    {
        JsRealm realm = e.MainRealm;
 
        // A plain function: (this, args) -> value.
        realm.DefineData(realm.Global, "stockOf", JsValue.FromObject(realm.NewFunction("stockOf", 1, (_, args) =>
        {
            if (args.Length == 0 || !args[0].IsString)
                throw new JsException(realm.NewError("TypeError", "stockOf: expected a SKU string, got " + realm.Describe(args.Length > 0 ? args[0] : JsValue.Undefined)));
            return stock.TryGetValue(args[0].AsString, out int n) ? JsValue.FromNumber(n) : JsValue.Null;
        })), JsAttributes.Method);
 
        // A .NET exception thrown inside a host function reaches script as a catchable Error.
        realm.DefineData(realm.Global, "reserve", JsValue.FromObject(realm.NewFunction("reserve", 2, (_, args) =>
        {
            string sku = args[0].AsString;
            int qty = (int)args[1].AsNumber;
            if (stock[sku] < qty) throw new InvalidOperationException($"only {stock[sku]} {sku} left");
            stock[sku] -= qty;
            return JsValue.FromNumber(stock[sku]);
        })), JsAttributes.Method);
 
        // A Task becomes a Promise; script awaits it.
        realm.DefineData(realm.Global, "priceOf", JsValue.FromObject(realm.NewFunction("priceOf", 1, (_, args) =>
        {
            string sku = args[0].AsString;
            return JsMarshal.FromTask(realm, "priceOf", async () =>
            {
                await Task.Delay(10);                                    // a database or HTTP call
                return sku == "pen" ? 1.5 : 4.25;
            }, (_, price) => JsValue.FromNumber(price));
        })), JsAttributes.Method);
    },
});
 
Console.WriteLine(engine.ToDisplayString(engine.Evaluate("stockOf('pen') + ' pens, ' + stockOf('nib') + ' nibs'", "stock.js")));
 
Console.WriteLine(engine.ToDisplayString(engine.Evaluate("""
    try { reserve('ink', 2); } catch (e) { `${e.name}: ${e.message} (${e.dotnetType})` }
    """, "reserve.js")));
 
JsValue promise = engine.Evaluate("""
    (async () => {
      const price = await priceOf('pen');
      reserve('pen', 4);
      return `4 pens: $${price * 4}, ${stockOf('pen')} left`;
    })()
    """, "order.js");
Console.WriteLine(engine.ToDisplayString(await engine.AwaitAsync(promise)));
 
// Read script values back in C#.
JsValue cart = engine.Evaluate("({ sku: 'pen', qty: 3, tags: ['office'] })", "cart.js");
JsObject obj = cart.AsObject!;
Console.WriteLine($"{engine.MainRealm.Get(obj, "sku").AsString} x {engine.MainRealm.Get(obj, "qty").AsNumber}, {engine.MainRealm.Describe(cart)}");
 
// A .NET exception nobody catches arrives in C# as a JsException with the original inside.
try
{
    engine.Evaluate("reserve('ink', 1);", "uncaught.js");
}
catch (JsException e)
{
    Console.WriteLine($"InnerException: {e.InnerException?.GetType().Name}: {e.InnerException?.Message}");
    Console.WriteLine(DiagnosticRenderer.Render(e.Report!));
}
Text
12 pens, null nibs
Error: InvalidOperationException: only 0 ink left (System.InvalidOperationException)
4 pens: $6, 8 left
pen x 3, an object with keys sku, qty, tags
InnerException: InvalidOperationException: only 0 ink left
error[Error]: InvalidOperationException: only 0 ink left
 --> uncaught.js:1:1
  |
1 | reserve('ink', 1);
  | ^^^^^^^
  |
  = note: thrown by .NET code: System.InvalidOperationException in Program.<lambda>
  = stack: Program.<lambda> (Program.cs:26) ← <top-level> (uncaught.js:1)

How it works

  • Functions. realm.NewFunction(name, length, fn) makes a function; DefineData puts it on the global object. JsAttributes.Method makes it writable and configurable but not enumerable, like a built-in. engine.DefineGlobalFunction(name, length, fn) is a shortcut that the record log also remembers.
  • Throwing to script. throw new JsException(realm.NewError("TypeError", "…")) throws a real script error. Any other .NET exception becomes an Error whose message is <Type>: <message>, with the full type name in a non-enumerable dotnetType property.
  • Exceptions reaching C#. A script error is a JsException with the thrown Value and the Report. When it started as a .NET exception, InnerException is the original, and the report’s stack interleaves .NET frames (Program.<lambda> (Program.cs:26)) with script frames. Under NativeAOT, .NET frames show method names only.
  • Tasks. JsMarshal.FromTask turns a Task<T> into a promise. While your async method runs, its continuations are routed back to the engine’s thread, so it may call into script safely. Wait with AwaitAsync, or pump the engine with RunUntilIdle.
  • Reading values. JsValue is a 16-byte struct: test it with IsString, IsNumber, IsObject and read it with AsString, AsNumber, AsObject. realm.Get, Set, OwnKeys and Call run the language’s own operations, so getters and proxy traps run.

Good to know

  • An engine isn’t thread-safe. Host callbacks run on the engine’s thread.
  • A script that calls a host function spends fuel for the call, so a loop over a host call is still bounded by the step limit. The time spent inside your .NET code is yours to bound.
  • To expose whole classes, records and enums without writing each function, use the generator: Expose .NET objects with [JsExport].
  • The full reference is the host API.