From 6373c0547ddb9c9ff7e13699f499374912530e40 Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Tue, 25 Aug 2026 10:59:42 +0200 Subject: [PATCH] More progress --- .../Extensions/EventJintExtension.cs | 17 +- .../Scripting/EngineExtensions.cs | 28 +++ .../Scripting/Extensions/HttpJintExtension.cs | 119 +++++------ .../Extensions/StringAsyncJintExtension.cs | 59 +++--- .../Scripting/IJintExtension.cs | 6 +- .../Scripting/Internal/JintExtensions.cs | 4 +- .../Scripting/JintScriptEngine.cs | 15 +- .../Scripting/ScriptExecutionContext.cs | 171 +++++++++------ .../Assets/AssetsJintExtension.cs | 198 +++++++++--------- .../Contents/ContentsJintExtension.cs | 50 ++--- .../Contents/Counter/CounterJintExtension.cs | 47 ++--- .../Contents/ReferencesJintExtension.cs | 79 ++++--- .../Scripting/JintScriptEngineTests.cs | 25 +-- 13 files changed, 429 insertions(+), 389 deletions(-) create mode 100644 backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/EngineExtensions.cs 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 3d1cdb3db..44d897fbe 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 @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using Jint; using Jint.Native; using Squidex.Domain.Apps.Core.Properties; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; @@ -65,11 +66,13 @@ public sealed class EventJintExtension(IUrlGenerator urlGenerator) : IJintExtens } } - public void Extend(ScriptExecutionContext context) + public void Extend(Engine engine) { - context.Engine.SetValue("console", FlowConsoleWrapper.Instance); + var context = engine.GetContext(); - context.Engine.SetValue("contentAction", new EventDelegate(() => + engine.SetValue("console", FlowConsoleWrapper.Instance); + + engine.SetValue("contentAction", new EventDelegate(() => { if (context.TryGetValue("event", out var temp) && temp is EnrichedContentEvent contentEvent) { @@ -79,7 +82,7 @@ public sealed class EventJintExtension(IUrlGenerator urlGenerator) : IJintExtens return JsValue.Null; })); - context.Engine.SetValue("contentUrl", new EventDelegate(() => + engine.SetValue("contentUrl", new EventDelegate(() => { if (context.TryGetValue("event", out var temp) && temp is EnrichedContentEvent contentEvent) { @@ -89,7 +92,7 @@ public sealed class EventJintExtension(IUrlGenerator urlGenerator) : IJintExtens return JsValue.Null; })); - context.Engine.SetValue("assetContentSlugUrl", new EventDelegate(() => + engine.SetValue("assetContentSlugUrl", new EventDelegate(() => { if (context.TryGetValue("event", out var temp) && temp is EnrichedAssetEvent assetEvent) { @@ -109,8 +112,8 @@ public sealed class EventJintExtension(IUrlGenerator urlGenerator) : IJintExtens return JsValue.Null; }); - context.Engine.SetValue("assetContentUrl", assetUrl); - context.Engine.SetValue("assetContentAppUrl", assetUrl); + engine.SetValue("assetContentUrl", assetUrl); + engine.SetValue("assetContentAppUrl", assetUrl); } public void Describe(AddDescription describe, ScriptScope scope) diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/EngineExtensions.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/EngineExtensions.cs new file mode 100644 index 000000000..07d74e9c2 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/EngineExtensions.cs @@ -0,0 +1,28 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Jint; + +namespace Squidex.Domain.Apps.Core.Scripting; + +public static class EngineExtensions +{ + public static ScriptExecutionContext GetContext(this Engine engine) + { + return ScriptExecutionContext.GetContext(engine); + } + + public static void Schedule(this Engine engine, Func action) + { + ScriptExecutionContext.GetContext(engine).Schedule(action); + } + + public static void Schedule(this Engine engine, Func> action, Action? callback) + { + ScriptExecutionContext.GetContext(engine).Schedule(action, callback); + } +} diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/HttpJintExtension.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/HttpJintExtension.cs index 6a683b461..efb3ae4d0 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/HttpJintExtension.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/HttpJintExtension.cs @@ -21,48 +21,48 @@ public sealed class HttpJintExtension(IHttpClientFactory httpClientFactory) : IJ private delegate void HttpJsonWithBodyDelegate(string url, JsValue body, Action callback, JsValue? headers = null, bool ignoreError = false); private delegate void HttpRequestDelegate(JsValue requestInit, Action callback); - public void ExtendAsync(ScriptExecutionContext context) + public void ExtendAsync(Engine engine) { - AddBodyMethod(context, HttpMethod.Patch, "patchJSON"); - AddBodyMethod(context, HttpMethod.Post, "postJSON"); - AddBodyMethod(context, HttpMethod.Put, "putJSON"); - AddMethod(context, HttpMethod.Delete, "deleteJSON"); - AddMethod(context, HttpMethod.Get, "getJSON"); - AddMethod(context, "request"); + AddBodyMethod(engine, HttpMethod.Patch, "patchJSON"); + AddBodyMethod(engine, HttpMethod.Post, "postJSON"); + AddBodyMethod(engine, HttpMethod.Put, "putJSON"); + AddMethod(engine, HttpMethod.Delete, "deleteJSON"); + AddMethod(engine, HttpMethod.Get, "getJSON"); + AddMethod(engine, "request"); } - private void AddMethod(ScriptExecutionContext context, string name) + private void AddMethod(Engine engine, string name) { var action = new HttpRequestDelegate((requestInit, callback) => { - var httpRequest = ParseRequestInit(requestInit); - Request(context, httpRequest.Method, httpRequest.Url, httpRequest.Body, callback, httpRequest.Headers, true, true); + var (url, method, headers, body) = ParseRequestInit(requestInit); + Request(engine, method, url, body, callback, headers, true, true); }); - context.Engine.SetValue(name, action); + engine.SetValue(name, action); } - private void AddMethod(ScriptExecutionContext context, HttpMethod method, string name) + private void AddMethod(Engine engine, HttpMethod method, string name) { var action = new HttpJsonDelegate((url, callback, headers, ignoreError) => { - Request(context, method, url, null, callback, headers, ignoreError); + Request(engine, method, url, null, callback, headers, ignoreError); }); - context.Engine.SetValue(name, action); + engine.SetValue(name, action); } - private void AddBodyMethod(ScriptExecutionContext context, HttpMethod method, string name) + private void AddBodyMethod(Engine engine, HttpMethod method, string name) { var action = new HttpJsonWithBodyDelegate((url, body, callback, headers, ignoreError) => { - Request(context, method, url, body, callback, headers, ignoreError); + Request(engine, method, url, body, callback, headers, ignoreError); }); - context.Engine.SetValue(name, action); + engine.SetValue(name, action); } - private void Request(ScriptExecutionContext context, HttpMethod method, string url, JsValue? body, Action callback, JsValue? headers, bool ignoreError, bool forceRawResponse = false) + private void Request(Engine engine, HttpMethod method, string url, JsValue? body, Action callback, JsValue? headers, bool ignoreError, bool forceRawResponse = false) { if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) { @@ -74,53 +74,67 @@ public sealed class HttpJintExtension(IHttpClientFactory httpClientFactory) : IJ throw new JavaScriptException("Callback is not defined."); } - context.Schedule(async (scheduler, ct) => + // The request reads javascript values and is therefore created while we are still inside the engine. + var request = CreateRequest(engine, method, uri, body, headers); + + engine.Schedule(async ct => { try { - var httpClient = httpClientFactory.CreateClient("Jint"); - - var request = CreateRequest(context, method, uri, body, headers); - var response = await httpClient.SendAsync(request, ct); - - if (!ignoreError) + using (request) { - response.EnsureSuccessStatusCode(); - } + var httpClient = httpClientFactory.CreateClient("Jint"); - JsValue responseObject; + using var response = await httpClient.SendAsync(request, ct); - var responseString = await response.Content.ReadAsStringAsync(ct); - - if (ignoreError && (forceRawResponse || !response.IsSuccessStatusCode || string.IsNullOrEmpty(responseString))) - { - responseObject = JsValue.FromObject(context.Engine, new Dictionary + if (!ignoreError) { - ["statusCode"] = (int)response.StatusCode, - ["headers"] = + response.EnsureSuccessStatusCode(); + } + + var responseString = await response.Content.ReadAsStringAsync(ct); + + return ( + StatusCode: (int)response.StatusCode, + Headers: response.Content.Headers .Concat(response.Headers) .Concat(response.TrailingHeaders) .GroupBy(x => x.Key) .ToDictionary(x => x.Key, x => x.Last().Value.First()), - ["body"] = responseString, - }); - } - else - { - responseObject = ParseResponse(context, responseString, ct); + Body: responseString, + IsRaw: ignoreError && (forceRawResponse || !response.IsSuccessStatusCode || string.IsNullOrEmpty(responseString)) + ); } - - scheduler.Run(callback, responseObject); } - catch (Exception ex) + catch (Exception ex) when (ex is not OperationCanceledException) { throw new JavaScriptException(ex.Message); } + }, + response => + { + JsValue responseObject; + + if (response.IsRaw) + { + responseObject = JsValue.FromObject(engine, new Dictionary + { + ["statusCode"] = response.StatusCode, + ["headers"] = response.Headers, + ["body"] = response.Body, + }); + } + else + { + responseObject = new JsonParser(engine).Parse(response.Body); + } + + callback(responseObject); }); } - private static HttpRequestMessage CreateRequest(ScriptExecutionContext context, + private static HttpRequestMessage CreateRequest(Engine engine, HttpMethod method, Uri uri, JsValue? body, @@ -166,7 +180,7 @@ public sealed class HttpJintExtension(IHttpClientFactory httpClientFactory) : IJ } else { - var jsonWriter = new JsonSerializer(context.Engine); + var jsonWriter = new JsonSerializer(engine); var jsonContent = jsonWriter.Serialize(body, JsValue.Undefined, JsValue.Undefined)?.ToString(); if (jsonContent != null) @@ -179,19 +193,6 @@ public sealed class HttpJintExtension(IHttpClientFactory httpClientFactory) : IJ return request; } - private static JsValue ParseResponse(ScriptExecutionContext context, string responseString, - CancellationToken ct) - { - ct.ThrowIfCancellationRequested(); - - var jsonParser = new JsonParser(context.Engine); - var jsonValue = jsonParser.Parse(responseString); - - ct.ThrowIfCancellationRequested(); - - return jsonValue; - } - public void Describe(AddDescription describe, ScriptScope scope) { if (!scope.HasFlag(ScriptScope.Async)) diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/StringAsyncJintExtension.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/StringAsyncJintExtension.cs index dbd1fcadd..b8d5fb3b7 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/StringAsyncJintExtension.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/StringAsyncJintExtension.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using Jint; using Jint.Native; using Jint.Runtime; using Squidex.AI; @@ -20,39 +21,40 @@ public sealed class StringAsyncJintExtension(ITranslator translator, IChatAgent private delegate void TextGenerateDelegate(string prompt, Action callback); private delegate void TextTranslateDelegate(string text, string language, Action callback, string sourceLanguage); - public void ExtendAsync(ScriptExecutionContext context) + public void ExtendAsync(Engine engine) { var generate = new TextGenerateDelegate((prompt, callback) => { - Generate(context, prompt, callback); + Generate(engine, prompt, callback); }); var translate = new TextTranslateDelegate((text, language, callback, sourceLanguage) => { - Translate(context, text, language, callback, sourceLanguage); + Translate(engine, text, language, callback, sourceLanguage); }); - context.Engine.SetValue("generate", generate); - context.Engine.SetValue("translate", translate); + engine.SetValue("generate", generate); + engine.SetValue("translate", translate); } - private void Generate(ScriptExecutionContext context, string prompt, Action callback) + private void Generate(Engine engine, string prompt, Action callback) { if (callback == null) { throw new JavaScriptException("Callback is not defined."); } - context.Schedule(async (scheduler, ct) => + // We are still inside the engine here, therefore the callback can be invoked directly. + if (string.IsNullOrWhiteSpace(prompt)) + { + callback(JsValue.Null); + return; + } + + engine.Schedule(async ct => { try { - if (string.IsNullOrWhiteSpace(prompt)) - { - scheduler.Run(callback, JsValue.Null); - return; - } - var request = new ChatRequest { Prompt = prompt, @@ -60,41 +62,44 @@ public sealed class StringAsyncJintExtension(ITranslator translator, IChatAgent var result = await chatAgent.PromptAsync(request, ct: ct); - scheduler.Run(callback, JsValue.FromObject(context.Engine, result.Content)); + return result.Content; } - catch (Exception ex) + catch (Exception ex) when (ex is not OperationCanceledException) { throw new JavaScriptException(ex.Message); } - }); + }, + content => callback(JsValue.FromObject(engine, content))); } - private void Translate(ScriptExecutionContext context, string text, string language, Action callback, string sourceLanguage) + private void Translate(Engine engine, string text, string language, Action callback, string sourceLanguage) { if (callback == null) { throw new JavaScriptException("Callback is not defined."); } - context.Schedule(async (scheduler, ct) => + // We are still inside the engine here, therefore the callback can be invoked directly. + if (string.IsNullOrWhiteSpace(text) || string.IsNullOrWhiteSpace(language)) + { + callback(JsValue.Null); + return; + } + + engine.Schedule(async ct => { try { - if (string.IsNullOrWhiteSpace(text) || string.IsNullOrWhiteSpace(language)) - { - scheduler.Run(callback, JsValue.Null); - return; - } - var translation = await translator.TranslateAsync(text, language, sourceLanguage, ct); - scheduler.Run(callback, JsValue.FromObject(context.Engine, translation.Text)); + return translation.Text; } - catch (Exception ex) + catch (Exception ex) when (ex is not OperationCanceledException) { throw new JavaScriptException(ex.Message); } - }); + }, + translated => callback(JsValue.FromObject(engine, translated))); } public void Describe(AddDescription describe, ScriptScope scope) diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/IJintExtension.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/IJintExtension.cs index 0f0f6d391..014c92219 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/IJintExtension.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/IJintExtension.cs @@ -15,11 +15,7 @@ public interface IJintExtension { } - void Extend(ScriptExecutionContext context) - { - } - - void ExtendAsync(ScriptExecutionContext context) + void ExtendAsync(Engine engine) { } } 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 7787448fe..86c882877 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 @@ -37,7 +37,7 @@ public static class JintExtensions { foreach (var extension in extensions) { - extension.ExtendAsync(context); + extension.ExtendAsync(context.Engine); } return context; @@ -48,7 +48,7 @@ public static class JintExtensions { foreach (var extension in extensions) { - extension.Extend(context); + extension.Extend(context.Engine); } return context; diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScriptEngine.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScriptEngine.cs index 8ad87b9a6..b2f056f8d 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScriptEngine.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScriptEngine.cs @@ -62,6 +62,11 @@ public sealed class JintScriptEngine(IMemoryCache cache, IOptions TransformAsync(DataScriptVars vars, string script, ScriptOptions options = default, @@ -110,6 +115,11 @@ public sealed class JintScriptEngine(IMemoryCache cache, IOptions(engine, ct); } diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptExecutionContext.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptExecutionContext.cs index fc92c80ab..9f2a527c0 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptExecutionContext.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptExecutionContext.cs @@ -5,27 +5,54 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Runtime.CompilerServices; using Acornima.Ast; using Jint; using Jint.Native; +using Squidex.Infrastructure; using Squidex.Infrastructure.Tasks; namespace Squidex.Domain.Apps.Core.Scripting; -public abstract class ScriptExecutionContext(Engine engine) : ScriptVars +public abstract class ScriptExecutionContext : ScriptVars { - public Engine Engine { get; } = engine; + private static readonly ConditionalWeakTable Contexts = new ConditionalWeakTable(); + + public Engine Engine { get; } + + protected ScriptExecutionContext(Engine engine) + { + Engine = engine; + + // The extensions only get the engine and resolve the context from there. + Contexts.AddOrUpdate(engine, this); + } + + public static ScriptExecutionContext GetContext(Engine engine) + { + if (!Contexts.TryGetValue(engine, out var context)) + { + ThrowHelper.InvalidOperationException("Engine is not attached to a script context."); + return default!; + } + + return context; + } public abstract JsValue Evaluate(Prepared