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..2dc19097d 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 @@ -7,6 +7,7 @@ using Jint; using Jint.Native; +using Jint.Runtime.Interop; using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Core.Scripting.Internal; @@ -73,7 +74,10 @@ public static class JintExtensions { foreach (var (key, item) in vars) { - engine.SetValue(key, item); + // Deferred instead of Engine.SetValue, which maps every variable now. The global itself is + // installed eagerly, so existence checks and enumeration see the name without materializing + // anything; only the mapping waits for the first read of the value. + engine.Advanced.AddLazyGlobal(key, e => MapVariable(e, item)); } } @@ -81,4 +85,18 @@ public static class JintExtensions return context; } + + /// + /// The conversion performs, including its special case for + /// a CLR type, so deferring a variable cannot change what the script sees. + /// + private static JsValue MapVariable(Engine engine, object? item) + { + if (item is Type type) + { + return TypeReference.CreateTypeReference(engine, type); + } + + return JsValue.FromObject(engine, item); + } } diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/WritableContext.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/WritableContext.cs index a04b3a255..3ccfcab70 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/WritableContext.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/WritableContext.cs @@ -1,4 +1,4 @@ -// ========================================================================== +// ========================================================================== // Squidex Headless CMS // ========================================================================== // Copyright (c) Squidex UG (haftungsbeschraenkt) @@ -8,6 +8,7 @@ using Jint; using Jint.Native; using Jint.Native.Object; +using Jint.Runtime.Descriptors; namespace Squidex.Domain.Apps.Core.Scripting; @@ -20,9 +21,17 @@ internal sealed class WritableContext : ObjectInstance { this.vars = vars; + // Scripts touch a fraction of the variables, but mapping one is not always cheap: a content data + // variable builds a wrapper, a user variable walks and groups every claim. The descriptors are + // installed eagerly - so key order, enumeration and existence checks are exactly what they were - + // and only the mapping waits for the first read of a value. Once it has run the descriptor drops + // back to an ordinary data property and rejoins the write inline cache, which is what a + // hand-written CustomJsValue descriptor cannot do. foreach (var (key, item) in vars) { - base.Set(key, FromObject(engine, item), this); + SetOwnProperty(key, PropertyDescriptor.CreateLazy( + (Engine: engine, Item: item), + static state => FromObject(state.Engine, state.Item))); } } diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineTests.cs index 365f44cb3..fec93fd7b 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineTests.cs @@ -1047,6 +1047,153 @@ public class JintScriptEngineTests : IClassFixture Assert.Equal(JsonValue.Create("number,json,user"), actual); } + [Fact] + public void Should_not_map_unread_variable() + { + var principal = new CountingPrincipal(); + + var vars = new ScriptVars + { + ["number"] = 13, + ["user"] = principal, + }; + + const string script = @" + number + 1; + "; + + var actual = sut.Execute(vars, script); + + Assert.Equal(JsonValue.Create(14), actual); + Assert.Equal(0, principal.Reads); + } + + [Fact] + public void Should_see_unread_variable_in_enumeration() + { + var principal = new CountingPrincipal(); + + var vars = new ScriptVars + { + ["user"] = principal, + }; + + const string script = @" + ('user' in globalThis) + ',' + (Object.getOwnPropertyNames(globalThis).indexOf('user') >= 0); + "; + + var actual = sut.Execute(vars, script); + + Assert.Equal(JsonValue.Create("true,true"), actual); + Assert.Equal(0, principal.Reads); + } + + [Fact] + public void Should_map_variable_on_first_read() + { + var principal = new CountingPrincipal(); + + var vars = new ScriptVars + { + ["user"] = principal, + }; + + const string script = @" + user.id; + "; + + var actual = sut.Execute(vars, script); + + Assert.Equal(JsonValue.Create("user1"), actual); + Assert.True(principal.Reads > 0); + } + + [Fact] + public void Should_not_map_unread_context_variable() + { + var principal = new CountingPrincipal(); + + var vars = new ScriptVars + { + ["number"] = 13, + ["user"] = principal, + }; + + const string script = @" + ctx.number + 1; + "; + + var actual = sut.Execute(vars, script, new ScriptOptions { AsContext = true }); + + Assert.Equal(JsonValue.Create(14), actual); + Assert.Equal(0, principal.Reads); + } + + [Fact] + public void Should_see_unread_context_variable_in_enumeration() + { + var principal = new CountingPrincipal(); + + var vars = new ScriptVars + { + ["number"] = 13, + ["user"] = principal, + }; + + const string script = @" + Object.keys(ctx).join(',') + '|' + ('user' in ctx); + "; + + var actual = sut.Execute(vars, script, new ScriptOptions { AsContext = true }); + + Assert.Equal(JsonValue.Create("number,user|true"), actual); + Assert.Equal(0, principal.Reads); + } + + [Fact] + public void Should_map_context_variable_on_first_read() + { + var principal = new CountingPrincipal(); + + var vars = new ScriptVars + { + ["user"] = principal, + }; + + const string script = @" + ctx.user.id; + "; + + var actual = sut.Execute(vars, script, new ScriptOptions { AsContext = true }); + + Assert.Equal(JsonValue.Create("user1"), actual); + Assert.True(principal.Reads > 0); + } + + private sealed class CountingPrincipal : ClaimsPrincipal + { + public int Reads { get; private set; } + + public CountingPrincipal() + : base(new ClaimsIdentity( + [ + new Claim(OpenIdClaims.Subject, "user1"), + new Claim(OpenIdClaims.Name, "user"), + ], "Squidex")) + { + } + + public override IEnumerable Claims + { + get + { + Reads++; + + return base.Claims; + } + } + } + private static ScriptVars CreateVars() { return new ScriptVars