using AngleSharp.Dom; using Jint; using Jint.Native; using Jint.Native.Object; using Jint.Runtime.Descriptors; using Jint.Runtime.Interop; namespace XFESystem.Browser; /// Explicit, identity-preserving DOM projection, without CLR reflection wrappers. 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 wrappers = new(); private readonly DependentIdentityMap nodes = new(); private readonly DependentIdentityMap 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)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 values) => ListValues(values.Select(Wrap)); private JsValue LiveList(Func> values) => CallInternal(bootstrap.Get("liveList"), [new ClrFunction(engine, "read collection", (_, _) => new JsArray(engine, values().Select(Wrap).ToArray()))]); private JsValue ListValues(IEnumerable 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)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 SelfAndDescendants(INode node) { if (node is IElement element) yield return element; foreach (var child in Descendants(node)) yield return child; } private static IEnumerable 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? 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()) 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 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 getter, Action? 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 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)); } } }