Skip to content

Releases: cocoar-dev/Cocoar.JsEval

v4.1.0

Choose a tag to compare

@windischb windischb released this 23 May 21:38

Cocoar.JsEval v4.1.0

Wolverine 6 / static-analysis friendly. JsEngine is now constructor-pure and registered type-based — apps on Wolverine 6's strict ServiceLocationPolicy.NotAllowed default can inject JsEngine into handlers without per-consumer AlwaysUseServiceLocationFor<T> allowlist entries. The only service that still needs an explicit allowlist entry is IJsModuleBuilder — a single, intentional locator boundary, instead of a transitive chain through every consumer.

Added

  • IJsModuleBuilder / JsModuleBuilder in Cocoar.JsEval — owns the IServiceProvider-based module activation (constructor-parameter resolution via DI + ActivatorUtilities). Registered scoped by AddJsEval. The single intentional locator boundary in the package.

Changed

  • JsEngine constructorJsEngine(IJsModuleRegistry, IJsModuleBuilder, JsEngineOptions, ILogger<JsEngine>?). No more IServiceProvider. Only direct new JsEngine(...) callers need to update.
  • IJsModuleRegistry reduced to GetRegisteredModuleDefinitions(). The BuildModuleInstance / BuildSingleModuleInstance methods moved to IJsModuleBuilder. TsDefinitionService and other registry consumers unaffected.
  • DI registration in AddJsEval switched from lambda-factory closures to type-based registration (AddScoped<JsEngine>(), TryAddScoped<IJsModuleBuilder, JsModuleBuilder>()). Wolverine 6's strict codegen rejects opaque ImplementationFactory closures regardless of how clean the underlying ctor is — type-based registration lets the static analyzer walk the ctor like any other service.

Migration (only for direct new JsEngine(...) callers)

// Before (4.0):
new JsEngine(serviceProvider, moduleRegistry, options, logger);

// After (4.1):
new JsEngine(moduleRegistry, new JsModuleBuilder(serviceProvider, moduleRegistry), options, logger);

Wolverine 6 allowlist

After bumping, all per-consumer AlwaysUseServiceLocationFor<T>() entries for JsEngine-dependent services can be deleted from Program.cs. The only remaining entry needed is for IJsModuleBuilder — modules can declare arbitrary host-resolved ctor params, so the locator pattern is unavoidable inside that single service:

opts.CodeGeneration.AlwaysUseServiceLocationFor<IJsModuleBuilder>();

Hosts only ever inject JsEngine, never IJsModuleBuilder, so the boundary stays clean.

v4.0.0

Choose a tag to compare

@windischb windischb released this 06 May 15:09

Cocoar.JsEval v4.0.0

Security hardening release — breaking. Unsafe-by-default JS globals are now off by default; opt-in via the corresponding builder flag. Closes the threat-model findings tracked in .local/security-untrusted-script-hardening.md (F1–F6).

Added

  • Opt-in builder flagsEnableNewObject(), EnableRequire(), EnableTimers(), EnableConsole(). Symmetrical with the existing EnableFetch() / EnableDebugMode().
  • EnableNewObjectAssemblyFallback(params Assembly[]) — explicit allowlist for NewObject's FindType fallback. Additive across calls. Without it, NewObject is alias-only.
  • WithExecutionTimeout(TimeSpan) + WithMaxStatements(int) — defense-in-depth defaults: 10 s / 5 000 000. Pass Timeout.InfiniteTimeSpan / 0 to disable.
  • TranslationOptions.MaxAstDepth (default 256) on JsExpressionTranslator — depth guard that prevents host-crashing StackOverflowException on deeply nested scripts.
  • TsTranspiler.MaxParseDepth (default 128) — pre-parse paren/bracket/brace scan that rejects deeply nested input with a controlled TsTranspileException before the embedded TS compiler (running as JS inside Jint, which amplifies stack ~10×) can exhaust the .NET stack.

Changed

  • GetValue<T> preserves reference identity for non-primitive types (IQueryable<T>, custom classes set via SetValue) instead of routing through a JSON round-trip.
  • ExecuteAsync-Catch now uses a when filter — Stop()-driven cancellation stays silent; timeouts (System.TimeoutException) propagate so consumers observe runaway scripts.

Removed

  • exit() JS global — left the engine permanently dead. Use an IIFE for early-return: (() => { if (cond) return early; … })().
  • NewObject AppDomain-wide assembly walk via Cocoar.Reflectensions.TypeHelper.FindType. Resolution is now strictly TypeAliases ∪ EnableNewObjectAssemblyFallback assemblies.

Migration

// Before (3.x — implicit defaults)
services.AddJsEval();

// After (4.0 — explicit opt-in for what your scripts actually need)
services.AddJsEval(b => b
    .EnableNewObject()
    .EnableNewObjectAssemblyFallback(typeof(MyDomainType).Assembly)
    .EnableConsole()
    .EnableTimers()
    .EnableRequire());

For DB-stored scripts that called exit(), rewrite to an IIFE:

// Before:  if (cond) exit();   later code…
// After:   (() => { if (cond) return; later code… })();

v3.3.0

Choose a tag to compare

@windischb windischb released this 27 Apr 14:38

Cocoar.JsEval v3.3.0

Property-based discriminator mappings, Type.IsOneOf, and full Monaco TypeScript narrowing. LINQ predicates now handle the full range of type-guard patterns — including optional chaining.

