From 4f3c1da1037950d6d39ce87044ced6a0b397e443 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Sun, 4 Oct 2026 18:07:07 +0200 Subject: [PATCH 1/6] feat: configure Jint before page scripts run --- CHANGELOG.md | 1 + README.md | 21 ++++++ .../EngineConfigurationTests.cs | 73 +++++++++++++++++++ src/AngleSharp.Js/EngineInstance.cs | 1 + src/AngleSharp.Js/JsScriptingOptions.cs | 14 ++++ 5 files changed, 110 insertions(+) create mode 100644 src/AngleSharp.Js.Tests/EngineConfigurationTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index a0691b9..b645be4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # 1.1.1 - Fixed canonical DOM node identity and concrete prototypes through `DomSameObject` getters (#140) +- Added `JsScriptingOptions.ConfigureEngine` to configure Jint before any page script runs # 1.1.0 diff --git a/README.md b/README.md index 4bcaac2..f1cf05a 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,27 @@ var config = Configuration.Default }); ``` +Use `ConfigureEngine` to install Jint execution constraints before inline page scripts run: + +```cs +using Jint; + +var config = Configuration.Default + .WithJs(new JsScriptingOptions + { + ConfigureEngine = options => options + .MaxStatements(100_000) + .TimeoutInterval(TimeSpan.FromSeconds(5)) + .LimitMemory(16 * 1024 * 1024), + }); +``` + +The callback runs once for each window's engine, before the DOM module loader, object wrapper, +and call stack guard are installed. Jint controls the scope and reset behavior of its constraints. +The scripting service copies the callback when `WithJs` is called; any state captured by that +callback 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..f0e1cb5 --- /dev/null +++ b/src/AngleSharp.Js.Tests/EngineConfigurationTests.cs @@ -0,0 +1,73 @@ +namespace AngleSharp.Js.Tests +{ + using AngleSharp.Dom; + using Jint; + using Jint.Runtime; + using NUnit.Framework; + using System; + using System.Threading.Tasks; + + public class EngineConfigurationTests + { + [Test] + public async Task ConstraintsAreInstalledBeforeInlineScriptsRun() + { + var config = Configuration.Default + .WithJs(new JsScriptingOptions { ConfigureEngine = options => options.MaxStatements(64) }) + .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 ConfigurationCallbackIsCopiedBeforeAnEngineIsCreated() + { + var options = new JsScriptingOptions { ConfigureEngine = engine => engine.MaxStatements(64) }; + var config = Configuration.Default.WithJs(options).WithEventLoop(); + options.ConfigureEngine = null; + + using (var context = BrowsingContext.New(config)) + { + var document = await context.OpenNewAsync().ConfigureAwait(false); + Assert.Throws(() => document.ExecuteScript( + "for (var i = 0; i < 1000; i++) {}")); + } + } + + [Test] + public async Task CallbackRunsOncePerWindowAndRetainsDomWrapping() + { + var calls = 0; + var config = Configuration.Default.WithJs(new JsScriptingOptions + { + ConfigureEngine = options => + { + calls++; + options.MaxStatements(1000); + }, + }).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); + } + } + } +} diff --git a/src/AngleSharp.Js/EngineInstance.cs b/src/AngleSharp.Js/EngineInstance.cs index d9ef5a2..f47e523 100644 --- a/src/AngleSharp.Js/EngineInstance.cs +++ b/src/AngleSharp.Js/EngineInstance.cs @@ -40,6 +40,7 @@ public EngineInstance(IWindow window, IDictionary assignments, I _engine = new Engine((o) => { + options.ConfigureEngine?.Invoke(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 diff --git a/src/AngleSharp.Js/JsScriptingOptions.cs b/src/AngleSharp.Js/JsScriptingOptions.cs index 6499a2b..fa390f9 100644 --- a/src/AngleSharp.Js/JsScriptingOptions.cs +++ b/src/AngleSharp.Js/JsScriptingOptions.cs @@ -17,12 +17,26 @@ public sealed class JsScriptingOptions /// public Int32 MaxCallStackDepth { get; set; } = 10000; + /// + /// Gets or sets the callback used to configure each window's Jint engine + /// before any script runs. Use this to install execution constraints or + /// other host options. The DOM module loader, object wrapper, and call + /// stack guard are installed after this callback. + /// + /// + /// The callback is retained when the scripting service copies these + /// options. Captured state remains owned by the caller. The callback can + /// run more than once when the service is used for multiple windows. + /// + public Action ConfigureEngine { get; set; } + // An engine is built per window, long after the options were handed over, so // reading them then would let a later edit of the caller's object decide how // the next document behaves. The service takes this copy instead. internal JsScriptingOptions Clone() => new JsScriptingOptions { MaxCallStackDepth = MaxCallStackDepth, + ConfigureEngine = ConfigureEngine, }; } } From f76c9127b33648b283a8850b1494b13aa16b0f29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Sun, 4 Oct 2026 18:20:14 +0200 Subject: [PATCH 2/6] fix: prepare DOM proxies before native engine initialization --- README.md | 4 +++ .../EngineConfigurationTests.cs | 25 +++++++++++++++++ src/AngleSharp.Js/EngineInstance.cs | 27 +++++++++++-------- src/AngleSharp.Js/JsScriptingOptions.cs | 6 ++++- 4 files changed, 50 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index f1cf05a..201ea74 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,10 @@ var config = Configuration.Default The callback runs once for each window's engine, before the DOM module loader, object wrapper, and call stack guard are installed. Jint controls the scope and reset behavior of its constraints. +Native initialization callbacks registered through `options.Configure(engine => ...)` run after +DOM proxies and global properties are installed, so they can add host globals containing DOM +values. Use these callbacks for host/global setup, and evaluate scripts through the document +after engine construction has completed. The scripting service copies the callback when `WithJs` is called; any state captured by that callback remains owned by the host. Hosts sharing one configuration across windows must make their captured state safe for that use. diff --git a/src/AngleSharp.Js.Tests/EngineConfigurationTests.cs b/src/AngleSharp.Js.Tests/EngineConfigurationTests.cs index f0e1cb5..9eaaa9f 100644 --- a/src/AngleSharp.Js.Tests/EngineConfigurationTests.cs +++ b/src/AngleSharp.Js.Tests/EngineConfigurationTests.cs @@ -69,5 +69,30 @@ public async Task CallbackRunsOncePerWindowAndRetainsDomWrapping() Assert.AreEqual(2, calls); } } + + [Test] + public async Task NativeEngineInitializationCanUseDomBindings() + { + using (var sourceContext = BrowsingContext.New(Configuration.Default)) + { + var source = await sourceContext.OpenAsync(request => request.Content("

