Releases: cocoar-dev/Cocoar.JsEval
Release list
v4.1.0
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/JsModuleBuilderinCocoar.JsEval— owns theIServiceProvider-based module activation (constructor-parameter resolution via DI +ActivatorUtilities). Registered scoped byAddJsEval. The single intentional locator boundary in the package.
Changed
JsEngineconstructor —JsEngine(IJsModuleRegistry, IJsModuleBuilder, JsEngineOptions, ILogger<JsEngine>?). No moreIServiceProvider. Only directnew JsEngine(...)callers need to update.IJsModuleRegistryreduced toGetRegisteredModuleDefinitions(). TheBuildModuleInstance/BuildSingleModuleInstancemethods moved toIJsModuleBuilder.TsDefinitionServiceand other registry consumers unaffected.- DI registration in
AddJsEvalswitched from lambda-factory closures to type-based registration (AddScoped<JsEngine>(),TryAddScoped<IJsModuleBuilder, JsModuleBuilder>()). Wolverine 6's strict codegen rejects opaqueImplementationFactoryclosures 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
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 flags —
EnableNewObject(),EnableRequire(),EnableTimers(),EnableConsole(). Symmetrical with the existingEnableFetch()/EnableDebugMode(). EnableNewObjectAssemblyFallback(params Assembly[])— explicit allowlist forNewObject'sFindTypefallback. Additive across calls. Without it,NewObjectis alias-only.WithExecutionTimeout(TimeSpan)+WithMaxStatements(int)— defense-in-depth defaults: 10 s / 5 000 000. PassTimeout.InfiniteTimeSpan/0to disable.TranslationOptions.MaxAstDepth(default 256) onJsExpressionTranslator— depth guard that prevents host-crashingStackOverflowExceptionon deeply nested scripts.TsTranspiler.MaxParseDepth(default 128) — pre-parse paren/bracket/brace scan that rejects deeply nested input with a controlledTsTranspileExceptionbefore 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 viaSetValue) instead of routing through a JSON round-trip.ExecuteAsync-Catch now uses awhenfilter —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; … })().NewObjectAppDomain-wide assembly walk viaCocoar.Reflectensions.TypeHelper.FindType. Resolution is now strictlyTypeAliases ∪ EnableNewObjectAssemblyFallbackassemblies.
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
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'dType.Iscalls. LINQ expands toOrElsechain. TypeScript emits a conditional-type overload so Monaco narrows the union (value is PersonView | CompanyView).AddDiscriminatorMappings<T>(propertyName, ...)onJsEvalBuilder— property-based mappings.("ParticipantType", ("person", typeof(PersonView)), …)generatesp.ParticipantType == "person"in LINQ + Monaco narrowing; plain string values generate property equality only.DiscriminatorMappingconstructors for property-based discrimination:new(baseType, value, propertyName)andnew(baseType, value, concreteType, propertyName).JsEngineauto-registersTypeglobal whenDiscriminatorMappingsare configured.DefinitionBuilder.AddDiscriminatorMappings— emitsdeclare const Typewith typedIs()/IsOneOf<D>()overloads for Monaco IntelliSense.- Namespace-mapping fallback in
Type.Is— lookup order: explicit mapping → type alias → namespace-mapped name.
Changed
DiscriminatorMappingis now aclass(wasrecord). AddsPropertyNameandConcreteType;IsMatchis a pre-compiledFunc<object,string,bool>.TsDefinitionServiceskips property-only mappings (nullConcreteType) when emitting Monaco narrowing overloads.
Removed
DiscriminatorEntry— builder helper struct removed;AddDiscriminatorMappingsoverloads 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.CollectNarrowingsnow recognizesp.Prop == "value"(BinaryExpression{Equal}) alongsideTypeBinaryExpression. - Optional chaining (
?.) in AND-narrowing predicates.Type.Is(p, 'person') && p.Email?.endsWith(...)now resolves subtype-only properties correctly.VisitChainElementnow triesTryResolveViaIntersectionon failed lookups, mirroringVisitMember. Workaround (p.Email && p.Email.endsWith(...)) no longer needed. DefinitionBuildermaps collections to TypeScript array types.List<T>,IEnumerable<T>, etc. are nowArray<T>in the generated.d.tsinstead ofSystem.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
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 viaInvokeFunction. 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
JsEnginewas resolved from the root provider + disposed after each iteration, so every iteration after the first hitObjectDisposedExceptionon async paths. All benchmarks now open a fresh DI scope per iteration. Side effect:EngineBenchmarks.AsyncAwaitandValueBenchmarks.TaskInteropnow produce real numbers instead ofNA; "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 | 9× |
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 intendedTests: 329 green (Engine 148, Linq 134, TypeScript 29, Modules 18).
v3.1.4
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")— emitsTat root scope in.d.tsAND makesNewObject("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.tsfiles viaAddTsDefinitionContributor<T>()on the builder.AddLinq()is the first internal user.JsEvalBuilder.AddRuntimeOnlyExtensionMethods(...)— register extension methods with Jint for runtime resolution without pollutingTsDefinition's emitted.d.ts.- Standalone equivalents on
DefinitionBuilder—AddType(type, alias)andMapNamespace(...)for non-DI consumers.
Changed
linq.d.tsandcocoar-jseval-linq.d.tsare auto-emitted byAddLinq()viaTsDefinitionService.GetTsDefinitions(). Both reflection-generated from C# types —linq.d.tsfrom the newLinqGlobalclass,cocoar-jseval-linq.d.tsfromIJsStringAliases/IJsArrayAliases<T>metadata interfaces. No hand-written files left in the Linq package.linq.*return types now matchTsDefinition's output —linq.guid('…')isSystem.Guid, numeric helpers returnnumber. Previous versions referenced undeclaredGuid/Decimal/Longidentifiers that Monaco resolved toany.- 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.Enumerablemethods, 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>.Enumeratoretc.) keep the v3.1.3 interface-merging behavior.
Removed
LinqTypeScriptDefinitionhelper class (theRead()/WriteTo(path)methods). UseTsDefinitionService.GetTsDefinitions()— it now surfacescocoar-jseval-linq.d.tsandlinq.d.tsautomatically whenAddLinq()is on the builder.
Fixed
GitVersion.yml:is-main-branchwas pointing atmain, but the repo's trunk isdevelop. GitVersion fell through to theotherrule and emitted empty prerelease labels (3.1.3-.1). Now correctly marksdevelop.- Prerelease workflow: feature-branch prereleases use a
0.0.0-<label>.Nbase 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
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.NormalizeTypeNamebaked<T>into the returned string;BuildTypeStringappended it again fromTypeDefinition.GenericArguments. Result:Promise<T><T>— a parse error. The fix brings the handling in line with every other generic type:NormalizeTypeNamereturns the bare wrapper ("Promise"), and the caller owns generic-arg composition once. -
ref Tparameters and return types leaked the .NET ByRef suffix&into the TS output. Properties likeCurrent: T&onSpan<T>.Enumeratortripped TypeScript's intersection operator (which needs a right-hand operand). The existing ByRef check usedType.FullName.EndsWith('&'), butFullNameisnullfor generic type parameters — soref Ton a method return silently slipped through. Now usesType.IsByRef || Type.IsPointerwithGetElementType()to unwrap — the spec-correct path. -
Name-colliding types were declared twice in the same namespace.
TypeDefinition.FromTypehas an internalFriendlyName-based cache; distinctTypeinputs that collapse to the same friendly name return the sameTypeDefinitionreference. The renderer was adding that reference tonamespace.Typeson every encounter —System.d.tsalone 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:
- Stale. A decade behind current TypeScript. Anyone using them as their Monaco source would be missing every post-ES5-core addition.
- 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— assertsPromise<string>is present and><(double-generics marker) is absentRender_RefReturn_DoesNotLeakAmpersand— asserts no&;or&>sequences anywhere in the output (uses aRefReturningSampletype withref intmethods)GetTsDefinitions_DoesNotShipLibFiles— assertslib.es5.d.ts/lib.es2015.core.d.tsare 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
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:
Category—Error/Warning/Suggestion/Message(matches TypeScript's own enum)Code— the TS error number (e.g.1005for"',' expected"), so editors can cross-reference the official catalogueMessage— 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.tsand 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.ParserExceptionfrom a brokenEvaluateExpression, you'll now catchTsTranspileExceptionearlier instead. Recommended: do so at save-time and show the diagnostics to the admin. - No other API change.
Transpile(string) : stringkeeps the same signature — it just throws where it used to silently succeed with broken output. TranspileWithSourceMapis 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
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 === true → x, x === false → !x, x !== true → !x, x !== false → x — 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
=== truestill 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 noIIFleaks 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
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 tonullif the guarded target is null. Works in both SQL translation and in-memoryExpression.Compile(). - Nullish coalescing (
??) in predicates. Maps toExpression.Coalesce. Composes naturally with?.. JsEngineDI 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?.City→u.Address == null ? null : u.Address.City(result type nullable)- Nested chains like
a?.b?.cwrap 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()whereIdis aGuid) — no wastedConditionnode ??usesExpression.Coalescedirectly; left side must be a reference type orNullable<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
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>>JsLinqExtensions—Where/Find/Count/Any/OrderBy/OrderByDescending/ThenBy/ThenByDescendingdirectly onIQueryable<T>- Dual naming — JS and C# LINQ both work. The translator accepts
u.Tags.some(...)andu.Tags.Any(...);str.includes(x)andstr.Contains(x);str.toLowerCase()andstr.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 withLinqTypeScriptDefinition.WriteTo(path)so Monaco IntelliSense knows both name sets linq.*typed literal DSL —linq.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 preservedlinq.today()/linq.now()/linq.utcNow()— zero-arg helpers that capture the current date/time at translation time. Compose withAddDays/AddHoursfor relative predicates:todos.where(t => t.DueDate < linq.today().AddDays(7))- Enum coercion —
u.Status === 'Active'andu.Status === 1both 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 implicitConvert(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 againstDateTimecolumns work transparently- Property Dependency Collector —
ExpressionDependencyCollector.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 packagesJsEngine.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.Linqservices.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 worksDirect 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... |