-
-
Notifications
You must be signed in to change notification settings - Fork 560
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Refactoring for custom module loader support
- Loading branch information
1 parent
1de2334
commit 44f1b2e
Showing
4 changed files
with
385 additions
and
45 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"); | ||
} | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
Oops, something went wrong.