Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,29 @@ public override PropertyDescriptor GetOwnProperty(JsValue property)
return valueProperties?.GetValueOrDefault(propertyName) ?? PropertyDescriptor.Undefined;
}

protected override OwnPropertyProbe ProbeOwnProperty(JsValue property)
{
// Deliberately mirrors GetOwnProperty above, minus the descriptor: the flags are on the descriptor
// itself, so an existence or enumerability question is answered without ever reading CustomValue,
// which is what maps the JSON value to a JsValue. The engine trusts the answer without verifying it,
// so the two must stay in step.
EnsurePropertiesInitialized();

var propertyName = property.AsString();

if (propertyName.Equals("toJSON", StringComparison.OrdinalIgnoreCase))
{
return OwnPropertyProbe.Missing;
}

if (!valueProperties.TryGetValue(propertyName, out var propertyDescriptor))
{
return OwnPropertyProbe.Missing;
}

return propertyDescriptor.Enumerable ? OwnPropertyProbe.Enumerable : OwnPropertyProbe.NonEnumerable;
}

public override IEnumerable<KeyValuePair<JsValue, PropertyDescriptor>> GetOwnProperties()
{
EnsurePropertiesInitialized();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

using Jint;
using Jint.Native;
using Jint.Runtime.Interop;
using Squidex.Infrastructure;

namespace Squidex.Domain.Apps.Core.Scripting.Internal;
Expand Down Expand Up @@ -73,12 +74,29 @@ internal static ScriptExecutionContext<T> ExtendWithVariables<T>(this ScriptExec
{
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));
}
}

engine.SetValue("async", true);

return context;
}

