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.
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!)); }
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;DefineDataputs it on the global object.JsAttributes.Methodmakes 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 anErrorwhose message is<Type>: <message>, with the full type name in a non-enumerabledotnetTypeproperty. - Exceptions reaching C#. A script error is a
JsExceptionwith the thrownValueand theReport. When it started as a .NET exception,InnerExceptionis 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.FromTaskturns aTask<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 withAwaitAsync, or pump the engine withRunUntilIdle. - Reading values.
JsValueis a 16-byte struct: test it withIsString,IsNumber,IsObjectand read it withAsString,AsNumber,AsObject.realm.Get,Set,OwnKeysandCallrun 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.