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.
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:
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:
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!)); } }
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.1in script isapi.TaxRatein 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; anOrderreturned to script becomes a plain object.RejectUnknownProperties = trueturns a misspelled property into an error with a suggestion, as withstausabove. - 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 anAction<T>orFunc<…>parameter accepts a script function.IEnumerable<T>becomes an iterator. - Conversions are strict. The string
"7.25"isn’t accepted where adecimalis expected; the report says so and suggestsNumber("7.25"). - Only exported members exist.
[JsIgnore]hides a member, andEquals,GetHashCodeandGetTypeare 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:
// <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:
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:
[JsExport] public class Pricing { public decimal Quote(int quantity) => quantity * 1.5m; public decimal Quote(long quantity) => quantity * 1.5m; }
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
InstallAllandWrapinConfigure, 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. JsExportsis 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.