| | | 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.Notifications; |
| | | 8 | | using Elsa.Expressions.JavaScript.ObjectConverters; |
| | | 9 | | using Elsa.Expressions.JavaScript.Options; |
| | | 10 | | using Elsa.Mediator.Contracts; |
| | | 11 | | using Jint; |
| | | 12 | | using Microsoft.Extensions.Caching.Memory; |
| | | 13 | | using Microsoft.Extensions.Configuration; |
| | | 14 | | using Microsoft.Extensions.Options; |
| | | 15 | | |
| | | 16 | | // ReSharper disable ConvertClosureToMethodGroup |
| | | 17 | | namespace Elsa.Expressions.JavaScript.Services; |
| | | 18 | | |
| | | 19 | | /// <summary> |
| | | 20 | | /// Provides a JavaScript evaluator using Jint. |
| | | 21 | | /// </summary> |
| | 208 | 22 | | public class JintJavaScriptEvaluator(IConfiguration configuration, INotificationSender mediator, IOptions<JintOptions> s |
| | | 23 | | : IJavaScriptEvaluator |
| | | 24 | | { |
| | 208 | 25 | | private readonly JintOptions _jintOptions = scriptOptions.Value; |
| | | 26 | | |
| | | 27 | | /// <inheritdoc /> |
| | | 28 | | [RequiresUnreferencedCode("The Jint library uses reflection and can't be statically analyzed.")] |
| | | 29 | | public async Task<object?> EvaluateAsync(string expression, |
| | | 30 | | Type returnType, |
| | | 31 | | ExpressionExecutionContext context, |
| | | 32 | | ExpressionEvaluatorOptions? options = null, |
| | | 33 | | Action<Engine>? configureEngine = null, |
| | | 34 | | CancellationToken cancellationToken = default) |
| | | 35 | | { |
| | | 36 | | // The script is prepared before the engine is configured so that the identifiers the expression |
| | | 37 | | // actually references are known while the globals are being installed. Handlers that would |
| | | 38 | | // otherwise have to build a global speculatively can then skip the ones the expression cannot read. |
| | 309 | 39 | | var preparedScript = GetOrCreatePrepareScript(expression); |
| | 305 | 40 | | var engine = await GetConfiguredEngine(configureEngine, context, options, cancellationToken); |
| | 305 | 41 | | await mediator.SendAsync(new EvaluatingJavaScript(engine, context, expression, preparedScript.ReferencedGlobals) |
| | 305 | 42 | | var result = await ExecuteExpressionAndGetResultAsync(engine, preparedScript, cancellationToken); |
| | 298 | 43 | | await mediator.SendAsync(new EvaluatedJavaScript(engine, context, expression, result), cancellationToken); |
| | | 44 | | |
| | 298 | 45 | | return result.ConvertTo(returnType); |
| | 298 | 46 | | } |
| | | 47 | | |
| | | 48 | | private async Task<Engine> GetConfiguredEngine(Action<Engine>? configureEngine, ExpressionExecutionContext context, |
| | | 49 | | { |
| | 305 | 50 | | options ??= new(); |
| | | 51 | | |
| | 305 | 52 | | var engineOptions = new Jint.Options |
| | 305 | 53 | | { |
| | 305 | 54 | | ExperimentalFeatures = ExperimentalFeature.TaskInterop |
| | 305 | 55 | | }; |
| | | 56 | | |
| | | 57 | | // Jint 4.14 changed this default to LiveView, which exposes a CLR array to script as a live view over |
| | | 58 | | // the original array rather than as a copy. Keeping the copy semantics means a script that mutates an |
| | | 59 | | // array does not reach back into the workflow's own data, and that an array survives a round trip as |
| | | 60 | | // object[] the way it always has. Hosts that want the live view can opt in via ConfigureEngineOptions. |
| | 305 | 61 | | engineOptions.Interop.ArrayConversion = ArrayConversionMode.Copy; |
| | | 62 | | |
| | | 63 | | // Expose CLR enums to script as their member name. This is what EnumToStringConverter used to do by |
| | | 64 | | // hand for values crossing the boundary; the built-in switch also covers the direction that converter |
| | | 65 | | // could not reach, a constant read off a registered enum type such as LogPersistenceMode.Include, which |
| | | 66 | | // used to produce a number and therefore never compared equal to the same value held in a variable. |
| | | 67 | | // Values going back to the CLR keep accepting both the name and the number. |
| | 305 | 68 | | engineOptions.Interop.EnumConversion = EnumConversionMode.String; |
| | | 69 | | |
| | 305 | 70 | | ConfigureClrAccess(engineOptions); |
| | 305 | 71 | | ConfigureObjectConverters(engineOptions); |
| | 305 | 72 | | ConfigureExecutionConstraints(engineOptions, cancellationToken); |
| | | 73 | | |
| | 305 | 74 | | await mediator.SendAsync(new CreatingJavaScriptEngine(engineOptions, context), cancellationToken); |
| | 305 | 75 | | _jintOptions.ConfigureEngineOptionsCallback(engineOptions, context); |
| | | 76 | | |
| | 305 | 77 | | var engine = new Engine(engineOptions); |
| | | 78 | | |
| | 305 | 79 | | configureEngine?.Invoke(engine); |
| | 305 | 80 | | ConfigureArgumentGetters(engine, options); |
| | 305 | 81 | | ConfigureConfigurationAccess(engine); |
| | 305 | 82 | | _jintOptions.ConfigureEngineCallback(engine, context); |
| | | 83 | | |
| | 305 | 84 | | return engine; |
| | 305 | 85 | | } |
| | | 86 | | |
| | | 87 | | private void ConfigureClrAccess(Jint.Options options) |
| | | 88 | | { |
| | 305 | 89 | | if (_jintOptions.AllowClrAccess) |
| | 19 | 90 | | options.AllowClr(); |
| | 305 | 91 | | } |
| | | 92 | | |
| | | 93 | | private void ConfigureExecutionConstraints(Jint.Options options, CancellationToken cancellationToken) |
| | | 94 | | { |
| | | 95 | | // An expression that never returns would otherwise occupy the calling thread forever. |
| | 305 | 96 | | if (_jintOptions.ExecutionTimeout is { } executionTimeout) |
| | 305 | 97 | | options.TimeoutInterval(executionTimeout); |
| | | 98 | | |
| | 305 | 99 | | if (_jintOptions.MaxStatements is { } maxStatements) |
| | 1 | 100 | | options.MaxStatements(maxStatements); |
| | | 101 | | |
| | 305 | 102 | | if (_jintOptions.MemoryLimit is { } memoryLimit) |
| | 1 | 103 | | options.LimitMemory(memoryLimit); |
| | | 104 | | |
| | 305 | 105 | | if (_jintOptions.MaxRecursionDepth is { } maxRecursionDepth) |
| | 1 | 106 | | options.LimitRecursion(maxRecursionDepth); |
| | | 107 | | |
| | | 108 | | // Cancelling the workflow should also abort a script that is still running. |
| | 305 | 109 | | options.CancellationToken(cancellationToken); |
| | 305 | 110 | | } |
| | | 111 | | |
| | | 112 | | private void ConfigureObjectConverters(Jint.Options options) |
| | | 113 | | { |
| | | 114 | | // Each converter declares the CLR types it handles. A converter registered without them has to be |
| | | 115 | | // offered every value crossing the boundary, which costs Jint its compiled member-read and |
| | | 116 | | // method-invoker lanes for every wrapped .NET object in the engine; declaring the types keeps those |
| | | 117 | | // lanes for the members and methods no converter can observe. |
| | 305 | 118 | | options.AddObjectConverter(new ByteArrayConverter(), typeof(byte[])); |
| | 305 | 119 | | options.AddObjectConverter(new JsonElementConverter(), typeof(JsonElement)); |
| | 305 | 120 | | } |
| | | 121 | | |
| | | 122 | | private void ConfigureArgumentGetters(Engine engine, ExpressionEvaluatorOptions options) |
| | | 123 | | { |
| | 618 | 124 | | foreach (var argument in options.Arguments) |
| | 7 | 125 | | engine.SetValue($"get{argument.Key}", (Func<object?>)(() => argument.Value)); |
| | 305 | 126 | | } |
| | | 127 | | |
| | | 128 | | private void ConfigureConfigurationAccess(Engine engine) |
| | | 129 | | { |
| | 305 | 130 | | if (_jintOptions.AllowConfigurationAccess) |
| | 0 | 131 | | engine.SetValue("getConfig", (Func<string, object?>)(name => configuration.GetSection(name).Value)); |
| | 305 | 132 | | } |
| | | 133 | | |
| | | 134 | | private async Task<object?> ExecuteExpressionAndGetResultAsync(Engine engine, Prepared<Script> preparedScript, Cance |
| | | 135 | | { |
| | | 136 | | // EvaluateAsync awaits a returned promise instead of blocking the calling thread on it, which matters |
| | | 137 | | // for expressions that await a .NET Task, such as the ones calling getSecret(). |
| | 305 | 138 | | var result = await engine.EvaluateAsync(preparedScript, cancellationToken); |
| | 298 | 139 | | return result.ToObject(); |
| | 298 | 140 | | } |
| | | 141 | | |
| | | 142 | | private Prepared<Script> GetOrCreatePrepareScript(string expression) |
| | | 143 | | { |
| | | 144 | | // The key type keeps these entries distinct from any other consumer of the shared cache, so the |
| | | 145 | | // expression itself can be used as the key. A cache hit then costs a dictionary lookup rather than |
| | | 146 | | // a hash of the entire expression plus the allocations needed to render that hash as a string. |
| | 309 | 147 | | var cacheKey = new ScriptCacheKey(expression); |
| | | 148 | | |
| | | 149 | | // Looking the entry up directly rather than through GetOrCreate keeps the factory closure off the |
| | | 150 | | // hot path: it is only needed on a miss. |
| | 309 | 151 | | if (memoryCache.TryGetValue(cacheKey, out Prepared<Script> cachedScript)) |
| | 63 | 152 | | return cachedScript; |
| | | 153 | | |
| | 246 | 154 | | using var entry = memoryCache.CreateEntry(cacheKey); |
| | | 155 | | |
| | 246 | 156 | | if (_jintOptions.ScriptCacheTimeout.HasValue) |
| | 246 | 157 | | entry.SetSlidingExpiration(_jintOptions.ScriptCacheTimeout.Value); |
| | | 158 | | |
| | 246 | 159 | | var preparedScript = PrepareScript(expression); |
| | 242 | 160 | | entry.Value = preparedScript; |
| | 242 | 161 | | return preparedScript; |
| | 242 | 162 | | } |
| | | 163 | | |
| | | 164 | | private Prepared<Script> PrepareScript(string expression) |
| | | 165 | | { |
| | 246 | 166 | | var prepareOptions = new ScriptPreparationOptions |
| | 246 | 167 | | { |
| | 246 | 168 | | ParsingOptions = new() |
| | 246 | 169 | | { |
| | 246 | 170 | | AllowReturnOutsideFunction = true |
| | 246 | 171 | | }, |
| | 246 | 172 | | |
| | 246 | 173 | | // Collected once per distinct expression, alongside the parse that is already cached, and read by |
| | 246 | 174 | | // the handlers that would otherwise register a global for every variable, workflow input and |
| | 246 | 175 | | // activity output in scope. |
| | 246 | 176 | | CollectReferencedGlobals = true |
| | 246 | 177 | | }; |
| | 246 | 178 | | return Engine.PrepareScript(expression, options: prepareOptions); |
| | | 179 | | } |
| | | 180 | | |
| | | 181 | | /// <summary> |
| | | 182 | | /// Identifies a prepared script in the shared memory cache. |
| | | 183 | | /// </summary> |
| | 0 | 184 | | private readonly record struct ScriptCacheKey(string Expression); |
| | | 185 | | } |