captured

")).ConfigureAwait(false); + var element = source.QuerySelector("p"); + var config = Configuration.Default.WithJs(new JsScriptingOptions + { + ConfigureEngine = options => options.Configure(engine => + { + engine.SetValue("hostNode", element); + Assert.IsTrue(engine.Global.HasOwnProperty("document")); + }), + }).WithEventLoop(); + + using (var context = BrowsingContext.New(config)) + { + var document = await context.OpenNewAsync().ConfigureAwait(false); + Assert.AreEqual("captured", document.ExecuteScript("document.body.textContent = hostNode.textContent; hostNode.textContent")); + Assert.AreEqual("captured", document.Body.TextContent); + } + } + } } } diff --git a/src/AngleSharp.Js/EngineInstance.cs b/src/AngleSharp.Js/EngineInstance.cs index f47e523..a981dd7 100644 --- a/src/AngleSharp.Js/EngineInstance.cs +++ b/src/AngleSharp.Js/EngineInstance.cs @@ -22,12 +22,13 @@ sealed class EngineInstance // Jint's StackGuard.Disabled, which is internal. private const Int32 StackGuardDisabled = -1; - private readonly Engine _engine; - private readonly PrototypeCache _prototypes; + // Assigned by the first Jint initialization callback, before host callbacks can use the DOM. + private Engine _engine; + private PrototypeCache _prototypes; private readonly ReferenceCache _references; private readonly ConditionalWeakTable _sameObjects; private readonly LibrarySet _libs; - private readonly DomNodeInstance _window; + private DomNodeInstance _window; private readonly JsImportMap _importMap; #endregion @@ -37,15 +38,17 @@ sealed class EngineInstance public EngineInstance(IWindow window, IDictionary assignments, IEnumerable libs, JsScriptingOptions options) { _importMap = new JsImportMap(); + _libs = new LibrarySet(libs); + _references = new ReferenceCache(); + _sameObjects = new ConditionalWeakTable(); _engine = new Engine((o) => { + o.Configure(engine => InitializeDomBindings(engine, window, assignments, libs)); options.ConfigureEngine?.Invoke(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. + // Jint installs the wrapper before running initialization callbacks. Our first + // callback prepares the caches and globals before a host callback can wrap a DOM value. 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 @@ -53,10 +56,12 @@ public EngineInstance(IWindow window, IDictionary assignments, I // reports an ordinary "Maximum call stack size exceeded" error instead. o.Constraints.MaxExecutionStackCount = options.MaxCallStackDepth > 0 ? options.MaxCallStackDepth : StackGuardDisabled; }); - _libs = new LibrarySet(libs); - _prototypes = new PrototypeCache(_engine, _libs); - _references = new ReferenceCache(); - _sameObjects = new ConditionalWeakTable(); + } + + private void InitializeDomBindings(Engine engine, IWindow window, IDictionary assignments, IEnumerable libs) + { + _engine = engine; + _prototypes = new PrototypeCache(engine, _libs); foreach (var assignment in assignments) { diff --git a/src/AngleSharp.Js/JsScriptingOptions.cs b/src/AngleSharp.Js/JsScriptingOptions.cs index fa390f9..cf0d842 100644 --- a/src/AngleSharp.Js/JsScriptingOptions.cs +++ b/src/AngleSharp.Js/JsScriptingOptions.cs @@ -19,7 +19,7 @@ public sealed class JsScriptingOptions /// /// Gets or sets the callback used to configure each window's Jint engine - /// before any script runs. Use this to install execution constraints or + /// before any page script runs. Use this to install execution constraints or /// other host options. The DOM module loader, object wrapper, and call /// stack guard are installed after this callback. /// @@ -27,6 +27,10 @@ public sealed class JsScriptingOptions /// The callback is retained when the scripting service copies these /// options. Captured state remains owned by the caller. The callback can /// run more than once when the service is used for multiple windows. + /// Native Jint initialization callbacks registered with Options.Configure + /// run after DOM proxies and global properties are installed. Jint's + /// construction-time callbacks are for host/global setup; evaluate scripts + /// through the document after engine construction has completed. /// public Action ConfigureEngine { get; set; } From ccbc3d07db6e9a3c380cf202bf28e930c18ce4c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Sun, 4 Oct 2026 20:54:34 +0200 Subject: [PATCH 3/6] Use an engine factory and retain readonly instance state --- CHANGELOG.md | 2 +- README.md | 21 ++++---- .../EngineConfigurationTests.cs | 48 +++++++++---------- src/AngleSharp.Js/EngineInstance.cs | 39 ++++++--------- src/AngleSharp.Js/JsScriptingOptions.cs | 24 +++++----- 5 files changed, 61 insertions(+), 73 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b645be4..e7551e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ # 1.1.1 - Fixed canonical DOM node identity and concrete prototypes through `DomSameObject` getters (#140) -- Added `JsScriptingOptions.ConfigureEngine` to configure Jint before any page script runs +- 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 201ea74..c5a3ba9 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ var config = Configuration.Default }); ``` -Use `ConfigureEngine` to install Jint execution constraints before inline page scripts run: +Use `EngineCreator` to create a Jint engine with execution constraints before inline page scripts run: ```cs using Jint; @@ -54,21 +54,20 @@ using Jint; var config = Configuration.Default .WithJs(new JsScriptingOptions { - ConfigureEngine = options => options + EngineCreator = options => new Engine(options .MaxStatements(100_000) .TimeoutInterval(TimeSpan.FromSeconds(5)) - .LimitMemory(16 * 1024 * 1024), + .LimitMemory(16 * 1024 * 1024)), }); ``` -The callback runs once for each window's engine, before the DOM module loader, object wrapper, -and call stack guard are installed. Jint controls the scope and reset behavior of its constraints. -Native initialization callbacks registered through `options.Configure(engine => ...)` run after -DOM proxies and global properties are installed, so they can add host globals containing DOM -values. Use these callbacks for host/global setup, and evaluate scripts through the document -after engine construction has completed. -The scripting service copies the callback when `WithJs` is called; any state captured by that -callback remains owned by the host. Hosts sharing one configuration across windows must make +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. +The scripting service copies the factory when `WithJs` is called; any state captured by that +factory 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., diff --git a/src/AngleSharp.Js.Tests/EngineConfigurationTests.cs b/src/AngleSharp.Js.Tests/EngineConfigurationTests.cs index 9eaaa9f..b4c9d05 100644 --- a/src/AngleSharp.Js.Tests/EngineConfigurationTests.cs +++ b/src/AngleSharp.Js.Tests/EngineConfigurationTests.cs @@ -1,6 +1,7 @@ namespace AngleSharp.Js.Tests { using AngleSharp.Dom; + using AngleSharp.Scripting; using Jint; using Jint.Runtime; using NUnit.Framework; @@ -13,7 +14,7 @@ public class EngineConfigurationTests public async Task ConstraintsAreInstalledBeforeInlineScriptsRun() { var config = Configuration.Default - .WithJs(new JsScriptingOptions { ConfigureEngine = options => options.MaxStatements(64) }) + .WithJs(new JsScriptingOptions { EngineCreator = options => new Engine(options.MaxStatements(64)) }) .WithEventLoop(); using (var context = BrowsingContext.New(config)) @@ -30,11 +31,11 @@ public async Task ConstraintsAreInstalledBeforeInlineScriptsRun() } [Test] - public async Task ConfigurationCallbackIsCopiedBeforeAnEngineIsCreated() + public async Task EngineCreatorIsCopiedBeforeAnEngineIsCreated() { - var options = new JsScriptingOptions { ConfigureEngine = engine => engine.MaxStatements(64) }; + var options = new JsScriptingOptions { EngineCreator = engineOptions => new Engine(engineOptions.MaxStatements(64)) }; var config = Configuration.Default.WithJs(options).WithEventLoop(); - options.ConfigureEngine = null; + options.EngineCreator = engineOptions => new Engine(engineOptions); using (var context = BrowsingContext.New(config)) { @@ -45,15 +46,15 @@ public async Task ConfigurationCallbackIsCopiedBeforeAnEngineIsCreated() } [Test] - public async Task CallbackRunsOncePerWindowAndRetainsDomWrapping() + public async Task CreatorRunsOncePerWindowAndRetainsDomWrapping() { var calls = 0; var config = Configuration.Default.WithJs(new JsScriptingOptions { - ConfigureEngine = options => + EngineCreator = options => { calls++; - options.MaxStatements(1000); + return new Engine(options.MaxStatements(1000)); }, }).WithEventLoop(); @@ -71,27 +72,26 @@ public async Task CallbackRunsOncePerWindowAndRetainsDomWrapping() } [Test] - public async Task NativeEngineInitializationCanUseDomBindings() + public async Task CreatorReturnsTheEngineUsedForInlineScriptsAndDomBindings() { - using (var sourceContext = BrowsingContext.New(Configuration.Default)) + Engine created = null; + var config = Configuration.Default.WithJs(new JsScriptingOptions { - var source = await sourceContext.OpenAsync(request => request.Content("

