Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/JsxCore/JsxCore.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="Jint" Version="4.15.3" />
<PackageReference Include="Jint" Version="4.16.0" />
</ItemGroup>

<!--
Expand Down
6 changes: 3 additions & 3 deletions src/JsxCore/Rendering/JsxModuleLoader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -161,8 +161,8 @@ public Module LoadModule(Engine engine, ResolvedSpecifier resolved)

// The name the engine gives back as the referencing location when this module's own
// relative imports are resolved, so it has to be the one the engine would have derived
// from the resolved specifier itself.
var location = resolved.Uri?.LocalPath ?? resolved.Key;
// from the resolved specifier itself. LocationOf is that rule, published for exactly this.
var location = ModuleFactory.LocationOf(resolved);
var prepared = _modules.GetOrParse(location, () => ReadSource(resolved.Key));
return ModuleFactory.BuildSourceTextModule(engine, in prepared);
}
Expand Down Expand Up @@ -231,7 +231,7 @@ private Module LoadPackageModule(Engine engine, ResolvedSpecifier resolved)

// What is cached is the module as the engine sees it, after the CommonJS wrapping, so a
// second engine pays for neither the read nor the transform.
var location = resolved.Uri?.LocalPath ?? resolved.Key;
var location = ModuleFactory.LocationOf(resolved);
var prepared = _modules.GetOrParse(
location,
() => ModuleTransform.Apply(path, kind, File.ReadAllText(path), new EngineSpecifierRewriter(npm)).Source);
Expand Down
107 changes: 87 additions & 20 deletions src/JsxCore/Rendering/JsxServerRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@
using System.Text.Json;
using Acornima.Ast;
using Jint;
using Jint.Constraints;
using Jint.Native;
using Jint.Runtime;
using Jint.Runtime.Interop;
using JsxCore.Compilation;
using JsxCore.Compilation.Assets;
using JsxCore.Compilation.Modules;
using JsxCore.Interop;
using JsonParser = Jint.Native.Json.JsonParser;