Added

  • Type.IsOneOf(value, ['a','b',…]) — shorthand for multiple OR'd Type.Is calls. LINQ expands to OrElse chain. TypeScript emits a conditional-type overload so Monaco narrows the union (value is PersonView | CompanyView).
  • AddDiscriminatorMappings<T>(propertyName, ...) on JsEvalBuilder — property-based mappings. ("ParticipantType", ("person", typeof(PersonView)), …) generates p.ParticipantType == "person" in LINQ + Monaco narrowing; plain string values generate property equality only.
  • DiscriminatorMapping constructors for property-based discrimination: new(baseType, value, propertyName) and new(baseType, value, concreteType, propertyName).
  • JsEngine auto-registers Type global when DiscriminatorMappings are configured.
  • DefinitionBuilder.AddDiscriminatorMappings — emits declare const Type with typed Is() / IsOneOf<D>() overloads for Monaco IntelliSense.
  • Namespace-mapping fallback in Type.Is — lookup order: explicit mapping → type alias → namespace-mapped name.

Changed

  • DiscriminatorMapping is now a class (was record). Adds PropertyName and ConcreteType; IsMatch is a pre-compiled Func<object,string,bool>.
  • TsDefinitionService skips property-only mappings (null ConcreteType) when emitting Monaco narrowing overloads.

Removed

  • DiscriminatorEntry — builder helper struct removed; AddDiscriminatorMappings overloads use native C# tuple syntax directly.

Fixed

  • AND-narrowing for combined discriminator mappings. Type.Is(p, 'person') && p.Email.endsWith(...) now resolves subtype-only properties correctly for combined mappings. CollectNarrowings now recognizes p.Prop == "value" (BinaryExpression{Equal}) alongside TypeBinaryExpression.
  • Optional chaining (?.) in AND-narrowing predicates. Type.Is(p, 'person') && p.Email?.endsWith(...) now resolves subtype-only properties correctly. VisitChainElement now tries TryResolveViaIntersection on failed lookups, mirroring VisitMember. Workaround (p.Email && p.Email.endsWith(...)) no longer needed.
  • DefinitionBuilder maps collections to TypeScript array types. List<T>, IEnumerable<T>, etc. are now Array<T> in the generated .d.ts instead of System.Collections.Generic.List$1<T>. .some(), .includes(), .filter() no longer show as Monaco errors. IReadOnlyList<T> / IReadOnlyCollection<T>ReadonlyArray<T>; Dictionary<K,V> / IDictionary<K,V>Record<K,V>.

Example

services.AddJsEval(b => b
    .AddDiscriminatorMappings<Principal>("ParticipantType",
        ("person",  typeof(PersonView)),
        ("company", typeof(CompanyView)))
);
// All of these now work in LINQ predicates:
(p) => Type.Is(p, 'person') && p.Email.endsWith('@example.com')
(p) => Type.Is(p, 'person') && p.Email?.endsWith('@example.com')   // ← fixed in 3.3.0
(p) => Type.IsOneOf(p, ['person', 'company']) && p.DisplayName.startsWith('A')

Tests: 372 green (Engine 165, Linq 160, TypeScript 29, Modules 18).

v3.2.0

Choose a tag to compare

@windischb windischb released this 24 Apr 17:28

ES-module execution now follows standard JS semantics — top-level code runs once per unique script on a given engine, exports are cached. Unlocks a ~115× speed-up for repeated import-based scripts on pooled engines. New PrepareModule API mirrors the existing Prepare for ES modules.

Added

  • JsEngine.PrepareModule(string)JsPreparedModule — pre-parse an ES module script once, reuse across calls. Thread-safe, cacheable globally.
  • JsEngine.ExecuteAsync(JsPreparedModule) — overload accepting a pre-parsed module. Cached by reference identity.

Changed (behavioural)

  • ExecuteAsync(string) / ExecuteAsync(JsPreparedModule) follow standard ES-module semantics. Top-level code runs once per unique script content per engine. Repeated calls return the cached module namespace instead of re-parsing + re-evaluating. Matches Node / browsers / Deno.
  • Migration: scripts relying on top-level re-execution (e.g. export const id = Math.random() yielding a new id per call) must expose per-call work as exported functions invoked via InvokeFunction. For true per-call re-execution, use the lightweight path (Evaluate / EvaluateAsync) which has no module system.
  • Module registry memory leak fixed — the previous __main_0__, __main_1__, … naming accumulated one entry per call in Jint's module dict. Now O(unique scripts) instead of O(calls).

Fixed

  • Benchmark harness — scoped JsEngine was resolved from the root provider + disposed after each iteration, so every iteration after the first hit ObjectDisposedException on async paths. All benchmarks now open a fresh DI scope per iteration. Side effect: EngineBenchmarks.AsyncAwait and ValueBenchmarks.TaskInterop now produce real numbers instead of NA; "Engine creation (cold start)" now measures ~9.8 µs instead of the ~1.5 µs DI cache lookup.

Performance (pooled engine, hot loop)

Scenario Before After Factor
ExecuteAsync(string) — repeated same script 14 µs 123 ns 115×
ExecuteAsync(prepared) — repeated 12 µs 1.3 µs

Fresh-engine paths unchanged (nothing to cache on first use). Full table in PERFORMANCE-COMPARISON.md.

Example

// Prepare once (cacheable, thread-safe)
var preparedModule = JsEngine.PrepareModule("""
    import * as common from 'common';
    export function newGuid() { return common.Guid.New().toString(); }
""");

// Per request (pooled engine via scoped DI)
await engine.ExecuteAsync(preparedModule);   // 35 µs first call, 1.3 µs subsequent
var id = engine.InvokeFunction("newGuid");   // fresh GUID each call — as intended

Tests: 329 green (Engine 148, Linq 134, TypeScript 29, Modules 18).