/// <summary>
/// The conversion <see cref="Engine.SetValue(string, object)"/> performs, including its special case for
/// a CLR type, so deferring a variable cannot change what the script sees.
/// </summary>
private static JsValue MapVariable(Engine engine, object? item)
{
if (item is Type type)
{
return TypeReference.CreateTypeReference(engine, type);
}

return JsValue.FromObject(engine, item);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,29 @@ namespace Squidex.Domain.Apps.Core.Scripting.Internal;

public sealed class JintObjectConverter : IObjectConverter
{
/// <summary>
/// The CLR types this converter answers for, declared at registration so the engine can keep its
/// compiled interop member-read lane for members whose declared type can never reach this converter.
/// </summary>
/// <remarks>
/// Matching is by assignability, so <see cref="IUser"/> covers every implementation. Registering the
/// converter without this set makes every wrapped CLR member read in the engine take the slow lane.
/// Enums are not listed: they are handled natively through
/// <see cref="Options.InteropOptions.EnumConversion"/>.
/// </remarks>
public static readonly Type[] HandledTypes =
[
typeof(IUser),
typeof(ClaimsPrincipal),
typeof(ScriptVars),
typeof(JsonValue),
typeof(DomainId),
typeof(Guid),
typeof(Instant),
typeof(Status),
typeof(ContentData),
];

public static readonly JintObjectConverter Instance = new JintObjectConverter();

private JintObjectConverter()
Expand All @@ -31,12 +54,6 @@ public bool TryConvert(Engine engine, object value, [MaybeNullWhen(false)] out J
{
result = null!;

if (value is Enum)
{
result = value.ToString();
return true;
}

switch (value)
{
case IUser user:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
// ==========================================================================

using System.Collections;
using System.Globalization;
using Jint;
using Jint.Native;
using Jint.Native.Object;
Expand All @@ -18,10 +17,6 @@ namespace Squidex.Domain.Apps.Core.Scripting.Internal;

public static class JsonMapper
{
private sealed class JsonObjectInstance(Engine engine) : ObjectInstance(engine)
{
}

public static JsValue Map(JsonValue value, Engine engine)
{
switch (value.Value)
Expand All @@ -33,9 +28,9 @@ public static JsValue Map(JsonValue value, Engine engine)
case false:
return JsBoolean.False;
case double n:
return new JsNumber(n);
return JsNumber.Create(n);
case string s:
return new JsString(s);
return JsString.Create(s);
case JsonObject o:
return FromObject(o, engine);
case JsonArray a:
Expand All @@ -58,16 +53,20 @@ private static JsArray FromArray(JsonArray arr, Engine engine)
return engine.Intrinsics.Array.Construct(target);
}

private static JsonObjectInstance FromObject(JsonObject obj, Engine engine)
private static JsObject FromObject(JsonObject obj, Engine engine)
{
var target = new JsonObjectInstance(engine);
// Built through the hidden class machinery, so JSON objects sharing a key sequence - every content
// item of the same schema does - share one hidden class and keep a script reading them monomorphic.
// A bare ObjectInstance subclass can never be in shape mode and is outside the read caches entirely.
var entries = new KeyValuePair<string, JsValue>[obj.Count];

var index = 0;
foreach (var (key, value) in obj)
{
target.Set(key, Map(value, engine));
entries[index++] = new KeyValuePair<string, JsValue>(key, Map(value, engine));
}

return target;
return JsObject.CreateFromEntries(engine, entries);
}

public static JsonValue Map(JsValue? value)
Expand Down Expand Up @@ -116,11 +115,15 @@ public static JsonValue Map(JsValue? value)

if (value is JsArray a)
{
var result = new JsonArray((int)a.Length);
var length = a.Length;

var result = new JsonArray((int)length);

for (var i = 0; i < a.Length; i++)
// The indexed accessor reads the dense backing directly, where a string key would allocate one
// key per element and route through the full property lookup.
for (var i = 0u; i < length; i++)
{
result.Add(Map(a.Get(i.ToString(CultureInfo.InvariantCulture))));
result.Add(Map(a[i]));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No idea what this means.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's just a simpler way to index array adirectly without creating a temporary string to create numeric index which Jint then has to transform to number again.

}

return result;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,10 +143,11 @@ private ScriptExecutionContext<T> CreateEngine<T>(ScriptOptions options, Cancell

var engine = new Engine(engineOptions =>
{
engineOptions.AddObjectConverter(JintObjectConverter.Instance);
engineOptions.AddObjectConverter(JintObjectConverter.Instance, JintObjectConverter.HandledTypes);
engineOptions.AllowClrWrite(!options.Readonly);
engineOptions.Interop.EnumConversion = EnumConversionMode.String;
engineOptions.SetTypeConverter(engine => new CustomClrConverter(engine));
engineOptions.SetReferencesResolver(NullPropagation.Instance);
engineOptions.SetReferencesResolver(NullPropagation.Instance, NullPropagation.Interests);
engineOptions.Strict();

if (!Debugger.IsAttached)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,38 @@ namespace Squidex.Domain.Apps.Core.Scripting;

public sealed class NullPropagation : IReferenceResolver
{
/// <summary>
/// The situations this resolver actually answers, declared so the engine keeps the fast paths for
/// everything else.
/// </summary>
/// <remarks>
/// Deliberately omitted are <see cref="ReferenceResolverInterests.ObjectPropertyBase"/> and
/// <see cref="ReferenceResolverInterests.PrimitivePropertyBase"/>, the pair that disables the
/// non-computed member-read inline caches, the dense-array indexed-read lane and the member-call callee
/// lane engine-wide. <see cref="TryPropertyReference"/> declines every base that is not null or
/// undefined, so those are situations where the engine consulting this resolver could never change the
/// result. Interests are a subscription filter and not a promise: a situation not subscribed to behaves
/// exactly as if no resolver were registered.
/// </remarks>
public const ReferenceResolverInterests Interests =
ReferenceResolverInterests.NullishPropertyBase |
ReferenceResolverInterests.UnresolvableReference |
ReferenceResolverInterests.NonCallableCallee;

public static readonly NullPropagation Instance = new NullPropagation();

/// <summary>
/// Answers a read of a name that resolves to no binding, so that an unknown name does not throw a
/// reference error.
/// </summary>
/// <remarks>
/// Passing the reference base straight through hands script the engine's internal sentinel for the
/// unresolvable state - a <see cref="JsString"/> reading <c>[[Unresolvable]]</c> - rather than
/// <c>undefined</c>, which is documented on <see cref="IReferenceResolver.TryUnresolvableReference"/> and
/// on <see cref="Reference.Base"/>. That is what scripts have always seen here, so it is kept and pinned
/// by a test; assigning <see cref="JsValue.Undefined"/> instead would be the tidier behaviour but a
/// breaking change for existing tenant scripts.
/// </remarks>
public bool TryUnresolvableReference(Engine engine, Reference reference, out JsValue value)
{
value = reference.Base;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// ==========================================================================
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
Expand All @@ -8,6 +8,7 @@
using Jint;
using Jint.Native;
using Jint.Native.Object;
using Jint.Runtime.Descriptors;

namespace Squidex.Domain.Apps.Core.Scripting;

Expand All @@ -20,9 +21,17 @@ public WritableContext(Engine engine, ScriptVars vars)
{
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)));
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
<ItemGroup>
<PackageReference Include="Fluid.Core" Version="2.31.0" />
<PackageReference Include="GeoJSON.Net" Version="1.4.1" />
<PackageReference Include="Jint" Version="4.8.0" />
<PackageReference Include="Jint" Version="4.15.3" />
<PackageReference Include="Meziantou.Analyzer" Version="3.0.50">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
Expand Down
Loading
Loading