diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/UpdateValues.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/UpdateValues.cs index 3cb1465ea..c4140f177 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/UpdateValues.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/UpdateValues.cs @@ -14,6 +14,7 @@ namespace Squidex.Domain.Apps.Core.ConvertContent; public sealed class UpdateValues(ContentData existingData, IScriptEngine scriptEngine, bool canUnset) : IContentValueConverter, IContentDataConverter { + private static readonly ScriptOptions Options = new ScriptOptions { Readonly = true }; private ScriptVars? vars; public void ConvertDataBefore(Schema schema, ContentData source) @@ -32,8 +33,6 @@ public sealed class UpdateValues(ContentData existingData, IScriptEngine scriptE if (Updates.IsUpdate(value, out var expression)) { - var options = new ScriptOptions { Readonly = true }; - // Reuse the vars to save allocations. vars ??= new ScriptVars { @@ -44,7 +43,7 @@ public sealed class UpdateValues(ContentData existingData, IScriptEngine scriptE vars["$self"] = value; // Put the expression in brackets to return an object directly. - var result = scriptEngine.Execute(vars, $"({expression})", options); + var result = scriptEngine.Execute(vars, $"({expression})", Options); if (result.Value is JsonObject obj) { @@ -93,8 +92,6 @@ public sealed class UpdateValues(ContentData existingData, IScriptEngine scriptE return (false, source); } - var options = new ScriptOptions { Readonly = true }; - // Reuse the vars to save allocations. vars ??= new ScriptVars { @@ -105,7 +102,7 @@ public sealed class UpdateValues(ContentData existingData, IScriptEngine scriptE vars["$self"] = obj; // Put the expression in brackets to return an object directly. - var result = scriptEngine.Execute(vars, $"({expression})", options); + var result = scriptEngine.Execute(vars, $"({expression})", Options); return (false, result); } diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Extensions/EventJintExtension.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Extensions/EventJintExtension.cs index 44d897fbe..a58160614 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Extensions/EventJintExtension.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Extensions/EventJintExtension.cs @@ -68,13 +68,11 @@ public sealed class EventJintExtension(IUrlGenerator urlGenerator) : IJintExtens public void Extend(Engine engine) { - var context = engine.GetContext(); - engine.SetValue("console", FlowConsoleWrapper.Instance); engine.SetValue("contentAction", new EventDelegate(() => { - if (context.TryGetValue("event", out var temp) && temp is EnrichedContentEvent contentEvent) + if (engine.TryGetVar("event", out var contentEvent)) { return contentEvent.Status.ToString(); } @@ -84,7 +82,7 @@ public sealed class EventJintExtension(IUrlGenerator urlGenerator) : IJintExtens engine.SetValue("contentUrl", new EventDelegate(() => { - if (context.TryGetValue("event", out var temp) && temp is EnrichedContentEvent contentEvent) + if (engine.TryGetVar("event", out var contentEvent)) { return urlGenerator.ContentUI(contentEvent.AppId, contentEvent.SchemaId, contentEvent.Id); } @@ -94,7 +92,7 @@ public sealed class EventJintExtension(IUrlGenerator urlGenerator) : IJintExtens engine.SetValue("assetContentSlugUrl", new EventDelegate(() => { - if (context.TryGetValue("event", out var temp) && temp is EnrichedAssetEvent assetEvent) + if (engine.TryGetVar("event", out var assetEvent)) { return urlGenerator.AssetContent(assetEvent.AppId, assetEvent.FileName.Slugify()); } @@ -104,7 +102,7 @@ public sealed class EventJintExtension(IUrlGenerator urlGenerator) : IJintExtens var assetUrl = new EventDelegate(() => { - if (context.TryGetValue("event", out var temp) && temp is EnrichedAssetEvent assetEvent) + if (engine.TryGetVar("event", out var assetEvent)) { return urlGenerator.AssetContent(assetEvent.AppId, assetEvent.Id.ToString()); } diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/EngineExtensions.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/EngineExtensions.cs index 07d74e9c2..ac9312b17 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/EngineExtensions.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/EngineExtensions.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Diagnostics.CodeAnalysis; using Jint; namespace Squidex.Domain.Apps.Core.Scripting; @@ -16,6 +17,11 @@ public static class EngineExtensions return ScriptExecutionContext.GetContext(engine); } + public static bool TryGetVar(this Engine engine, string key, [MaybeNullWhen(false)] out T value) + { + return ScriptExecutionContext.GetContext(engine).TryGetValueIfExists(key, out value); + } + public static void Schedule(this Engine engine, Func action) { ScriptExecutionContext.GetContext(engine).Schedule(action); diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/IAsyncScript.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/IAsyncScript.cs new file mode 100644 index 000000000..be09e4a93 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/IAsyncScript.cs @@ -0,0 +1,33 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Infrastructure.Json.Objects; + +namespace Squidex.Domain.Apps.Core.Scripting; + +public interface IAsyncScript : IDisposable +{ + ValueTask TransformAsync(DataScriptVars vars, + CancellationToken ct = default); + + ValueTask ExecuteAsync(ScriptVars vars, + CancellationToken ct = default); + + async ValueTask EvaluateAsync(ScriptVars vars, + CancellationToken ct = default) + { + try + { + return (await ExecuteAsync(vars, ct)).Equals(true); + } + catch + { + return false; + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/IScript.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/IScript.cs new file mode 100644 index 000000000..440e4abd9 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/IScript.cs @@ -0,0 +1,30 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Infrastructure.Json.Objects; + +namespace Squidex.Domain.Apps.Core.Scripting; + +public interface IScript : IDisposable +{ + ContentData Transform(DataScriptVars vars); + + JsonValue Execute(ScriptVars vars); + + bool Evaluate(ScriptVars vars) + { + try + { + return Execute(vars).Equals(true); + } + catch + { + return false; + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/IScriptEngine.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/IScriptEngine.cs index 629b49885..e71c56b00 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/IScriptEngine.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/IScriptEngine.cs @@ -1,4 +1,4 @@ -// ========================================================================== +// ========================================================================== // Squidex Headless CMS // ========================================================================== // Copyright (c) Squidex UG (haftungsbeschraenkt) @@ -12,19 +12,62 @@ namespace Squidex.Domain.Apps.Core.Scripting; public interface IScriptEngine { - Task ExecuteAsync(ScriptVars vars, string script, ScriptOptions options = default, - CancellationToken ct = default); + IScript CreateScript(string script, ScriptOptions options = default); + + IAsyncScript CreateAsyncScript(string script, ScriptOptions options = default); + + ContentData Transform(DataScriptVars vars, string script, ScriptOptions options = default) + { + using var compiled = CreateScript(script, options); - Task TransformAsync(DataScriptVars vars, string script, ScriptOptions options = default, - CancellationToken ct = default); + return compiled.Transform(vars); + } + + JsonValue Execute(ScriptVars vars, string script, ScriptOptions options = default) + { + using var compiled = CreateScript(script, options); - JsonValue Execute(ScriptVars vars, string script, ScriptOptions options = default); + return compiled.Execute(vars); + } bool Evaluate(ScriptVars vars, string script, ScriptOptions options = default) { try { - return Execute(vars, script, options).Equals(true); + using var compiled = CreateScript(script, options); + + return compiled.Evaluate(vars); + } + catch + { + return false; + } + } + + async ValueTask TransformAsync(DataScriptVars vars, string script, ScriptOptions options = default, + CancellationToken ct = default) + { + using var compiled = CreateAsyncScript(script, options); + + return await compiled.TransformAsync(vars, ct); + } + + async ValueTask ExecuteAsync(ScriptVars vars, string script, ScriptOptions options = default, + CancellationToken ct = default) + { + using var compiled = CreateAsyncScript(script, options); + + return await compiled.ExecuteAsync(vars, ct); + } + + async ValueTask EvaluateAsync(ScriptVars vars, string script, ScriptOptions options = default, + CancellationToken ct = default) + { + try + { + using var compiled = CreateAsyncScript(script, options); + + return await compiled.EvaluateAsync(vars, ct); } catch { diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/AsyncScriptExecutionContext.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/AsyncScriptExecutionContext.cs new file mode 100644 index 000000000..327256a73 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/AsyncScriptExecutionContext.cs @@ -0,0 +1,171 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Jint; +using Squidex.Infrastructure.Tasks; + +namespace Squidex.Domain.Apps.Core.Scripting.Internal; + +internal sealed class AsyncScriptExecutionContext : ScriptExecutionContext +{ + private readonly TaskCompletionSource tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly CancellationTokenRegistration cancellationRegistration; + private readonly CancellationToken cancellationToken; + private readonly JintScript script; + private readonly Engine engine; + private int pendingTasks = 1; + + private sealed class CompletedValue + { + public T Value { get; init; } + } + + public bool IsCompleted + { + get => tcs.Task.IsCompleted; + } + + internal AsyncScriptExecutionContext(Engine engine, JintScript script, CancellationToken ct) + : base(engine) + { + this.engine = engine; + + // The lock belongs to the script, because the engine is shared between all executions. + this.script = script; + + cancellationToken = ct; + + // Settle the source on cancellation, so that pending callbacks do not enter the engine anymore. + cancellationRegistration = cancellationToken.Register(static state => + { + var self = (AsyncScriptExecutionContext)state!; + + self.tcs.TrySetCanceled(self.cancellationToken); + }, + this); + } + + public async Task WaitForCompletionAsync(Func fallback) + { + TryComplete(); + try + { + var result = await tcs.Task; + if (result != null) + { + return result.Value; + } + + return await script.RunLockedAsync(() => fallback(), cancellationToken); + } + finally + { + await cancellationRegistration.DisposeAsync(); + } + } + + public void Complete(T value) + { + tcs.TrySetResult(new CompletedValue { Value = value }); + } + + public override void Fail(Exception exception) + { + TryFail(exception); + } + + public override void Schedule(Func action) + { + ScheduleCoreAsync(async ct => + { + await action(ct); + return true; + }, + null); + } + + public override void Schedule(Func> action, Action? callback) + { + ScheduleCoreAsync(async ct => + { + var result = await action(ct); + return result; + }, + callback); + } + + private void ScheduleCoreAsync(Func> action, Action? callback) + { + if (IsCompleted) + { + return; + } + + async Task ScheduleAsync() + { + TryStart(); + try + { + // The action must not touch the engine, so that parallel tasks do not block each other. + var result = await action(cancellationToken); + + // The callback converts javascript values and is therefore the only part that needs the lock. + await RunLockedAsync(() => callback?.Invoke(result)); + + TryComplete(); + } + catch (Exception ex) + { + TryFail(ex); + } + } + + ScheduleAsync().Forget(); + } + + private Task RunLockedAsync(Action action) + { + // The lock is owned by the script, because the engine is shared between all executions. + return script.RunLockedAsync(() => + { + // Late callbacks must not touch the engine anymore, the next execution might have started. + if (IsCompleted) + { + return true; + } + + // The task can take a while, therefore the action gets a fresh timeout. + engine.Constraints.Reset(); + + action(); + + // The evaluation does not wait for the promises anymore, therefore the continuations that + // the callback has unblocked have to be executed here, while the lock is still held. + engine.Advanced.ProcessTasks(); + return true; + }, + cancellationToken); + } + + private void TryFail(Exception exception) + { + tcs.TrySetException(exception); + } + + private void TryStart() + { + Interlocked.Increment(ref pendingTasks); + } + + private void TryComplete(CompletedValue? result = null) + { + if (Interlocked.Decrement(ref pendingTasks) <= 0) + { + tcs.TrySetResult(result); + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JintExtensions.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JintExtensions.cs index 86c882877..235595af5 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JintExtensions.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JintExtensions.cs @@ -31,54 +31,4 @@ public static class JintExtensions return ids; } - - internal static ScriptExecutionContext ExtendWithAsyncFunctions(this ScriptExecutionContext context, - IEnumerable extensions) - { - foreach (var extension in extensions) - { - extension.ExtendAsync(context.Engine); - } - - return context; - } - - internal static ScriptExecutionContext ExtendWithFunctions(this ScriptExecutionContext context, - IEnumerable extensions) - { - foreach (var extension in extensions) - { - extension.Extend(context.Engine); - } - - return context; - } - - internal static ScriptExecutionContext ExtendWithVariables(this ScriptExecutionContext context, - ScriptVars vars, - ScriptOptions options) - { - var engine = context.Engine; - - context.CopyFrom(vars); - - if (options.AsContext) - { - var contextInstance = new WritableContext(engine, vars); - - engine.SetValue("ctx", contextInstance); - engine.SetValue("context", contextInstance); - } - else - { - foreach (var (key, item) in vars) - { - engine.SetValue(key, item); - } - } - - engine.SetValue("async", true); - - return context; - } } diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScript.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScript.cs new file mode 100644 index 000000000..c7fc8f791 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScript.cs @@ -0,0 +1,366 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Diagnostics; +using Acornima; +using Acornima.Ast; +using Jint; +using Jint.Constraints; +using Jint.Native; +using Jint.Native.Promise; +using Jint.Runtime; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Core.Scripting.ContentWrapper; +using Squidex.Domain.Apps.Core.Scripting.Internal; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Json.Objects; +using Squidex.Infrastructure.Translations; +using Squidex.Infrastructure.Validation; + +namespace Squidex.Domain.Apps.Core.Scripting; + +internal sealed class JintScript : IScript, IAsyncScript, IDisposable +{ + // The engine is shared between all executions, therefore only one of them can run at a time. + private readonly SemaphoreSlim executionGate = new SemaphoreSlim(1); + + // The engine is also shared with the callbacks of the scheduled tasks, which run on other threads. + private readonly SemaphoreSlim engineLock = new SemaphoreSlim(1); + + // Jint only creates the constraint for a token that can actually be cancelled, therefore the engine is + // built with a placeholder that is never cancelled and repointed for each execution. + private readonly CancellationTokenSource placeholder = new CancellationTokenSource(); + private readonly CancellationConstraint? cancellation; + private readonly Engine engine; + private readonly Prepared