< Summary

Information
Class: Elsa.Expressions.JavaScript.Services.JintJavaScriptEvaluator
Assembly: Elsa.Expressions.JavaScript
File(s): /home/runner/work/elsa-core/elsa-core/src/modules/Elsa.Expressions.JavaScript/Services/JintJavaScriptEvaluator.cs
Line coverage
97%
Covered lines: 86
Uncovered lines: 2
Coverable lines: 88
Total lines: 201
Line coverage: 97.7%
Branch coverage
95%
Covered branches: 23
Total branches: 24
Branch coverage: 95.8%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

/home/runner/work/elsa-core/elsa-core/src/modules/Elsa.Expressions.JavaScript/Services/JintJavaScriptEvaluator.cs

#LineLine coverage
 1using System.Diagnostics.CodeAnalysis;
 2using System.Text.Json;
 3using Acornima.Ast;
 4using Elsa.Expressions.Helpers;
 5using Elsa.Expressions.Models;
 6using Elsa.Expressions.JavaScript.Contracts;
 7using Elsa.Expressions.JavaScript.Helpers;
 8using Elsa.Expressions.JavaScript.Notifications;
 9using Elsa.Expressions.JavaScript.ObjectConverters;
 10using Elsa.Expressions.JavaScript.Options;
 11using Elsa.Mediator.Contracts;
 12using Jint;
 13using Jint.Runtime.Interop;
 14using Microsoft.Extensions.Caching.Memory;
 15using Microsoft.Extensions.Configuration;
 16using Microsoft.Extensions.Options;
 17
 18// ReSharper disable ConvertClosureToMethodGroup
 19namespace Elsa.Expressions.JavaScript.Services;
 20
 21/// <summary>
 22/// Provides a JavaScript evaluator using Jint.
 23/// </summary>
 19924public class JintJavaScriptEvaluator(IConfiguration configuration, INotificationSender mediator, IOptions<JintOptions> s
 25    : IJavaScriptEvaluator
 26{
 19927    private readonly JintOptions _jintOptions = scriptOptions.Value;
 28
 29    /// <inheritdoc />
 30    [RequiresUnreferencedCode("The Jint library uses reflection and can't be statically analyzed.")]
 31    public async Task<object?> EvaluateAsync(string expression,
 32        Type returnType,
 33        ExpressionExecutionContext context,
 34        ExpressionEvaluatorOptions? options = null,
 35        Action<Engine>? configureEngine = null,
 36        CancellationToken cancellationToken = default)
 37    {
 38        // The script is prepared before the engine is configured so that the identifiers the expression
 39        // actually references are known while the globals are being installed. Handlers that would
 40        // otherwise have to build a global speculatively can then skip the ones the expression cannot read.
 28941        var preparedScript = GetOrCreatePrepareScript(expression);
 28542        var engine = await GetConfiguredEngine(configureEngine, context, options, cancellationToken);
 28543        await mediator.SendAsync(new EvaluatingJavaScript(engine, context, expression, preparedScript.ReferencedGlobals)
 28544        var result = await ExecuteExpressionAndGetResultAsync(engine, preparedScript, cancellationToken);
 27945        await mediator.SendAsync(new EvaluatedJavaScript(engine, context, expression, result), cancellationToken);
 46
 27947        return result.ConvertTo(returnType);
 27948    }
 49
 50    private async Task<Engine> GetConfiguredEngine(Action<Engine>? configureEngine, ExpressionExecutionContext context, 
 51    {
 28552        options ??= new();
 53
 28554        var engineOptions = new Jint.Options
 28555        {
 28556            ExperimentalFeatures = ExperimentalFeature.TaskInterop
 28557        };
 58
 59        // Jint 4.14 changed this default to LiveView, which exposes a CLR array to script as a live view over
 60        // the original array rather than as a copy. Keeping the copy semantics means a script that mutates an
 61        // array does not reach back into the workflow's own data, and that an array survives a round trip as
 62        // object[] the way it always has. Hosts that want the live view can opt in via ConfigureEngineOptions.
 28563        engineOptions.Interop.ArrayConversion = ArrayConversionMode.Copy;
 64
 65        // Expose CLR enums to script as their member name. This is what EnumToStringConverter used to do by
 66        // hand for values crossing the boundary; the built-in switch also covers the direction that converter
 67        // could not reach, a constant read off a registered enum type such as LogPersistenceMode.Include, which
 68        // used to produce a number and therefore never compared equal to the same value held in a variable.
 69        // Values going back to the CLR keep accepting both the name and the number.
 28570        engineOptions.Interop.EnumConversion = EnumConversionMode.String;
 71
 28572        ConfigureClrAccess(engineOptions);
 28573        ConfigureObjectWrapper(engineOptions);
 28574        ConfigureObjectConverters(engineOptions);
 28575        ConfigureExecutionConstraints(engineOptions, cancellationToken);
 76
 28577        await mediator.SendAsync(new CreatingJavaScriptEngine(engineOptions, context), cancellationToken);
 28578        _jintOptions.ConfigureEngineOptionsCallback(engineOptions, context);
 79
 28580        var engine = new Engine(engineOptions);
 81
 28582        configureEngine?.Invoke(engine);
 28583        ConfigureArgumentGetters(engine, options);
 28584        ConfigureConfigurationAccess(engine);
 28585        _jintOptions.ConfigureEngineCallback(engine, context);
 86
 28587        return engine;
 28588    }
 89
 90    private void ConfigureClrAccess(Jint.Options options)
 91    {
 28592        if (_jintOptions.AllowClrAccess)
 1993            options.AllowClr();
 28594    }
 95
 96    private void ConfigureObjectWrapper(Jint.Options options)
 97    {
 28598        options.SetWrapObjectHandler((engine, target, type) =>
 28599        {
 451100            var instance = ObjectWrapper.Create(engine, target);
 285101
 451102            if (ObjectArrayHelper.DetermineIfObjectIsArrayLikeClrCollection(target.GetType()))
 429103                instance.Prototype = engine.Intrinsics.Array.PrototypeObject;
 285104
 451105            return instance;
 285106        });
 285107    }
 108
 109    private void ConfigureExecutionConstraints(Jint.Options options, CancellationToken cancellationToken)
 110    {
 111        // An expression that never returns would otherwise occupy the calling thread forever.
 285112        if (_jintOptions.ExecutionTimeout is { } executionTimeout)
 285113            options.TimeoutInterval(executionTimeout);
 114
 285115        if (_jintOptions.MaxStatements is { } maxStatements)
 1116            options.MaxStatements(maxStatements);
 117
 285118        if (_jintOptions.MemoryLimit is { } memoryLimit)
 1119            options.LimitMemory(memoryLimit);
 120
 285121        if (_jintOptions.MaxRecursionDepth is { } maxRecursionDepth)
 1122            options.LimitRecursion(maxRecursionDepth);
 123
 124        // Cancelling the workflow should also abort a script that is still running.
 285125        options.CancellationToken(cancellationToken);
 285126    }
 127
 128    private void ConfigureObjectConverters(Jint.Options options)
 129    {
 130        // Each converter declares the CLR types it handles. A converter registered without them has to be
 131        // offered every value crossing the boundary, which costs Jint its compiled member-read and
 132        // method-invoker lanes for every wrapped .NET object in the engine; declaring the types keeps those
 133        // lanes for the members and methods no converter can observe.
 285134        options.AddObjectConverter(new ByteArrayConverter(), typeof(byte[]));
 285135        options.AddObjectConverter(new JsonElementConverter(), typeof(JsonElement));
 285136    }
 137
 138    private void ConfigureArgumentGetters(Engine engine, ExpressionEvaluatorOptions options)
 139    {
 576140        foreach (var argument in options.Arguments)
 6141            engine.SetValue($"get{argument.Key}", (Func<object?>)(() => argument.Value));
 285142    }
 143
 144    private void ConfigureConfigurationAccess(Engine engine)
 145    {
 285146        if (_jintOptions.AllowConfigurationAccess)
 0147            engine.SetValue("getConfig", (Func<string, object?>)(name => configuration.GetSection(name).Value));
 285148    }
 149
 150    private async Task<object?> ExecuteExpressionAndGetResultAsync(Engine engine, Prepared<Script> preparedScript, Cance
 151    {
 152        // EvaluateAsync awaits a returned promise instead of blocking the calling thread on it, which matters
 153        // for expressions that await a .NET Task, such as the ones calling getSecret().
 285154        var result = await engine.EvaluateAsync(preparedScript, cancellationToken);
 279155        return result.ToObject();
 279156    }
 157
 158    private Prepared<Script> GetOrCreatePrepareScript(string expression)
 159    {
 160        // The key type keeps these entries distinct from any other consumer of the shared cache, so the
 161        // expression itself can be used as the key. A cache hit then costs a dictionary lookup rather than
 162        // a hash of the entire expression plus the allocations needed to render that hash as a string.
 289163        var cacheKey = new ScriptCacheKey(expression);
 164
 165        // Looking the entry up directly rather than through GetOrCreate keeps the factory closure off the
 166        // hot path: it is only needed on a miss.
 289167        if (memoryCache.TryGetValue(cacheKey, out Prepared<Script> cachedScript))
 62168            return cachedScript;
 169
 227170        using var entry = memoryCache.CreateEntry(cacheKey);
 171
 227172        if (_jintOptions.ScriptCacheTimeout.HasValue)
 227173            entry.SetSlidingExpiration(_jintOptions.ScriptCacheTimeout.Value);
 174
 227175        var preparedScript = PrepareScript(expression);
 223176        entry.Value = preparedScript;
 223177        return preparedScript;
 223178    }
 179
 180    private Prepared<Script> PrepareScript(string expression)
 181    {
 227182        var prepareOptions = new ScriptPreparationOptions
 227183        {
 227184            ParsingOptions = new()
 227185            {
 227186                AllowReturnOutsideFunction = true
 227187            },
 227188
 227189            // Collected once per distinct expression, alongside the parse that is already cached, and read by
 227190            // the handlers that would otherwise register a global for every variable, workflow input and
 227191            // activity output in scope.
 227192            CollectReferencedGlobals = true
 227193        };
 227194        return Engine.PrepareScript(expression, options: prepareOptions);
 195    }
 196
 197    /// <summary>
 198    /// Identifies a prepared script in the shared memory cache.
 199    /// </summary>
 0200    private readonly record struct ScriptCacheKey(string Expression);
 201}