using AngleSharp.Dom;
using Jint;
using Jint.Native;
using Jint.Native.Object;
using Jint.Runtime.Descriptors;
using Jint.Runtime.Interop;
namespace XFESystem.Browser;
/// <summary>Explicit, identity-preserving DOM projection, without CLR reflection wrappers.</summary>
public sealed partial class DomBridge
{
private readonly Engine engine;
private readonly IDocument document;
private readonly Uri address;
private readonly BrowserLimits limits;
private readonly BrowserDomHost host;
private sealed record NodeIdentityValue(int Id);
private readonly DependentIdentityMap<INode,ObjectInstance> wrappers = new();
private readonly DependentIdentityMap<ObjectInstance,INode> nodes = new();
private readonly DependentIdentityMap<INode,NodeIdentityValue> identities = new();
private readonly ObjectInstance bootstrap;
private INode? focused;
private int listenerCount;
private int nextNodeId;
private string readyState = "loading";
private int callbackDepth;
private long validatedCharacters;
private bool characterBudgetValid;
internal bool IsExecutingCallback => callbackDepth > 0;
internal long BudgetValidationCount { get; private set; }
internal long BudgetValidationTicks { get; private set; }
internal long BudgetNodeVisits { get; private set; }
public DomBridge(JavaScriptRuntime runtime, IDocument document, Uri address, BrowserLimits limits, BrowserDomHost? host = null)
{
engine = runtime.Engine; this.document = document; this.address = address; this.limits = limits; this.host = host ?? new();
runtime.ScriptEntering = () => characterBudgetValid = false;
ValidateDocument(document, limits);
foreach (var name in new[] { "window", "self", "parent", "top" }) engine.SetValue(name, engine.Global);
engine.SetValue("__xfeListenerAdded", new ClrFunction(engine, "listenerAdded", (_, _) =>
{
if (++listenerCount > limits.MaximumPendingTasks * 8) { --listenerCount; throw new BrowserLimitException("The DOM event listener limit was reached."); }
return JsValue.Undefined;
}));
engine.SetValue("__xfeListenerRemoved", new ClrFunction(engine, "listenerRemoved", (_, _) => { listenerCount = Math.Max(0, listenerCount - 1); return JsValue.Undefined; }));
engine.SetValue("__xfeObserverAdded", new ClrFunction(engine, "observerAdded", (_, args) =>
{
if (Value(args, 0).AsNumber() > limits.MaximumPendingTasks) throw new BrowserLimitException("The DOM observer limit was reached.");
return JsValue.Undefined;
}));
engine.SetValue("__xfeEventError", new ClrFunction(engine, "eventError", (_, args) =>
{
var console = engine.GetValue("console");
((Func<JsValue, JsValue[], JsValue>)console.AsObject().Get("error").ToObject()!)(console, args);
return JsValue.Undefined;
}));
InstallDomParserCallback();
InstallEncodingCallbacks();
InstallCssomCallbacks();
InstallNetworkCallbacks();
Function(engine.Global, "__xfeMediaMatches", args => BrowserMediaQuery.Matches(Argument(args, 0), this.host.Viewport?.Invoke() ?? new(800, 600)));
// Fixed trusted Web API setup precedes all author code. Its finite
// initialization allowance must not consume a small page-script budget.
var statements = engine.Constraints.Find<Jint.Constraints.MaxStatementsConstraint>();
int originalStatements = statements?.MaxStatements ?? 0;
if (statements is not null) statements.MaxStatements = Math.Max(originalStatements, 10_000);
try
{
bootstrap = engine.Evaluate(BrowserDomBootstrap.Prepared).AsObject();
InstallNodeMutationPrototypes();
engine.SetValue("document", Wrap(document));
InstallWindow();
CallInternal(bootstrap.Get("configureWindow"), []);
}
catch
{
if (statements is not null) statements.MaxStatements = originalStatements;
engine.Constraints.Reset(); throw;
}
if (statements is not null) statements.MaxStatements = originalStatements;
engine.Constraints.Reset();
}
public int NodeIdentity(INode node)
{
if (identities.TryGetValue(node, out var known)) return known.Id;
if (identities.Count >= limits.MaximumDomNodes) throw new BrowserLimitException("The DOM identity limit was reached.");
int id = checked(++nextNodeId); identities.Add(node,new(id)); return id;
}
public INode? ResolveNode(int identity)
{foreach(var pair in identities)if(pair.Value.Id==identity)return pair.Key;return null;}
public INode? FocusedNode => focused;
public void NotifyLayoutChanged() => engine.Invoke(bootstrap.Get("notifyResize"), bootstrap, []);
public bool DispatchEvent(INode? target, string type, BrowserDomEventInit? init = null)
{
characterBudgetValid = false;
init ??= new(); var values = new BrowserHostObject(engine);
Data(values, "bubbles", init.Bubbles); Data(values, "cancelable", init.Cancelable);
Data(values, "clientX", init.ClientX); Data(values, "clientY", init.ClientY); Data(values, "deltaX", init.DeltaX); Data(values, "deltaY", init.DeltaY);
Data(values, "button", init.Button); Data(values, "buttons", init.Buttons); Data(values, "key", init.Key); Data(values, "code", init.Code);
Data(values, "ctrlKey", init.CtrlKey); Data(values, "shiftKey", init.ShiftKey); Data(values, "altKey", init.AltKey); Data(values, "metaKey", init.MetaKey);
Data(values, "data", init.Data); Data(values, "inputType", init.InputType);
var evt = engine.Invoke(bootstrap.Get("event"), bootstrap, [(JsValue)type, values]);
callbackDepth++;
JsValue result;
try { result = engine.Invoke(bootstrap.Get("dispatch"), bootstrap, [target is null ? engine.Global : Wrap(target), evt]); engine.Evaluate(BrowserDomBootstrap.FlushJobs); }
catch { callbackDepth--; throw; }
callbackDepth--; return result.AsBoolean();
}
public void CompleteLoading()
{
if (readyState == "complete") return;
readyState = "interactive"; DispatchEvent(document, "readystatechange", new() { Bubbles = false, Cancelable = false });
DispatchEvent(document, "DOMContentLoaded", new() { Cancelable = false }); readyState = "complete";
DispatchEvent(document, "readystatechange", new() { Bubbles = false, Cancelable = false });
DispatchEvent(null, "load", new() { Bubbles = false, Cancelable = false });
}
private void Changed(INode node, string type, string? name = null, string? oldValue = null, INode[]? added = null, INode[]? removed = null)
{
if (type != "attributes") characterBudgetValid = false;
if (Connected(node) && (type != "attributes" || node is not IElement element || element.GetAttribute(name!) != oldValue || element.LocalName == "canvas" && name is "width" or "height"))
{
if (type == "attributes" && node is IElement changed && host.AttributeChanged is not null) host.AttributeChanged(changed, name!);
else host.Mutated?.Invoke();
}
CallInternal(bootstrap.Get("mutation"), [Wrap(node), type, Text(name), Text(oldValue), List(added ?? []), List(removed ?? [])]);
}
private JsValue DispatchInternal(INode target, string type, bool bubbles, bool cancelable)
{
var init = new BrowserHostObject(engine); Data(init, "bubbles", bubbles); Data(init, "cancelable", cancelable);
var evt = CallInternal(bootstrap.Get("event"), [type, init]); return CallInternal(bootstrap.Get("dispatch"), [Wrap(target), evt]);
}
private void Focus(INode? node)
{
if (focused == node) return; var old = focused; focused = node;
if (old is not null) { DispatchInternal(old, "blur", false, false); DispatchInternal(old, "focusout", true, false); }
if (node is not null) { DispatchInternal(node, "focus", false, false); DispatchInternal(node, "focusin", true, false); }
host.FocusChanged?.Invoke(node);
}
private readonly HashSet<IElement> createdScripts = new(ReferenceEqualityComparer.Instance);
internal bool IsDynamicScript(IElement element) => createdScripts.Contains(element);
private JsValue Created(INode node, bool createdByApi = false)
{
var result = Wrap(node);
try { ValidateBudget(); }
catch
{
wrappers.Remove(node); nodes.Remove(result.AsObject());
identities.Remove(node);
throw;
}
if (createdByApi && node is IElement { LocalName: "script" } script) createdScripts.Add(script);
return result;
}
private JsValue Insert(INode parent, INode child, INode? before)
{
if (before is not null && before.Parent != parent) throw new ArgumentException("Reference node is not a child of this parent.");
if (Ancestor(child, parent)) throw new ArgumentException("A DOM node cannot contain itself.");
var oldParent = child.Parent; var oldNext = child.NextSibling;
var added = child.NodeType == NodeType.DocumentFragment ? child.ChildNodes.ToArray() : [child];
parent.InsertBefore(child, before);
try { ValidateBudget(); }
catch
{
foreach (var addedNode in added) addedNode.Parent?.RemoveChild(addedNode);
if (child.NodeType == NodeType.DocumentFragment) foreach (var addedNode in added) child.AppendChild(addedNode);
if (oldParent is not null) oldParent.InsertBefore(child, oldNext);
throw;
}
if (oldParent is not null) Changed(oldParent, "childList", removed: added);
Changed(parent, "childList", added: added);
foreach (var addedNode in added) if (Connected(addedNode)) foreach (var resource in SelfAndDescendants(addedNode).Where(e => e.LocalName is "script" or "link")) host.ResourceRequested?.Invoke(resource);
return Wrap(child);
}
private void MutateText(INode node, JsValue value)
{
var text = LimitedText(value); var old = node.ChildNodes.ToArray(); var oldText = node.TextContent; node.TextContent = text;
try { ValidateBudget(); }
catch { node.TextContent = oldText; if (old.Length != 0) { foreach (var child in node.ChildNodes.ToArray()) node.RemoveChild(child); foreach (var child in old) node.AppendChild(child); } throw; }
Changed(node, node.NodeType is NodeType.Text or NodeType.Comment ? "characterData" : "childList", oldValue: oldText, added: node.ChildNodes.ToArray(), removed: old);
}
private static string InnerTextValue(INode node)
{
if (node is IText) return node.TextContent;
if (node is IElement { LocalName: "br" }) return "\n";
return string.Concat(node.ChildNodes.Select(InnerTextValue));
}
private void MutateInnerText(IElement element, JsValue value)
{
string[] lines = LimitedText(value).Replace("\r\n", "\n").Replace('\r', '\n').Split('\n');
var added = new List<INode>();
for (int index = 0; index < lines.Length; index++)
{
if (index != 0) added.Add(document.CreateElement("br"));
if (lines[index].Length != 0) added.Add(document.CreateTextNode(lines[index]));
if (added.Count > limits.MaximumDomNodes) throw new BrowserLimitException("The innerText replacement exceeds its DOM node limit.");
}
var removed = element.ChildNodes.ToArray();
foreach (var child in removed) element.RemoveChild(child);
foreach (var child in added) element.AppendChild(child);
try { ValidateBudget(); }
catch { foreach (var child in added) element.RemoveChild(child); foreach (var child in removed) element.AppendChild(child); characterBudgetValid = false; throw; }
Changed(element, "childList", added: added.ToArray(), removed: removed);
}
private void MutateHtml(IElement element, JsValue value)
{
var previous = element.ChildNodes.ToArray(); element.InnerHtml = LimitedText(value);
try { ValidateBudget(); }
catch { foreach (var child in element.ChildNodes.ToArray()) element.RemoveChild(child); foreach (var child in previous) element.AppendChild(child); throw; }
Changed(element, "childList", added: element.ChildNodes.ToArray(), removed: previous);
// Fragment-inserted scripts are inert. Link styles still trigger page-owned loading.
if (Connected(element)) foreach (var link in element.QuerySelectorAll("link")) host.ResourceRequested?.Invoke(link);
}
private void SetAttribute(IElement element, string name, JsValue value)
{
long before = AttributeAndFormCharacters(element, name);
var previous = element.GetAttribute(name); element.SetAttribute(name, LimitedText(value));
try
{
if (element.GetAttribute(name) != previous)
{
if (characterBudgetValid) AdjustAttributeBudget(AttributeAndFormCharacters(element, name) - before);
else ValidateBudget();
}
CanvasAttributeChanged(element, name);
}
catch { if (previous is null) element.RemoveAttribute(name); else element.SetAttribute(name, previous); characterBudgetValid = false; throw; }
Changed(element, "attributes", name, previous);
if ((Connected(element) && (element.LocalName is "script" or "link") || element.LocalName == "img") && (name is "src" or "href" or "rel")) host.ResourceRequested?.Invoke(element);
}
private void RemoveAttribute(IElement element, string name)
{
long before = AttributeAndFormCharacters(element, name);
var previous = element.GetAttribute(name); element.RemoveAttribute(name);
try
{
if (characterBudgetValid) AdjustAttributeBudget(AttributeAndFormCharacters(element, name) - before);
else if (element is AngleSharp.Html.Dom.IHtmlInputElement or AngleSharp.Html.Dom.IHtmlTextAreaElement) ValidateBudget();
CanvasAttributeChanged(element, name);
}
catch { if (previous is not null) element.SetAttribute(name, previous); characterBudgetValid = false; throw; }
if (previous is not null) Changed(element, "attributes", name, previous);
}
private void SetTitle(IDocument owner, JsValue value)
{
var previous = owner.Title; var previousElement = owner.Head?.QuerySelector("title"); owner.Title = LimitedText(value);
try { ValidateBudget(); } catch { owner.Title = previous; if (previousElement is null) owner.Head?.QuerySelector("title")?.Remove(); throw; }
Changed(owner, "childList");
}
private JsValue List(IEnumerable<INode> values)
{
// Snapshot membership immediately, but create JS wrappers only for
// entries the page actually reads. Reading a NodeList's length must
// not project the entire result tree into the interpreter.
var snapshot = values.ToArray();
return LiveList(() => snapshot);
}
private JsValue LiveList(Func<IEnumerable<INode>> values, string kind = "NodeList")
{
var collection = new BrowserHostObject(engine);
Function(collection, "length", _ => values().Count());
Function(collection, "item", args => Wrap(values().ElementAtOrDefault(NumberIndex(Value(args, 0)))));
Function(collection, "all", _ => new JsArray(engine, values().Select(Wrap).ToArray()));
return CallInternal(bootstrap.Get("liveList"), [collection, kind]);
}
private JsValue ListValues(IEnumerable<JsValue> values) => CallInternal(bootstrap.Get("list"), [new JsArray(engine, values.ToArray())]);
// These are private trusted ScriptFunctions. Their direct call delegate preserves the outer
// evaluation constraints; re-entering Engine.Invoke here would reset the statement budget.
private JsValue CallInternal(JsValue callable, JsValue[] arguments) =>
((Func<JsValue, JsValue[], JsValue>)callable.ToObject()!)(bootstrap, arguments);
private INode RequireNode(JsValue value) => OptionalNode(value) ?? throw new ArgumentException("Expected a node created by this document.");
private INode? OptionalNode(JsValue value) => value.IsNull() || value.IsUndefined() ? null : value.IsObject() && nodes.TryGetValue(value.AsObject(), out var node) ? node : throw new ArgumentException("Expected a node created by this document.");
private INode NodeOrText(JsValue value) => value.IsObject() ? RequireNode(value) : document.CreateTextNode(value.IsNull() ? "null" : value.IsUndefined() ? "undefined" : LimitedText(value));
private string ResolveUrl(string? url) => string.IsNullOrEmpty(url) ? string.Empty : Uri.TryCreate(address, url, out var resolved) ? resolved.AbsoluteUri : url;
private static bool Connected(INode node) { while (node.Parent is not null) node = node.Parent; return node.NodeType == NodeType.Document; }
private static bool Ancestor(INode parent, INode child) { for (var node = child; node is not null; node = node.Parent) if (node == parent) return true; return false; }
private static IEnumerable<IElement> SelfAndDescendants(INode node) { if (node is IElement element) yield return element; foreach (var child in Descendants(node)) yield return child; }
private static IEnumerable<IElement> Descendants(INode root)
{
// Live collection length/index reads can traverse this tree many
// times. Walk existing links instead of allocating an iterator and a
// ChildNodes enumerator at every node of every traversal.
var node = root.FirstChild;
while (node is not null)
{
if (node is IElement element) yield return element;
if (node.FirstChild is not null) { node = node.FirstChild; continue; }
while (node is not null && node != root && node.NextSibling is null) node = node.Parent;
node = node == root ? null : node?.NextSibling;
}
}
private void ValidateBudget(IEnumerable<INode>? additionalDetachedRoots = null)
{
characterBudgetValid = false;
long started = System.Diagnostics.Stopwatch.GetTimestamp();
BudgetValidationCount++;
// Dirty form values are not HTML attributes/text nodes and need their own combined budget.
long formCharacters = 0;
foreach (var element in wrappers.Keys.OfType<IElement>())
if (element is AngleSharp.Html.Dom.IHtmlInputElement or AngleSharp.Html.Dom.IHtmlTextAreaElement)
if ((formCharacters += FormValue(element).Length) > limits.MaximumHtmlCharacters)
throw new BrowserLimitException("The editable controls exceed their combined character limit.");
BudgetNodeVisits += ValidateNodes(document, limits, wrappers.Keys.Where(node => node != document && node.Parent is null).Concat(additionalDetachedRoots ?? []), formCharacters, out validatedCharacters);
characterBudgetValid = true;
BudgetValidationTicks += System.Diagnostics.Stopwatch.GetTimestamp() - started;
}
private static long AttributeAndFormCharacters(IElement element, string name)
{
var attribute = element.GetAttributeNode(name);
return (attribute is null ? 0 : attribute.Name.Length + attribute.Value.Length) +
(element is AngleSharp.Html.Dom.IHtmlInputElement or AngleSharp.Html.Dom.IHtmlTextAreaElement ? FormValue(element).Length : 0);
}
private void AdjustAttributeBudget(long difference)
{
long next = validatedCharacters + difference;
if (next > limits.MaximumHtmlCharacters) throw new BrowserLimitException("The document exceeds its DOM text limit: " + next + " > " + limits.MaximumHtmlCharacters + ".");
validatedCharacters = next;
}
// Web IDL operations can be wrapped/replaced by page JavaScript (for
// example form.submit instrumentation). This exposes no additional host
// capability: calls still use only the explicit primitive callback.
private void Function(ObjectInstance target, string name, Func<JsValue[], JsValue> callback) => target.DefineOwnProperty(name, new PropertyDescriptor(new ClrFunction(engine, name, (_, args) => callback(args)), true, true, true));
private static void Data(ObjectInstance target, string name, JsValue value, bool writable = false) => target.DefineOwnProperty(name, new PropertyDescriptor(value, writable, true, false));
private void Property(ObjectInstance target, string name, Func<JsValue> getter, Action<JsValue>? setter = null)
{
var get = new ClrFunction(engine, "get " + name, (_, _) => getter());
var set = setter is null ? null : new ClrFunction(engine, "set " + name, (_, args) => { setter(Value(args, 0)); return JsValue.Undefined; });
target.DefineOwnProperty(name, new GetSetPropertyDescriptor(get, set, true, false));
}
private string LimitedText(JsValue value)
{
if (value.IsObject()) throw new ArgumentException("DOM text requires a primitive value."); var text = value.IsNull() ? string.Empty : value.ToString();
if (text.Length > limits.MaximumHtmlCharacters) throw new BrowserLimitException("The DOM text exceeds its character limit."); return text;
}
private string Argument(JsValue[] arguments, int index) => LimitedText(Value(arguments, index));
private string ShortArgument(JsValue[] arguments, int index) { var text = Argument(arguments, index); if (text.Length > 1_024) throw new BrowserLimitException("The DOM name exceeds its character limit."); return text; }
private static JsValue Value(JsValue[] arguments, int index) => index < arguments.Length ? arguments[index] : JsValue.Undefined;
private static JsValue Text(string? value) => value is null ? JsValue.Null : value;
private static bool Bool(JsValue value) => !value.IsNull() && !value.IsUndefined() && (!value.IsBoolean() || value.AsBoolean()) && (!value.IsNumber() || value.AsNumber() != 0) && (!value.IsString() || value.AsString().Length != 0);
internal static void ValidateDocument(IDocument document, BrowserLimits limits) => ValidateNodes(document, limits, []);
private static int ValidateNodes(IDocument document, BrowserLimits limits, IEnumerable<INode> detachedRoots, long additionalCharacters = 0) =>
ValidateNodes(document, limits, detachedRoots, additionalCharacters, out _);
private static int ValidateNodes(IDocument document, BrowserLimits limits, IEnumerable<INode> detachedRoots, long additionalCharacters, out long observedCharacters)
{
var pending = new Stack<(INode Node, int Depth)>(); pending.Push((document, 0)); foreach (var node in detachedRoots) pending.Push((node, 0));
var count = 0; var characters = additionalCharacters;
while (pending.TryPop(out var entry))
{
if (++count > limits.MaximumDomNodes || entry.Depth > limits.MaximumDomDepth) throw new BrowserLimitException("The document exceeds its DOM node or depth limit.");
if (entry.Node.NodeType is NodeType.Text or NodeType.Comment) characters += entry.Node.TextContent.Length;
if (entry.Node is IElement element) foreach (var attribute in element.Attributes) characters += attribute.Name.Length + attribute.Value.Length;
if (characters > limits.MaximumHtmlCharacters) throw new BrowserLimitException("The document exceeds its DOM text limit: " + characters + " > " + limits.MaximumHtmlCharacters + ".");
foreach (var child in entry.Node.ChildNodes) pending.Push((child, entry.Depth + 1));
}
observedCharacters = characters; return count;
}
}
using AngleSharp.Dom;
using Jint;
using Jint.Native;
using Jint.Native.Object;
using Jint.Runtime.Descriptors;
using Jint.Runtime.Interop;
namespace XFESystem.Browser;
/// <summary>Explicit, identity-preserving DOM projection, without CLR reflection wrappers.</summary>
public sealed partial class DomBridge
{
private readonly Engine engine;
private readonly IDocument document;
private readonly Uri address;
private readonly BrowserLimits limits;
private readonly BrowserDomHost host;
private sealed record NodeIdentityValue(int Id);
private readonly DependentIdentityMap<INode,ObjectInstance> wrappers = new();
private readonly DependentIdentityMap<ObjectInstance,INode> nodes = new();
private readonly DependentIdentityMap<INode,NodeIdentityValue> identities = new();
private readonly ObjectInstance bootstrap;
private INode? focused;
private int listenerCount;
private int nextNodeId;
private string readyState = "loading";
private int callbackDepth;
private long validatedCharacters;
private bool characterBudgetValid;
internal bool IsExecutingCallback => callbackDepth > 0;
internal long BudgetValidationCount { get; private set; }
internal long BudgetValidationTicks { get; private set; }
internal long BudgetNodeVisits { get; private set; }
public DomBridge(JavaScriptRuntime runtime, IDocument document, Uri address, BrowserLimits limits, BrowserDomHost? host = null)
{
engine = runtime.Engine; this.document = document; this.address = address; this.limits = limits; this.host = host ?? new();
runtime.ScriptEntering = () => characterBudgetValid = false;
ValidateDocument(document, limits);
foreach (var name in new[] { "window", "self", "parent", "top" }) engine.SetValue(name, engine.Global);
engine.SetValue("__xfeListenerAdded", new ClrFunction(engine, "listenerAdded", (_, _) =>
{
if (++listenerCount > limits.MaximumPendingTasks * 8) { --listenerCount; throw new BrowserLimitException("The DOM event listener limit was reached."); }
return JsValue.Undefined;
}));
engine.SetValue("__xfeListenerRemoved", new ClrFunction(engine, "listenerRemoved", (_, _) => { listenerCount = Math.Max(0, listenerCount - 1); return JsValue.Undefined; }));
engine.SetValue("__xfeObserverAdded", new ClrFunction(engine, "observerAdded", (_, args) =>
{
if (Value(args, 0).AsNumber() > limits.MaximumPendingTasks) throw new BrowserLimitException("The DOM observer limit was reached.");
return JsValue.Undefined;
}));
engine.SetValue("__xfeEventError", new ClrFunction(engine, "eventError", (_, args) =>
{
var console = engine.GetValue("console");
((Func<JsValue, JsValue[], JsValue>)console.AsObject().Get("error").ToObject()!)(console, args);
return JsValue.Undefined;
}));
InstallDomParserCallback();
InstallEncodingCallbacks();
InstallCssomCallbacks();
InstallNetworkCallbacks();
Function(engine.Global, "__xfeMediaMatches", args => BrowserMediaQuery.Matches(Argument(args, 0), this.host.Viewport?.Invoke() ?? new(800, 600)));
// Fixed trusted Web API setup precedes all author code. Its finite
// initialization allowance must not consume a small page-script budget.
var statements = engine.Constraints.Find<Jint.Constraints.MaxStatementsConstraint>();
int originalStatements = statements?.MaxStatements ?? 0;
if (statements is not null) statements.MaxStatements = Math.Max(originalStatements, 10_000);
try
{
bootstrap = engine.Evaluate(BrowserDomBootstrap.Prepared).AsObject();
InstallNodeMutationPrototypes();
engine.SetValue("document", Wrap(document));
InstallWindow();
CallInternal(bootstrap.Get("configureWindow"), []);
}
catch
{
if (statements is not null) statements.MaxStatements = originalStatements;
engine.Constraints.Reset(); throw;
}
if (statements is not null) statements.MaxStatements = originalStatements;
engine.Constraints.Reset();
}
public int NodeIdentity(INode node)
{
if (identities.TryGetValue(node, out var known)) return known.Id;
if (identities.Count >= limits.MaximumDomNodes) throw new BrowserLimitException("The DOM identity limit was reached.");
int id = checked(++nextNodeId); identities.Add(node,new(id)); return id;
}
public INode? ResolveNode(int identity)
{foreach(var pair in identities)if(pair.Value.Id==identity)return pair.Key;return null;}
public INode? FocusedNode => focused;
public void NotifyLayoutChanged() => engine.Invoke(bootstrap.Get("notifyResize"), bootstrap, []);
public bool DispatchEvent(INode? target, string type, BrowserDomEventInit? init = null)
{
characterBudgetValid = false;
init ??= new(); var values = new BrowserHostObject(engine);
Data(values, "bubbles", init.Bubbles); Data(values, "cancelable", init.Cancelable);
Data(values, "clientX", init.ClientX); Data(values, "clientY", init.ClientY); Data(values, "deltaX", init.DeltaX); Data(values, "deltaY", init.DeltaY);
Data(values, "button", init.Button); Data(values, "buttons", init.Buttons); Data(values, "key", init.Key); Data(values, "code", init.Code);
Data(values, "ctrlKey", init.CtrlKey); Data(values, "shiftKey", init.ShiftKey); Data(values, "altKey", init.AltKey); Data(values, "metaKey", init.MetaKey);
Data(values, "data", init.Data); Data(values, "inputType", init.InputType);
var evt = engine.Invoke(bootstrap.Get("event"), bootstrap, [(JsValue)type, values]);
callbackDepth++;
JsValue result;
try { result = engine.Invoke(bootstrap.Get("dispatch"), bootstrap, [target is null ? engine.Global : Wrap(target), evt]); engine.Evaluate(BrowserDomBootstrap.FlushJobs); }
catch { callbackDepth--; throw; }
callbackDepth--; return result.AsBoolean();
}
public void CompleteLoading()
{
if (readyState == "complete") return;
readyState = "interactive"; DispatchEvent(document, "readystatechange", new() { Bubbles = false, Cancelable = false });
DispatchEvent(document, "DOMContentLoaded", new() { Cancelable = false }); readyState = "complete";
DispatchEvent(document, "readystatechange", new() { Bubbles = false, Cancelable = false });
DispatchEvent(null, "load", new() { Bubbles = false, Cancelable = false });
}
private void Changed(INode node, string type, string? name = null, string? oldValue = null, INode[]? added = null, INode[]? removed = null)
{
if (type != "attributes") characterBudgetValid = false;
if (Connected(node) && (type != "attributes" || node is not IElement element || element.GetAttribute(name!) != oldValue || element.LocalName == "canvas" && name is "width" or "height"))
{
if (type == "attributes" && node is IElement changed && host.AttributeChanged is not null) host.AttributeChanged(changed, name!);
else host.Mutated?.Invoke();
}
CallInternal(bootstrap.Get("mutation"), [Wrap(node), type, Text(name), Text(oldValue), List(added ?? []), List(removed ?? [])]);
}
private JsValue DispatchInternal(INode target, string type, bool bubbles, bool cancelable)
{
var init = new BrowserHostObject(engine); Data(init, "bubbles", bubbles); Data(init, "cancelable", cancelable);
var evt = CallInternal(bootstrap.Get("event"), [type, init]); return CallInternal(bootstrap.Get("dispatch"), [Wrap(target), evt]);
}
private void Focus(INode? node)
{
if (focused == node) return; var old = focused; focused = node;
if (old is not null) { DispatchInternal(old, "blur", false, false); DispatchInternal(old, "focusout", true, false); }
if (node is not null) { DispatchInternal(node, "focus", false, false); DispatchInternal(node, "focusin", true, false); }
host.FocusChanged?.Invoke(node);
}
private readonly HashSet<IElement> createdScripts = new(ReferenceEqualityComparer.Instance);
internal bool IsDynamicScript(IElement element) => createdScripts.Contains(element);
private JsValue Created(INode node, bool createdByApi = false)
{
var result = Wrap(node);
try { ValidateBudget(); }
catch
{
wrappers.Remove(node); nodes.Remove(result.AsObject());
identities.Remove(node);
throw;
}
if (createdByApi && node is IElement { LocalName: "script" } script) createdScripts.Add(script);
return result;
}
private JsValue Insert(INode parent, INode child, INode? before)
{
if (before is not null && before.Parent != parent) throw new ArgumentException("Reference node is not a child of this parent.");
if (Ancestor(child, parent)) throw new ArgumentException("A DOM node cannot contain itself.");
var oldParent = child.Parent; var oldNext = child.NextSibling;
var added = child.NodeType == NodeType.DocumentFragment ? child.ChildNodes.ToArray() : [child];
parent.InsertBefore(child, before);
try { ValidateBudget(); }
catch
{
foreach (var addedNode in added) addedNode.Parent?.RemoveChild(addedNode);
if (child.NodeType == NodeType.DocumentFragment) foreach (var addedNode in added) child.AppendChild(addedNode);
if (oldParent is not null) oldParent.InsertBefore(child, oldNext);
throw;
}
if (oldParent is not null) Changed(oldParent, "childList", removed: added);
Changed(parent, "childList", added: added);
foreach (var addedNode in added) if (Connected(addedNode)) foreach (var resource in SelfAndDescendants(addedNode).Where(e => e.LocalName is "script" or "link")) host.ResourceRequested?.Invoke(resource);
return Wrap(child);
}
private void MutateText(INode node, JsValue value)
{
var text = LimitedText(value); var old = node.ChildNodes.ToArray(); var oldText = node.TextContent; node.TextContent = text;
try { ValidateBudget(); }
catch { node.TextContent = oldText; if (old.Length != 0) { foreach (var child in node.ChildNodes.ToArray()) node.RemoveChild(child); foreach (var child in old) node.AppendChild(child); } throw; }
Changed(node, node.NodeType is NodeType.Text or NodeType.Comment ? "characterData" : "childList", oldValue: oldText, added: node.ChildNodes.ToArray(), removed: old);
}
private static string InnerTextValue(INode node)
{
if (node is IText) return node.TextContent;
if (node is IElement { LocalName: "br" }) return "\n";
return string.Concat(node.ChildNodes.Select(InnerTextValue));
}
private void MutateInnerText(IElement element, JsValue value)
{
string[] lines = LimitedText(value).Replace("\r\n", "\n").Replace('\r', '\n').Split('\n');
var added = new List<INode>();
for (int index = 0; index < lines.Length; index++)
{
if (index != 0) added.Add(document.CreateElement("br"));
if (lines[index].Length != 0) added.Add(document.CreateTextNode(lines[index]));
if (added.Count > limits.MaximumDomNodes) throw new BrowserLimitException("The innerText replacement exceeds its DOM node limit.");
}
var removed = element.ChildNodes.ToArray();
foreach (var child in removed) element.RemoveChild(child);
foreach (var child in added) element.AppendChild(child);
try { ValidateBudget(); }
catch { foreach (var child in added) element.RemoveChild(child); foreach (var child in removed) element.AppendChild(child); characterBudgetValid = false; throw; }
Changed(element, "childList", added: added.ToArray(), removed: removed);
}
private void MutateHtml(IElement element, JsValue value)
{
var previous = element.ChildNodes.ToArray(); element.InnerHtml = LimitedText(value);
try { ValidateBudget(); }
catch { foreach (var child in element.ChildNodes.ToArray()) element.RemoveChild(child); foreach (var child in previous) element.AppendChild(child); throw; }
Changed(element, "childList", added: element.ChildNodes.ToArray(), removed: previous);
// Fragment-inserted scripts are inert. Link styles still trigger page-owned loading.
if (Connected(element)) foreach (var link in element.QuerySelectorAll("link")) host.ResourceRequested?.Invoke(link);
}
private void SetAttribute(IElement element, string name, JsValue value)
{
long before = AttributeAndFormCharacters(element, name);
var previous = element.GetAttribute(name); element.SetAttribute(name, LimitedText(value));
try
{
if (element.GetAttribute(name) != previous)
{
if (characterBudgetValid) AdjustAttributeBudget(AttributeAndFormCharacters(element, name) - before);
else ValidateBudget();
}
CanvasAttributeChanged(element, name);
}
catch { if (previous is null) element.RemoveAttribute(name); else element.SetAttribute(name, previous); characterBudgetValid = false; throw; }
Changed(element, "attributes", name, previous);
if ((Connected(element) && (element.LocalName is "script" or "link") || element.LocalName == "img") && (name is "src" or "href" or "rel")) host.ResourceRequested?.Invoke(element);
}
private void RemoveAttribute(IElement element, string name)
{
long before = AttributeAndFormCharacters(element, name);
var previous = element.GetAttribute(name); element.RemoveAttribute(name);
try
{
if (characterBudgetValid) AdjustAttributeBudget(AttributeAndFormCharacters(element, name) - before);
else if (element is AngleSharp.Html.Dom.IHtmlInputElement or AngleSharp.Html.Dom.IHtmlTextAreaElement) ValidateBudget();
CanvasAttributeChanged(element, name);
}
catch { if (previous is not null) element.SetAttribute(name, previous); characterBudgetValid = false; throw; }
if (previous is not null) Changed(element, "attributes", name, previous);
}
private void SetTitle(IDocument owner, JsValue value)
{
var previous = owner.Title; var previousElement = owner.Head?.QuerySelector("title"); owner.Title = LimitedText(value);
try { ValidateBudget(); } catch { owner.Title = previous; if (previousElement is null) owner.Head?.QuerySelector("title")?.Remove(); throw; }
Changed(owner, "childList");
}
private JsValue List(IEnumerable<INode> values)
{
// Snapshot membership immediately, but create JS wrappers only for
// entries the page actually reads. Reading a NodeList's length must
// not project the entire result tree into the interpreter.
var snapshot = values.ToArray();
return LiveList(() => snapshot);
}
private JsValue LiveList(Func<IEnumerable<INode>> values, string kind = "NodeList")
{
var collection = new BrowserHostObject(engine);
Function(collection, "length", _ => values().Count());
Function(collection, "item", args => Wrap(values().ElementAtOrDefault(NumberIndex(Value(args, 0)))));
Function(collection, "all", _ => new JsArray(engine, values().Select(Wrap).ToArray()));
return CallInternal(bootstrap.Get("liveList"), [collection, kind]);
}
private JsValue ListValues(IEnumerable<JsValue> values) => CallInternal(bootstrap.Get("list"), [new JsArray(engine, values.ToArray())]);
// These are private trusted ScriptFunctions. Their direct call delegate preserves the outer
// evaluation constraints; re-entering Engine.Invoke here would reset the statement budget.
private JsValue CallInternal(JsValue callable, JsValue[] arguments) =>
((Func<JsValue, JsValue[], JsValue>)callable.ToObject()!)(bootstrap, arguments);
private INode RequireNode(JsValue value) => OptionalNode(value) ?? throw new ArgumentException("Expected a node created by this document.");
private INode? OptionalNode(JsValue value) => value.IsNull() || value.IsUndefined() ? null : value.IsObject() && nodes.TryGetValue(value.AsObject(), out var node) ? node : throw new ArgumentException("Expected a node created by this document.");
private INode NodeOrText(JsValue value) => value.IsObject() ? RequireNode(value) : document.CreateTextNode(value.IsNull() ? "null" : value.IsUndefined() ? "undefined" : LimitedText(value));
private string ResolveUrl(string? url) => string.IsNullOrEmpty(url) ? string.Empty : Uri.TryCreate(address, url, out var resolved) ? resolved.AbsoluteUri : url;
private static bool Connected(INode node) { while (node.Parent is not null) node = node.Parent; return node.NodeType == NodeType.Document; }
private static bool Ancestor(INode parent, INode child) { for (var node = child; node is not null; node = node.Parent) if (node == parent) return true; return false; }
private static IEnumerable<IElement> SelfAndDescendants(INode node) { if (node is IElement element) yield return element; foreach (var child in Descendants(node)) yield return child; }
private static IEnumerable<IElement> Descendants(INode root)
{
// Live collection length/index reads can traverse this tree many
// times. Walk existing links instead of allocating an iterator and a
// ChildNodes enumerator at every node of every traversal.
var node = root.FirstChild;
while (node is not null)
{
if (node is IElement element) yield return element;
if (node.FirstChild is not null) { node = node.FirstChild; continue; }
while (node is not null && node != root && node.NextSibling is null) node = node.Parent;
node = node == root ? null : node?.NextSibling;
}
}
private void ValidateBudget(IEnumerable<INode>? additionalDetachedRoots = null)
{
characterBudgetValid = false;
long started = System.Diagnostics.Stopwatch.GetTimestamp();
BudgetValidationCount++;
// Dirty form values are not HTML attributes/text nodes and need their own combined budget.
long formCharacters = 0;
foreach (var element in wrappers.Keys.OfType<IElement>())
if (element is AngleSharp.Html.Dom.IHtmlInputElement or AngleSharp.Html.Dom.IHtmlTextAreaElement)
if ((formCharacters += FormValue(element).Length) > limits.MaximumHtmlCharacters)
throw new BrowserLimitException("The editable controls exceed their combined character limit.");
BudgetNodeVisits += ValidateNodes(document, limits, wrappers.Keys.Where(node => node != document && node.Parent is null).Concat(additionalDetachedRoots ?? []), formCharacters, out validatedCharacters);
characterBudgetValid = true;
BudgetValidationTicks += System.Diagnostics.Stopwatch.GetTimestamp() - started;
}
private static long AttributeAndFormCharacters(IElement element, string name)
{
var attribute = element.GetAttributeNode(name);
return (attribute is null ? 0 : attribute.Name.Length + attribute.Value.Length) +
(element is AngleSharp.Html.Dom.IHtmlInputElement or AngleSharp.Html.Dom.IHtmlTextAreaElement ? FormValue(element).Length : 0);
}
private void AdjustAttributeBudget(long difference)
{
long next = validatedCharacters + difference;
if (next > limits.MaximumHtmlCharacters) throw new BrowserLimitException("The document exceeds its DOM text limit: " + next + " > " + limits.MaximumHtmlCharacters + ".");
validatedCharacters = next;
}
// Web IDL operations can be wrapped/replaced by page JavaScript (for
// example form.submit instrumentation). This exposes no additional host
// capability: calls still use only the explicit primitive callback.
private void Function(ObjectInstance target, string name, Func<JsValue[], JsValue> callback) => target.DefineOwnProperty(name, new PropertyDescriptor(new ClrFunction(engine, name, (_, args) => callback(args)), true, true, true));
private static void Data(ObjectInstance target, string name, JsValue value, bool writable = false) => target.DefineOwnProperty(name, new PropertyDescriptor(value, writable, true, false));
private void Property(ObjectInstance target, string name, Func<JsValue> getter, Action<JsValue>? setter = null)
{
var get = new ClrFunction(engine, "get " + name, (_, _) => getter());
var set = setter is null ? null : new ClrFunction(engine, "set " + name, (_, args) => { setter(Value(args, 0)); return JsValue.Undefined; });
target.DefineOwnProperty(name, new GetSetPropertyDescriptor(get, set, true, false));
}
private string LimitedText(JsValue value)
{
if (value.IsObject()) throw new ArgumentException("DOM text requires a primitive value."); var text = value.IsNull() ? string.Empty : value.ToString();
if (text.Length > limits.MaximumHtmlCharacters) throw new BrowserLimitException("The DOM text exceeds its character limit."); return text;
}
private string Argument(JsValue[] arguments, int index) => LimitedText(Value(arguments, index));
private string ShortArgument(JsValue[] arguments, int index) { var text = Argument(arguments, index); if (text.Length > 1_024) throw new BrowserLimitException("The DOM name exceeds its character limit."); return text; }
private static JsValue Value(JsValue[] arguments, int index) => index < arguments.Length ? arguments[index] : JsValue.Undefined;
private static JsValue Text(string? value) => value is null ? JsValue.Null : value;
private static bool Bool(JsValue value) => !value.IsNull() && !value.IsUndefined() && (!value.IsBoolean() || value.AsBoolean()) && (!value.IsNumber() || value.AsNumber() != 0) && (!value.IsString() || value.AsString().Length != 0);
internal static void ValidateDocument(IDocument document, BrowserLimits limits) => ValidateNodes(document, limits, []);
private static int ValidateNodes(IDocument document, BrowserLimits limits, IEnumerable<INode> detachedRoots, long additionalCharacters = 0) =>
ValidateNodes(document, limits, detachedRoots, additionalCharacters, out _);
private static int ValidateNodes(IDocument document, BrowserLimits limits, IEnumerable<INode> detachedRoots, long additionalCharacters, out long observedCharacters)
{
var pending = new Stack<(INode Node, int Depth)>(); pending.Push((document, 0)); foreach (var node in detachedRoots) pending.Push((node, 0));
var count = 0; var characters = additionalCharacters;
while (pending.TryPop(out var entry))
{
if (++count > limits.MaximumDomNodes || entry.Depth > limits.MaximumDomDepth) throw new BrowserLimitException("The document exceeds its DOM node or depth limit.");
if (entry.Node.NodeType is NodeType.Text or NodeType.Comment) characters += entry.Node.TextContent.Length;
if (entry.Node is IElement element) foreach (var attribute in element.Attributes) characters += attribute.Name.Length + attribute.Value.Length;
if (characters > limits.MaximumHtmlCharacters) throw new BrowserLimitException("The document exceeds its DOM text limit: " + characters + " > " + limits.MaximumHtmlCharacters + ".");
foreach (var child in entry.Node.ChildNodes) pending.Push((child, entry.Depth + 1));
}
observedCharacters = characters; return count;
}
}