v3.1.4

Choose a tag to compare

@windischb windischb released this 20 Apr 04:41
d67ae18

Short-name aliases in Monaco + NewObject, plus the Linq .d.ts files are now reflection-generated from C# — no more hand-written declarations drifting from runtime. Pure additive, no breaking API changes.

Added

  • JsEvalBuilder.AddTypeAlias<T>("Name") — emits T at root scope in .d.ts AND makes NewObject("Name") return the type at runtime. Single config, both layers.
  • JsEvalBuilder.MapNamespace(prefix, target) — bulk-map a whole namespace tree. Empty target = full flatten (deeply nested sub-namespaces collapse to root too). System.* stays qualified by default.
  • IJsTsDefinitionContributor — new extension point. Any package can contribute .d.ts files via AddTsDefinitionContributor<T>() on the builder. AddLinq() is the first internal user.
  • JsEvalBuilder.AddRuntimeOnlyExtensionMethods(...) — register extension methods with Jint for runtime resolution without polluting TsDefinition's emitted .d.ts.
  • Standalone equivalents on DefinitionBuilderAddType(type, alias) and MapNamespace(...) for non-DI consumers.

Changed

  • linq.d.ts and cocoar-jseval-linq.d.ts are auto-emitted by AddLinq() via TsDefinitionService.GetTsDefinitions(). Both reflection-generated from C# types — linq.d.ts from the new LinqGlobal class, cocoar-jseval-linq.d.ts from IJsStringAliases / IJsArrayAliases<T> metadata interfaces. No hand-written files left in the Linq package.
  • linq.* return types now match TsDefinition's outputlinq.guid('…') is System.Guid, numeric helpers return number. Previous versions referenced undeclared Guid / Decimal / Long identifiers that Monaco resolved to any.
  • PascalCase string/array aliases work at plain Jint runtime. "abc".Contains('x') and [1,2,3].Any(x => x > 0) now resolve to real BCL / System.Linq.Enumerable methods, not just inside translator-processed predicates.
  • Collision detection throws at render time when two distinct types resolve to the same short name — but only when a user rule is involved. Natural nested-type collisions (Span<T>.Enumerator etc.) keep the v3.1.3 interface-merging behavior.

Removed

  • LinqTypeScriptDefinition helper class (the Read() / WriteTo(path) methods). Use TsDefinitionService.GetTsDefinitions() — it now surfaces cocoar-jseval-linq.d.ts and linq.d.ts automatically when AddLinq() is on the builder.

Fixed

  • GitVersion.yml: is-main-branch was pointing at main, but the repo's trunk is develop. GitVersion fell through to the other rule and emitted empty prerelease labels (3.1.3-.1). Now correctly marks develop.
  • Prerelease workflow: feature-branch prereleases use a 0.0.0-<label>.N base instead of inheriting GitVersion's predicted version, so a branch name can't silently imply a release target. Develop still uses the real version.

Example

services.AddJsEval(js => js
    .AddLinq()
    .MapNamespace("TimeToDo.Infrastructure.Persistence.Marten.Projections", "")
    .MapNamespace("TimeToDo.Domain.Identity", ""));

Monaco now shows CustomerView on hover instead of the 70-character namespace path, NewObject('CustomerView') returns the right type, and scripts that compare c.Id === linq.guid('…') type-check honestly.

Tests: 329 green (Engine 148, Linq 134, TypeScript 29, Modules 18).

v3.1.3

Choose a tag to compare

@windischb windischb released this 19 Apr 09:34
558c35d

Cocoar.JsEval v3.1.3

Cocoar.JsEval.TsDefinition now emits valid TypeScript — and stops shipping a 10-year-old standard library.

This release is a pure fix-up of Cocoar.JsEval.TsDefinition. No API additions, no changes to any other package. Three latent renderer bugs silently produced .d.ts output that the TypeScript compiler refuses — so anyone piping GetTsDefinitions() into Monaco's worker, ts.createProgram, or an IDE language service has been running with partially broken IntelliSense and never knew.

The failure 3.1.2 and earlier users were hitting

A probe of the full default module set's output (ran ts.createSourceFile over every non-lib. file produced by GetTsDefinitions()):

Before 3.1.3:
  AngleSharp.d.ts   10 parse diagnostics
  Cocoar.d.ts       93 parse diagnostics
  Dapper.d.ts       46 parse diagnostics
  SqlKata.d.ts      91 parse diagnostics
  System.d.ts      465 parse diagnostics
  → 5 of 9 files unusable to any real TypeScript consumer

The errors were invisible at the C# side — the renderer returned strings, nobody ever fed them through a TS parser to verify — but any consumer who did got a silently-degraded IntelliSense experience, missing completions for every affected type. This release lands those files clean:

After 3.1.3:
  → 9 of 9 files parse with zero diagnostics

What 3.1.3 does

Three independent renderer bugs, all fixed:

  • Task<T> / ValueTask<T> emitted the generic argument twice. NormalizeTypeName baked <T> into the returned string; BuildTypeString appended it again from TypeDefinition.GenericArguments. Result: Promise<T><T> — a parse error. The fix brings the handling in line with every other generic type: NormalizeTypeName returns the bare wrapper ("Promise"), and the caller owns generic-arg composition once.

  • ref T parameters and return types leaked the .NET ByRef suffix & into the TS output. Properties like Current: T& on Span<T>.Enumerator tripped TypeScript's intersection operator (which needs a right-hand operand). The existing ByRef check used Type.FullName.EndsWith('&'), but FullName is null for generic type parameters — so ref T on a method return silently slipped through. Now uses Type.IsByRef || Type.IsPointer with GetElementType() to unwrap — the spec-correct path.

  • Name-colliding types were declared twice in the same namespace. TypeDefinition.FromType has an internal FriendlyName-based cache; distinct Type inputs that collapse to the same friendly name return the same TypeDefinition reference. The renderer was adding that reference to namespace.Types on every encounter — System.d.ts alone emitted 40+ duplicate declarations (Type, Attribute, AdjustmentRule, RuntimeTypeHandle, …). Dedup'd on add.

