Recursive AI

Expose .NET objects with [JsExport]

All oneecma recipes

Mark C# classes, records and enums with [JsExport] and OneEcma’s source generator writes the bindings at build time: methods, properties, async methods and callbacks work in script, with no reflection at run time, and a wrong argument names the .NET member, the path and both types.

Terminal
dotnet add package RecursiveAI.OneEcma --version 1.0.0
dotnet add package RecursiveAI.OneEcma.Generators --version 1.0.0

RecursiveAI.OneEcma.Generators is an analyzer: it runs in the compiler and adds nothing to your output or dependencies. dotnet add package references it with PrivateAssets="all" already.

Shop.cs:

C#
using RecursiveAI.OneEcma;
 
namespace Shop;
 
[JsExport]
public enum OrderStatus { Pending, Paid, Shipped }
 
[JsExport]
public record LineItem(string Sku, int Quantity, decimal Price);      // copied by value
 
[JsExport(RejectUnknownProperties = true)]
public record Order(int Id, List<LineItem> LineItems, OrderStatus Status = OrderStatus.Pending);
 
[JsExport]
public class OrderApi                                                  // shared by reference
{
    private readonly Dictionary<int, Order> _orders = new()
    {
        [1] = new Order(1, [new LineItem("pen", 3, 1.5m), new LineItem("ink", 1, 7.25m)]),
    };
 
    public decimal TaxRate { get; set; }
 
    public Order GetOrder(int id) =>
        _orders.TryGetValue(id, out Order? o) ? o : throw new KeyNotFoundException($"No order with id {id}");
 
    public Order AddOrder(Order order) => _orders[order.Id] = order;
 
    public decimal Total(Order order) => order.LineItems.Sum(i => i.Quantity * i.Price) * (1 + TaxRate);
 
    public async Task<Order> FetchOrderAsync(int id)
    {
        await Task.Delay(5);
        return GetOrder(id);
    }
 
    public void ForEach(Action<Order> visit)
    {
        foreach (Order o in _orders.Values) visit(o);
    }
 
    [JsIgnore]
    public void DeleteAll() => _orders.Clear();                        // not visible to script
}

Program.cs, in a project whose RootNamespace is Shop:

C#
using RecursiveAI.OneEcma;
using RecursiveAI.OneEcma.Diagnostics;
using Shop;
 
var api = new OrderApi();
var engine = new ScriptEngine(new ScriptEngineOptions
{
    Configure = e =>
    {
        JsExports.InstallAll(e.MainRealm);                             // generated: every [JsExport] type
        e.MainRealm.DefineData(e.Global, "api", e.MainRealm.Wrap(api));
    },
});
 
JsValue result = await engine.AwaitAsync(engine.Evaluate("""
    (async () => {
      api.taxRate = 0.1;
      api.addOrder({ id: 2, lineItems: [{ sku: "book", quantity: 2, price: 12 }], status: "Paid" });
      const order = await api.fetchOrderAsync(1);
      const lines = [];
      api.forEach(o => lines.push(`#${o.id} ${o.status}: ${api.total(o)}`));
      return [order.lineItems.length + " lines on #1", ...lines, typeof api.deleteAll, OrderStatus.Shipped].join("\n");
    })()
    """, "orders.js"));
Console.WriteLine(engine.ToDisplayString(result));
Console.WriteLine($"C# sees tax rate {api.TaxRate}");
 
string[] mistakes =
[
    "api.addOrder({ id: 3, lineItems: [{ sku: 'pen', quantity: 1, price: '7.25' }] });",
    "api.addOrder({ id: 4, lineItems: [], staus: 'Paid' });",
    "api.getOrder(99);",
];
foreach (string source in mistakes)
{
    try
    {
        engine.Evaluate(source, "case.js");
    }
    catch (JsException e)
    {
        Console.WriteLine(DiagnosticRenderer.Render(e.Report!));
    }
}
Text
2 lines on #1
#1 Pending: 12.925
#2 Paid: 26.4
undefined
Shipped
C# sees tax rate 0.1
error[TypeError]: Shop.OrderApi.AddOrder: argument 'order.lineItems[0].price' must be a finite number (System.Decimal), got string "7.25"
 --> case.js:1:1
  |
1 | api.addOrder({ id: 3, lineItems: [{ sku: 'pen', quantity: 1, price: '7.25' }] });
  | ^^^^^^^^^^^^ `order.lineItems[0].price` is string "7.25", expected a finite number (System.Decimal)
  |
  = help: convert the string first: Number("7.25")
  = note: host function: Shop.OrderApi.AddOrder(Order order)
  = stack: Shop.OrderApi.AddOrder [.NET] ← <top-level> (case.js:1)
 
