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: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
71 changes: 71 additions & 0 deletions src/AngleSharp.Js.Tests/EcmaTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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" => $"<script type=module>import data from '{jsonUrl}' with {{ type: 'json' }}; {show}data.secret;</script>",
"importmap" => $"<script type=importmap>{{ \"imports\": {{ \"secret\": \"{jsonUrl}\" }} }}</script><script type=module>import data from 'secret' with {{ type: 'json' }}; {show}data.secret;</script>",
"dynamic" => $"<script>import('{jsonUrl}', {{ with: {{ type: 'json' }} }}).then(m => {{ {show}m.default.secret; }});</script>",
"script" => $"<script type=module>import '{scriptUrl}';</script>",
_ => $"<script type=module>import data from '{jsonUrl}' with {{ type: 'bytes' }}; {show}String(data.length);</script>",
};
var config = Configuration.Default
.WithJs()
.WithEventLoop()
.With(new MockHttpClientRequester(new Dictionary<String, String>()))
.WithDefaultLoader(new LoaderOptions { IsResourceLoadingEnabled = true });
var html = "<!doctype html><div id=test>pending</div>" + 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("<script type=module>import x from './a.js'; show(x);</script>", "/dir/a.js")]
[TestCase("<script type=module>import x from '/a.js'; show(x);</script>", "/a.js")]
[TestCase("<script type=module>import x from '../a.js'; show(x);</script>", "/a.js")]
[TestCase("<script type=module src=mod.js></script>", "/dir/a.js")]
[TestCase("<base href=/other/><script type=module>import x from './a.js'; show(x);</script>", "/other/a.js")]
[TestCase("<script>import('./a.js').then(m => show(m.default));</script>", "/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<String, String>
{
{ "/dir/mod.js", "import x from './a.js'; show(x);" },
{ path, "export default 'ok';" },
}))
.WithDefaultLoader(new LoaderOptions { IsResourceLoadingEnabled = true });
var html = "<!doctype html><div id=test>pending</div><script>function show(x) { document.getElementById('test').textContent = x; }</script>" + 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);
}
}
}
2 changes: 1 addition & 1 deletion src/AngleSharp.Js/EngineInstance.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ public EngineInstance(IWindow window, IDictionary<String, Object> 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);
Expand Down
133 changes: 89 additions & 44 deletions src/AngleSharp.Js/JsModuleLoader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<IResourceLoader>();
_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))
Expand All @@ -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;

Expand All @@ -114,5 +136,28 @@ private string FetchModule(Uri moduleUrl)

return content;
}

private Byte[] FetchModuleBytes(Url moduleUrl)
{
if (_resourceLoader == null)
{
return Array.Empty<Byte>();
}

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}'.");
}
}
}
8 changes: 5 additions & 3 deletions src/AngleSharp.Js/JsScriptingService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
Loading