namespace JsxCore.Rendering;
Expand Down Expand Up @@ -131,14 +133,20 @@ private async Task<ServerRenderResult> ExecuteAsync(
{
var buildId = _compilation.BuildId;
var pooled = Rent(buildId);

// The scope the engine's globals bridge resolves from, for as long as this render holds
// the engine. Cleared afterwards so an engine waiting in the pool is not holding on to
// a request whose scope has ended.
pooled.Globals.Services = services;
pooled.Deadline.Begin(_options.ServerRendering.Timeout, cancellationToken);
try
{
return Render(pooled.Engine, view, modelJson, contextJson, services, entryPoint);
return Render(pooled.Engine, view, modelJson, contextJson, entryPoint);
}
finally
{
pooled.Deadline.End();
pooled.Globals.Services = null;
Return(pooled);
}
}
Expand All @@ -153,13 +161,10 @@ private ServerRenderResult Render(
LocatedView view,
string modelJson,
string contextJson,
IServiceProvider services,
string entryPoint)
{
try
{
InstallGlobals(engine, services);

var parser = new JsonParser(engine);
var props = new JsObject(engine);
props.Set("model", parser.Parse(modelJson));
Expand All @@ -186,6 +191,14 @@ private ServerRenderResult Render(
$"JsxCore failed to server-render '{view.ViewName}': {ex.Message}{Environment.NewLine}" +
$"{ex.JavaScriptStackTrace}", ex);
}
catch (TimeoutException ex)
{
// The engine reports an elapsed budget in its own words, which do not say whose budget
// it was. Which one ran out is the part a host can act on, so the render says it.
throw new JsxRenderException(
$"JsxCore failed to server-render '{view.ViewName}'.",
new TimeoutException("The render exceeded the configured server rendering timeout.", ex));
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
throw new JsxRenderException($"JsxCore failed to server-render '{view.ViewName}'.", ex);
Expand All @@ -207,22 +220,49 @@ private ServerRenderResult Render(
/// <summary>The object the registered .NET globals are installed on, for one render.</summary>
private const string GlobalsName = "__jsxcore_dotnet";

private void InstallGlobals(Engine engine, IServiceProvider services)
/// <summary>
/// The .NET side of the globals bridge for one pooled engine: what the application registered,
/// and which request's scope the render currently holding the engine resolves it from.
/// </summary>
/// <remarks>
/// <para>
/// The registered objects come from the request's service scope, so one of them can be a
/// database context, a localizer, or anything else a scope builds on demand — and most views
/// read none of them. Building the bridge is therefore left until a view asks for it: the
/// engine holds the global as a factory, and a render that never mentions <c>dotnet:globals</c>
/// resolves no service and builds no wrapper. Importing one is not asking for it either, since
/// the JavaScript side hands out proxies that reach this only when a member is read.
/// </para>
/// <para>
/// Read at the moment a view asks, rather than captured per render, so a global registered
/// after startup is still reachable — which is what the catch-all export exists for.
/// </para>
/// </remarks>
private sealed class GlobalsBridge(JsxGlobalRegistry globals)
{
var registrations = _options.Globals.Registrations;
if (registrations.Count == 0)
{
engine.SetValue(GlobalsName, JsValue.Undefined);
return;
}
/// <summary>The scope of the render holding this engine, or null between renders.</summary>
public IServiceProvider? Services { get; set; }

var globals = new JsObject(engine);
foreach (var (name, registration) in registrations)
public JsValue Build(Engine engine)
{
globals.Set(name, JsValue.FromObject(engine, registration.Factory(services)));
}
var registrations = globals.Registrations;
var services = Services;

if (services is null || registrations.Count == 0)
{
// What an application that registered nothing has always presented, and what the
// runtime reads as "there is nothing here to reach" when a view asks anyway.
return JsValue.Undefined;
}

var bridge = new JsObject(engine);
foreach (var (name, registration) in registrations)
{
bridge.Set(name, JsValue.FromObject(engine, registration.Factory(services)));
}

engine.SetValue(GlobalsName, globals);
return bridge;
}
}

private PooledEngine Rent(string buildId)
Expand Down Expand Up @@ -309,14 +349,27 @@ private static void Discard(PooledEngine engine)

private PooledEngine CreateEngine(string buildId)
{
// Watching means the views can be recompiled while the application runs, and every rebuild
// throws this compilation's parsed modules away for a new set that one or two engines will
// read before the next edit does the same. Preparing those the cheaper way is worth more
// than the bookkeeping the fuller preparation would hand each engine. A server that will
// not recompile keeps its parses for the life of the pool, which is where the fuller
// preparation earns back what it costs.
var staticAnalysis = _options.WatchForChanges != true;

var loader = new JsxModuleLoader(
_compilation.Layout,
_runtime,
_options.AllowNodeModules ? _npm : null,
_moduleCache.Get(buildId, static () => new ServerModuleCache()));
_moduleCache.Get(buildId, () => new ServerModuleCache(staticAnalysis)));

var settings = _options.ServerRendering;
var deadline = new RenderDeadline();

// One budget for the whole render rather than one per entry into the engine. The engine
// resets its constraints at every entry from the host, and a render enters several times,
// so a plain timeout would give each entry the configured budget over again; this one is
// armed by the host and declines that reset.
var deadline = new OperationDeadlineConstraint();

var engine = new Engine(options =>
{
Expand Down Expand Up @@ -344,12 +397,21 @@ private PooledEngine CreateEngine(string buildId)
// and isServerRender() used to answer that wrongly.
engine.SetValue(ServerFlag, true);

// Declared once for the engine, and before the snapshot below, which is what makes it a
// per-render bridge without a per-render write: the engine resolves the factory the first
// time a view reads the global and not at all otherwise, and returning the engine to the
// pool puts the property back to unresolved, so the next render resolves against the next
// request's scope. Installed after the snapshot instead, it would be a global the restore
// has to remove and the next render has to add again.
var globals = new GlobalsBridge(_options.Globals);
engine.Advanced.AddLazyGlobal(GlobalsName, globals, static (engine, globals) => globals.Build(engine));

// Taken last, so that everything above it is part of what the engine is built with: a render
// returning the engine to the pool restores this surface, and the shims and the flag have to
// survive that rather than be swept away with the render's own leavings.
var cleanGlobals = engine.Advanced.CaptureGlobalSnapshot();

return new PooledEngine(engine, buildId, cleanGlobals, deadline);
return new PooledEngine(engine, buildId, cleanGlobals, deadline, globals);
}

private static IEnumerable<string> MemberNames(System.Reflection.MemberInfo member)
Expand Down Expand Up @@ -394,9 +456,14 @@ public void Dispose()
/// <param name="Deadline">
/// The engine's own time budget, armed for the render currently holding it.
/// </param>
/// <param name="Globals">
/// The engine's bridge to the registered .NET objects, pointed at the scope of the render
/// currently holding it.
/// </param>
private sealed record PooledEngine(
Engine Engine,
string BuildId,
GlobalSnapshot CleanGlobals,
RenderDeadline Deadline);
OperationDeadlineConstraint Deadline,
GlobalsBridge Globals);
}
86 changes: 0 additions & 86 deletions src/JsxCore/Rendering/RenderDeadline.cs

This file was deleted.

22 changes: 20 additions & 2 deletions src/JsxCore/Rendering/ServerModuleCache.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,35 @@ namespace JsxCore.Rendering;
/// grows, or refills after a rebuild.
/// </summary>
/// <remarks>
/// <para>
/// A pool fills under a burst of requests, and the engines it builds walk the same modules in the
/// same order — so the natural race is every engine parsing every module and all but one result
/// being thrown away. The <see cref="Lazy{T}"/> holds that door: whoever asks first parses, and
/// the rest wait for that result instead of duplicating it.
/// </para>
/// <para>
/// <paramref name="staticAnalysis"/> chooses which half of the engine's preparation trade this
/// compilation wants. Preparing with the analysis pass costs about twice a plain parse and hands
/// every engine a tree with the interpreter's own bookkeeping already on it; preparing without it
/// leaves each engine to work that out as it reaches a node. Whichever is cheaper depends on how
/// many engines end up reading a parse, and a set of modules thrown away on the next edit is read
/// by very few.
/// </para>
/// </remarks>
internal sealed class ServerModuleCache
internal sealed class ServerModuleCache(bool staticAnalysis)
{
/// <summary>
/// Shared, because it is a description of how to prepare rather than state: one instance for
/// every cache that wants a parse without the analysis pass.
/// </summary>
private static readonly ModulePreparationOptions ParseOnly = new() { StaticAnalysis = false };

private readonly ModulePreparationOptions? _preparation = staticAnalysis ? null : ParseOnly;

private readonly ConcurrentDictionary<string, Lazy<Prepared<AstModule>>> _modules = new(StringComparer.Ordinal);

public Prepared<AstModule> GetOrParse(string location, Func<string> readSource) =>
_modules.GetOrAdd(
location,
l => new Lazy<Prepared<AstModule>>(() => Engine.PrepareModule(readSource(), l))).Value;
l => new Lazy<Prepared<AstModule>>(() => Engine.PrepareModule(readSource(), l, _preparation))).Value;
}
Loading
Loading