error[TypeError]: Shop.OrderApi.AddOrder: argument 'order' has no property 'staus'
 --> case.js:1:1
  |
1 | api.addOrder({ id: 4, lineItems: [], staus: 'Paid' });
  | ^^^^^^^^^^^^ unknown property 'staus' in `order`
  |
  = help: did you mean 'status'?
  = help: it accepts: id, lineItems, status
  = note: host function: Shop.OrderApi.AddOrder(Order order)
  = stack: Shop.OrderApi.AddOrder [.NET] ← <top-level> (case.js:1)
 
error[Error]: KeyNotFoundException: No order with id 99
 --> case.js:1:1
  |
1 | api.getOrder(99);
  | ^^^^^^^^^^^^
  |
  = note: thrown by .NET code: System.Collections.Generic.KeyNotFoundException in Shop.OrderApi.GetOrder
  = stack: Shop.OrderApi.GetOrder (Shop.cs:25) ← <top-level> (case.js:1)

What crosses, and how

  • Classes are shared by reference. Script holds a handle to the .NET object, so api.taxRate = 0.1 in script is api.TaxRate in C#. realm.Wrap(obj) returns the same script object each time.
  • Records and structs are copied by value. A plain script object becomes a new Order; an Order returned to script becomes a plain object. RejectUnknownProperties = true turns a misspelled property into an error with a suggestion, as with staus above.
  • Names become camelCase (GetOrder → getOrder); types keep their names. Enums cross as member names, case-insensitive on the way in.
  • Async and callbacks. A Task<T> return is a promise, and an Action<T> or Func<…> parameter accepts a script function. IEnumerable<T> becomes an iterator.
  • Conversions are strict. The string "7.25" isn’t accepted where a decimal is expected; the report says so and suggests Number("7.25").
  • Only exported members exist. [JsIgnore] hides a member, and Equals, GetHashCode and GetType are never exported. Nothing else of .NET is reachable from script.

What the generator writes

Build with <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles> to see the files under obj/…/generated/RecursiveAI.OneEcma.Generators/. For the project above it writes one binding per type and JsExports.g.cs:

C#
// <auto-generated/>
namespace Shop
{
    /// <summary>Installs every <c>[JsExport]</c> type of this assembly into a realm.</summary>
    public static class JsExports
    {
        public static void InstallAll(JsRealm realm)
        {
            realm.Install(global::Shop.OrderApiJsBinding.JsType);
            realm.Install(global::Shop.OrderStatusJsBinding.JsType);
        }
 
        /// <summary>The script names of the exported types, in install order.</summary>
        public static readonly string[] Names = ["OrderApi", "OrderStatus"];
    }
}

Each binding is plain C# over the public host API. This is the start of getOrder in Shop.OrderApiJsBinding.g.cs:

C#
private static JsValue M0(JsRealm realm, JsValue thisValue, global::System.ReadOnlySpan<JsValue> args)
{
    global::Shop.OrderApi self = JsMarshal.This<global::Shop.OrderApi>(thisValue, "Shop.OrderApi.GetOrder");
    var c = new JsConv(realm, "Shop.OrderApi.GetOrder", "Shop.OrderApi.GetOrder(int id)");
 
    c.Arg("id");
    if (args.Length <= 0)
    {
        c.Missing("an integer (System.Int32)");
        throw c.ToException();
    }
    // …

Because the marshalling is written at build time, it works under NativeAOT and trimming.

Mistakes caught at build time

A type the generator can’t bind is a compile error with the fix in the message. Two overloads that accept the same kinds of script values, for example:

C#
[JsExport]
public class Pricing
{
    public decimal Quote(int quantity) => quantity * 1.5m;
    public decimal Quote(long quantity) => quantity * 1.5m;
}
Text
Bad.cs(9,20): error OE1004: Overloads 'Shop.Pricing.Quote(int quantity)' and 'Shop.Pricing.Quote(long quantity)' of 'Shop.Pricing.Quote' accept the same kinds of script values for 1 argument(s), so a call cannot pick one. Fix: give one a different script name with [JsExport(Name = "...")], make their parameter kinds differ, or add [JsIgnore] to one.

A type with errors gets no binding and is left out of InstallAll; the other types still generate.

Good to know

  • Put InstallAll and Wrap in Configure, as above. A failed run is replayed to explain it; without the setup there the replay can’t reproduce the run, and the report gains = note: explanation unavailable: … in place of what the replay would add.
  • JsExports is generated in the project’s root namespace.
  • Every supported type, the full list of boundary messages and the eight generator diagnostics are in [JsExport] bindings. A larger worked host is samples/OrderHost.