diff --git a/CHANGELOG.md b/CHANGELOG.md
index c1836ab..47bb348 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -28,6 +28,8 @@ Released on Friday, November 6 2026.
- Added classes with implicit conversions standing for a WebIDL union, e.g. of a string and a sequence of strings, which fixes such conversions failing; an implicit conversion from such a class is what a script receives for it
- Added the same-origin policy: script gets `null` for a node of another origin, and for its window a `CrossOriginWindow` with only `window`, `self`, `frames`, `parent`, `top`, `closed`, `length`, `location` (only to be set, and only for frames inside the own document), `postMessage`, `focus`, `blur` and `close`
- Added `performance.mark`, `measure`, `getEntries`, `getEntriesByName`, `getEntriesByType`, `clearMarks`, `clearMeasures`, along with `PerformanceEntry`, `PerformanceMark` and `PerformanceMeasure`
+- Fixed modules being read from the local disk; every module is loaded through the configured requesters (#153) @michaelthyregod
+- Fixed modules imported by a relative address being requested from the wrong address (e.g. `./a.js` of `/dir/page.html` from `/dir/page.html/a.js`, and the imports of a module of another origin from the own origin); they are resolved against what imports them (#153) @michaelthyregod
# 1.1.4
diff --git a/src/AngleSharp.Js.Tests/EcmaTests.cs b/src/AngleSharp.Js.Tests/EcmaTests.cs
index b63af1a..39ad976 100644
--- a/src/AngleSharp.Js.Tests/EcmaTests.cs
+++ b/src/AngleSharp.Js.Tests/EcmaTests.cs
@@ -6,6 +6,7 @@ namespace AngleSharp.Js.Tests
using NUnit.Framework;
using System;
using System.Collections.Generic;
+ using System.IO;
using System.Threading.Tasks;
[TestFixture]
@@ -493,5 +494,75 @@ public async Task MicrotasksRunAfterSynchronousWorkInOrder()
var document = await context.OpenAsync(r => r.Content(html)).WhenStable().ConfigureAwait(false);
Assert.AreEqual("sync,m1,m2", document.GetElementById("test")?.TextContent);
}
+
+ [TestCase("direct")]
+ [TestCase("importmap")]
+ [TestCase("dynamic")]
+ [TestCase("script")]
+ [TestCase("bytes")]
+ public async Task ModuleIsNotReadFromTheLocalDisk(String way)
+ {
+ var json = Path.GetTempFileName();
+ var script = Path.GetTempFileName();
+
+ try
+ {
+ File.WriteAllText(json, "{ \"secret\": \"from disk\" }");
+ File.WriteAllText(script, "document.getElementById('test').textContent = 'from disk';");
+ var jsonUrl = new Uri(json).AbsoluteUri;
+ var scriptUrl = new Uri(script).AbsoluteUri;
+ var show = "document.getElementById('test').textContent = ";
+ var page = way switch
+ {
+ "direct" => $"",
+ "importmap" => $"",
+ "dynamic" => $"",
+ "script" => $"",
+ _ => $"",
+ };
+ var config = Configuration.Default
+ .WithJs()
+ .WithEventLoop()
+ .With(new MockHttpClientRequester(new Dictionary()))
+ .WithDefaultLoader(new LoaderOptions { IsResourceLoadingEnabled = true });
+ var html = "pending
" + page;
+ var document = await BrowsingContext.New(config)
+ .OpenAsync(r => r.Content(html).Address("https://example.com/"))
+ .WhenStable()
+ .ConfigureAwait(false);
+ Assert.AreEqual("pending", document.GetElementById("test").TextContent);
+ }
+ finally
+ {
+ File.Delete(json);
+ File.Delete(script);
+ }
+ }
+
+ [TestCase("", "/dir/a.js")]
+ [TestCase("", "/a.js")]
+ [TestCase("", "/a.js")]
+ [TestCase("", "/dir/a.js")]
+ [TestCase("", "/other/a.js")]
+ [TestCase("", "/dir/a.js")]
+ public async Task RelativeModuleIsLoadedFromTheAddressOfWhatImportsIt(String page, String path)
+ {
+ // Only the expected address serves the module; any other one answers with nothing.
+ var config = Configuration.Default
+ .WithJs()
+ .WithEventLoop()
+ .With(new MockHttpClientRequester(new Dictionary
+ {
+ { "/dir/mod.js", "import x from './a.js'; show(x);" },
+ { path, "export default 'ok';" },
+ }))
+ .WithDefaultLoader(new LoaderOptions { IsResourceLoadingEnabled = true });
+ var html = "pending
" + page;
+ var document = await BrowsingContext.New(config)
+ .OpenAsync(r => r.Content(html).Address("https://example.com/dir/page.html"))
+ .WhenStable()
+ .ConfigureAwait(false);
+ Assert.AreEqual("ok", document.GetElementById("test").TextContent);
+ }
}
}
diff --git a/src/AngleSharp.Js/EngineInstance.cs b/src/AngleSharp.Js/EngineInstance.cs
index 96ec062..b63f10d 100644
--- a/src/AngleSharp.Js/EngineInstance.cs
+++ b/src/AngleSharp.Js/EngineInstance.cs
@@ -49,7 +49,7 @@ public EngineInstance(IWindow window, IDictionary assignments, I
{
_importMap = new JsImportMap();
var engineOptions = new Options();
- engineOptions.EnableModules(new JsModuleLoader(this, window.Document, false));
+ engineOptions.EnableModules(new JsModuleLoader(this, window.Document));
// The handler uses the caches initialized below. The creator must return the
// engine before DOM objects are wrapped or page scripts are evaluated.
engineOptions.SetWrapObjectHandler(WrapObject);
diff --git a/src/AngleSharp.Js/JsModuleLoader.cs b/src/AngleSharp.Js/JsModuleLoader.cs
index ec547c4..a200cbf 100644
--- a/src/AngleSharp.Js/JsModuleLoader.cs
+++ b/src/AngleSharp.Js/JsModuleLoader.cs
@@ -9,23 +9,28 @@ namespace AngleSharp.Js
using System;
using System.Linq;
- internal class JsModuleLoader : DefaultModuleLoader
+ internal class JsModuleLoader : ModuleLoader
{
private readonly EngineInstance _instance;
private readonly IResourceLoader _resourceLoader;
private readonly IElement _scriptElement;
- private readonly string _documentUrl;
+ private readonly IDocument _document;
- public JsModuleLoader(EngineInstance instance, IDocument document, bool restrictToBasePath = true) : base (document.Url, restrictToBasePath)
+ public JsModuleLoader(EngineInstance instance, IDocument document)
{
_instance = instance;
_resourceLoader = document.Context.GetService();
_scriptElement = document.CreateElement(TagNames.Script);
- _documentUrl = document.Url;
+ _document = document;
}
+ // The key of a resolved module is its address, and its Uri stays null: Jint gives a module
+ // loaded with a Uri only the path of it as location, so the modules it imports would lose
+ // the origin. With the key as location, they are resolved against the full address.
public override ResolvedSpecifier Resolve(string referencingModuleLocation, ModuleRequest moduleRequest)
{
+ var specifier = moduleRequest.Specifier;
+
if (referencingModuleLocation != null && _instance.ImportMap.Scopes.Count > 0)
{
foreach (var scopePath in _instance.ImportMap.Scopes.Keys.OrderByDescending(k => k.Length))
@@ -34,76 +39,93 @@ public override ResolvedSpecifier Resolve(string referencingModuleLocation, Modu
{
var scopeImports = _instance.ImportMap.Scopes[scopePath];
- if (scopeImports.TryGetValue(moduleRequest.Specifier, out var scopeModuleUrl))
+ if (scopeImports.TryGetValue(specifier, out var scopeModuleUrl))
{
- if (!scopeModuleUrl.IsAbsoluteUri)
- {
- scopeModuleUrl = new Uri(new Uri(_documentUrl), scopeModuleUrl);
- }
-
- return new ResolvedSpecifier(
- moduleRequest,
- moduleRequest.Specifier,
- scopeModuleUrl,
- SpecifierType.RelativeOrAbsolute);
+ return Resolved(moduleRequest, new Url(GetDocumentBaseUrl(), scopeModuleUrl.OriginalString));
}
}
}
}
- if (_instance.ImportMap.Imports.TryGetValue(moduleRequest.Specifier, out var moduleUrl))
+ if (_instance.ImportMap.Imports.TryGetValue(specifier, out var moduleUrl))
{
- if (!moduleUrl.IsAbsoluteUri)
- {
- moduleUrl = new Uri(new Uri(_documentUrl), moduleUrl);
- }
-
- return new ResolvedSpecifier(
- moduleRequest,
- moduleRequest.Specifier,
- moduleUrl,
- SpecifierType.RelativeOrAbsolute);
+ return Resolved(moduleRequest, new Url(GetDocumentBaseUrl(), moduleUrl.OriginalString));
}
- // Before passing to the base default module loader, make sure any relative paths are prepended with a dot
- // as Jint will otherwise resolve them as absolute file paths when running on Linux
- if (moduleRequest.Specifier.StartsWith("/"))
+ if (specifier.StartsWith("/") || specifier.StartsWith("./") || specifier.StartsWith("../"))
{
- moduleRequest = new ModuleRequest($".{moduleRequest.Specifier}", moduleRequest.Attributes);
+ return Resolved(moduleRequest, new Url(GetBaseUrl(referencingModuleLocation), specifier));
}
- if (referencingModuleLocation?.StartsWith("/") == true)
+ var url = new Url(specifier);
+
+ if (!url.IsInvalid && url.IsAbsolute)
{
- referencingModuleLocation = $".{referencingModuleLocation}";
+ return Resolved(moduleRequest, url);
}
-
- return base.Resolve(referencingModuleLocation, moduleRequest);
+
+ // A bare name, which no address belongs to. The module scripts of the document are
+ // added to the engine under such a name, so it must stay what it is to find them.
+ return new ResolvedSpecifier(moduleRequest, specifier, null, SpecifierType.Bare);
}
+ // Every module comes through AngleSharp's resource loader, whatever its scheme, so the
+ // requesters the host registered decide what a page can reach.
protected override string LoadModuleContents(Engine engine, ResolvedSpecifier resolved)
{
- if (resolved.Uri?.Scheme.IsOneOf(ProtocolNames.Http, ProtocolNames.Https) == true)
+ return FetchModule(GetModuleUrl(resolved));
+ }
+
+ protected override Byte[] LoadModuleContentsAsBytes(Engine engine, ResolvedSpecifier resolved)
+ {
+ return FetchModuleBytes(GetModuleUrl(resolved));
+ }
+
+ private static ResolvedSpecifier Resolved(ModuleRequest moduleRequest, Url url)
+ {
+ return new ResolvedSpecifier(moduleRequest, url.Href, null, SpecifierType.RelativeOrAbsolute);
+ }
+
+ // A module resolves against the address of what imports it, which is a module or a script
+ // that was loaded; one written into the document resolves against the base URL of it.
+ private Url GetBaseUrl(string referencingModuleLocation)
+ {
+ if (referencingModuleLocation != null)
{
- return FetchModule(resolved.Uri);
+ var url = new Url(referencingModuleLocation);
+
+ if (!url.IsInvalid && url.IsAbsolute)
+ {
+ return url;
+ }
}
- else
+
+ return GetDocumentBaseUrl();
+ }
+
+ private Url GetDocumentBaseUrl()
+ {
+ return _document.BaseUrl ?? new Url(_document.Url);
+ }
+
+ private static Url GetModuleUrl(ResolvedSpecifier resolved)
+ {
+ if (resolved.Type != SpecifierType.RelativeOrAbsolute)
{
- return base.LoadModuleContents(engine, resolved);
+ throw new NotSupportedException($"The module '{resolved.ModuleRequest.Specifier}' could not be resolved to a URL.");
}
+
+ return new Url(resolved.Key);
}
- private string FetchModule(Uri moduleUrl)
+ private string FetchModule(Url moduleUrl)
{
if (_resourceLoader == null)
{
return string.Empty;
}
- var importUrl = Url.Convert(moduleUrl);
-
- var request = new ResourceRequest(_scriptElement, importUrl);
-
- var response = _resourceLoader.FetchAsync(request).Task.Result;
+ var response = Fetch(moduleUrl);
string content;
@@ -114,5 +136,28 @@ private string FetchModule(Uri moduleUrl)
return content;
}
+
+ private Byte[] FetchModuleBytes(Url moduleUrl)
+ {
+ if (_resourceLoader == null)
+ {
+ return Array.Empty();
+ }
+
+ var response = Fetch(moduleUrl);
+
+ using (var content = new MemoryStream())
+ {
+ response.Content.CopyTo(content);
+ return content.ToArray();
+ }
+ }
+
+ private IResponse Fetch(Url moduleUrl)
+ {
+ var request = new ResourceRequest(_scriptElement, moduleUrl);
+ var response = _resourceLoader.FetchAsync(request).Task.Result;
+ return response ?? throw new InvalidOperationException($"No requester could load the module '{moduleUrl.Href}'.");
+ }
}
}
diff --git a/src/AngleSharp.Js/JsScriptingService.cs b/src/AngleSharp.Js/JsScriptingService.cs
index 0501203..f7c89cd 100644
--- a/src/AngleSharp.Js/JsScriptingService.cs
+++ b/src/AngleSharp.Js/JsScriptingService.cs
@@ -100,10 +100,12 @@ public async Task EvaluateScriptAsync(IResponse response, ScriptOptions options,
var content = await reader.ReadToEndAsync().ConfigureAwait(false);
var type = options.Element?.Type;
// A module is imported by this, so it has to tell it apart from other modules:
- // one written into the document would have the address of the document.
+ // one written into the document would have the address of the document. One that
+ // was loaded has its address, which is what the modules it imports resolve against.
var isModule = type.Isi("module");
- var sourceUrl = isModule ? options.Element?.Source : response.Address?.Href ?? options.Element?.Source;
- var position = isModule || !String.IsNullOrEmpty(options.Element?.Source) ? default : FindInDocument(options.Document, content);
+ var isInline = String.IsNullOrEmpty(options.Element?.Source);
+ var sourceUrl = isModule && isInline ? null : response.Address?.Href ?? options.Element?.Source;
+ var position = isModule || !isInline ? default : FindInDocument(options.Document, content);
await options.EventLoop.EnqueueAsync(_ =>
{
var document = options.Document ?? throw new ArgumentNullException(nameof(options));