captured

")).ConfigureAwait(false); - var element = source.QuerySelector("p"); - var config = Configuration.Default.WithJs(new JsScriptingOptions + EngineCreator = options => { - ConfigureEngine = options => options.Configure(engine => - { - engine.SetValue("hostNode", element); - Assert.IsTrue(engine.Global.HasOwnProperty("document")); - }), - }).WithEventLoop(); + created = new Engine(options.Configure(engine => engine.SetValue("hostValue", "configured"))); + return created; + }, + }).WithEventLoop(); - using (var context = BrowsingContext.New(config)) - { - var document = await context.OpenNewAsync().ConfigureAwait(false); - Assert.AreEqual("captured", document.ExecuteScript("document.body.textContent = hostNode.textContent; hostNode.textContent")); - Assert.AreEqual("captured", document.Body.TextContent); - } + 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')")); } } } diff --git a/src/AngleSharp.Js/EngineInstance.cs b/src/AngleSharp.Js/EngineInstance.cs index a981dd7..e09723b 100644 --- a/src/AngleSharp.Js/EngineInstance.cs +++ b/src/AngleSharp.Js/EngineInstance.cs @@ -22,13 +22,12 @@ sealed class EngineInstance // Jint's StackGuard.Disabled, which is internal. private const Int32 StackGuardDisabled = -1; - // Assigned by the first Jint initialization callback, before host callbacks can use the DOM. - private Engine _engine; - private PrototypeCache _prototypes; + private readonly Engine _engine; + private readonly PrototypeCache _prototypes; private readonly ReferenceCache _references; private readonly ConditionalWeakTable _sameObjects; private readonly LibrarySet _libs; - private DomNodeInstance _window; + private readonly DomNodeInstance _window; private readonly JsImportMap _importMap; #endregion @@ -38,31 +37,21 @@ sealed class EngineInstance public EngineInstance(IWindow window, IDictionary assignments, IEnumerable libs, JsScriptingOptions options) { _importMap = new JsImportMap(); + 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; + _engine = options.EngineCreator.Invoke(engineOptions) + ?? throw new InvalidOperationException("The engine creator must return a Jint engine."); _libs = new LibrarySet(libs); + _prototypes = new PrototypeCache(_engine, _libs); _references = new ReferenceCache(); _sameObjects = new ConditionalWeakTable(); - _engine = new Engine((o) => - { - o.Configure(engine => InitializeDomBindings(engine, window, assignments, libs)); - options.ConfigureEngine?.Invoke(o); - o.EnableModules(new JsModuleLoader(this, window.Document, false)); - // Jint installs the wrapper before running initialization callbacks. Our first - // callback prepares the caches and globals before a host callback can wrap a DOM value. - 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; - }); - } - - private void InitializeDomBindings(Engine engine, IWindow window, IDictionary assignments, IEnumerable libs) - { - _engine = engine; - _prototypes = new PrototypeCache(engine, _libs); - foreach (var assignment in assignments) { _engine.SetValue(assignment.Key, assignment.Value); diff --git a/src/AngleSharp.Js/JsScriptingOptions.cs b/src/AngleSharp.Js/JsScriptingOptions.cs index cf0d842..f992f00 100644 --- a/src/AngleSharp.Js/JsScriptingOptions.cs +++ b/src/AngleSharp.Js/JsScriptingOptions.cs @@ -18,21 +18,21 @@ public sealed class JsScriptingOptions public Int32 MaxCallStackDepth { get; set; } = 10000; /// - /// Gets or sets the callback used to configure each window's Jint engine - /// before any page script runs. Use this to install execution constraints or - /// other host options. The DOM module loader, object wrapper, and call - /// stack guard are installed after this callback. + /// Gets or sets the factory used to create each window's Jint engine + /// before any page script runs. The default creates a new engine with + /// the supplied options. /// /// - /// The callback is retained when the scripting service copies these - /// options. Captured state remains owned by the caller. The callback can + /// The factory must construct a fresh engine using the supplied options, + /// which contain the DOM module loader, object wrapper, and stack guard. + /// It may configure additional Jint options before constructing the engine. + /// DOM bindings are installed after the factory returns; do not access + /// DOM objects or evaluate page scripts while creating the engine. + /// The factory is retained when the scripting service copies these + /// options. Captured state remains owned by the caller. The factory can /// run more than once when the service is used for multiple windows. - /// Native Jint initialization callbacks registered with Options.Configure - /// run after DOM proxies and global properties are installed. Jint's - /// construction-time callbacks are for host/global setup; evaluate scripts - /// through the document after engine construction has completed. /// - public Action ConfigureEngine { get; set; } + public Func EngineCreator { get; set; } = options => new Jint.Engine(options); // An engine is built per window, long after the options were handed over, so // reading them then would let a later edit of the caller's object decide how @@ -40,7 +40,7 @@ public sealed class JsScriptingOptions internal JsScriptingOptions Clone() => new JsScriptingOptions { MaxCallStackDepth = MaxCallStackDepth, - ConfigureEngine = ConfigureEngine, + EngineCreator = EngineCreator ?? throw new ArgumentException("The engine creator cannot be null.", nameof(EngineCreator)), }; } } From c415cb06332fccd47cf19b789061a91d6c65fba6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Sun, 4 Oct 2026 21:52:40 +0200 Subject: [PATCH 4/6] Register engine creation as a browsing context service --- README.md | 19 ++++---- .../EngineConfigurationTests.cs | 48 ++++++++++--------- src/AngleSharp.Js/EngineCreator.cs | 14 ++++++ src/AngleSharp.Js/EngineInstance.cs | 3 +- src/AngleSharp.Js/JsScriptingOptions.cs | 18 ------- 5 files changed, 51 insertions(+), 51 deletions(-) create mode 100644 src/AngleSharp.Js/EngineCreator.cs diff --git a/README.md b/README.md index c5a3ba9..0e912bf 100644 --- a/README.md +++ b/README.md @@ -46,19 +46,18 @@ var config = Configuration.Default }); ``` -Use `EngineCreator` to create a Jint engine with execution constraints before inline page scripts run: +Register an `EngineCreator` service to configure Jint before inline page scripts run: ```cs using Jint; +using AngleSharp.Js; var config = Configuration.Default - .WithJs(new JsScriptingOptions - { - EngineCreator = options => new Engine(options - .MaxStatements(100_000) - .TimeoutInterval(TimeSpan.FromSeconds(5)) - .LimitMemory(16 * 1024 * 1024)), - }); + .With(new EngineCreator(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. @@ -66,8 +65,8 @@ Those options contain AngleSharp's DOM module loader, object wrapper, and call s 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. -The scripting service copies the factory when `WithJs` is called; any state captured by that -factory remains owned by the host. Hosts sharing one configuration across windows must make +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., diff --git a/src/AngleSharp.Js.Tests/EngineConfigurationTests.cs b/src/AngleSharp.Js.Tests/EngineConfigurationTests.cs index b4c9d05..d70305e 100644 --- a/src/AngleSharp.Js.Tests/EngineConfigurationTests.cs +++ b/src/AngleSharp.Js.Tests/EngineConfigurationTests.cs @@ -14,7 +14,8 @@ public class EngineConfigurationTests public async Task ConstraintsAreInstalledBeforeInlineScriptsRun() { var config = Configuration.Default - .WithJs(new JsScriptingOptions { EngineCreator = options => new Engine(options.MaxStatements(64)) }) + .With(new EngineCreator(options => new Engine(options.MaxStatements(64)))) + .WithJs() .WithEventLoop(); using (var context = BrowsingContext.New(config)) @@ -31,17 +32,26 @@ public async Task ConstraintsAreInstalledBeforeInlineScriptsRun() } [Test] - public async Task EngineCreatorIsCopiedBeforeAnEngineIsCreated() + public async Task CreatorServiceCanBeCreatedForEachBrowsingContext() { - var options = new JsScriptingOptions { EngineCreator = engineOptions => new Engine(engineOptions.MaxStatements(64)) }; - var config = Configuration.Default.WithJs(options).WithEventLoop(); - options.EngineCreator = engineOptions => new Engine(engineOptions); + var services = 0; + Func createService = _ => + { + services++; + var limit = services == 1 ? 64 : 2000; + return options => new Engine(options.MaxStatements(limit)); + }; + var config = Configuration.Default.With(createService).WithJs().WithEventLoop(); - using (var context = BrowsingContext.New(config)) + using (var first = BrowsingContext.New(config)) + using (var second = BrowsingContext.New(config)) { - var document = await context.OpenNewAsync().ConfigureAwait(false); - Assert.Throws(() => document.ExecuteScript( + 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); } } @@ -49,14 +59,11 @@ public async Task EngineCreatorIsCopiedBeforeAnEngineIsCreated() public async Task CreatorRunsOncePerWindowAndRetainsDomWrapping() { var calls = 0; - var config = Configuration.Default.WithJs(new JsScriptingOptions + var config = Configuration.Default.With(new EngineCreator(options => { - EngineCreator = options => - { - calls++; - return new Engine(options.MaxStatements(1000)); - }, - }).WithEventLoop(); + calls++; + return new Engine(options.MaxStatements(1000)); + })).WithJs().WithEventLoop(); using (var firstContext = BrowsingContext.New(config)) using (var secondContext = BrowsingContext.New(config)) @@ -75,14 +82,11 @@ public async Task CreatorRunsOncePerWindowAndRetainsDomWrapping() public async Task CreatorReturnsTheEngineUsedForInlineScriptsAndDomBindings() { Engine created = null; - var config = Configuration.Default.WithJs(new JsScriptingOptions + var config = Configuration.Default.With(new EngineCreator(options => { - EngineCreator = options => - { - created = new Engine(options.Configure(engine => engine.SetValue("hostValue", "configured"))); - return created; - }, - }).WithEventLoop(); + created = new Engine(options.Configure(engine => engine.SetValue("hostValue", "configured"))); + return created; + })).WithJs().WithEventLoop(); using (var context = BrowsingContext.New(config)) { diff --git a/src/AngleSharp.Js/EngineCreator.cs b/src/AngleSharp.Js/EngineCreator.cs new file mode 100644 index 0000000..a0cf3c3 --- /dev/null +++ b/src/AngleSharp.Js/EngineCreator.cs @@ -0,0 +1,14 @@ +namespace AngleSharp.Js +{ + /// + /// Creates a window's Jint engine. Register this service with the configuration + /// to customize engine creation before page scripts run. + /// + /// 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(Jint.Options options); +} diff --git a/src/AngleSharp.Js/EngineInstance.cs b/src/AngleSharp.Js/EngineInstance.cs index e09723b..b655e94 100644 --- a/src/AngleSharp.Js/EngineInstance.cs +++ b/src/AngleSharp.Js/EngineInstance.cs @@ -45,7 +45,8 @@ public EngineInstance(IWindow window, IDictionary assignments, I // 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; - _engine = options.EngineCreator.Invoke(engineOptions) + var creator = window.Document.Context.GetService(); + _engine = (creator is null ? new Engine(engineOptions) : creator.Invoke(engineOptions)) ?? throw new InvalidOperationException("The engine creator must return a Jint engine."); _libs = new LibrarySet(libs); _prototypes = new PrototypeCache(_engine, _libs); diff --git a/src/AngleSharp.Js/JsScriptingOptions.cs b/src/AngleSharp.Js/JsScriptingOptions.cs index f992f00..6499a2b 100644 --- a/src/AngleSharp.Js/JsScriptingOptions.cs +++ b/src/AngleSharp.Js/JsScriptingOptions.cs @@ -17,30 +17,12 @@ public sealed class JsScriptingOptions /// public Int32 MaxCallStackDepth { get; set; } = 10000; - /// - /// Gets or sets the factory used to create each window's Jint engine - /// before any page script runs. The default creates a new engine with - /// the supplied options. - /// - /// - /// The factory must construct a fresh engine using the supplied options, - /// which contain the DOM module loader, object wrapper, and stack guard. - /// It may configure additional Jint options before constructing the engine. - /// DOM bindings are installed after the factory returns; do not access - /// DOM objects or evaluate page scripts while creating the engine. - /// The factory is retained when the scripting service copies these - /// options. Captured state remains owned by the caller. The factory can - /// run more than once when the service is used for multiple windows. - /// - public Func EngineCreator { get; set; } = options => new Jint.Engine(options); - // An engine is built per window, long after the options were handed over, so // reading them then would let a later edit of the caller's object decide how // the next document behaves. The service takes this copy instead. internal JsScriptingOptions Clone() => new JsScriptingOptions { MaxCallStackDepth = MaxCallStackDepth, - EngineCreator = EngineCreator ?? throw new ArgumentException("The engine creator cannot be null.", nameof(EngineCreator)), }; } } From 76f27e39b014539a2db206b94b4b165ed039f8a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Sun, 4 Oct 2026 21:57:01 +0200 Subject: [PATCH 5/6] Preserve engine creator registrations in worker contexts --- .../EngineConfigurationTests.cs | 48 +++++++++++++++++++ src/AngleSharp.Js/Dom/Worker.cs | 9 ++++ 2 files changed, 57 insertions(+) diff --git a/src/AngleSharp.Js.Tests/EngineConfigurationTests.cs b/src/AngleSharp.Js.Tests/EngineConfigurationTests.cs index d70305e..2e21502 100644 --- a/src/AngleSharp.Js.Tests/EngineConfigurationTests.cs +++ b/src/AngleSharp.Js.Tests/EngineConfigurationTests.cs @@ -1,11 +1,14 @@ 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.Threading; using System.Threading.Tasks; public class EngineConfigurationTests @@ -98,5 +101,50 @@ public async Task CreatorReturnsTheEngineUsedForInlineScriptsAndDomBindings() Assert.AreEqual(true, document.ExecuteScript("hostNode === document.querySelector('p')")); } } + + [TestCase(false)] + [TestCase(true)] + public async Task WorkersInheritTheEngineCreatorService(Boolean contextFactory) + { + var services = 0; + EngineCreator CreateEngine(String hostValue) => options => 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")); + 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, () => From 25dc67b1fb35f6e8acf312241edbf2110a3578f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Sun, 4 Oct 2026 23:29:24 +0200 Subject: [PATCH 6/6] Pass the current window to the engine creator --- README.md | 2 +- .../EngineConfigurationTests.cs | 27 ++++++++++++++----- src/AngleSharp.Js/EngineCreator.cs | 5 +++- src/AngleSharp.Js/EngineInstance.cs | 2 +- 4 files changed, 27 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 0e912bf..e08377a 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ using Jint; using AngleSharp.Js; var config = Configuration.Default - .With(new EngineCreator(options => new Engine(options + .With(new EngineCreator((window, options) => new Engine(options .MaxStatements(100_000) .TimeoutInterval(TimeSpan.FromSeconds(5)) .LimitMemory(16 * 1024 * 1024)))) diff --git a/src/AngleSharp.Js.Tests/EngineConfigurationTests.cs b/src/AngleSharp.Js.Tests/EngineConfigurationTests.cs index 2e21502..cc69a88 100644 --- a/src/AngleSharp.Js.Tests/EngineConfigurationTests.cs +++ b/src/AngleSharp.Js.Tests/EngineConfigurationTests.cs @@ -8,6 +8,7 @@ namespace AngleSharp.Js.Tests using Jint.Runtime; using NUnit.Framework; using System; + using System.Collections.Concurrent; using System.Threading; using System.Threading.Tasks; @@ -17,7 +18,7 @@ public class EngineConfigurationTests public async Task ConstraintsAreInstalledBeforeInlineScriptsRun() { var config = Configuration.Default - .With(new EngineCreator(options => new Engine(options.MaxStatements(64)))) + .With(new EngineCreator((window, options) => new Engine(options.MaxStatements(64)))) .WithJs() .WithEventLoop(); @@ -42,7 +43,7 @@ public async Task CreatorServiceCanBeCreatedForEachBrowsingContext() { services++; var limit = services == 1 ? 64 : 2000; - return options => new Engine(options.MaxStatements(limit)); + return (window, options) => new Engine(options.MaxStatements(limit)); }; var config = Configuration.Default.With(createService).WithJs().WithEventLoop(); @@ -62,9 +63,11 @@ public async Task CreatorServiceCanBeCreatedForEachBrowsingContext() public async Task CreatorRunsOncePerWindowAndRetainsDomWrapping() { var calls = 0; - var config = Configuration.Default.With(new EngineCreator(options => + 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(); @@ -78,6 +81,7 @@ public async Task CreatorRunsOncePerWindowAndRetainsDomWrapping() 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()); } } @@ -85,7 +89,7 @@ public async Task CreatorRunsOncePerWindowAndRetainsDomWrapping() public async Task CreatorReturnsTheEngineUsedForInlineScriptsAndDomBindings() { Engine created = null; - var config = Configuration.Default.With(new EngineCreator(options => + var config = Configuration.Default.With(new EngineCreator((window, options) => { created = new Engine(options.Configure(engine => engine.SetValue("hostValue", "configured"))); return created; @@ -107,8 +111,13 @@ public async Task CreatorReturnsTheEngineUsedForInlineScriptsAndDomBindings() public async Task WorkersInheritTheEngineCreatorService(Boolean contextFactory) { var services = 0; - EngineCreator CreateEngine(String hostValue) => options => new Engine(options.MaxStatements(256) - .Configure(engine => engine.SetValue("hostValue", hostValue))); + 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"); @@ -135,6 +144,12 @@ public async Task WorkersInheritTheEngineCreatorService(Boolean contextFactory) 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); diff --git a/src/AngleSharp.Js/EngineCreator.cs b/src/AngleSharp.Js/EngineCreator.cs index a0cf3c3..11f99a7 100644 --- a/src/AngleSharp.Js/EngineCreator.cs +++ b/src/AngleSharp.Js/EngineCreator.cs @@ -1,14 +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(Jint.Options options); + 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 b655e94..24a9856 100644 --- a/src/AngleSharp.Js/EngineInstance.cs +++ b/src/AngleSharp.Js/EngineInstance.cs @@ -46,7 +46,7 @@ public EngineInstance(IWindow window, IDictionary assignments, I // 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(engineOptions)) + _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);