Also in 3.1.3: lib.es5.d.ts / lib.es2015.core.d.ts stop shipping

Those were vendored copies of a very old TypeScript standard library (predating TS 4.x — the Microsoft copyright header in the file dates to the original 1.x-era bundle), historically shipped as a convenience for Monaco integrations. Two problems:

  1. Stale. A decade behind current TypeScript. Anyone using them as their Monaco source would be missing every post-ES5-core addition.
  2. Shadowing risk. Monaco's own TypeScript language service loads its own version-matched libs internally. Stacking ours on top could override fresher types — exactly the wrong default.

Neither this package nor any other Cocoar.JsEval.* package reads these files; they were pass-through resources. Dropping them removes ~4,940 lines of dead weight from the binary.

global.d.ts (hand-written, declares JsEval-specific globals — fetch, NewObject, exit, require) is unchanged and still ships.

Compatibility

Breaking for consumers who were reading lib.es5.d.ts / lib.es2015.core.d.ts directly from GetTsDefinitions(). If your code does:

var defs = tsDefService.GetTsDefinitions();
var lib5 = defs["lib.es5.d.ts"];   // ← KeyNotFoundException in 3.1.3

…you have two supported migration paths:

Path A — trust Monaco's own libs (recommended). Monaco ships a version-matched set automatically. Don't inject anything from us.

Path B — embed fresh ES libs from the upcoming Cocoar.JsEval.TypeScript.V8 package. That package exposes EmbeddedResources.LibFiles — a 97-file, TS 6.0.2, ES-only set with no DOM surface. Usable even if you don't use the V8 transpiler itself:

using Cocoar.JsEval.TypeScript.V8.Internal;  // (coming in 3.2.0)

foreach (var (name, content) in EmbeddedResources.LibFiles)
    monacoEditor.LanguageService.AddExtraLib(content, $"ts:filename/{name}");

NormalizeTypeName contract change (direct callers only). For Task<T> / ValueTask<T> the method now returns "Promise" instead of "Promise<T>" — the caller is expected to append the generic args from TypeDefinition.GenericArguments. Non-generic Task / ValueTask still return "Promise<void>" (unchanged; that comes from TypeMappings, not GenericTypeMappings). Consumers who use the bundled TypeScriptRenderer see no change — the final rendered .d.ts output for Task<string> is still Promise<string>, just composed once instead of twice.

Tests

Three new regression tests in JsEval.Tests.Engine/TsDefinitionTests.cs lock in the fixes:

  • Render_TaskOfT_DoesNotProduceDoubleGenerics — asserts Promise<string> is present and >< (double-generics marker) is absent
  • Render_RefReturn_DoesNotLeakAmpersand — asserts no &; or &> sequences anywhere in the output (uses a RefReturningSample type with ref int methods)
  • GetTsDefinitions_DoesNotShipLibFiles — asserts lib.es5.d.ts / lib.es2015.core.d.ts are absent from the output

The three pre-existing Task*_MapsToPromise* tests were updated to reflect the new NormalizeTypeName contract ("Promise" instead of "Promise<string>"); end-to-end rendering is verified via the new regression tests that exercise the full BuildTypeString path.

Full suite: 306 tests green (Engine 140, Linq 119, TypeScript 29, Modules 18 — Fetch excluded as usual since it needs network).

Packages

Package Changed in 3.1.3
Cocoar.JsEval.TsDefinition ✅ three renderer fixes + stop shipping stale lib files
Cocoar.JsEval no change
Cocoar.JsEval.Engine no change
Cocoar.JsEval.Linq no change
Cocoar.JsEval.TypeScript no change
All modules no change

v3.1.2

Choose a tag to compare

@windischb windischb released this 18 Apr 13:51

TypeScript transpiler surfaces errors you can act on, and optional Source Maps.

Two fixes in Cocoar.JsEval.TypeScript that move the save-time story from "hope the JS is valid" to "fail fast with structured, editor-actionable diagnostics." Plus an opt-in Source Map pipeline so a Monaco-backed editor can map a runtime-error position in generated JS back to the original TypeScript line.

The failure 3.1.1 and earlier users are hitting

TsTranspiler.Transpile("const x: number = 42 @@@ broken") returned… "use strict";. No exception. No signal. The broken JS got persisted, and an admin saw the failure later — miles downstream — as a generic Jint.ParserException at EvaluateExpression time, with no line/column anchored to the original TypeScript.

Root cause: ts.transpileModule only reports diagnostics when called with reportDiagnostics: true. The transpiler was calling it without that flag, so every diagnostic (syntax error, missing brace, garbage input) was silently dropped.

What 3.1.2 does

try
{
    var js = transpiler.Transpile(userSource);
    // use js…
}
catch (TsTranspileException ex)
{
    foreach (var d in ex.Errors)
        Console.WriteLine($"TS{d.Code} at line {d.Line}, col {d.Column}: {d.Message}");
    // d.Category / d.Code / d.Message / d.Line / d.Column — strongly typed
}

