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";
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();
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;
}));
InstallEncodingCallbacks();
InstallCssomCallbacks();
bootstrap = engine.Evaluate(BrowserDomBootstrap.Source).AsObject();
engine.SetValue("document", Wrap(document));
InstallWindow();
}
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)
{
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]);
var result = engine.Invoke(bootstrap.Get("dispatch"), bootstrap, [target is null ? engine.Global : Wrap(target), evt]);
engine.Evaluate("void 0"); 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)
{
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 JsValue Created(INode node)
{
var result = Wrap(node);
try { ValidateBudget(); }
catch
{
wrappers.Remove(node); nodes.Remove(result.AsObject());
identities.Remove(node);
throw;
}
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 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)
{
var previous = element.GetAttribute(name); element.SetAttribute(name, LimitedText(value));
try { ValidateBudget(); CanvasAttributeChanged(element, name); }
catch { if (previous is null) element.RemoveAttribute(name); else element.SetAttribute(name, previous); throw; }
Changed(element, "attributes", name, previous);
if (Connected(element) && (element.LocalName is "script" or "link") && (name is "src" or "href" or "rel")) host.ResourceRequested?.Invoke(element);
}
private void RemoveAttribute(IElement element, string name)
{
var previous = element.GetAttribute(name); element.RemoveAttribute(name);
try { CanvasAttributeChanged(element, name); } catch { if (previous is not null) element.SetAttribute(name, previous); throw; }
if (previous is not null) Changed(element, "attributes", name, previous);
}
private void SetTitle(JsValue value)
{
var previous = document.Title; var previousElement = document.Head?.QuerySelector("title"); document.Title = LimitedText(value);
try { ValidateBudget(); } catch { document.Title = previous; if (previousElement is null) document.Head?.QuerySelector("title")?.Remove(); throw; }
Changed(document, "childList");
}
private JsValue List(IEnumerable<INode> values) => ListValues(values.Select(Wrap));
private JsValue LiveList(Func<IEnumerable<INode>> values) => CallInternal(bootstrap.Get("liveList"),
[new ClrFunction(engine, "read collection", (_, _) => new JsArray(engine, values().Select(Wrap).ToArray()))]);
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 node) { foreach (var child in node.ChildNodes) { if (child is IElement element) yield return element; foreach (var descendant in Descendants(child)) yield return descendant; } }
private void ValidateBudget(IEnumerable<INode>? additionalDetachedRoots = null)
{
// 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.");
ValidateNodes(document, limits, wrappers.Keys.Where(node => node != document && node.Parent is null).Concat(additionalDetachedRoots ?? []), formCharacters);
}
private void Function(ObjectInstance target, string name, Func<JsValue[], JsValue> callback) => target.DefineOwnProperty(name, new PropertyDescriptor(new ClrFunction(engine, name, (_, args) => callback(args)), false, true, false));
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 void ValidateNodes(IDocument document, BrowserLimits limits, IEnumerable<INode> detachedRoots, long additionalCharacters = 0)
{
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.");
foreach (var child in entry.Node.ChildNodes) pending.Push((child, entry.Depth + 1));
}
}
}
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";
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();
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;
}));
InstallEncodingCallbacks();
InstallCssomCallbacks();
bootstrap = engine.Evaluate(BrowserDomBootstrap.Source).AsObject();
engine.SetValue("document", Wrap(document));
InstallWindow();
}
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)
{
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]);
var result = engine.Invoke(bootstrap.Get("dispatch"), bootstrap, [target is null ? engine.Global : Wrap(target), evt]);
engine.Evaluate("void 0"); 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)
{
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 JsValue Created(INode node)
{
var result = Wrap(node);
try { ValidateBudget(); }
catch
{
wrappers.Remove(node); nodes.Remove(result.AsObject());
identities.Remove(node);
throw;
}
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 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)
{
var previous = element.GetAttribute(name); element.SetAttribute(name, LimitedText(value));
try { ValidateBudget(); CanvasAttributeChanged(element, name); }
catch { if (previous is null) element.RemoveAttribute(name); else element.SetAttribute(name, previous); throw; }
Changed(element, "attributes", name, previous);
if (Connected(element) && (element.LocalName is "script" or "link") && (name is "src" or "href" or "rel")) host.ResourceRequested?.Invoke(element);
}
private void RemoveAttribute(IElement element, string name)
{
var previous = element.GetAttribute(name); element.RemoveAttribute(name);
try { CanvasAttributeChanged(element, name); } catch { if (previous is not null) element.SetAttribute(name, previous); throw; }
if (previous is not null) Changed(element, "attributes", name, previous);
}
private void SetTitle(JsValue value)
{
var previous = document.Title; var previousElement = document.Head?.QuerySelector("title"); document.Title = LimitedText(value);
try { ValidateBudget(); } catch { document.Title = previous; if (previousElement is null) document.Head?.QuerySelector("title")?.Remove(); throw; }
Changed(document, "childList");
}
private JsValue List(IEnumerable<INode> values) => ListValues(values.Select(Wrap));
private JsValue LiveList(Func<IEnumerable<INode>> values) => CallInternal(bootstrap.Get("liveList"),
[new ClrFunction(engine, "read collection", (_, _) => new JsArray(engine, values().Select(Wrap).ToArray()))]);
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 node) { foreach (var child in node.ChildNodes) { if (child is IElement element) yield return element; foreach (var descendant in Descendants(child)) yield return descendant; } }
private void ValidateBudget(IEnumerable<INode>? additionalDetachedRoots = null)
{
// 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.");
ValidateNodes(document, limits, wrappers.Keys.Where(node => node != document && node.Parent is null).Concat(additionalDetachedRoots ?? []), formCharacters);
}
private void Function(ObjectInstance target, string name, Func<JsValue[], JsValue> callback) => target.DefineOwnProperty(name, new PropertyDescriptor(new ClrFunction(engine, name, (_, args) => callback(args)), false, true, false));
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 void ValidateNodes(IDocument document, BrowserLimits limits, IEnumerable<INode> detachedRoots, long additionalCharacters = 0)
{
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.");
foreach (var child in entry.Node.ChildNodes) pending.Push((child, entry.Depth + 1));
}
}
}