| | | 1 | | using System.Diagnostics.CodeAnalysis; |
| | | 2 | | using System.Text.Json; |
| | | 3 | | using Acornima.Ast; |
| | | 4 | | using Elsa.Expressions.Helpers; |
| | | 5 | | using Elsa.Expressions.Models; |
| | | 6 | | using Elsa.Expressions.JavaScript.Contracts; |
| | | 7 | | using Elsa.Expressions.JavaScript.Helpers; |
| | | 8 | | using Elsa.Expressions.JavaScript.Notifications; |
| | | 9 | | using Elsa.Expressions.JavaScript.ObjectConverters; |
| | | 10 | | using Elsa.Expressions.JavaScript.Options; |
| | | 11 | | using Elsa.Mediator.Contracts; |
| | | 12 | | using Jint; |
| | | 13 | | using Jint.Runtime.Interop; |
| | | 14 | | using Microsoft.Extensions.Caching.Memory; |
| | | 15 | | using Microsoft.Extensions.Configuration; |
| | | 16 | | using Microsoft.Extensions.Options; |
| | | 17 | | |
| | | 18 | | // ReSharper disable ConvertClosureToMethodGroup |
| | | 19 | | namespace Elsa.Expressions.JavaScript.Services; |
| | | 20 | | |
| | | 21 | | /// <summary> |
| | | 22 | | /// Provides a JavaScript evaluator using Jint. |
| | | 23 | | /// </summary> |
| | 199 | 24 | | public class JintJavaScriptEvaluator(IConfiguration configuration, INotificationSender mediator, IOptions<JintOptions> s |
| | | 25 | | : IJavaScriptEvaluator |
| | | 26 | | { |
| | 199 | 27 | | 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. |
| | 289 | 41 | | var preparedScript = GetOrCreatePrepareScript(expression); |
| | 285 | 42 | | var engine = await GetConfiguredEngine(configureEngine, context, options, cancellationToken); |
| | 285 | 43 | | await mediator.SendAsync(new EvaluatingJavaScript(engine, context, expression, preparedScript.ReferencedGlobals) |
| | 285 | 44 | | var result = await ExecuteExpressionAndGetResultAsync(engine, preparedScript, cancellationToken); |
| | 279 | 45 | | await mediator.SendAsync(new EvaluatedJavaScript(engine, context, expression, result), cancellationToken); |
| | | 46 | | |
| | 279 | 47 | | return result.ConvertTo(returnType); |
| | 279 | 48 | | } |
| | | 49 | | |
| | | 50 | | private async Task<Engine> GetConfiguredEngine(Action<Engine>? configureEngine, ExpressionExecutionContext context, |
| | | 51 | | { |
| | 285 | 52 | | options ??= new(); |
| | | 53 | | |
| | 285 | 54 | | var engineOptions = new Jint.Options |
| | 285 | 55 | | { |
| | 285 | 56 | | ExperimentalFeatures = ExperimentalFeature.TaskInterop |
| | 285 | 57 | | }; |
| | | 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. |
| | 285 | 63 | | 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. |
| | 285 | 70 | | engineOptions.Interop.EnumConversion = EnumConversionMode.String; |
| | | 71 | | |
| | 285 | 72 | | ConfigureClrAccess(engineOptions); |
| | 285 | 73 | | ConfigureObjectWrapper(engineOptions); |
| | 285 | 74 | | ConfigureObjectConverters(engineOptions); |
| | 285 | 75 | | ConfigureExecutionConstraints(engineOptions, cancellationToken); |
| | | 76 | | |
| | 285 | 77 | | await mediator.SendAsync(new CreatingJavaScriptEngine(engineOptions, context), cancellationToken); |
| | 285 | 78 | | _jintOptions.ConfigureEngineOptionsCallback(engineOptions, context); |
| | | 79 | | |
| | 285 | 80 | | var engine = new Engine(engineOptions); |
| | | 81 | | |
| | 285 | 82 | | configureEngine?.Invoke(engine); |
| | 285 | 83 | | ConfigureArgumentGetters(engine, options); |
| | 285 | 84 | | ConfigureConfigurationAccess(engine); |
| | 285 | 85 | | _jintOptions.ConfigureEngineCallback(engine, context); |
| | | 86 | | |
| | 285 | 87 | | return engine; |
| | 285 | 88 | | } |
| | | 89 | | |
| | | 90 | | private void ConfigureClrAccess(Jint.Options options) |
| | | 91 | | { |
| | 285 | 92 | | if (_jintOptions.AllowClrAccess) |
| | 19 | 93 | | options.AllowClr(); |
| | 285 | 94 | | } |
| | | 95 | | |
| | | 96 | | private void ConfigureObjectWrapper(Jint.Options options) |
| | | 97 | | { |
| | 285 | 98 | | options.SetWrapObjectHandler((engine, target, type) => |
| | 285 | 99 | | { |
| | 451 | 100 | | var instance = ObjectWrapper.Create(engine, target); |
| | 285 | 101 | | |
| | 451 | 102 | | if (ObjectArrayHelper.DetermineIfObjectIsArrayLikeClrCollection(target.GetType())) |
| | 429 | 103 | | instance.Prototype = engine.Intrinsics.Array.PrototypeObject; |
| | 285 | 104 | | |
| | 451 | 105 | | return instance; |
| | 285 | 106 | | }); |
| | 285 | 107 | | } |
| | | 108 | | |
| | | 109 | | private void ConfigureExecutionConstraints(Jint.Options options, CancellationToken cancellationToken) |
| | | 110 | | { |
| | | 111 | | // An expression that never returns would otherwise occupy the calling thread forever. |
| | 285 | 112 | | if (_jintOptions.ExecutionTimeout is { } executionTimeout) |
| | 285 | 113 | | options.TimeoutInterval(executionTimeout); |
| | | 114 | | |
| | 285 | 115 | | if (_jintOptions.MaxStatements is { } maxStatements) |
| | 1 | 116 | | options.MaxStatements(maxStatements); |
| | | 117 | | |
| | 285 | 118 | | if (_jintOptions.MemoryLimit is { } memoryLimit) |
| | 1 | 119 | | options.LimitMemory(memoryLimit); |
| | | 120 | | |
| | 285 | 121 | | if (_jintOptions.MaxRecursionDepth is { } maxRecursionDepth) |
| | 1 | 122 | | options.LimitRecursion(maxRecursionDepth); |
| | | 123 | | |
| | | 124 | | // Cancelling the workflow should also abort a script that is still running. |
| | 285 | 125 | | options.CancellationToken(cancellationToken); |
| | 285 | 126 | | } |
| | | 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. |
| | 285 | 134 | | options.AddObjectConverter(new ByteArrayConverter(), typeof(byte[])); |
| | 285 | 135 | | options.AddObjectConverter(new JsonElementConverter(), typeof(JsonElement)); |
| | 285 | 136 | | } |
| | | 137 | | |
| | | 138 | | private void ConfigureArgumentGetters(Engine engine, ExpressionEvaluatorOptions options) |
| | | 139 | | { |
| | 576 | 140 | | foreach (var argument in options.Arguments) |
| | 6 | 141 | | engine.SetValue($"get{argument.Key}", (Func<object?>)(() => argument.Value)); |
| | 285 | 142 | | } |
| | | 143 | | |
| | | 144 | | private void ConfigureConfigurationAccess(Engine engine) |
| | | 145 | | { |
| | 285 | 146 | | if (_jintOptions.AllowConfigurationAccess) |
| | 0 | 147 | | engine.SetValue("getConfig", (Func<string, object?>)(name => configuration.GetSection(name).Value)); |
| | 285 | 148 | | } |
| | | 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(). |
| | 285 | 154 | | var result = await engine.EvaluateAsync(preparedScript, cancellationToken); |
| | 279 | 155 | | return result.ToObject(); |
| | 279 | 156 | | } |
| | | 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. |
| | 289 | 163 | | 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. |
| | 289 | 167 | | if (memoryCache.TryGetValue(cacheKey, out Prepared<Script> cachedScript)) |
| | 62 | 168 | | return cachedScript; |
| | | 169 | | |
| | 227 | 170 | | using var entry = memoryCache.CreateEntry(cacheKey); |
| | | 171 | | |
| | 227 | 172 | | if (_jintOptions.ScriptCacheTimeout.HasValue) |
| | 227 | 173 | | entry.SetSlidingExpiration(_jintOptions.ScriptCacheTimeout.Value); |
| | | 174 | | |
| | 227 | 175 | | var preparedScript = PrepareScript(expression); |
| | 223 | 176 | | entry.Value = preparedScript; |
| | 223 | 177 | | return preparedScript; |
| | 223 | 178 | | } |
| | | 179 | | |
| | | 180 | | private Prepared<Script> PrepareScript(string expression) |
| | | 181 | | { |
| | 227 | 182 | | var prepareOptions = new ScriptPreparationOptions |
| | 227 | 183 | | { |
| | 227 | 184 | | ParsingOptions = new() |
| | 227 | 185 | | { |
| | 227 | 186 | | AllowReturnOutsideFunction = true |
| | 227 | 187 | | }, |
| | 227 | 188 | | |
| | 227 | 189 | | // Collected once per distinct expression, alongside the parse that is already cached, and read by |
| | 227 | 190 | | // the handlers that would otherwise register a global for every variable, workflow input and |
| | 227 | 191 | | // activity output in scope. |
| | 227 | 192 | | CollectReferencedGlobals = true |
| | 227 | 193 | | }; |
| | 227 | 194 | | return Engine.PrepareScript(expression, options: prepareOptions); |
| | | 195 | | } |
| | | 196 | | |
| | | 197 | | /// <summary> |
| | | 198 | | /// Identifies a prepared script in the shared memory cache. |
| | | 199 | | /// </summary> |
| | 0 | 200 | | private readonly record struct ScriptCacheKey(string Expression); |
| | | 201 | | } |