mirror of https://github.com/Squidex/squidex.git
30 changed files with 1072 additions and 564 deletions
@ -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<ContentData> TransformAsync(DataScriptVars vars, |
|||
CancellationToken ct = default); |
|||
|
|||
ValueTask<JsonValue> ExecuteAsync(ScriptVars vars, |
|||
CancellationToken ct = default); |
|||
|
|||
async ValueTask<bool> EvaluateAsync(ScriptVars vars, |
|||
CancellationToken ct = default) |
|||
{ |
|||
try |
|||
{ |
|||
return (await ExecuteAsync(vars, ct)).Equals(true); |
|||
} |
|||
catch |
|||
{ |
|||
return false; |
|||
} |
|||
} |
|||
} |
|||
@ -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; |
|||
} |
|||
} |
|||
} |
|||
@ -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<T> : ScriptExecutionContext |
|||
{ |
|||
private readonly TaskCompletionSource<CompletedValue?> tcs = new TaskCompletionSource<CompletedValue?>(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<T>)state!; |
|||
|
|||
self.tcs.TrySetCanceled(self.cancellationToken); |
|||
}, |
|||
this); |
|||
} |
|||
|
|||
public async Task<T> WaitForCompletionAsync(Func<T> 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<CancellationToken, Task> action) |
|||
{ |
|||
ScheduleCoreAsync(async ct => |
|||
{ |
|||
await action(ct); |
|||
return true; |
|||
}, |
|||
null); |
|||
} |
|||
|
|||
public override void Schedule<TResult>(Func<CancellationToken, Task<TResult>> action, Action<TResult>? callback) |
|||
{ |
|||
ScheduleCoreAsync(async ct => |
|||
{ |
|||
var result = await action(ct); |
|||
return result; |
|||
}, |
|||
callback); |
|||
} |
|||
|
|||
private void ScheduleCoreAsync<TResult>(Func<CancellationToken, Task<TResult>> action, Action<TResult>? 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<bool> 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); |
|||
} |
|||
} |
|||
} |
|||
@ -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<Script> parsed; |
|||
private readonly ScriptOptions scriptOptions; |
|||
private readonly JintScriptOptions engineOptions; |
|||
private readonly GlobalSnapshot snapshot; |
|||
|
|||
public JintScript( |
|||
Prepared<Script> parsed, |
|||
ScriptOptions scriptOptions, |
|||
JintScriptOptions engineOptions, |
|||
IJintExtension[] extensions, |
|||
bool allowAsync) |
|||
{ |
|||
this.parsed = parsed; |
|||
this.scriptOptions = scriptOptions; |
|||
this.engineOptions = engineOptions; |
|||
|
|||
engine = new Engine(options => |
|||
{ |
|||
options.AddObjectConverter(JintObjectConverter.Instance); |
|||
options.AllowOperatorOverloading(); |
|||
options.AllowClrWrite(!scriptOptions.Readonly); |
|||
options.SetTypeConverter(engine => new CustomClrConverter(engine)); |
|||
options.SetReferencesResolver(NullPropagation.Instance); |
|||
options.Strict(); |
|||
|
|||
if (!Debugger.IsAttached) |
|||
{ |
|||
options.Constraints.PromiseTimeout = engineOptions.TimeoutPromise; |
|||
options.TimeoutInterval(engineOptions.TimeoutScript); |
|||
options.CancellationToken(placeholder.Token); |
|||
} |
|||
}); |
|||
|
|||
cancellation = engine.Constraints.Find<CancellationConstraint>(); |
|||
|
|||
// The extensions resolve the variables from the context of the current execution, therefore they
|
|||
// are registered once, even though the variables are different for each execution.
|
|||
foreach (var extension in extensions) |
|||
{ |
|||
extension.Extend(engine); |
|||
} |
|||
|
|||
if (allowAsync) |
|||
{ |
|||
foreach (var extension in extensions) |
|||
{ |
|||
extension.ExtendAsync(engine); |
|||
} |
|||
} |
|||
|
|||
if (scriptOptions.CanDisallow) |
|||
{ |
|||
engine.AddDisallow(); |
|||
} |
|||
|
|||
if (scriptOptions.CanReject) |
|||
{ |
|||
engine.AddReject(); |
|||
} |
|||
|
|||
// The evaluation does not wait for the promises anymore, therefore an unhandled rejection would be
|
|||
// swallowed. The tracker only fires when nothing handles the rejection.
|
|||
engine.Advanced.PromiseRejectionTracker += (_, args) => |
|||
{ |
|||
if (args.Operation == PromiseRejectionOperation.Reject) |
|||
{ |
|||
ScriptExecutionContext.GetContext(engine).Fail( |
|||
new JavaScriptException($"Promise was rejected with value {args.Value}.")); |
|||
} |
|||
}; |
|||
|
|||
snapshot = engine.Advanced.CaptureGlobalSnapshot(); |
|||
} |
|||
|
|||
public JsonValue Execute(ScriptVars vars) |
|||
{ |
|||
Guard.NotNull(vars); |
|||
|
|||
return Run(vars, (vars, _) => |
|||
{ |
|||
JsonValue? completed = null; |
|||
|
|||
engine.SetValue("complete", new Action<JsValue?>(value => |
|||
{ |
|||
completed ??= JsonMapper.Map(value); |
|||
})); |
|||
|
|||
var result = engine.Evaluate(parsed); |
|||
|
|||
return completed ?? JsonMapper.Map(result); |
|||
}); |
|||
} |
|||
|
|||
public ValueTask<JsonValue> ExecuteAsync(ScriptVars vars, |
|||
CancellationToken ct = default) |
|||
{ |
|||
Guard.NotNull(vars); |
|||
|
|||
return RunAsync<JsonValue, ScriptVars>(vars, async (vars, context, ct) => |
|||
{ |
|||
engine.SetValue("complete", new Action<JsValue?>(value => |
|||
{ |
|||
context.Complete(JsonMapper.Map(value)); |
|||
})); |
|||
|
|||
// The evaluation holds the lock, but returns without waiting for the pending promises.
|
|||
var result = await RunLockedAsync(() => engine.Evaluate(parsed), ct); |
|||
|
|||
return await context.WaitForCompletionAsync(() => JsonMapper.Map(result)); |
|||
}, ct); |
|||
} |
|||
|
|||
public ContentData Transform(DataScriptVars vars) |
|||
{ |
|||
Guard.NotNull(vars); |
|||
|
|||
return Run(vars, (vars, _) => |
|||
{ |
|||
ContentData? result = null; |
|||
|
|||
engine.SetValue("complete", new Action<JsValue?>(_ => |
|||
{ |
|||
result ??= vars.Data; |
|||
})); |
|||
|
|||
engine.SetValue("replace", new Action(() => |
|||
{ |
|||
if (result == null && TransformData(out var modified)) |
|||
{ |
|||
result = modified; |
|||
} |
|||
})); |
|||
|
|||
engine.Evaluate(parsed); |
|||
|
|||
return result ?? vars.Data!; |
|||
}); |
|||
} |
|||
|
|||
public ValueTask<ContentData> TransformAsync(DataScriptVars vars, |
|||
CancellationToken ct = default) |
|||
{ |
|||
Guard.NotNull(vars); |
|||
|
|||
return RunAsync<ContentData, DataScriptVars>(vars, async (vars, context, ct) => |
|||
{ |
|||
var data = vars.Data!; |
|||
|
|||
engine.SetValue("complete", new Action<JsValue?>(_ => |
|||
{ |
|||
if (!context.IsCompleted) |
|||
{ |
|||
context.Complete(data); |
|||
} |
|||
})); |
|||
|
|||
engine.SetValue("replace", new Action(() => |
|||
{ |
|||
if (!context.IsCompleted && TransformData(out var modified)) |
|||
{ |
|||
context.Complete(modified); |
|||
} |
|||
})); |
|||
|
|||
// The evaluation holds the lock, but returns without waiting for the pending promises.
|
|||
await RunLockedAsync(() => engine.Evaluate(parsed), ct); |
|||
|
|||
return await context.WaitForCompletionAsync(() => data); |
|||
}, ct); |
|||
} |
|||
|
|||
private T Run<T, TVars>(TVars vars, Func<TVars, ScriptExecutionContext, T> action) where TVars : ScriptVars |
|||
{ |
|||
executionGate.Wait(); |
|||
try |
|||
{ |
|||
// The extensions resolve the variables over the context, therefore it is also needed here.
|
|||
var context = new ScriptExecutionContext(engine); |
|||
context.CopyFrom(vars); |
|||
|
|||
PrepareEngine(vars, default); |
|||
return action(vars, context); |
|||
} |
|||
catch (Exception ex) |
|||
{ |
|||
throw MapException(ex); |
|||
} |
|||
finally |
|||
{ |
|||
executionGate.Release(); |
|||
} |
|||
} |
|||
|
|||
private async ValueTask<T> RunAsync<T, TVars>(TVars vars, Func<TVars, AsyncScriptExecutionContext<T>, CancellationToken, ValueTask<T>> action, |
|||
CancellationToken ct) where TVars : ScriptVars |
|||
{ |
|||
using var combined = CancellationTokenSource.CreateLinkedTokenSource(ct); |
|||
|
|||
// Enforce a timeout after a configured time span.
|
|||
combined.CancelAfter(engineOptions.TimeoutExecution); |
|||
|
|||
await executionGate.WaitAsync(ct); |
|||
try |
|||
{ |
|||
PrepareEngine(vars, combined.Token); |
|||
|
|||
// The extensions resolve the variables over the context, therefore it is also needed here.
|
|||
var context = new AsyncScriptExecutionContext<T>(engine, this, combined.Token); |
|||
context.CopyFrom(vars); |
|||
|
|||
return await action(vars, context, combined.Token); |
|||
} |
|||
catch (Exception ex) |
|||
{ |
|||
throw MapException(ex); |
|||
} |
|||
finally |
|||
{ |
|||
// Stop pending tasks before the token source is disposed, they must not touch the engine anymore.
|
|||
await combined.CancelAsync(); |
|||
|
|||
// The next execution reuses the engine, therefore we wait until no callback is inside anymore.
|
|||
// Callbacks that are still waiting for the lock are rejected, because the token is cancelled by now.
|
|||
await engineLock.WaitAsync(default(CancellationToken)); |
|||
engineLock.Release(); |
|||
|
|||
executionGate.Release(); |
|||
} |
|||
} |
|||
|
|||
public void Dispose() |
|||
{ |
|||
engine.Dispose(); |
|||
engineLock.Dispose(); |
|||
executionGate.Dispose(); |
|||
placeholder.Dispose(); |
|||
} |
|||
|
|||
internal async Task<T> RunLockedAsync<T>(Func<T> action, CancellationToken ct) |
|||
{ |
|||
// Only one thread is allowed inside the engine, no matter whether it is an execution or a callback.
|
|||
// A callback that is reached synchronously from the engine thread asks for the lock while the
|
|||
// evaluation still holds it. That is not a deadlock, because the wait yields instead of blocking and
|
|||
// the callback continues on another thread once the evaluation is done. It must not be made
|
|||
// reentrant, that would run the promise jobs in the middle of a statement.
|
|||
await engineLock.WaitAsync(ct); |
|||
try |
|||
{ |
|||
return action(); |
|||
} |
|||
finally |
|||
{ |
|||
engineLock.Release(); |
|||
} |
|||
} |
|||
|
|||
private bool TransformData(out ContentData result) |
|||
{ |
|||
var dataInstance = |
|||
scriptOptions.AsContext ? |
|||
engine.GetValue("ctx").AsObject().Get("data") : |
|||
engine.GetValue("data"); |
|||
|
|||
if (dataInstance != null && |
|||
dataInstance.IsObject() && |
|||
dataInstance.AsObject() is ContentDataObject dataObject && |
|||
dataObject.TryUpdate(out var modified)) |
|||
{ |
|||
result = modified; |
|||
return true; |
|||
} |
|||
|
|||
result = null!; |
|||
return false; |
|||
} |
|||
|
|||
private void PrepareEngine(ScriptVars vars, CancellationToken ct) |
|||
{ |
|||
// Removes everything the previous execution has declared, including global const and let.
|
|||
engine.Advanced.RestoreGlobalSnapshot(snapshot); |
|||
engine.Constraints.Reset(); |
|||
|
|||
// The engine is created once, therefore the token of the current execution is assigned here.
|
|||
// The generic reset above does not clear it, so the callbacks keep the token as well.
|
|||
cancellation?.Reset(ct); |
|||
|
|||
if (scriptOptions.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); |
|||
} |
|||
} |
|||
} |
|||
|
|||
internal static Exception MapException(Exception inner) |
|||
{ |
|||
static Exception BuildException(string errorKey, string message, Exception? inner = null) |
|||
{ |
|||
return new ValidationException(T.Get(errorKey, new { message }), inner); |
|||
} |
|||
|
|||
switch (inner) |
|||
{ |
|||
case ArgumentException: |
|||
return BuildException("common.jsParseError", inner.Message); |
|||
case ParseErrorException: |
|||
return BuildException("common.jsError", inner.Message); |
|||
case ScriptPreparationException: |
|||
return BuildException("common.jsError", inner.Message); |
|||
case JavaScriptException: |
|||
return BuildException("common.jsError", inner.Message); |
|||
case JintException: |
|||
return BuildException("common.jsError", inner.Message); |
|||
case DomainException: |
|||
return inner; |
|||
default: |
|||
return BuildException("common.jsError", inner.GetType().Name, inner); |
|||
} |
|||
} |
|||
} |
|||
Loading…
Reference in new issue