Every TsDiagnostic carries:

  • CategoryError / Warning / Suggestion / Message (matches TypeScript's own enum)
  • Code — the TS error number (e.g. 1005 for "',' expected"), so editors can cross-reference the official catalogue
  • Message — flattened human-readable message (chains unfolded)
  • Line, Column — 1-based position in the source the compiler saw

Behavioural change for consumers that previously got silent broken output: the exception now fires at save time, where the admin can fix it, instead of query time, where the app is already processing a request.

Source Maps

New opt-in API returns JS, Source Map v3 JSON, and any non-error diagnostics:

var result = transpiler.TranspileWithSourceMap(userSource);
// result.Js         — plain JS, trailing //# sourceMappingURL= stripped
// result.SourceMap  — Source Map v3 JSON string
// result.Warnings   — IReadOnlyList<TsDiagnostic> (only non-errors; errors throw)

The Source Map lets a Monaco-backed editor turn a JS-side runtime-error position into the original TypeScript line and column. The //# sourceMappingURL= comment is stripped from Js so consumers pick their own embedding strategy (inline base64, sidecar file, in-memory cache).

Known limitation (and future path)

ts.transpileModule is single-file and skips semantic checking. Type errors like const x: number = "oops" still slip through — this release only catches syntax-level issues. Catching type errors requires ts.createProgram, which means:

  • Embedding lib.d.ts and the library declaration files
  • Implementing a virtual compiler host (readFile / writeFile / fileExists / …)
  • ~1-5 second cold start, significantly more memory

Out of scope for 3.1.2 — deferred to a possible future Cocoar.JsEval.TypeScript.TypeCheck add-on. Meanwhile: Monaco's language service covers authoring-time type-checking client-side, and this release covers save-time syntax validation server-side — together that's strong coverage for the typical admin-authoring flow.

Compatibility

  • Behavioural break for consumers relying on the silent-failure behaviour. If your code was catching the downstream Jint.ParserException from a broken EvaluateExpression, you'll now catch TsTranspileException earlier instead. Recommended: do so at save-time and show the diagnostics to the admin.
  • No other API change. Transpile(string) : string keeps the same signature — it just throws where it used to silently succeed with broken output.
  • TranspileWithSourceMap is strictly additive.

Tests

Nine new unit tests in JsEval.Tests.TypeScript cover the diagnostic shape, multi-line error positions, the ts.transpileModule type-error limitation (locked in so we notice if a future upgrade changes it), the Source Map JSON shape, and the sourceMappingURL-stripping contract. Full suite green: 305 tests across all packages.

Also in 3.1.2: Guid is no longer masked as string (Cocoar.JsEval.TsDefinition)

The type-mapping table had [typeof(Guid)] = "string". In Monaco that meant t.CustomerId === 'abc-…' and t.CustomerId === linq.guid('abc-…') both type-checked as string === string — admins writing access-policy scripts could bypass the typed literal by accident and never know. The override is gone; Guid now falls through the normal rendering path and resolves to Guid (or System.Guid with namespaces enabled) so Monaco sees it as its own type.

One line gone, one mis-cast with it. Consumers who actually wanted the old mapping can restore it in their own TypeScriptRendererDefaults.TypeMappings.

v3.1.1

Choose a tag to compare

@windischb windischb released this 18 Apr 10:54

Cocoar.JsEval v3.1.1

Optional chaining actually works against Marten / EF / LINQ2DB now.

v3.1.0 shipped optional chaining (?.) but couldn't survive contact with real LINQ providers — every ?. in a Where clause produced nested IIF nodes that Marten, EF Core, and LINQ2DB refuse. Downstream projects rolled back to hand-written != null && chains. This release rewrites the chain lowering so the common shapes emit the form a C# author would hand-write: pure &&-conjunctions, zero IIF nodes, translated natively by every mainstream provider.

The failures v3.1.0 users are hitting

Two real-world scripts from an authorization project's demo-seed.json, both rolled back to pre-?. syntax:

// 1. Person-name-prefix authorization, two-hop optional chain
(p) => p.Type === 'Person' && p.IsActive && (
  p.Person?.Firstname?.startsWith('A') ||
  p.Person?.Firstname?.startsWith('L') ||
  p.Person?.Firstname?.startsWith('P'))

// 2. Customer-scoped access filter, single optional hop in binary comparison
(t) => t.Customer?.Id === linq.guid('...acme...') ||
       t.Customer?.Id === linq.guid('...alpine...') ||
       t.Customer?.Id === linq.guid('...central...')
Marten.Exceptions.BadLinqExpressionException: Whoa pardner, Marten could not parse
'IIF((p.Person == null), null, IIF((p.Person.Firstname == null), null,
 Convert(p.Person.Firstname.StartsWith("A"), Nullable`1)))'
Marten.Exceptions.BadLinqExpressionException: Unsupported nested operator 'NotEqual'
 as an operand in a binary expression

Both scripts run green against Marten in this release. Verified end-to-end in the JsEval.Marten.Sandbox project against PostgreSQL.

Four translator rewrites

The fix needed four interlocking pieces — dropping any one leaves a known-broken shape on the table:

1. VisitChain emits one flat conditional, not N nested

// v3.1.0:  IIF(g1 == null, null, IIF(g2 == null, null, body))
// v3.1.1:  IIF(g1 != null && g2 != null, body, null)

All guards combine into a single positive test. Non-nullable value-type guards (Guid, int) are skipped — they can never be null.

2. NormalizeToBool collapses bool-context chains to &&-chains

At every boolean-context site (lambda body with TResult=bool, operand of !, &&, ||, ternary test), a chain whose body is bool rewrites from IIF(test, body, null) to test && body — no IIF at all.

(u) => u.Address?.City.startsWith('V')
// → u.Address != null && u.Address.City.StartsWith("V")

3. VisitBinary flattens chains against known-non-null constants

For == / === / < / <= / > / >=, when one operand is a chain IIF and the other is a known-non-null constant (literal, linq.* typed literal, or closure pointing at a non-null value), the translator flattens:

t.Customer?.Id === linq.guid('...')
// → t.Customer != null && t.Customer.Id == guid

!= is deliberately excluded — null != X has distinct CLR semantics (returns true) that don't match the &&-flatten shape.

4. Redundant bool-constant comparisons collapse

x === truex, x === false!x, x !== true!x, x !== falsex — whenever the non-constant side is plain bool (not bool?, where lifted semantics matter). Marten specifically can translate StartsWith("x") but not StartsWith("x") == true; this unblocks v3.1.0-syntax scripts that used === true as the nullable-bool workaround.

Verified against all three mainstream providers

Each of the JsEval.*.Sandbox projects now includes an OptionalChainingScenario that exercises the two rollback scripts in three variants each (natural v3.1.1 syntax, v3.1.0 === true style, hand-rolled workaround) against real databases:

Provider DB Natural === true Hand-rolled
Marten Postgres ✓ 3 rows ✓ 3 rows ✓ 3 rows
EF Core SQLite ✓ 3 rows ✓ 3 rows ✓ 3 rows
LINQ2DB SQLite ✓ 3 rows ✓ 3 rows ✓ 3 rows

All three script variants produce functionally identical results on every provider — the natural v3.1.1 form is just auto-generated from cleaner JS source instead of hand-rolling it.

Truth table preserved

Script match no match inner null outer null
p.Person?.Firstname?.startsWith('A')
!p.Person?.Firstname?.startsWith('A') ✓ (JS: !undefined === true)
t.Customer?.Id === guid
u.IsActive === true u.IsActive
u.IsActive === false !u.IsActive

Compatibility

  • Drop-in from 3.1.0. Scripts that used === true still work; the new simplification makes them equivalent to the natural form. Hand-rolled != null && chains still work unchanged.
  • No API surface changed. JsExpressionTranslator.Translate, JsLinqExtensions, JsEngine — all unchanged.
  • Non-chain predicates unaffected. u.IsActive, u.Name.startsWith('A'), u.Tags.some(...) etc. produce byte-identical Expression trees to v3.1.0.
  • 19 new unit tests cover the truth table for single/double-guard chains × (direct / ! / && / || / ternary / ===-constant / === true / === false) × (null / match / non-match). Shape assertions prove no IIF leaks into bool-context and no IIF-inside-binary-comparison.

Sandbox

New OptionalChainingScenario in all three sandbox projects (JsEval.Marten.Sandbox, JsEval.EfCore.Sandbox, JsEval.Linq2Db.Sandbox) reproduces both rollback scripts against real databases. Marten needs Postgres on localhost:5432 (postgres/postgres/postgres); EF Core and LINQ2DB use SQLite in-memory so they're runnable with zero setup.

Packages

Package Changed in 3.1.1
Cocoar.JsEval.Linq Chain flatten + binary-comparison flatten + bool-const collapse + boolean-context &&-rewrite
everything else version bump only, for consistency

Acknowledgement

Surfaced by the same authorization-project integration that drove v3.1.0 — the first real workload to exercise optional chaining against Marten, and by extension the first to hit every single shape that v3.1.0 got wrong. This is the release that makes ?. actually work where people need it.

v3.1.0

Choose a tag to compare

@windischb windischb released this 17 Apr 15:51

Cocoar.JsEval v3.1.0

Optional chaining, nullish coalescing, and a DI lifetime fix.

A focused follow-up to v3.0.0 based on authoring friction surfaced by a real-world ABAC-style authorization project built on Cocoar.JsEval.Linq. The script-syntax reference in that project's docs claimed ?. and ?? were supported — they weren't. Rather than correct the docs, we fixed the library. Both features slot into the translator without touching the hot path, and they make null-safe predicates authorable rather than boilerplate.

Highlights

  • Optional chaining (?.) in predicates. Each ?. step short-circuits the rest of the chain to null if the guarded target is null. Works in both SQL translation and in-memory Expression.Compile().
  • Nullish coalescing (??) in predicates. Maps to Expression.Coalesce. Composes naturally with ?..
  • JsEngine DI lifetime is now scoped (was transient). One engine per DI scope, shared across collaborators — matches the "Jint is not thread-safe" contract.

Null-safe predicates, without the ceremony

Before v3.1, writing a null-safe predicate meant explicit != null && … chains:

// v3.0 — verbose but explicit
(u) => u.Address != null && u.Address.City != null && u.Address.City.startsWith('V')

With v3.1:

// Optional chaining short-circuits the rest of the chain on null
(u) => u.Address?.City.startsWith('V') === true

// Nullish coalescing for fallback values
(u) => (u.Address?.City ?? '') === 'Vienna'

// Both combine with every existing translator feature — linq.*, enums, method maps, closures, etc.
todos.where(t => (t.Customer?.Tier ?? 'none') === user.Tier)

What the translator emits

  • u.Address?.Cityu.Address == null ? null : u.Address.City (result type nullable)
  • Nested chains like a?.b?.c wrap outermost-first: a == null ? null : (a.b == null ? null : a.b.c) — so the outer guard short-circuits past the inner
  • Non-nullable value-type guards are skipped (the target can never be null — e.g. u.Id?.ToString() where Id is a Guid) — no wasted Condition node
  • ?? uses Expression.Coalesce directly; left side must be a reference type or Nullable<T> (CLR contract)

For in-memory evaluation (auto-membership recalc, unit-test shims, small IEnumerable filtering), this replaces a try/catch-around-NRE safety net with actual null-safe navigation.

DI lifetime: Scoped, not Transient

Before v3.1, AddJsEval registered JsEngine as transient. Every GetRequiredService<JsEngine>() produced a fresh engine. Two services depending on JsEngine in the same HTTP request would get different engines — and any globals set via SetValue on one would be invisible to the other.

// v3.1: one engine per scope, shared across collaborators
public class AccessPolicyEngine(JsEngine engine) { /* sets globals, runs scripts */ }
public class AutoMembershipRecalc(JsEngine engine) { /* same engine, sees same globals */ }

Jint engines are not thread-safe — so scoped is the correct lifetime: shared within a request pipeline, isolated across concurrent requests.

Migration from v3.0

If you relied on each resolve producing a fresh engine, either:

  • Wrap the work in its own DI scope: using var scope = sp.CreateScope(); var engine = scope.ServiceProvider.GetRequiredService<JsEngine>();
  • Construct explicitly: new JsEngine(sp, moduleRegistry, options, logger)

Most consumers want the new default — this flips the footgun into the safer shape.

v3.0.0

Choose a tag to compare

@windischb windischb released this 17 Apr 12:06

Cocoar.JsEval v3.0.0

JavaScript → real .NET Expression Trees — any LINQ provider, byte-identical SQL.

::: info From v2.0.0?
v2.0.0 was released and unlisted from NuGet within the same day after first-adopter integration surfaced an API friction point (see Migration from v2.0.0). Install v3.0.0 directly. v3.0.0 includes everything from v2.0.0 plus the API cleanup — no feature regressions.
:::

v3.0.0 introduces Cocoar.JsEval.Linq, which turns JS/TS arrow functions into real Expression<Func<T, TResult>> trees that Marten, EF Core, LINQ2DB (or any other IQueryable<T> provider) translates to native SQL — with the same output as if you had written the predicate in C# yourself.

Headline Feature: JS → LINQ

// Expose any IQueryable<T> to JS:
users.where(u => u.Name.startsWith('A') && u.IsActive)
     .orderBy(u => u.Name)
     .thenByDescending(u => u.Age)
// → SQL: WHERE name LIKE 'A%' AND is_active ORDER BY name ASC, age DESC

// Prefer C# LINQ-style names? Same Expression tree, same SQL:
users.where(u => u.Name.StartsWith('A') && u.IsActive)
     .orderBy(u => u.Name)
     .thenByDescending(u => u.Age)

// Relative dates inline — no host closure needed:
todos.where(t => t.DueDate < linq.today().AddDays(7))

Verified against three providers in sandbox tests — Marten (PostgreSQL/JSONB), EF Core (SQLite), LINQ2DB (SQLite) — the emitted SQL is byte-identical to what the C# compiler produces for the equivalent source lambda.

Highlights

  • Cocoar.JsEval.Linq — JS arrow function → Expression<Func<T, TResult>>
  • JsLinqExtensionsWhere / Find / Count / Any / OrderBy / OrderByDescending / ThenBy / ThenByDescending directly on IQueryable<T>
  • Dual naming — JS and C# LINQ both work. The translator accepts u.Tags.some(...) and u.Tags.Any(...); str.includes(x) and str.Contains(x); str.toLowerCase() and str.ToLower(). Both styles produce byte-identical Expression trees — pick whichever fits your background
  • TypeScript declaration merging via embedded cocoar-jseval-linq.d.ts — grab it with LinqTypeScriptDefinition.WriteTo(path) so Monaco IntelliSense knows both name sets
  • linq.* typed literal DSLlinq.decimal('99.99'), linq.guid('…'), linq.date('2024-01-01'), linq.dateUtc, linq.dateOffset, linq.dateOnly, linq.timeOnly, linq.timeSpan, linq.int, linq.long, linq.double. Translator intercepts at AST level so precision is preserved
  • linq.today() / linq.now() / linq.utcNow() — zero-arg helpers that capture the current date/time at translation time. Compose with AddDays/AddHours for relative predicates: todos.where(t => t.DueDate < linq.today().AddDays(7))
  • Enum coercionu.Status === 'Active' and u.Status === 1 both just work: the translator detects the enum property and converts the literal into a typed enum constant (string matching is case-insensitive). Output is the ORM-neutral native form — no implicit Convert(enum, Int32) wrapper — so both int-stored and string-stored enums translate correctly
  • CsDateTime — fluent date arithmetic in JS: cutoff.AddDays(7).AddHours(3), with implicit operators so comparisons against DateTime columns work transparently
  • Property Dependency CollectorExpressionDependencyCollector.Collect(expr) returns the set of properties a predicate touches. Essential for reactive re-evaluation, cache invalidation, change-triggered queries
  • Pluggable IJsMethodMap — extend the JS-to-.NET method translation with your own mappings
  • ReflectionCache — ~65% faster + 74% fewer allocations on nested-lambda predicates for hot loops (ABAC rule engines, bulk filtering, …)
  • JsEvalBuilder.RegisterEngineConfigurator — generic extension point for add-on packages
  • JsEngine.UnderlyingEngine & JsEngine.EvaluateExpression(string): JsValue — direct access to the Jint engine and a value-returning evaluation path for advanced integrations (e.g. custom LINQ wiring without reflection hacks)

Quick Start

dotnet add package Cocoar.JsEval.Engine
dotnet add package Cocoar.JsEval.Linq
services.AddJsEval(b => b
    .AddLinq()            // registers JsLinqExtensions + linq.* globals
    .EnableCsDateTime()); // optional: fluent DateTime API in JS

// Per request:
var engine = sp.GetRequiredService<IJsEngine>();
engine.SetValue("users", martenSession.Query<User>());

using (JsLinqContext.Scope(engine))
{
    var result = engine.Evaluate(
        "users.where(u => u.Name.startsWith('A') && u.IsActive)");
    var filtered = (IQueryable<User>)result.ToObject()!;
    var list = filtered.ToList();   // executes the translated SQL
}

IntelliSense for JS and C# LINQ aliases

Ship the embedded .d.ts next to your scripts so Monaco / VS Code knows both name sets:

// At startup, export the type-definitions file:
LinqTypeScriptDefinition.WriteTo("scripts/cocoar-jseval-linq.d.ts");
/// <reference path="./cocoar-jseval-linq.d.ts" />

// Now IntelliSense autocompletes BOTH:
u.Name.includes("x")   //  ✓
u.Name.Contains("x")   //  ✓
u.Tags.some(t => ...)  //  ✓
u.Tags.Any(t => ...)   //  ✓

Property Dependency Tracking

For reactive rule engines that must decide "did this change affect the query?":

var jsFn = engine.Evaluate(
    "(u) => u.Name.startsWith('A') && u.IsActive && u.Address.City === 'Vienna'");
var expr = JsExpressionTranslator.Translate<User, bool>(jsFn, engine);

var deps = ExpressionDependencyCollector.Collect(expr);
// deps.TopLevel: { "Name", "IsActive", "Address" }

userStore.OnUpdated += (user, changedProps) =>
{
    if (changedProps.Any(deps.DependsOn))
        RerunGroupMembership(user);   // else: skip
};

⚠️ Breaking Changes

Migration from v2.0.0

If you briefly had v2.0.0 installed, only one thing changed:

IJsEngine interface is gone. Use JsEngine directly.

// Before (v2.0.0)
var engine = sp.GetRequiredService<IJsEngine>();
public MyService(IJsEngine engine) { ... }

// After (v3.0.0)
var engine = sp.GetRequiredService<JsEngine>();
public MyService(JsEngine engine) { ... }

Why: IJsEngine claimed engine-swap abstraction that the library doesn't actually support (the whole codebase depends on Jint's JsValue/ScriptFunction/Acornima AST). Removing the ceremonial interface made room for the proper escape hatches (UnderlyingEngine, EvaluateExpression) that advanced consumers — like custom LINQ translator wiring — actually need.

No other v2.0.0 API changed. All features (AddLinq(), EnableCsDateTime(), linq.*, enum coercion, …) work identically.

Migration from v1.0.0 — Cocoar.JsEval.Expressions removed

The string-DSL expression builder is gone. It's superseded by Cocoar.JsEval.Linq, which produces the same Expression Trees from predicates written in natural JS/TS syntax — no more string paths.

Internal utilities (PropertyPath, ListHolder, EnumExpressionHelper, ExpressionRewriter) migrated into Cocoar.JsEval.Linq/Building/ as implementation details of the translator. If you relied on them directly, open an issue — we can promote specific helpers back to public on request.

Rewrite DSL-style calls as JS/TS predicates:

// Before (v1.0)
var expr = ExpressionHelper.Equal<User, string>("Name", "Alice");
var results = session.Query<User>().Where(expr).ToList();

// After (v3.0) — write the predicate in JS/TS and let the translator handle it:
engine.SetValue("users", session.Query<User>());
using (JsLinqContext.Scope(engine))
{
    var result = engine.EvaluateExpression("users.where(u => u.Name === 'Alice')");
    var filtered = (IQueryable<User>)result.ToObject()!;
    var list = filtered.ToList();
}
// Before: dotted path via string
var expr = ExpressionHelper.StartsWith<User>("Customer.Name", "A");

// After: direct property access
users.where(u => u.Customer.Name.startsWith('A'))
// Before: FilterBuilder
var filter = new FilterBuilder<User>()
    .Where("IsActive", true)
    .WhereIf(ids.Count > 0, b => b.Contains("Id", ids))
    .Build();

// After: plain JS chaining (same translated Expression)
const allowedIds = [linq.guid('...'), linq.guid('...')]; // or via closure
users.where(u => u.IsActive)
     .where(u => allowedIds.includes(u.Id))
// Before: EnumExpressionHelper — explicit storage-strategy choice in C#
var expr = EnumExpressionHelper.EqualsAsString<User, Status>("Status", Status.Active);

// After: direct comparison in JS — translator coerces string/int literals to
// the typed enum constant (case-insensitive for strings). The emitted Expression
// is the ORM-neutral native form, so int-stored and string-stored enums both
// translate correctly without any strategy flag.
users.where(u => u.Status === 'Active')
users.where(u => u.Status === 1)        // numeric ordinal also works

Direct replacements are available for every v1 feature. For edge cases (custom method mappings, unusual providers), use IJsMethodMap or a custom wrapper (see the LINQ guide).


Packages

Package Description
Cocoar.JsEval Core interfaces and helpers
Cocoar.JsEval.Engine JsEngine + fetch() + CsDateTime + DI
Cocoar.JsEval.TypeScript TypeScript 6.0 transpiler
Cocoar.JsEval.TsDefinition .d.ts generation for IntelliSense
Cocoar.JsEval.Linq (new) JS → LINQ: Expression translator, extensions, dependency collector, linq.* typed literals
Cocoar.JsEval.Module.Common Guid, Sleep, Random
Cocoar.JsEval.Module.Http Fluent HTTP client
`Cocoar.JsEva...
Read more