diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4b0f9de..8b6b3fd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
- Fixed background thread leaks when worker construction is rejected
- Fixed canonical DOM node identity and concrete prototypes through `DomSameObject` getters (#140)
+- Added `JsScriptingOptions.EngineCreator` to create a configured Jint engine before any page script runs
# 1.1.0
diff --git a/README.md b/README.md
index 4bcaac2..e08377a 100644
--- a/README.md
+++ b/README.md
@@ -46,6 +46,29 @@ var config = Configuration.Default
});
```
+Register an `EngineCreator` service to configure Jint before inline page scripts run:
+
+```cs
+using Jint;
+using AngleSharp.Js;
+
+var config = Configuration.Default
+ .With(new EngineCreator((window, options) => new Engine(options
+ .MaxStatements(100_000)
+ .TimeoutInterval(TimeSpan.FromSeconds(5))
+ .LimitMemory(16 * 1024 * 1024))))
+ .WithJs();
+```
+
+The factory runs once for each window and must return a fresh engine using the supplied options.
+Those options contain AngleSharp's DOM module loader, object wrapper, and call stack guard.
+DOM bindings are installed after the factory returns. Configure the engine and host globals
+in the factory, then access DOM objects and evaluate page scripts through the document.
+Jint controls the scope and reset behavior of its constraints.
+Any state captured by the service remains owned by the host.
+Hosts sharing one configuration across windows must make
+their captured state safe for that use.
+
You can also use this part with a console for logging. The call for this is `WithConsoleLogger`, e.g.,
```cs
diff --git a/src/AngleSharp.Js.Tests/EngineConfigurationTests.cs b/src/AngleSharp.Js.Tests/EngineConfigurationTests.cs
new file mode 100644
index 0000000..cc69a88
--- /dev/null
+++ b/src/AngleSharp.Js.Tests/EngineConfigurationTests.cs
@@ -0,0 +1,165 @@
+namespace AngleSharp.Js.Tests
+{
+ using AngleSharp.Dom;
+ using AngleSharp.Io;
+ using AngleSharp.Js.Tests.Mocks;
+ using AngleSharp.Scripting;
+ using Jint;
+ using Jint.Runtime;
+ using NUnit.Framework;
+ using System;
+ using System.Collections.Concurrent;
+ using System.Threading;
+ using System.Threading.Tasks;
+
+ public class EngineConfigurationTests
+ {
+ [Test]
+ public async Task ConstraintsAreInstalledBeforeInlineScriptsRun()
+ {
+ var config = Configuration.Default
+ .With(new EngineCreator((window, options) => new Engine(options.MaxStatements(64))))
+ .WithJs()
+ .WithEventLoop();
+
+ using (var context = BrowsingContext.New(config))
+ {
+ var document = await context.OpenAsync(request => request.Content(
+ "
before
")).ConfigureAwait(false);
+
+ Assert.AreEqual("before", document.QuerySelector("#result").TextContent);
+ Assert.Throws(() => document.ExecuteScript(
+ "for (var i = 0; i < 1000; i++) {}"));
+ Assert.AreEqual("before", document.ExecuteScript("document.getElementById('result').textContent"));
+ }
+ }
+
+ [Test]
+ public async Task CreatorServiceCanBeCreatedForEachBrowsingContext()
+ {
+ var services = 0;
+ Func createService = _ =>
+ {
+ services++;
+ var limit = services == 1 ? 64 : 2000;
+ return (window, options) => new Engine(options.MaxStatements(limit));
+ };
+ var config = Configuration.Default.With(createService).WithJs().WithEventLoop();
+
+ using (var first = BrowsingContext.New(config))
+ using (var second = BrowsingContext.New(config))
+ {
+ var firstDocument = await first.OpenNewAsync().ConfigureAwait(false);
+ var secondDocument = await second.OpenNewAsync().ConfigureAwait(false);
+ Assert.Throws(() => firstDocument.ExecuteScript(
+ "for (var i = 0; i < 1000; i++) {}"));
+ Assert.AreEqual(100, secondDocument.ExecuteScript("var i = 0; for (; i < 100; i++) {} i;"));
+ Assert.AreEqual(2, services);
+ }
+ }
+
+ [Test]
+ public async Task CreatorRunsOncePerWindowAndRetainsDomWrapping()
+ {
+ var calls = 0;
+ var windows = new ConcurrentQueue();
+ var config = Configuration.Default.With(new EngineCreator((window, options) =>
+ {
+ calls++;
+ windows.Enqueue(window);
+ return new Engine(options.MaxStatements(1000));
+ })).WithJs().WithEventLoop();
+
+ using (var firstContext = BrowsingContext.New(config))
+ using (var secondContext = BrowsingContext.New(config))
+ {
+ var first = await firstContext.OpenAsync(request => request.Content("first
")).ConfigureAwait(false);
+ var second = await secondContext.OpenAsync(request => request.Content("second
")).ConfigureAwait(false);
+
+ Assert.AreEqual("first", first.ExecuteScript("document.querySelector('p').textContent"));
+ Assert.AreEqual("first", first.ExecuteScript("document.querySelector('p').textContent"));
+ Assert.AreEqual("second", second.ExecuteScript("document.querySelector('p').textContent"));
+ Assert.AreEqual(2, calls);
+ CollectionAssert.AreEqual(new[] { first.DefaultView, second.DefaultView }, windows.ToArray());
+ }
+ }
+
+ [Test]
+ public async Task CreatorReturnsTheEngineUsedForInlineScriptsAndDomBindings()
+ {
+ Engine created = null;
+ var config = Configuration.Default.With(new EngineCreator((window, options) =>
+ {
+ created = new Engine(options.Configure(engine => engine.SetValue("hostValue", "configured")));
+ return created;
+ })).WithJs().WithEventLoop();
+
+ using (var context = BrowsingContext.New(config))
+ {
+ var document = await context.OpenAsync(request => request.Content(
+ "before
")).ConfigureAwait(false);
+ Assert.AreEqual("configured", document.QuerySelector("p").TextContent);
+ Assert.AreSame(created, context.GetService().GetOrCreateJint(document));
+ created.SetValue("hostNode", document.QuerySelector("p"));
+ Assert.AreEqual(true, document.ExecuteScript("hostNode === document.querySelector('p')"));
+ }
+ }
+
+ [TestCase(false)]
+ [TestCase(true)]
+ public async Task WorkersInheritTheEngineCreatorService(Boolean contextFactory)
+ {
+ var services = 0;
+ var windows = new ConcurrentQueue();
+ EngineCreator CreateEngine(String hostValue) => (window, options) =>
+ {
+ windows.Enqueue(window);
+ return new Engine(options.MaxStatements(256)
+ .Configure(engine => engine.SetValue("hostValue", hostValue)));
+ };
+ Object registration = contextFactory
+ ? (Object)new Func(_ => CreateEngine("context-" + Interlocked.Increment(ref services)))
+ : CreateEngine("configured");
+ var config = Configuration.Default
+ .With(registration)
+ .WithJs()
+ .WithEventLoop()
+ .With(new DelayedRequester(0, "self.__host = hostValue; for (var i = 0; i < 10000; i++) {} self.__after = true;"))
+ .WithDefaultLoader(new LoaderOptions { IsResourceLoadingEnabled = true });
+
+ using (var context = BrowsingContext.New(config))
+ {
+ var document = await context.OpenNewAsync().ConfigureAwait(false);
+ Assert.AreEqual(contextFactory ? "context-1" : "configured", document.ExecuteScript("hostValue"));
+ var worker = new Dom.Worker(document.DefaultView, "https://example.com/worker.js");
+
+ try
+ {
+ for (var retries = 200; retries > 0 && !worker.IsInitialized && worker.StartupError is null; retries--)
+ {
+ await Task.Delay(10).ConfigureAwait(false);
+ }
+
+ Assert.IsInstanceOf(worker.StartupError);
+ Assert.AreEqual(contextFactory ? "context-2" : "configured", worker.EvaluateInWorker("self.__host"));
+ Assert.AreEqual("undefined", worker.EvaluateInWorker("typeof self.__after"));
+ var createdWindows = windows.ToArray();
+ Assert.AreEqual(2, createdWindows.Length);
+ Assert.AreSame(document.DefaultView, createdWindows[0]);
+ Assert.AreNotSame(document.DefaultView, createdWindows[1]);
+ Assert.AreNotSame(context, createdWindows[1].Document.Context);
+ Assert.AreSame(createdWindows[1], createdWindows[1].Document.DefaultView);
+ if (contextFactory)
+ {
+ Assert.AreEqual(2, services);
+ }
+ }
+ finally
+ {
+ worker.Terminate();
+ }
+ }
+ }
+ }
+}
diff --git a/src/AngleSharp.Js/Dom/Worker.cs b/src/AngleSharp.Js/Dom/Worker.cs
index 8a23c79..854a31a 100644
--- a/src/AngleSharp.Js/Dom/Worker.cs
+++ b/src/AngleSharp.Js/Dom/Worker.cs
@@ -72,6 +72,15 @@ public Worker(IWindow window, String source)
var workerConfig = Configuration.Default
.With(_scripting)
.WithOnly(_workerLoop);
+
+ foreach (var service in parentContext.OriginalServices)
+ {
+ if (service is EngineCreator || service is Func)
+ {
+ workerConfig = workerConfig.With(service);
+ }
+ }
+
_workerContext = BrowsingContext.New(workerConfig);
Enqueue(_workerLoop, TaskPriority.Critical, () =>
diff --git a/src/AngleSharp.Js/EngineCreator.cs b/src/AngleSharp.Js/EngineCreator.cs
new file mode 100644
index 0000000..11f99a7
--- /dev/null
+++ b/src/AngleSharp.Js/EngineCreator.cs
@@ -0,0 +1,17 @@
+namespace AngleSharp.Js
+{
+ using AngleSharp.Dom;
+
+ ///
+ /// Creates a window's Jint engine. Register this service with the configuration
+ /// to customize engine creation before page scripts run.
+ ///
+ /// The window whose engine is being created.
+ /// Options containing the DOM wrapper, module loader, and stack guard.
+ /// A fresh engine constructed with the supplied options.
+ ///
+ /// DOM bindings are installed after this delegate returns. Configure the engine
+ /// and host globals here; access DOM objects and run page scripts afterwards.
+ ///
+ public delegate Jint.Engine EngineCreator(IWindow window, Jint.Options options);
+}
diff --git a/src/AngleSharp.Js/EngineInstance.cs b/src/AngleSharp.Js/EngineInstance.cs
index d9ef5a2..24a9856 100644
--- a/src/AngleSharp.Js/EngineInstance.cs
+++ b/src/AngleSharp.Js/EngineInstance.cs
@@ -37,21 +37,17 @@ sealed class EngineInstance
public EngineInstance(IWindow window, IDictionary assignments, IEnumerable libs, JsScriptingOptions options)
{
_importMap = new JsImportMap();
-
- _engine = new Engine((o) =>
- {
- o.EnableModules(new JsModuleLoader(this, window.Document, false));
- // The handler answers out of the caches assigned right below, which only exist
- // once this constructor returns. Jint wraps nothing while it is configuring
- // itself, so that is safe - and Engine.Options is internal, so registering the
- // handler afterwards is not an option.
- o.SetWrapObjectHandler(WrapObject);
- // Left alone, the JS call stack is the native one, and a script recursing
- // deeper than it holds takes the whole process down - a StackOverflowException
- // cannot be caught. Guarded, the engine continues on a fresh stack and finally
- // reports an ordinary "Maximum call stack size exceeded" error instead.
- o.Constraints.MaxExecutionStackCount = options.MaxCallStackDepth > 0 ? options.MaxCallStackDepth : StackGuardDisabled;
- });
+ var engineOptions = new Options();
+ engineOptions.EnableModules(new JsModuleLoader(this, window.Document, false));
+ // 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);
+ // Left alone, the JS call stack is the native one, and a runaway recursion
+ // takes the process down with an uncatchable StackOverflowException.
+ engineOptions.Constraints.MaxExecutionStackCount = options.MaxCallStackDepth > 0 ? options.MaxCallStackDepth : StackGuardDisabled;
+ var creator = window.Document.Context.GetService();
+ _engine = (creator is null ? new Engine(engineOptions) : creator.Invoke(window, engineOptions))
+ ?? throw new InvalidOperationException("The engine creator must return a Jint engine.");
_libs = new LibrarySet(libs);
_prototypes = new PrototypeCache(_engine, _libs);
_references = new ReferenceCache();