Skip to content

Commit

Permalink
Refactoring for custom module loader support (#1731)
Browse files Browse the repository at this point in the history
  • Loading branch information
tomatosalat0 committed Jan 8, 2024
1 parent 1de2334 commit 8000a7f
Show file tree
Hide file tree
Showing 4 changed files with 385 additions and 45 deletions.
264 changes: 264 additions & 0 deletions Jint.Tests.PublicInterface/ModuleLoaderTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
using System.Collections.Concurrent;
using Jint.Native;
using Jint.Native.Json;
using Jint.Runtime.Modules;

#nullable enable

namespace Jint.Tests.PublicInterface;

public class ModuleLoaderTests
{
[Fact]
public void CustomModuleLoaderWithUriModuleLocations()
{
// Dummy module store which shows that different protocols can be
// used for modules.
var store = new ModuleStore(new Dictionary<string, string>()
{
["https://example.com/someModule.js"] = "export const DEFAULT_VALUE = 'remote';",
["https://example.com/test.js"] = "import { DEFAULT_VALUE } from 'someModule.js'; export const value = DEFAULT_VALUE;",
["file:///someModule.js"] = "export const value = 'local';",
["proprietary-protocol:///someModule.js"] = "export const value = 'proprietary';",
});
var sharedModules = new CachedModuleLoader(store);

var runA = RunModule("import { value } from 'https://example.com/test.js'; log(value);");
var runB = RunModule("import { value } from 'someModule.js'; log(value);");
var runC = RunModule("import { value } from 'proprietary-protocol:///someModule.js'; log(value);");

ExpectLoggedValue(runA, "remote");
ExpectLoggedValue(runB, "local");
ExpectLoggedValue(runC, "proprietary");

static void ExpectLoggedValue(ModuleScript executedScript, string expectedValue)
{
Assert.Single(executedScript.Logs);
Assert.Equal(expectedValue, executedScript.Logs[0]);
}

ModuleScript RunModule(string code)
{
var result = new ModuleScript(code, sharedModules);
result.Execute();
return result;
}
}

[Fact]
public void CustomModuleLoaderWithCachingSupport()
{
// Different engines use the same module loader.
// The module loader caches the parsed Esprima.Ast.Module
// which allows to re-use these for different engine runs.
var store = new ModuleStore(new Dictionary<string, string>()
{
["file:///localModule.js"] = "export const value = 'local';",
});
var sharedModules = new CachedModuleLoader(store);

// Simulate the re-use by simply running the same main entry point 10 times.
foreach (var _ in Enumerable.Range(0, 10))
{
var runner = new ModuleScript("import { value } from 'localModule.js'; log(value);", sharedModules);
runner.Execute();
}

Assert.Equal(1, sharedModules.ModulesParsed);
}

[Fact]
public void CustomModuleLoaderCanWorkWithJsonModules()
{
var store = new ModuleStore(new Dictionary<string, string>()
{
["file:///config.json"] = "{ \"value\": \"json\" }",
});
var sharedModules = new CachedModuleLoader(store);

var runner = new ModuleScript("import data from 'config.json' with { type: 'json' }; log(data.value);", sharedModules);
runner.Execute();

Assert.Single(runner.Logs);
Assert.Equal("json", runner.Logs[0]);
}

/// <summary>
/// A simple in-memory store for module sources. The keys
/// must be absolute <see cref="Uri.ToString()"/> values.
/// </summary>
/// <remarks>
/// This is just an example and not production ready code. The implementation
/// is missing important path traversal checks and other edge cases.
/// </remarks>
private sealed class ModuleStore
{
private const string DefaultProtocol = "file:///./";
private readonly IReadOnlyDictionary<string, string> _sourceCode;

public ModuleStore(IReadOnlyDictionary<string, string> sourceCode)
{
_sourceCode = sourceCode;
}

public ResolvedSpecifier Resolve(string? referencingModuleLocation, ModuleRequest moduleRequest)
{
Uri uri = Resolve(referencingModuleLocation, moduleRequest.Specifier);
return new ResolvedSpecifier(moduleRequest, moduleRequest.Specifier, uri, SpecifierType.Bare);
}

private Uri Resolve(string? referencingModuleLocation, string specifier)
{
if (Uri.TryCreate(specifier, UriKind.Absolute, out Uri? absoluteLocation))
return absoluteLocation;

if (!string.IsNullOrEmpty(referencingModuleLocation) && Uri.TryCreate(referencingModuleLocation, UriKind.Absolute, out Uri? baseUri))
{
if (Uri.TryCreate(baseUri, specifier, out Uri? relative))
return relative;
}

return new Uri(DefaultProtocol + specifier);
}

public string GetModuleSource(Uri uri)
{
if (!_sourceCode.TryGetValue(uri.ToString(), out var result))
throw new InvalidOperationException($"Module not found: {uri}");
return result;
}
}

/// <summary>
/// The main entry point for a module script. Allows
/// to use a script as a main module.
/// </summary>
private sealed class ModuleScript : IModuleLoader
{
private const string MainSpecifier = "____main____";
private readonly List<string> _logs = new();
private readonly Engine _engine;
private readonly string _main;
private readonly IModuleLoader _modules;

public ModuleScript(string main, IModuleLoader modules)
{
_main = main;
_modules = modules;

_engine = new Engine(options => options.EnableModules(this));
_engine.SetValue("log", _logs.Add);
}

public IReadOnlyList<string> Logs => _logs;

public void Execute()
{
_engine.Modules.Import(MainSpecifier);
}

ResolvedSpecifier IModuleLoader.Resolve(string? referencingModuleLocation, ModuleRequest moduleRequest)
{
if (moduleRequest.Specifier == MainSpecifier)
return new ResolvedSpecifier(moduleRequest, MainSpecifier, null, SpecifierType.Bare);
return _modules.Resolve(referencingModuleLocation, moduleRequest);
}

Module IModuleLoader.LoadModule(Engine engine, ResolvedSpecifier resolved)
{
if (resolved.ModuleRequest.Specifier == MainSpecifier)
return ModuleFactory.BuildSourceTextModule(engine, Engine.PrepareModule(_main, MainSpecifier));
return _modules.LoadModule(engine, resolved);
}
}

/// <summary>
/// <para>
/// A simple <see cref="IModuleLoader"/> implementation which will
/// re-use prepared <see cref="Esprima.Ast.Module"/> or <see cref="JsValue"/> modules to
/// produce <see cref="Jint.Runtime.Modules.Module"/>.
/// </para>
/// <para>
/// The module source gets loaded from <see cref="ModuleStore"/>.
/// </para>
/// </summary>
private sealed class CachedModuleLoader : IModuleLoader
{
private readonly ConcurrentDictionary<Uri, ParsedModule> _parsedModules = new();
private readonly ModuleStore _store;
#if NETCOREAPP1_0_OR_GREATER
private readonly Func<Uri, ResolvedSpecifier, ParsedModule> _moduleParser;
#endif
private int _modulesParsed;

public CachedModuleLoader(ModuleStore store)
{
_store = store;
#if NETCOREAPP1_0_OR_GREATER
_moduleParser = GetParsedModule;
#endif
}

public int ModulesParsed => _modulesParsed;

public ResolvedSpecifier Resolve(string? referencingModuleLocation, ModuleRequest moduleRequest)
{
return _store.Resolve(referencingModuleLocation, moduleRequest);
}

public Module LoadModule(Engine engine, ResolvedSpecifier resolved)
{
Assert.NotNull(resolved.Uri);
#if NETCOREAPP1_0_OR_GREATER
var parsedModule = _parsedModules.GetOrAdd(resolved.Uri, _moduleParser, resolved);
#else
var parsedModule = _parsedModules.GetOrAdd(resolved.Uri, _ => GetParsedModule(resolved.Uri, resolved));
#endif
return parsedModule.ToModule(engine);
}

private ParsedModule GetParsedModule(Uri uri, ResolvedSpecifier resolved)
{
var script = _store.GetModuleSource(resolved.Uri!);
var result = resolved.ModuleRequest.IsJsonModule()
? ParsedModule.JsonModule(script, resolved.Uri!.ToString())
: ParsedModule.TextModule(script, resolved.Uri!.ToString());
Interlocked.Increment(ref _modulesParsed);
return result;
}

private sealed class ParsedModule
{
private readonly Esprima.Ast.Module? _textModule;
private readonly (JsValue Json, string Location)? _jsonModule;

private ParsedModule(Esprima.Ast.Module? textModule, (JsValue Json, string Location)? jsonModule)
{
_textModule = textModule;
_jsonModule = jsonModule;
}

public static ParsedModule TextModule(string script, string location)
=> new(Engine.PrepareModule(script, location), null);

public static ParsedModule JsonModule(string json, string location)
=> new(null, (ParseJson(json), location));

private static JsValue ParseJson(string json)
{
var engine = new Engine();
var parser = new JsonParser(engine);
return parser.Parse(json);
}

public Module ToModule(Engine engine)
{
if (_jsonModule is not null)
return ModuleFactory.BuildJsonModule(engine, _jsonModule.Value.Json, _jsonModule.Value.Location);
if (_textModule is not null)
return ModuleFactory.BuildSourceTextModule(engine, _textModule);
throw new InvalidOperationException("Unexpected state - no module type available");
}
}
}
}
97 changes: 97 additions & 0 deletions Jint/Runtime/Modules/ModuleFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
using Esprima;
using Jint.Native;
using Jint.Native.Json;

namespace Jint.Runtime.Modules;

/// <summary>
/// Factory which creates a single runtime <see cref="Module"/> from a given source.
/// </summary>
public static class ModuleFactory
{
/// <summary>
/// Creates a <see cref="Module"/> for the usage within the given <paramref name="engine"/>
/// from the provided javascript <paramref name="code"/>.
/// </summary>
/// <remarks>
/// The returned modules location (see <see cref="Module.Location"/>) points to
/// <see cref="Uri.LocalPath"/> if <see cref="ResolvedSpecifier.Uri"/> is not null. If
/// <see cref="ResolvedSpecifier.Uri"/> is null, the modules location source will be null as well.
/// </remarks>
/// <exception cref="ParserException">Is thrown if the provided <paramref name="code"/> can not be parsed.</exception>
/// <exception cref="JavaScriptException">Is thrown if an error occured when parsing <paramref name="code"/>.</exception>
public static Module BuildSourceTextModule(Engine engine, ResolvedSpecifier resolved, string code)
{
var source = resolved.Uri?.LocalPath;
Esprima.Ast.Module module;
try
{
module = new JavaScriptParser().ParseModule(code, source);
}
catch (ParserException ex)
{
ExceptionHelper.ThrowSyntaxError(engine.Realm, $"Error while loading module: error in module '{source}': {ex.Error}");
module = null;
}
catch (Exception)
{
ExceptionHelper.ThrowJavaScriptException(engine, $"Could not load module {source}", (Location) default);
module = null;
}

return BuildSourceTextModule(engine, module);
}

/// <summary>
/// Creates a <see cref="Module"/> for the usage within the given <paramref name="engine"/>
/// from the parsed <paramref name="parsedModule"/>.
/// </summary>
/// <remarks>
/// The returned modules location (see <see cref="Module.Location"/>) will be set
/// to <see cref="Location.Source"/> of the <paramref name="parsedModule"/>.
/// </remarks>
public static Module BuildSourceTextModule(Engine engine, Esprima.Ast.Module parsedModule)
{
return new SourceTextModule(engine, engine.Realm, parsedModule, parsedModule.Location.Source, async: false);
}

/// <summary>
/// Creates a <see cref="Module"/> for the usage within the given <paramref name="engine"/> for the
/// provided JSON module <paramref name="jsonString"/>.
/// </summary>
/// <remarks>
/// The returned modules location (see <see cref="Module.Location"/>) points to
/// <see cref="Uri.LocalPath"/> if <see cref="ResolvedSpecifier.Uri"/> is not null. If
/// <see cref="ResolvedSpecifier.Uri"/> is null, the modules location source will be null as well.
/// </remarks>
/// <exception cref="JavaScriptException">Is thrown if an error occured when parsing <paramref name="jsonString"/>.</exception>
public static Module BuildJsonModule(Engine engine, ResolvedSpecifier resolved, string jsonString)
{
var source = resolved.Uri?.LocalPath;
JsValue module;
try
{
module = new JsonParser(engine).Parse(jsonString);
}
catch (Exception)
{
ExceptionHelper.ThrowJavaScriptException(engine, $"Could not load module {source}", (Location) default);
module = null;
}

return BuildJsonModule(engine, module, resolved.Uri?.LocalPath);
}

/// <summary>
/// Creates a <see cref="Module"/> for the usage within the given <paramref name="engine"/>
/// from the parsed JSON provided in <paramref name="parsedJson"/>.
/// </summary>
/// <remarks>
/// The returned modules location (see <see cref="Module.Location"/>) will be set
/// to <paramref name="location"/>.
/// </remarks>
public static Module BuildJsonModule(Engine engine, JsValue parsedJson, string? location)
{
return new SyntheticModule(engine, engine.Realm, parsedJson, location);
}
}

0 comments on commit 8000a7f

Please sign in to comment.