From 2ed49ecb8d784c1c6551218f340c16e4adfa7ec5 Mon Sep 17 00:00:00 2001 From: Navid EMAD Date: Fri, 12 Jun 2026 04:50:38 +0200 Subject: [PATCH 001/218] css: apply @layer block rules to the cascade Rules wrapped in @layer blocks (named, dotted sub-layer names, or anonymous) were dropped from the cascade: StyleManager.parseSheet applied top-level style rules and recursed into @media only, so @layer fell into the silent else arm on both the _css_rules path and the + + +
named
+
anonymous
+
dotted
+
after-statement
+
media-in-layer
+
print-in-layer
+
layer-in-media
+
nested-layer
+
important
+
visibility
+
dyn-target
+
dyn2-target
+
deep-nest-target
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/tests/css/media_at_rule_cascade.html b/src/browser/tests/css/media_at_rule_cascade.html index 217c18547..bd34128e9 100644 --- a/src/browser/tests/css/media_at_rule_cascade.html +++ b/src/browser/tests/css/media_at_rule_cascade.html @@ -256,7 +256,7 @@ + + + + + + +
+ + + + diff --git a/src/browser/tests/element/svg/hierarchy.html b/src/browser/tests/element/svg/hierarchy.html new file mode 100644 index 000000000..17dd71194 --- /dev/null +++ b/src/browser/tests/element/svg/hierarchy.html @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/tests/element/svg/svgsvg.html b/src/browser/tests/element/svg/svgsvg.html new file mode 100644 index 000000000..e73b55587 --- /dev/null +++ b/src/browser/tests/element/svg/svgsvg.html @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index 2ed88adc4..04a9bc2d2 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -126,7 +126,7 @@ pub fn is(self: *Element, comptime T: type) ?*T { if (T == Svg) { return svg; } - if (comptime std.mem.startsWith(u8, type_name, "webapi.element.svg.")) { + if (comptime std.mem.startsWith(u8, type_name, "browser.webapi.element.svg.")) { return svg.is(T); } }, @@ -583,6 +583,22 @@ pub fn hasAttribute(self: *const Element, name: String, frame: *Frame) !bool { return value != null; } +/// Like getAttributeNS, the namespace is currently ignored. +pub fn hasAttributeNS( + self: *const Element, + maybe_namespace: ?[]const u8, + local_name: String, + frame: *Frame, +) !bool { + if (maybe_namespace) |namespace| { + if (!std.mem.eql(u8, namespace, "http://www.w3.org/1999/xhtml")) { + log.warn(.not_implemented, "Element.hasAttributeNS", .{ .namespace = namespace }); + } + } + + return self.hasAttribute(local_name, frame); +} + pub fn hasAttributeSafe(self: *const Element, name: String) bool { return self._attributes.hasSafe(name); } @@ -1673,10 +1689,7 @@ pub fn getTag(self: *const Element) Tag { .head => .head, .unknown => .unknown, }, - .svg => |se| switch (se._type) { - .svg => .svg, - .generic => |g| g._tag, - }, + .svg => |se| se.getTag(), }; } @@ -1936,6 +1949,7 @@ pub const JsApi = struct { pub const style = bridge.accessor(Element.getOrCreateStyle, Element.setStyle, .{}); pub const attributes = bridge.accessor(Element.getAttributeNamedNodeMap, null, .{}); pub const hasAttribute = bridge.function(Element.hasAttribute, .{}); + pub const hasAttributeNS = bridge.function(Element.hasAttributeNS, .{}); pub const hasAttributes = bridge.function(Element.hasAttributes, .{}); pub const getAttribute = bridge.function(Element.getAttribute, .{}); pub const getAttributeNS = bridge.function(Element.getAttributeNS, .{}); diff --git a/src/browser/webapi/element/Svg.zig b/src/browser/webapi/element/Svg.zig index 1d30d41c6..8594eaa5b 100644 --- a/src/browser/webapi/element/Svg.zig +++ b/src/browser/webapi/element/Svg.zig @@ -16,13 +16,17 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const std = @import("std"); const lp = @import("lightpanda"); const js = @import("../../js/js.zig"); +const Frame = @import("../../Frame.zig"); const Node = @import("../Node.zig"); const Element = @import("../Element.zig"); +const AnimatedString = @import("../svg/AnimatedString.zig"); pub const Generic = @import("svg/Generic.zig"); +pub const Graphics = @import("svg/Graphics.zig"); const String = lp.String; @@ -32,24 +36,49 @@ _proto: *Element, _tag_name: String, // Svg elements are case-preserving pub const Type = union(enum) { - svg, + graphics: *Graphics, generic: *Generic, }; pub fn is(self: *Svg, comptime T: type) ?*T { - inline for (@typeInfo(Type).@"union".fields) |f| { - if (@field(Type, f.name) == self._type) { - if (f.type == T) { - return &@field(self._type, f.name); + switch (self._type) { + .graphics => |g| { + if (T == Graphics) { + return g; } - if (f.type == *T) { - return @field(self._type, f.name); + return g.is(T); + }, + .generic => |g| { + if (T == Generic) { + return g; } - } + }, } return null; } +pub fn getTag(self: *const Svg) Element.Tag { + return switch (self._type) { + .graphics => |g| switch (g._type) { + .svg => .svg, + .g => .g, + // No dedicated Element.Tag values; tag-name matching falls back + // to _tag_name, like it does for generic SVG elements. + .a, .use, .image, .defs => .unknown, + .geometry => |geo| switch (geo._type) { + .rect => .rect, + .circle => .circle, + .ellipse => .ellipse, + .line => .line, + .path => .path, + .polygon => .polygon, + .polyline => .polyline, + }, + }, + .generic => |g| g._tag, + }; +} + pub fn asElement(self: *Svg) *Element { return self._proto; } @@ -57,6 +86,25 @@ pub fn asNode(self: *Svg) *Node { return self.asElement().asNode(); } +// The nearest ancestor element, null when this is the outermost svg. +pub fn getOwnerSvgElement(self: *Svg) ?*Graphics.Svg { + var node = self.asNode().parentNode(); + while (node) |n| : (node = n.parentNode()) { + const element = n.is(Element) orelse return null; + if (element._namespace != .svg) { + return null; + } + const svg = element.as(Svg); + if (svg.is(Graphics.Svg)) |gfx_svg| { + return gfx_svg; + } + if (svg._tag_name.eql(.wrap("foreignObject"))) { + return null; + } + } + return null; +} + pub const JsApi = struct { pub const bridge = js.Bridge(Svg); @@ -65,6 +113,16 @@ pub const JsApi = struct { pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; + + // Overrides el.className to return a readonly AnimatedString + pub const className = bridge.accessor(_className, null, .{}); + fn _className(self: *Svg, frame: *Frame) !*AnimatedString { + return AnimatedString.getOrCreate(self.asElement(), .class, frame); + } + + pub const ownerSVGElement = bridge.accessor(Svg.getOwnerSvgElement, null, .{}); + // closest thing we can provide + pub const viewportElement = bridge.accessor(Svg.getOwnerSvgElement, null, .{}); }; const testing = @import("../../../testing.zig"); diff --git a/src/browser/webapi/element/svg/A.zig b/src/browser/webapi/element/svg/A.zig new file mode 100644 index 000000000..da5cad8dd --- /dev/null +++ b/src/browser/webapi/element/svg/A.zig @@ -0,0 +1,51 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const js = @import("../../../js/js.zig"); +const Frame = @import("../../../Frame.zig"); + +const Node = @import("../../Node.zig"); +const Element = @import("../../Element.zig"); +const AnimatedString = @import("../../svg/AnimatedString.zig"); + +const Graphics = @import("Graphics.zig"); + +const A = @This(); +_proto: *Graphics, + +pub fn asElement(self: *A) *Element { + return self._proto.asElement(); +} +pub fn asNode(self: *A) *Node { + return self.asElement().asNode(); +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(A); + + pub const Meta = struct { + pub const name = "SVGAElement"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const href = bridge.accessor(_href, null, .{}); + fn _href(self: *A, frame: *Frame) !*AnimatedString { + return AnimatedString.getOrCreate(self.asElement(), .href, frame); + } +}; diff --git a/src/browser/webapi/element/svg/Circle.zig b/src/browser/webapi/element/svg/Circle.zig new file mode 100644 index 000000000..067f23904 --- /dev/null +++ b/src/browser/webapi/element/svg/Circle.zig @@ -0,0 +1,44 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const js = @import("../../../js/js.zig"); + +const Node = @import("../../Node.zig"); +const Element = @import("../../Element.zig"); + +const Geometry = @import("Geometry.zig"); + +const Circle = @This(); +_proto: *Geometry, + +pub fn asElement(self: *Circle) *Element { + return self._proto.asElement(); +} +pub fn asNode(self: *Circle) *Node { + return self.asElement().asNode(); +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(Circle); + + pub const Meta = struct { + pub const name = "SVGCircleElement"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; +}; diff --git a/src/browser/webapi/element/svg/Defs.zig b/src/browser/webapi/element/svg/Defs.zig new file mode 100644 index 000000000..ba3f85cce --- /dev/null +++ b/src/browser/webapi/element/svg/Defs.zig @@ -0,0 +1,44 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const js = @import("../../../js/js.zig"); + +const Node = @import("../../Node.zig"); +const Element = @import("../../Element.zig"); + +const Graphics = @import("Graphics.zig"); + +const Defs = @This(); +_proto: *Graphics, + +pub fn asElement(self: *Defs) *Element { + return self._proto.asElement(); +} +pub fn asNode(self: *Defs) *Node { + return self.asElement().asNode(); +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(Defs); + + pub const Meta = struct { + pub const name = "SVGDefsElement"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; +}; diff --git a/src/browser/webapi/element/svg/Ellipse.zig b/src/browser/webapi/element/svg/Ellipse.zig new file mode 100644 index 000000000..00a1311b5 --- /dev/null +++ b/src/browser/webapi/element/svg/Ellipse.zig @@ -0,0 +1,44 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const js = @import("../../../js/js.zig"); + +const Node = @import("../../Node.zig"); +const Element = @import("../../Element.zig"); + +const Geometry = @import("Geometry.zig"); + +const Ellipse = @This(); +_proto: *Geometry, + +pub fn asElement(self: *Ellipse) *Element { + return self._proto.asElement(); +} +pub fn asNode(self: *Ellipse) *Node { + return self.asElement().asNode(); +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(Ellipse); + + pub const Meta = struct { + pub const name = "SVGEllipseElement"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; +}; diff --git a/src/browser/webapi/element/svg/G.zig b/src/browser/webapi/element/svg/G.zig new file mode 100644 index 000000000..26a264173 --- /dev/null +++ b/src/browser/webapi/element/svg/G.zig @@ -0,0 +1,44 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const js = @import("../../../js/js.zig"); + +const Node = @import("../../Node.zig"); +const Element = @import("../../Element.zig"); + +const Graphics = @import("Graphics.zig"); + +const G = @This(); +_proto: *Graphics, + +pub fn asElement(self: *G) *Element { + return self._proto.asElement(); +} +pub fn asNode(self: *G) *Node { + return self.asElement().asNode(); +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(G); + + pub const Meta = struct { + pub const name = "SVGGElement"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; +}; diff --git a/src/browser/webapi/element/svg/Generic.zig b/src/browser/webapi/element/svg/Generic.zig index dfde518d0..86af4455e 100644 --- a/src/browser/webapi/element/svg/Generic.zig +++ b/src/browser/webapi/element/svg/Generic.zig @@ -17,8 +17,10 @@ // along with this program. If not, see . const js = @import("../../../js/js.zig"); + const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); + const Svg = @import("../Svg.zig"); const Generic = @This(); diff --git a/src/browser/webapi/element/svg/Geometry.zig b/src/browser/webapi/element/svg/Geometry.zig new file mode 100644 index 000000000..280feb818 --- /dev/null +++ b/src/browser/webapi/element/svg/Geometry.zig @@ -0,0 +1,73 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const js = @import("../../../js/js.zig"); + +const Node = @import("../../Node.zig"); +const Element = @import("../../Element.zig"); + +const Graphics = @import("Graphics.zig"); +pub const Rect = @import("Rect.zig"); +pub const Circle = @import("Circle.zig"); +pub const Ellipse = @import("Ellipse.zig"); +pub const Line = @import("Line.zig"); +pub const Path = @import("Path.zig"); +pub const Polygon = @import("Polygon.zig"); +pub const Polyline = @import("Polyline.zig"); + +const Geometry = @This(); +_proto: *Graphics, +_type: Type, + +pub const Type = union(enum) { + rect: *Rect, + circle: *Circle, + ellipse: *Ellipse, + line: *Line, + path: *Path, + polygon: *Polygon, + polyline: *Polyline, +}; + +pub fn is(self: *Geometry, comptime T: type) ?*T { + inline for (@typeInfo(Type).@"union".fields) |f| { + if (@field(Type, f.name) == self._type) { + if (f.type == *T) { + return @field(self._type, f.name); + } + } + } + return null; +} + +pub fn asElement(self: *Geometry) *Element { + return self._proto.asElement(); +} +pub fn asNode(self: *Geometry) *Node { + return self.asElement().asNode(); +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(Geometry); + + pub const Meta = struct { + pub const name = "SVGGeometryElement"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; +}; diff --git a/src/browser/webapi/element/svg/Graphics.zig b/src/browser/webapi/element/svg/Graphics.zig new file mode 100644 index 000000000..ccb1386b9 --- /dev/null +++ b/src/browser/webapi/element/svg/Graphics.zig @@ -0,0 +1,75 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const js = @import("../../../js/js.zig"); +const Node = @import("../../Node.zig"); +const Element = @import("../../Element.zig"); +const SvgElement = @import("../Svg.zig"); + +pub const Svg = @import("Svg.zig"); +pub const G = @import("G.zig"); +pub const A = @import("A.zig"); +pub const Use = @import("Use.zig"); +pub const Image = @import("Image.zig"); +pub const Defs = @import("Defs.zig"); +pub const Geometry = @import("Geometry.zig"); + +const Graphics = @This(); +_proto: *SvgElement, +_type: Type, + +pub const Type = union(enum) { + svg: *Svg, + g: *G, + a: *A, + use: *Use, + image: *Image, + defs: *Defs, + geometry: *Geometry, +}; + +pub fn is(self: *Graphics, comptime T: type) ?*T { + inline for (@typeInfo(Type).@"union".fields) |f| { + if (@field(Type, f.name) == self._type) { + if (f.type == *T) { + return @field(self._type, f.name); + } + } + } + if (self._type == .geometry) { + return self._type.geometry.is(T); + } + return null; +} + +pub fn asElement(self: *Graphics) *Element { + return self._proto.asElement(); +} +pub fn asNode(self: *Graphics) *Node { + return self.asElement().asNode(); +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(Graphics); + + pub const Meta = struct { + pub const name = "SVGGraphicsElement"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; +}; diff --git a/src/browser/webapi/element/svg/Image.zig b/src/browser/webapi/element/svg/Image.zig new file mode 100644 index 000000000..73a36abac --- /dev/null +++ b/src/browser/webapi/element/svg/Image.zig @@ -0,0 +1,51 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const js = @import("../../../js/js.zig"); +const Frame = @import("../../../Frame.zig"); + +const Node = @import("../../Node.zig"); +const Element = @import("../../Element.zig"); +const AnimatedString = @import("../../svg/AnimatedString.zig"); + +const Graphics = @import("Graphics.zig"); + +const Image = @This(); +_proto: *Graphics, + +pub fn asElement(self: *Image) *Element { + return self._proto.asElement(); +} +pub fn asNode(self: *Image) *Node { + return self.asElement().asNode(); +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(Image); + + pub const Meta = struct { + pub const name = "SVGImageElement"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const href = bridge.accessor(_href, null, .{}); + fn _href(self: *Image, frame: *Frame) !*AnimatedString { + return AnimatedString.getOrCreate(self.asElement(), .href, frame); + } +}; diff --git a/src/browser/webapi/element/svg/Line.zig b/src/browser/webapi/element/svg/Line.zig new file mode 100644 index 000000000..d40e1e305 --- /dev/null +++ b/src/browser/webapi/element/svg/Line.zig @@ -0,0 +1,44 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const js = @import("../../../js/js.zig"); + +const Node = @import("../../Node.zig"); +const Element = @import("../../Element.zig"); + +const Geometry = @import("Geometry.zig"); + +const Line = @This(); +_proto: *Geometry, + +pub fn asElement(self: *Line) *Element { + return self._proto.asElement(); +} +pub fn asNode(self: *Line) *Node { + return self.asElement().asNode(); +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(Line); + + pub const Meta = struct { + pub const name = "SVGLineElement"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; +}; diff --git a/src/browser/webapi/element/svg/Path.zig b/src/browser/webapi/element/svg/Path.zig new file mode 100644 index 000000000..9308449fd --- /dev/null +++ b/src/browser/webapi/element/svg/Path.zig @@ -0,0 +1,44 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const js = @import("../../../js/js.zig"); + +const Node = @import("../../Node.zig"); +const Element = @import("../../Element.zig"); + +const Geometry = @import("Geometry.zig"); + +const Path = @This(); +_proto: *Geometry, + +pub fn asElement(self: *Path) *Element { + return self._proto.asElement(); +} +pub fn asNode(self: *Path) *Node { + return self.asElement().asNode(); +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(Path); + + pub const Meta = struct { + pub const name = "SVGPathElement"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; +}; diff --git a/src/browser/webapi/element/svg/Polygon.zig b/src/browser/webapi/element/svg/Polygon.zig new file mode 100644 index 000000000..752917079 --- /dev/null +++ b/src/browser/webapi/element/svg/Polygon.zig @@ -0,0 +1,44 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const js = @import("../../../js/js.zig"); + +const Node = @import("../../Node.zig"); +const Element = @import("../../Element.zig"); + +const Geometry = @import("Geometry.zig"); + +const Polygon = @This(); +_proto: *Geometry, + +pub fn asElement(self: *Polygon) *Element { + return self._proto.asElement(); +} +pub fn asNode(self: *Polygon) *Node { + return self.asElement().asNode(); +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(Polygon); + + pub const Meta = struct { + pub const name = "SVGPolygonElement"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; +}; diff --git a/src/browser/webapi/element/svg/Polyline.zig b/src/browser/webapi/element/svg/Polyline.zig new file mode 100644 index 000000000..a232e4950 --- /dev/null +++ b/src/browser/webapi/element/svg/Polyline.zig @@ -0,0 +1,44 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const js = @import("../../../js/js.zig"); + +const Node = @import("../../Node.zig"); +const Element = @import("../../Element.zig"); + +const Geometry = @import("Geometry.zig"); + +const Polyline = @This(); +_proto: *Geometry, + +pub fn asElement(self: *Polyline) *Element { + return self._proto.asElement(); +} +pub fn asNode(self: *Polyline) *Node { + return self.asElement().asNode(); +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(Polyline); + + pub const Meta = struct { + pub const name = "SVGPolylineElement"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; +}; diff --git a/src/browser/webapi/element/svg/Rect.zig b/src/browser/webapi/element/svg/Rect.zig index 43edf942c..c6dd4f40c 100644 --- a/src/browser/webapi/element/svg/Rect.zig +++ b/src/browser/webapi/element/svg/Rect.zig @@ -19,13 +19,13 @@ const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); -const Svg = @import("../Svg.zig"); +const Geometry = @import("Geometry.zig"); const Rect = @This(); -_proto: *Svg, +_proto: *Geometry, pub fn asElement(self: *Rect) *Element { - return self._proto._proto; + return self._proto.asElement(); } pub fn asNode(self: *Rect) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/svg/Svg.zig b/src/browser/webapi/element/svg/Svg.zig new file mode 100644 index 000000000..920b25c7f --- /dev/null +++ b/src/browser/webapi/element/svg/Svg.zig @@ -0,0 +1,95 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); + +const js = @import("../../../js/js.zig"); +const Frame = @import("../../../Frame.zig"); + +const Node = @import("../../Node.zig"); +const Element = @import("../../Element.zig"); +const TreeWalker = @import("../../TreeWalker.zig"); +const DOMRect = @import("../../DOMRect.zig"); +const DOMPoint = @import("../../DOMPoint.zig"); +const DOMMatrix = @import("../../DOMMatrix.zig"); +const SvgNumber = @import("../../svg/Number.zig"); + +const Graphics = @import("Graphics.zig"); + +const Svg = @This(); +_proto: *Graphics, + +pub fn asElement(self: *Svg) *Element { + return self._proto.asElement(); +} +pub fn asNode(self: *Svg) *Node { + return self.asElement().asNode(); +} + +pub fn getElementById(self: *Svg, id: []const u8) ?*Element { + if (id.len == 0) { + return null; + } + + var tw = TreeWalker.Full.Elements.init(self.asNode(), .{}); + while (tw.next()) |el| { + const element_id = el.getAttributeSafe(comptime .wrap("id")) orelse continue; + if (std.mem.eql(u8, element_id, id)) { + return el; + } + } + return null; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(Svg); + + pub const Meta = struct { + pub const name = "SVGSVGElement"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const getElementById = bridge.function(Svg.getElementById, .{}); + + pub const createSVGPoint = bridge.function(_createSVGPoint, .{}); + fn _createSVGPoint(_: *Svg, frame: *Frame) !*DOMPoint { + return DOMPoint.create(0, 0, 0, 1, frame._page); + } + + pub const createSVGMatrix = bridge.function(_createSVGMatrix, .{}); + fn _createSVGMatrix(_: *Svg, frame: *Frame) !*DOMMatrix { + const identity: [16]f64 = .{ + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, 0, 1, + }; + return DOMMatrix.create(identity, true, frame._page); + } + + pub const createSVGRect = bridge.function(_createSVGRect, .{}); + fn _createSVGRect(_: *Svg, frame: *Frame) !*DOMRect { + return DOMRect.init(0, 0, 0, 0, frame); + } + + pub const createSVGNumber = bridge.function(_createSVGNumber, .{}); + fn _createSVGNumber(_: *Svg, frame: *Frame) !*SvgNumber { + return frame._factory.create(SvgNumber{}); + } +}; diff --git a/src/browser/webapi/element/svg/Use.zig b/src/browser/webapi/element/svg/Use.zig new file mode 100644 index 000000000..fc49f7517 --- /dev/null +++ b/src/browser/webapi/element/svg/Use.zig @@ -0,0 +1,51 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const js = @import("../../../js/js.zig"); +const Frame = @import("../../../Frame.zig"); + +const Node = @import("../../Node.zig"); +const Element = @import("../../Element.zig"); +const AnimatedString = @import("../../svg/AnimatedString.zig"); + +const Graphics = @import("Graphics.zig"); + +const Use = @This(); +_proto: *Graphics, + +pub fn asElement(self: *Use) *Element { + return self._proto.asElement(); +} +pub fn asNode(self: *Use) *Node { + return self.asElement().asNode(); +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(Use); + + pub const Meta = struct { + pub const name = "SVGUseElement"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const href = bridge.accessor(_href, null, .{}); + fn _href(self: *Use, frame: *Frame) !*AnimatedString { + return AnimatedString.getOrCreate(self.asElement(), .href, frame); + } +}; diff --git a/src/browser/webapi/svg/AnimatedString.zig b/src/browser/webapi/svg/AnimatedString.zig new file mode 100644 index 000000000..0f3d6cc5f --- /dev/null +++ b/src/browser/webapi/svg/AnimatedString.zig @@ -0,0 +1,84 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); +const lp = @import("lightpanda"); + +const js = @import("../../js/js.zig"); +const Frame = @import("../../Frame.zig"); +const Element = @import("../Element.zig"); + +pub const Lookup = std.AutoHashMapUnmanaged(Key, *AnimatedString); + +const String = lp.String; + +const AnimatedString = @This(); + +_kind: Kind, +_element: *Element, + +pub const Kind = enum { class, href }; +pub const Key = struct { + element: *Element, + kind: Kind, +}; + +// Identity map for AnimatedString, help by the frame +pub fn getOrCreate(element: *Element, kind: Kind, frame: *Frame) !*AnimatedString { + const gop = try frame._svg_animated_strings.getOrPut(frame.arena, .{ .element = element, .kind = kind }); + if (!gop.found_existing) { + gop.value_ptr.* = try frame._factory.create(AnimatedString{ + ._element = element, + ._kind = kind, + }); + } + return gop.value_ptr.*; +} + +pub fn getBaseVal(self: *const AnimatedString) []const u8 { + return self._element.getAttributeSafe(self.attributeName()) orelse ""; +} + +pub fn setBaseVal(self: *AnimatedString, value: String, frame: *Frame) !void { + try self._element.setAttribute(self.attributeName(), value, frame); +} + +// No real animation, return the BaseVal +pub fn getAnimVal(self: *const AnimatedString) []const u8 { + return self.getBaseVal(); +} + +fn attributeName(self: *const AnimatedString) String { + return switch (self._kind) { + .href => comptime .wrap("href"), + .class => comptime .wrap("class"), + }; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(AnimatedString); + + pub const Meta = struct { + pub const name = "SVGAnimatedString"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const baseVal = bridge.accessor(AnimatedString.getBaseVal, AnimatedString.setBaseVal, .{}); + pub const animVal = bridge.accessor(AnimatedString.getAnimVal, null, .{}); +}; diff --git a/src/browser/webapi/svg/Number.zig b/src/browser/webapi/svg/Number.zig new file mode 100644 index 000000000..d1a29feca --- /dev/null +++ b/src/browser/webapi/svg/Number.zig @@ -0,0 +1,42 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const js = @import("../../js/js.zig"); + +const Number = @This(); +_value: f32 = 0, + +pub fn getValue(self: *const Number) f32 { + return self._value; +} + +pub fn setValue(self: *Number, value: f32) void { + self._value = value; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(Number); + + pub const Meta = struct { + pub const name = "SVGNumber"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const value = bridge.accessor(Number.getValue, Number.setValue, .{}); +}; From f66f0c191da8398c8b339d4eeb402d882c7e5a09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Sun, 12 Jul 2026 14:57:24 +0200 Subject: [PATCH 018/218] mcp: add HTTP transport and multi-session support Introduces an HTTP transport option to serve multipleagents from a single process. Each connection is routed to its own isolated browsing session using the `Mcp-Session-Id` header. Also adds new session management tools (`session_new`, `session_list`, `session_close`) and refactors the MCP server to support multiple V8 isolates with parking. --- README.md | 22 +++ src/Config.zig | 2 + src/help.zon | 17 +- src/main.zig | 21 +++ src/mcp.zig | 1 + src/mcp/HttpServer.zig | 347 +++++++++++++++++++++++++++++++++++++++++ src/mcp/Server.zig | 200 ++++++++++++++++++++---- src/mcp/Transport.zig | 7 + src/mcp/resources.zig | 2 +- src/mcp/tools.zig | 207 +++++++++++++++++++++--- 10 files changed, 771 insertions(+), 55 deletions(-) create mode 100644 src/mcp/HttpServer.zig diff --git a/README.md b/README.md index 806f24d0c..9ea1552c8 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,28 @@ Add to your MCP configuration: } ``` +#### HTTP transport and independent sessions + +For serving several agents from one process, start the MCP server over HTTP +instead of stdio by giving it a port (add `--host 0.0.0.0` to accept remote +connections): + +```bash +lightpanda mcp --port 9223 +``` + +Clients POST JSON-RPC to `http://host:9223/mcp`. Each connection is routed to +its own **browsing session** — its own page, cookies and memory — so agents no +longer clobber each other's page: + +- A client that `initialize`s without an `Mcp-Session-Id` header is assigned a + fresh session; the id comes back in the response's `Mcp-Session-Id` header. + Send it on subsequent requests to stay on that session (**isolation**). +- Two agents that send the **same** `Mcp-Session-Id` share one browsing context + (**sharing** — e.g. a workflow where several agents work the same page). +- The `session_new`, `session_list` and `session_close` tools manage sessions + explicitly. Sending `DELETE /mcp` with an `Mcp-Session-Id` closes that session. + [Read full documentation](https://lightpanda.io/docs/open-source/guides/mcp-server) A skill is available in [lightpanda-io/agent-skill](https://github.com/lightpanda-io/agent-skill). diff --git a/src/Config.zig b/src/Config.zig index cc825192b..4adc6949f 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -241,6 +241,8 @@ const Commands = cli.Builder(.{ .{ .name = "mcp", .options = .{ + .{ .name = "port", .type = ?u16 }, + .{ .name = "host", .type = []const u8, .default = "127.0.0.1" }, .{ .name = "cdp_port", .type = ?u16 }, }, .shared_options = CommonOptions, diff --git a/src/help.zon b/src/help.zon index 1163f001c..a8db5cee9 100644 --- a/src/help.zon +++ b/src/help.zon @@ -7,7 +7,7 @@ \\ agent starts an interactive AI agent that can browse the web \\ fetch fetches the specified URL \\ help displays this message - \\ mcp starts an MCP (Model Context Protocol) server over stdio + \\ mcp starts an MCP (Model Context Protocol) server (stdio or HTTP) \\ serve starts a WebSocket CDP server \\ version displays the version of {0s} \\ @@ -120,15 +120,28 @@ .mcp = \\usage: {0s} mcp [OPTIONS] [COMMON_OPTIONS] \\ - \\Starts an MCP (Model Context Protocol) server over stdio. + \\Starts an MCP (Model Context Protocol) server. Defaults to stdio; pass + \\--port to serve over HTTP with an independent browsing session per client. \\ \\options: + \\ --cdp-port + \\ Also run a CDP (WebSocket) server on this port. Cannot be + \\ combined with --port (they share one network listener). + \\ Defaults to disabled. \\ --cookie \\ Path to a JSON file to load cookies from (read-only). \\ Defaults to no cookie loading. \\ --cookie-jar \\ Path to a JSON file to save cookies to on exit (write-only). \\ Defaults to no cookie saving. + \\ --host + \\ Host to bind when --port is set (e.g. 0.0.0.0 for remote access). + \\ Defaults to "127.0.0.1". + \\ --port + \\ Serve MCP over HTTP on this port instead of stdio. Clients POST + \\ JSON-RPC to /mcp; route requests to a session with the + \\ Mcp-Session-Id header. + \\ Defaults to stdio (no HTTP server). , .agent = \\agent command diff --git a/src/main.zig b/src/main.zig index 4799eb3ef..f54e16cff 100644 --- a/src/main.zig +++ b/src/main.zig @@ -207,6 +207,27 @@ fn run(allocator: Allocator, main_arena: Allocator) !void { log.opts.format = .logfmt; + // --port serves MCP over HTTP instead of stdio. It and --cdp-port + // both need app.network's single listener, so they can't combine. + if (opts.port) |port| { + if (opts.cdp_port != null) { + log.fatal(.mcp, "port conflicts with cdp-port", .{ .hint = "both need the single network listener" }); + return; + } + const address = std.net.Address.parseIp(opts.host, port) catch |err| { + log.fatal(.mcp, "invalid address", .{ .err = err, .host = opts.host, .port = port }); + return; + }; + const http_server = try lp.mcp.HttpServer.init(allocator, app); + defer http_server.deinit(); + // Shutdown rides the already-registered Network.stop handler: + // a signal stops the accept loop, run() returns, deinit joins. + http_server.run(address) catch |err| { + log.fatal(.mcp, "mcp http error", .{ .err = err }); + }; + return; + } + var cdp_server: ?*lp.Server = null; if (opts.cdp_port) |port| { const address = std.net.Address.parseIp("127.0.0.1", port) catch |err| { diff --git a/src/mcp.zig b/src/mcp.zig index de013bc93..328a8153f 100644 --- a/src/mcp.zig +++ b/src/mcp.zig @@ -4,6 +4,7 @@ pub const protocol = @import("mcp/protocol.zig"); pub const Version = protocol.Version; pub const router = @import("mcp/router.zig"); pub const Server = @import("mcp/Server.zig"); +pub const HttpServer = @import("mcp/HttpServer.zig"); test { std.testing.refAllDecls(@This()); diff --git a/src/mcp/HttpServer.zig b/src/mcp/HttpServer.zig new file mode 100644 index 000000000..e1ff3d661 --- /dev/null +++ b/src/mcp/HttpServer.zig @@ -0,0 +1,347 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! HTTP (MCP "Streamable HTTP") transport for the browser-tools MCP server. +//! +//! Lets many clients drive one process, each on its own browsing session. +//! Threading rule: V8 isolates are thread-affine, so ALL browser work — every +//! session's Browser/Session — lives on a single worker thread that owns the +//! `Server`. Connection threads never touch a browser; they parse HTTP, +//! marshal a `Job` to the worker over a queue, block on its completion, then +//! write the response. Session routing follows the `Mcp-Session-Id` header: +//! an `initialize` without one mints a fresh session (isolation by default); +//! reusing an id joins that session (sharing on purpose). + +const std = @import("std"); +const lp = @import("lightpanda"); + +const App = @import("../App.zig"); +const Server = @import("Server.zig"); +const router = @import("router.zig"); + +const log = lp.log; +const posix = std.posix; + +const HttpServer = @This(); + +const ns_per_ms = std.time.ns_per_ms; + +/// Cap on a single JSON-RPC request body. Generous: agent tool payloads +/// (e.g. a `save` script) can be large, but this bounds a hostile client. +const max_request_bytes = 16 * 1024 * 1024; + +/// One unit of work handed from a connection thread to the browser worker. +/// Allocated on the connection thread's stack — safe because that thread +/// blocks on `done` for the whole time the worker reads `body`/`session_id` +/// and writes `out`. +const Job = struct { + kind: Kind, + body: []const u8, + session_id: ?[]const u8, + /// Where the worker writes the response. The connection thread owns the + /// backing buffer and reads it back once `done` fires. + out: *std.Io.Writer, + + /// Session id the worker actually routed to; echoed back as + /// `Mcp-Session-Id`. Stored in a fixed buffer (not a worker arena) so it + /// outlives the worker moving on to the next job. + assigned_buf: [128]u8 = undefined, + assigned_len: usize = 0, + + done: std.Thread.ResetEvent = .{}, + next: ?*Job = null, + + const Kind = enum { rpc, close }; + + fn assigned(self: *const Job) []const u8 { + return self.assigned_buf[0..self.assigned_len]; + } + + fn setAssigned(self: *Job, id: []const u8) void { + const n = @min(id.len, self.assigned_buf.len); + @memcpy(self.assigned_buf[0..n], id[0..n]); + self.assigned_len = n; + } +}; + +const Queue = struct { + mutex: std.Thread.Mutex = .{}, + cond: std.Thread.Condition = .{}, + head: ?*Job = null, + tail: ?*Job = null, + + fn push(self: *Queue, job: *Job) void { + self.mutex.lock(); + defer self.mutex.unlock(); + job.next = null; + if (self.tail) |t| t.next = job else self.head = job; + self.tail = job; + self.cond.signal(); + } + + /// Pop the next job, waiting at most `timeout_ms`. Returns null on timeout + /// (or spurious wakeup) so the worker can pump idle sessions and retry. + fn pop(self: *Queue, timeout_ms: u64) ?*Job { + self.mutex.lock(); + defer self.mutex.unlock(); + if (self.head == null) { + self.cond.timedWait(&self.mutex, timeout_ms * ns_per_ms) catch {}; + } + const job = self.head orelse return null; + self.head = job.next; + if (self.head == null) self.tail = null; + return job; + } +}; + +allocator: std.mem.Allocator, +app: *App, + +queue: Queue = .{}, +stopping: std.atomic.Value(bool) = .init(false), + +// The worker owns `server`; other threads must not touch it. +worker_thread: std.Thread = undefined, +worker_ready: std.Thread.ResetEvent = .{}, +worker_ok: bool = false, + +/// Create the server and start its browser worker thread. Returns once the +/// worker's default session is up (or errors if it failed to initialize). +pub fn init(allocator: std.mem.Allocator, app: *App) !*HttpServer { + const self = try allocator.create(HttpServer); + errdefer allocator.destroy(self); + self.* = .{ + .allocator = allocator, + .app = app, + }; + + self.worker_thread = try std.Thread.spawn(.{}, worker, .{self}); + self.worker_ready.wait(); + if (!self.worker_ok) { + self.worker_thread.join(); + return error.WorkerInitFailed; + } + return self; +} + +pub fn deinit(self: *HttpServer) void { + self.stopping.store(true, .release); + self.queue.cond.signal(); // nudge the worker off its idle wait + self.worker_thread.join(); + self.allocator.destroy(self); +} + +/// Accept MCP-over-HTTP connections until the network loop is stopped +/// (e.g. by the signal handler). Reuses the shared accept infrastructure; +/// blocks the calling thread in `Network.run`. +pub fn run(self: *HttpServer, address: std.net.Address) !void { + var bound = address; + try self.app.network.bind(&bound, self, onAccept); + log.note(.mcp, "mcp http server running", .{ .address = bound }); + self.app.network.run(); +} + +/// Network hands us a nonblocking accepted socket; each connection is served +/// by its own thread doing blocking IO, so we clear O_NONBLOCK first. +fn onAccept(ctx: *anyopaque, socket: posix.socket_t) void { + const self: *HttpServer = @ptrCast(@alignCast(ctx)); + + const flags = posix.fcntl(socket, posix.F.GETFL, 0) catch { + posix.close(socket); + return; + }; + _ = posix.fcntl(socket, posix.F.SETFL, flags & ~@as(u32, @bitCast(posix.O{ .NONBLOCK = true }))) catch { + posix.close(socket); + return; + }; + + const thread = std.Thread.spawn(.{}, handleConn, .{ self, socket }) catch |err| { + log.warn(.mcp, "mcp spawn", .{ .err = err }); + posix.close(socket); + return; + }; + thread.detach(); +} + +fn worker(self: *HttpServer) void { + var placeholder: std.Io.Writer.Allocating = .init(self.allocator); + defer placeholder.deinit(); + + const server = Server.init(self.allocator, self.app, &placeholder.writer) catch |err| { + log.err(.mcp, "mcp http server init", .{ .err = err }); + self.worker_ready.set(); + return; + }; + defer server.deinit(); + + server.enableIsolateParking(); + + self.worker_ok = true; + self.worker_ready.set(); + + var arena: std.heap.ArenaAllocator = .init(self.allocator); + defer arena.deinit(); + + while (!self.stopping.load(.acquire)) { + const wait_ms = server.idle(); + const job = self.queue.pop(wait_ms) orelse continue; + _ = arena.reset(.retain_capacity); + process(server, arena.allocator(), job); + job.done.set(); + } +} + +fn process(server: *Server, arena: std.mem.Allocator, job: *Job) void { + server.transport.retarget(job.out); + + if (job.kind == .close) { + if (job.session_id) |sid| _ = server.closeSession(sid); + return; + } + + const chosen = resolveSession(server, arena, job) catch |err| { + log.err(.mcp, "mcp session routing", .{ .err = err }); + return; + }; + job.setAssigned(chosen); + + router.handleMessage(server, arena, job.body) catch |err| { + log.err(.mcp, "mcp handle", .{ .err = err }); + }; +} + +/// Decide which session a request targets and make it active. An explicit +/// `Mcp-Session-Id` wins; otherwise `initialize` mints a new session and +/// everything else falls back to the default. +fn resolveSession(server: *Server, arena: std.mem.Allocator, job: *Job) ![]const u8 { + if (job.session_id) |sid| { + if (sid.len > 0) { + _ = try server.useSession(sid); + return sid; + } + } + + if (isInitialize(arena, job.body)) { + const sid = try server.nextSessionId(arena); + _ = try server.useSession(sid); + return sid; + } + + _ = try server.useSession(null); + return Server.default_session_id; +} + +fn isInitialize(arena: std.mem.Allocator, body: []const u8) bool { + const Peek = struct { method: ?[]const u8 = null }; + const peek = std.json.parseFromSliceLeaky(Peek, arena, body, .{ .ignore_unknown_fields = true }) catch return false; + const method = peek.method orelse return false; + return std.mem.eql(u8, method, "initialize"); +} + +fn handleConn(self: *HttpServer, socket: posix.socket_t) void { + const stream: std.net.Stream = .{ .handle = socket }; + defer stream.close(); + + var recv_buf: [16 * 1024]u8 = undefined; + var send_buf: [16 * 1024]u8 = undefined; + var stream_reader = stream.reader(&recv_buf); + var stream_writer = stream.writer(&send_buf); + var http_server = std.http.Server.init(stream_reader.interface(), &stream_writer.interface); + + var arena: std.heap.ArenaAllocator = .init(self.allocator); + defer arena.deinit(); + // Reused across requests (served serially), not reallocated per request. + var out: std.Io.Writer.Allocating = .init(self.allocator); + defer out.deinit(); + + while (!self.stopping.load(.acquire)) { + var request = http_server.receiveHead() catch return; // peer closed or bad head + _ = arena.reset(.retain_capacity); + out.clearRetainingCapacity(); + self.serve(&out.writer, arena.allocator(), &request) catch return; + if (!request.head.keep_alive) return; + } +} + +/// Handle one request: marshal it to the browser worker and write the reply. +/// MCP Streamable HTTP — POST carries a JSON-RPC message; DELETE closes the +/// session named by `Mcp-Session-Id`. std.http.Server owns the framing. +fn serve(self: *HttpServer, out: *std.Io.Writer, arena: std.mem.Allocator, request: *std.http.Server.Request) !void { + const method = request.head.method; + if (method != .POST and method != .DELETE) { + return request.respond("", .{ .status = .method_not_allowed, .keep_alive = false }); + } + if (request.head.expect != null) { + return request.respond("", .{ .status = .expectation_failed, .keep_alive = false }); + } + + // Read the session header and keep_alive before the body reader + // invalidates the head's string memory. + const session_id = try sessionHeader(arena, request); + const keep_alive = request.head.keep_alive; + + var body_buf: [8 * 1024]u8 = undefined; + const body = request.readerExpectNone(&body_buf).allocRemaining(arena, .limited(max_request_bytes)) catch { + return request.respond("", .{ .status = .payload_too_large, .keep_alive = false }); + }; + + var job: Job = .{ + .kind = if (method == .DELETE) .close else .rpc, + .body = body, + .session_id = session_id, + .out = out, + }; + self.queue.push(&job); + job.done.wait(); + + const resp = out.buffered(); + var headers: [2]std.http.Header = undefined; + var n: usize = 0; + headers[n] = .{ .name = "content-type", .value = "application/json" }; + n += 1; + if (job.assigned_len > 0) { + headers[n] = .{ .name = "mcp-session-id", .value = job.assigned() }; + n += 1; + } + return request.respond(resp, .{ + // An empty body means a notification (or a close): 202, no content. + .status = if (resp.len == 0) .accepted else .ok, + .keep_alive = keep_alive, + .extra_headers = headers[0..n], + }); +} + +/// Duplicate the `Mcp-Session-Id` request header into `arena`, or null. +fn sessionHeader(arena: std.mem.Allocator, request: *std.http.Server.Request) !?[]const u8 { + var it = request.iterateHeaders(); + while (it.next()) |header| { + if (std.ascii.eqlIgnoreCase(header.name, "mcp-session-id")) { + return try arena.dupe(u8, header.value); + } + } + return null; +} + +test "HttpServer - initialize is detected for session minting" { + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + const aa = arena.allocator(); + try std.testing.expect(isInitialize(aa, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"}")); + try std.testing.expect(!isInitialize(aa, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\"}")); + try std.testing.expect(!isInitialize(aa, "not json")); +} diff --git a/src/mcp/Server.zig b/src/mcp/Server.zig index 9861a4f16..0455f7d26 100644 --- a/src/mcp/Server.zig +++ b/src/mcp/Server.zig @@ -13,61 +13,194 @@ const CDPNode = @import("../cdp/Node.zig"); const Self = @This(); +/// Session every un-scoped request lands on. Over stdio there is only ever +/// this one; the HTTP transport routes to it when a client sends no +/// `Mcp-Session-Id`. +pub const default_session_id = "default"; + +/// One isolated browsing context. Each owns its own V8 isolate (via +/// `Browser`), so two agents driving different sessions never touch the same +/// page. Heap-allocated and never moved after `init`: `Browser` registers +/// self-pointers (watchdog, http_client) that must stay stable. +pub const Session = struct { + id: []const u8, + browser: lp.Browser, + session: *lp.Session, + notification: *lp.Notification, + node_registry: CDPNode.Registry, + + fn isDefault(self: *const Session) bool { + return std.mem.eql(u8, self.id, default_session_id); + } +}; + allocator: std.mem.Allocator, app: *App, -notification: *lp.Notification, -browser: lp.Browser, -session: *lp.Session, -node_registry: CDPNode.Registry, - +sessions: std.StringHashMapUnmanaged(*Session) = .empty, +/// Monotonic counter backing auto-generated session ids (`s1`, `s2`, …). +session_seq: u32 = 0, +/// When several sessions (each its own V8 isolate) share one thread, V8's +/// "current isolate" is a per-thread stack, so an isolate must be *entered* +/// around any use of it and left un-entered otherwise. The HTTP transport +/// sets this; stdio (one isolate, permanently entered by `Env`) leaves it +/// false and keeps its historical behavior. See `enterIsolate`/`exitIsolate`. +park_isolates: bool = false, +/// The session the request currently being handled targets. Safe as a single +/// field because every request is dispatched on one thread, one at a time; +/// the transport sets it (via `useSession`) before each dispatch. Tools and +/// resources read it rather than threading a session through every call. +active_session: *Session = undefined, transport: Transport, pub fn init(allocator: std.mem.Allocator, app: *App, writer: *std.io.Writer) !*Self { - const notification = try lp.Notification.init(allocator); - errdefer notification.deinit(); - const self = try allocator.create(Self); errdefer allocator.destroy(self); self.* = .{ .allocator = allocator, .app = app, - .browser = undefined, .transport = .init(allocator, writer), - .notification = notification, - .session = undefined, - .node_registry = CDPNode.Registry.init(allocator), }; + errdefer self.transport.deinit(); - try self.browser.init(app, .{}, null); - errdefer self.browser.deinit(); - - self.session = try self.browser.newSession(self.notification); - try self.session.enableConsoleCapture(); - - if (app.config.cookieFile()) |cookie_path| { - lp.cookies.loadFromFile(self.session, cookie_path); - } - + self.active_session = try self.createSession(default_session_id); return self; } pub fn deinit(self: *Self) void { - if (self.app.config.cookieJarFile()) |cookie_jar_path| { - lp.cookies.saveToFile(&self.session.cookie_jar, cookie_jar_path); - } + var it = self.sessions.valueIterator(); + while (it.next()) |entry| self.destroySession(entry.*); + self.sessions.deinit(self.allocator); - self.node_registry.deinit(); self.transport.deinit(); - self.browser.deinit(); - self.notification.deinit(); - self.allocator.destroy(self); } +/// Create the session named `id`, or return the existing one. The `id` is +/// duped, so the caller keeps ownership of its slice. +pub fn createSession(self: *Self, id: []const u8) !*Session { + if (self.sessions.get(id)) |existing| return existing; + + const owned_id = try self.allocator.dupe(u8, id); + errdefer self.allocator.free(owned_id); + + const entry = try self.allocator.create(Session); + errdefer self.allocator.destroy(entry); + + const notification = try lp.Notification.init(self.allocator); + errdefer notification.deinit(); + + entry.* = .{ + .id = owned_id, + .browser = undefined, + .session = undefined, + .notification = notification, + .node_registry = CDPNode.Registry.init(self.allocator), + }; + errdefer entry.node_registry.deinit(); + + try entry.browser.init(self.app, .{}, null); + errdefer entry.browser.deinit(); + + entry.session = try entry.browser.newSession(notification); + try entry.session.enableConsoleCapture(); + + // Only the default session is backed by the on-disk cookie file; named + // sessions start clean so agents stay isolated by default. + if (entry.isDefault()) { + if (self.app.config.cookieFile()) |cookie_path| { + lp.cookies.loadFromFile(entry.session, cookie_path); + } + } + + try self.sessions.put(self.allocator, owned_id, entry); + // Browser.init left the isolate entered; park it (see park_isolates). + self.exitIsolate(entry); + return entry; +} + +/// Switch to the multi-isolate discipline: park the default (which `Server.init` +/// left entered) and require every use to bracket with `enterIsolate`. The HTTP +/// transport calls this on its worker thread before serving anyone. +pub fn enableIsolateParking(self: *Self) void { + self.park_isolates = true; + self.exitIsolate(self.defaultSession()); +} + +/// Make `entry`'s isolate the current one for this thread. Must bracket any +/// use of its Browser/Session (dispatch, idle pumping, teardown). No-op under +/// stdio, where the single isolate is permanently current. +pub fn enterIsolate(self: *Self, entry: *Session) void { + if (self.park_isolates) entry.browser.env.isolate.enter(); +} + +pub fn exitIsolate(self: *Self, entry: *Session) void { + if (self.park_isolates) entry.browser.env.isolate.exit(); +} + +/// Tear down the session named `id`. Returns false if no such session, or if +/// it is the default (which lives for the whole process). +pub fn closeSession(self: *Self, id: []const u8) bool { + if (std.mem.eql(u8, id, default_session_id)) return false; + const entry = self.sessions.fetchRemove(id) orelse return false; + if (self.active_session == entry.value) self.active_session = self.defaultSession(); + self.destroySession(entry.value); + return true; +} + +fn destroySession(self: *Self, entry: *Session) void { + if (entry.isDefault()) { + if (self.app.config.cookieJarFile()) |cookie_jar_path| { + lp.cookies.saveToFile(&entry.session.cookie_jar, cookie_jar_path); + } + } + + // Re-enter so `Browser.deinit`'s `Env.deinit` exit stays balanced against + // a parked isolate (and operates on the current one). + self.enterIsolate(entry); + entry.node_registry.deinit(); + entry.browser.deinit(); + entry.notification.deinit(); + self.allocator.free(entry.id); + self.allocator.destroy(entry); +} + +/// The session an un-scoped (stdio, or header-less HTTP) request targets. +pub fn defaultSession(self: *Self) *Session { + return self.sessions.get(default_session_id).?; +} + +/// Point subsequent tool/resource dispatch at the session named `id`, creating +/// it on first use. A null or empty `id` selects the default. +pub fn useSession(self: *Self, id: ?[]const u8) !*Session { + const wanted = id orelse ""; + self.active_session = if (wanted.len == 0) self.defaultSession() else try self.createSession(wanted); + return self.active_session; +} + +/// A session id that is not currently in use, formatted into `arena`. +pub fn nextSessionId(self: *Self, arena: std.mem.Allocator) ![]const u8 { + while (true) { + self.session_seq += 1; + const candidate = try std.fmt.allocPrint(arena, "s{d}", .{self.session_seq}); + if (!self.sessions.contains(candidate)) return candidate; + } +} + +/// Pump every live session's pending transfers and return the shortest time +/// the caller may block before pumping again. See `Session.idleSlice`. pub fn idle(self: *Self) u31 { - return self.session.idleSlice(); + var wait: u31 = std.math.maxInt(u31); + var it = self.sessions.valueIterator(); + while (it.next()) |entry| { + // Pumping may resume JS (e.g. a completed script fetch), so it needs + // the session's isolate current. + self.enterIsolate(entry.*); + wait = @min(wait, entry.*.session.idleSlice()); + self.exitIsolate(entry.*); + } + return wait; } pub fn sendError(self: *Self, id: std.json.Value, code: protocol.ErrorCode, message: []const u8) !void { @@ -96,6 +229,10 @@ pub fn handleToolList(self: *Self, arena: std.mem.Allocator, req: protocol.Reque } pub fn handleToolCall(self: *Self, arena: std.mem.Allocator, req: protocol.Request) !void { + // Dispatch runs page JS, so enter the target isolate around it. + const entry = self.active_session; + self.enterIsolate(entry); + defer self.exitIsolate(entry); return tools.handleCall(self, arena, req); } @@ -104,6 +241,9 @@ pub fn handleResourceList(self: *Self, req: protocol.Request) !void { } pub fn handleResourceRead(self: *Self, arena: std.mem.Allocator, req: protocol.Request) !void { + const entry = self.active_session; + self.enterIsolate(entry); + defer self.exitIsolate(entry); return resources.handleRead(self, arena, req); } diff --git a/src/mcp/Transport.zig b/src/mcp/Transport.zig index c9df2ee1f..430a45be5 100644 --- a/src/mcp/Transport.zig +++ b/src/mcp/Transport.zig @@ -36,6 +36,13 @@ pub fn deinit(self: *Self) void { self.aw.deinit(); } +/// Point subsequent responses at a different sink. The HTTP transport uses +/// this to capture each request's response into its own buffer; safe because +/// the browser worker retargets and writes on a single thread. +pub fn retarget(self: *Self, writer: *std.io.Writer) void { + self.writer = writer; +} + pub fn sendResponse(self: *Self, response: anytype) !void { self.mutex.lock(); defer self.mutex.unlock(); diff --git a/src/mcp/resources.zig b/src/mcp/resources.zig index ed413c339..348a0c2b2 100644 --- a/src/mcp/resources.zig +++ b/src/mcp/resources.zig @@ -86,7 +86,7 @@ pub fn handleRead(server: *Server, arena: std.mem.Allocator, req: protocol.Reque return server.sendError(req_id, .InvalidRequest, "Resource not found"); }; - const frame = server.session.currentFrame() orelse { + const frame = server.active_session.session.currentFrame() orelse { return server.sendError(req_id, .FrameNotLoaded, "Page not loaded"); }; diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig index f03cc35f5..a795f708b 100644 --- a/src/mcp/tools.zig +++ b/src/mcp/tools.zig @@ -36,12 +36,46 @@ const save_schema = browser_tools.minify( \\} ); +const session_new_schema = browser_tools.minify( + \\{ + \\ "type": "object", + \\ "properties": { + \\ "name": { "type": "string", "description": "Optional id for the new session. Omit to get an auto-generated one. Reusing an existing id returns that session (a way to share one browsing context between agents)." } + \\ } + \\} +); + +const session_id_schema = browser_tools.minify( + \\{ + \\ "type": "object", + \\ "properties": { + \\ "id": { "type": "string", "description": "The session id." } + \\ }, + \\ "required": ["id"] + \\} +); + const extra_tools = [_]McpTool{ .{ .name = "save", .description = "Save the session as a reusable Lightpanda agent script. You hold the conversation, so synthesize the `script` yourself — `const page = new Page(); await page.goto(url);` then call the builtins you used as tools (extract, click, fill, …) as methods on `page` with the same object arguments. Keep `$LP_*` placeholders; never inline a resolved secret.\n\n" ++ browser_tools.save_synthesis_prompt ++ "\n\n" ++ browser_tools.save_script_rules, .inputSchema = save_schema, }, + .{ + .name = "session_new", + .description = "Create a new isolated browser session (its own page, cookies and memory) and return its id. Use it to give a separate agent its own browsing context, or to obtain an id to share. Pass that id back as the `Mcp-Session-Id` header to route calls to it.", + .inputSchema = session_new_schema, + }, + .{ + .name = "session_list", + .description = "List the active browser sessions with their id and current URL. The `default` session always exists.", + .inputSchema = browser_tools.minify("{ \"type\": \"object\", \"properties\": {} }"), + }, + .{ + .name = "session_close", + .description = "Close a browser session, freeing its page and memory. The `default` session cannot be closed.", + .inputSchema = session_id_schema, + }, }; const all_tools = browser_tool_list ++ extra_tools; @@ -49,6 +83,9 @@ const all_tools = browser_tool_list ++ extra_tools; /// Tools that bypass the browser-tool dispatch and have their own handlers. const ExtraTool = enum { save, + session_new, + session_list, + session_close, }; pub fn handleList(server: *Server, arena: std.mem.Allocator, req: protocol.Request) !void { @@ -68,6 +105,9 @@ pub fn handleCall(server: *Server, arena: std.mem.Allocator, req: protocol.Reque if (std.meta.stringToEnum(ExtraTool, call_params.name)) |tool| { return switch (tool) { .save => handleSave(server, arena, id, call_params.arguments), + .session_new => handleSessionNew(server, arena, id, call_params.arguments), + .session_list => handleSessionList(server, arena, id), + .session_close => handleSessionClose(server, arena, id, call_params.arguments), }; } @@ -85,7 +125,8 @@ fn dispatchBrowserTool( return server.sendError(id, .MethodNotFound, "Tool not found"); }; - const result = browser_tools.call(arena, server.session, &server.node_registry, name, arguments) catch |err| { + const active = server.active_session; + const result = browser_tools.call(arena, active.session, &active.node_registry, name, arguments) catch |err| { // evaluate/extract surface failures in-band so the LLM can self-correct; // other tools' operational failures are protocol-level. if (surfacesErrorInBand(tool)) { @@ -138,6 +179,72 @@ fn handleSave(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arg try sendToolResultText(server, id, msg, false); } +/// The session tools require the HTTP transport's parked-isolate discipline: +/// a second session means a second V8 isolate, only safe when isolates are +/// entered around use. Over stdio (one permanently-entered isolate) they are +/// all unsupported, kept uniform so clients see one consistent rule. +fn requireMultiSession(server: *Server, id: std.json.Value) !bool { + if (server.park_isolates) return true; + try sendToolResultText(server, id, "multiple sessions require the HTTP transport (start with --http-port)", true); + return false; +} + +fn handleSessionNew(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { + if (!try requireMultiSession(server, id)) return; + const Args = struct { name: ?[]const u8 = null }; + const args = browser_tools.parseArgsOrDefault(Args, arena, arguments) catch { + return server.sendError(id, .InvalidParams, "expected { name?: string }"); + }; + + const requested: ?[]const u8 = if (args.name) |n| (if (n.len > 0) n else null) else null; + const sid = requested orelse (server.nextSessionId(arena) catch + return sendErrorContent(server, id, "out of memory")); + + _ = server.createSession(sid) catch |err| + return sendErrorContent(server, id, @errorName(err)); + + return sendToolResultFmt(server, arena, id, "session {s}", .{sid}); +} + +fn handleSessionList(server: *Server, arena: std.mem.Allocator, id: std.json.Value) !void { + if (!try requireMultiSession(server, id)) return; + const Entry = struct { id: []const u8, url: ?[]const u8 }; + var list: std.ArrayList(Entry) = .empty; + + var it = server.sessions.valueIterator(); + while (it.next()) |entry| { + const url: ?[]const u8 = if (entry.*.session.currentFrame()) |frame| frame.url else null; + list.append(arena, .{ .id = entry.*.id, .url = url }) catch + return sendErrorContent(server, id, "out of memory"); + } + + const json = std.json.Stringify.valueAlloc(arena, list.items, .{ .emit_null_optional_fields = false }) catch + return sendErrorContent(server, id, "out of memory"); + try sendToolResultText(server, id, json, false); +} + +fn handleSessionClose(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { + if (!try requireMultiSession(server, id)) return; + const Args = struct { id: []const u8 }; + const args = browser_tools.parseArgs(Args, arena, arguments) catch { + return server.sendError(id, .InvalidParams, "expected { id: string }"); + }; + + if (std.mem.eql(u8, args.id, Server.default_session_id)) { + return sendErrorContent(server, id, "the default session cannot be closed"); + } + // Closing the session serving this very call would tear down the isolate + // mid-dispatch; require the client to be elsewhere first. + if (std.mem.eql(u8, args.id, server.active_session.id)) { + return sendErrorContent(server, id, "cannot close the session you are attached to"); + } + if (!server.closeSession(args.id)) { + return sendErrorContent(server, id, "no such session"); + } + + return sendToolResultFmt(server, arena, id, "closed session {s}", .{args.id}); +} + fn writeScript(path: []const u8, content: []const u8) !void { const file = try std.fs.cwd().createFile(path, .{ .truncate = true }); defer file.close(); @@ -154,6 +261,12 @@ fn sendErrorContent(server: *Server, id: std.json.Value, msg: []const u8) !void return sendToolResultText(server, id, msg, true); } +fn sendToolResultFmt(server: *Server, arena: std.mem.Allocator, id: std.json.Value, comptime fmt: []const u8, args: anytype) !void { + const msg = std.fmt.allocPrint(arena, fmt, args) catch + return sendErrorContent(server, id, "out of memory"); + return sendToolResultText(server, id, msg, false); +} + const router = @import("router.zig"); const testing = @import("../testing.zig"); @@ -861,11 +974,11 @@ test "MCP - Actions: click, fill, scroll, hover, press, selectOption, setChecked const server = try testLoadPage("http://localhost:9582/src/browser/tests/mcp_actions.html", &out.writer); defer server.deinit(); - const frame = server.session.currentFrame().?; + const frame = server.active_session.session.currentFrame().?; { const btn = frame.document.getElementById("btn", frame).?.asNode(); - const btn_id = (try server.node_registry.register(btn)).id; + const btn_id = (try server.active_session.node_registry.register(btn)).id; var btn_id_buf: [12]u8 = undefined; const btn_id_str = std.fmt.bufPrint(&btn_id_buf, "{d}", .{btn_id}) catch unreachable; const click_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"click\",\"arguments\":{\"backendNodeId\":", btn_id_str, "}}}" }); @@ -877,7 +990,7 @@ test "MCP - Actions: click, fill, scroll, hover, press, selectOption, setChecked { const inp = frame.document.getElementById("inp", frame).?.asNode(); - const inp_id = (try server.node_registry.register(inp)).id; + const inp_id = (try server.active_session.node_registry.register(inp)).id; var inp_id_buf: [12]u8 = undefined; const inp_id_str = std.fmt.bufPrint(&inp_id_buf, "{d}", .{inp_id}) catch unreachable; const fill_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"fill\",\"arguments\":{\"backendNodeId\":", inp_id_str, ",\"value\":\"hello\"}}}" }); @@ -889,7 +1002,7 @@ test "MCP - Actions: click, fill, scroll, hover, press, selectOption, setChecked { const sel = frame.document.getElementById("sel", frame).?.asNode(); - const sel_id = (try server.node_registry.register(sel)).id; + const sel_id = (try server.active_session.node_registry.register(sel)).id; var sel_id_buf: [12]u8 = undefined; const sel_id_str = std.fmt.bufPrint(&sel_id_buf, "{d}", .{sel_id}) catch unreachable; const fill_sel_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"fill\",\"arguments\":{\"backendNodeId\":", sel_id_str, ",\"value\":\"opt2\"}}}" }); @@ -901,7 +1014,7 @@ test "MCP - Actions: click, fill, scroll, hover, press, selectOption, setChecked { const scrollbox = frame.document.getElementById("scrollbox", frame).?.asNode(); - const scrollbox_id = (try server.node_registry.register(scrollbox)).id; + const scrollbox_id = (try server.active_session.node_registry.register(scrollbox)).id; var scroll_id_buf: [12]u8 = undefined; const scroll_id_str = std.fmt.bufPrint(&scroll_id_buf, "{d}", .{scrollbox_id}) catch unreachable; const scroll_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/call\",\"params\":{\"name\":\"scroll\",\"arguments\":{\"backendNodeId\":", scroll_id_str, ",\"y\":50}}}" }); @@ -912,7 +1025,7 @@ test "MCP - Actions: click, fill, scroll, hover, press, selectOption, setChecked { const el = frame.document.getElementById("hoverTarget", frame).?.asNode(); - const el_id = (try server.node_registry.register(el)).id; + const el_id = (try server.active_session.node_registry.register(el)).id; var id_buf: [12]u8 = undefined; const id_str = std.fmt.bufPrint(&id_buf, "{d}", .{el_id}) catch unreachable; const msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"tools/call\",\"params\":{\"name\":\"hover\",\"arguments\":{\"backendNodeId\":", id_str, "}}}" }); @@ -923,7 +1036,7 @@ test "MCP - Actions: click, fill, scroll, hover, press, selectOption, setChecked { const el = frame.document.getElementById("keyTarget", frame).?.asNode(); - const el_id = (try server.node_registry.register(el)).id; + const el_id = (try server.active_session.node_registry.register(el)).id; var id_buf: [12]u8 = undefined; const id_str = std.fmt.bufPrint(&id_buf, "{d}", .{el_id}) catch unreachable; const msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":6,\"method\":\"tools/call\",\"params\":{\"name\":\"press\",\"arguments\":{\"key\":\"Enter\",\"backendNodeId\":", id_str, "}}}" }); @@ -934,7 +1047,7 @@ test "MCP - Actions: click, fill, scroll, hover, press, selectOption, setChecked { const el = frame.document.getElementById("sel2", frame).?.asNode(); - const el_id = (try server.node_registry.register(el)).id; + const el_id = (try server.active_session.node_registry.register(el)).id; var id_buf: [12]u8 = undefined; const id_str = std.fmt.bufPrint(&id_buf, "{d}", .{el_id}) catch unreachable; const msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"tools/call\",\"params\":{\"name\":\"selectOption\",\"arguments\":{\"backendNodeId\":", id_str, ",\"value\":\"b\"}}}" }); @@ -945,7 +1058,7 @@ test "MCP - Actions: click, fill, scroll, hover, press, selectOption, setChecked { const el = frame.document.getElementById("chk", frame).?.asNode(); - const el_id = (try server.node_registry.register(el)).id; + const el_id = (try server.active_session.node_registry.register(el)).id; var id_buf: [12]u8 = undefined; const id_str = std.fmt.bufPrint(&id_buf, "{d}", .{el_id}) catch unreachable; const msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":8,\"method\":\"tools/call\",\"params\":{\"name\":\"setChecked\",\"arguments\":{\"backendNodeId\":", id_str, ",\"checked\":true}}}" }); @@ -956,7 +1069,7 @@ test "MCP - Actions: click, fill, scroll, hover, press, selectOption, setChecked { const el = frame.document.getElementById("rad", frame).?.asNode(); - const el_id = (try server.node_registry.register(el)).id; + const el_id = (try server.active_session.node_registry.register(el)).id; var id_buf: [12]u8 = undefined; const id_str = std.fmt.bufPrint(&id_buf, "{d}", .{el_id}) catch unreachable; const msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":9,\"method\":\"tools/call\",\"params\":{\"name\":\"setChecked\",\"arguments\":{\"backendNodeId\":", id_str, ",\"checked\":true}}}" }); @@ -999,10 +1112,10 @@ test "MCP - click that navigates clears node registry" { const server = try testLoadPage("http://localhost:9582/src/browser/tests/mcp_nav.html", &out.writer); defer server.deinit(); - const before_frame = server.session.currentFrame().?; + const before_frame = server.active_session.session.currentFrame().?; const link = before_frame.document.getElementById("navlink", before_frame).?.asNode(); - const link_id = (try server.node_registry.register(link)).id; - try testing.expect(server.node_registry.lookup_by_id.contains(link_id)); + const link_id = (try server.active_session.node_registry.register(link)).id; + try testing.expect(server.active_session.node_registry.lookup_by_id.contains(link_id)); var id_buf: [12]u8 = undefined; const id_str = std.fmt.bufPrint(&id_buf, "{d}", .{link_id}) catch unreachable; @@ -1013,8 +1126,8 @@ test "MCP - click that navigates clears node registry" { }); try router.handleMessage(server, aa, click_msg); - try testing.expect(server.session.currentFrame().? != before_frame); - try testing.expect(!server.node_registry.lookup_by_id.contains(link_id)); + try testing.expect(server.active_session.session.currentFrame().? != before_frame); + try testing.expect(!server.active_session.node_registry.lookup_by_id.contains(link_id)); } test "MCP - Actions by selector: hover, selectOption, setChecked" { @@ -1026,7 +1139,7 @@ test "MCP - Actions by selector: hover, selectOption, setChecked" { defer server.deinit(); // Single-page test: reach straight into the live page. - const page = server.session.pages.items[0]; + const page = server.active_session.session.pages.items[0]; { const msg = @@ -1291,8 +1404,8 @@ test "MCP - getCookies: defaults to current page, url filter, all flag" { const server = try testLoadPage("http://localhost:9582/src/browser/tests/mcp_press_form.htm", &out.writer); defer server.deinit(); - try server.session.cookie_jar.populateFromResponse("http://localhost:9582", "session=abc; Path=/"); - try server.session.cookie_jar.populateFromResponse("http://other.test/", "tracking=xyz; Path=/"); + try server.active_session.session.cookie_jar.populateFromResponse("http://localhost:9582", "session=abc; Path=/"); + try server.active_session.session.cookie_jar.populateFromResponse("http://other.test/", "tracking=xyz; Path=/"); const default_msg = \\{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"getCookies"}} @@ -1331,7 +1444,7 @@ test "MCP - getCookies without a loaded page refuses instead of dumping the jar" var server = try Server.init(testing.allocator, testing.test_app, &out.writer); defer server.deinit(); - try server.session.cookie_jar.populateFromResponse("http://example.com/", "session=abc; Path=/"); + try server.active_session.session.cookie_jar.populateFromResponse("http://example.com/", "session=abc; Path=/"); const msg = \\{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"getCookies"}} @@ -1358,14 +1471,64 @@ test "MCP - waitForState with bad state surfaces rich error" { try testing.expect(std.mem.indexOf(u8, written, "isError\":true") != null); } +test "MCP - sessions: new, list, attach isolation, close" { + defer testing.reset(); + const aa = testing.arena_allocator; + var out: std.io.Writer.Allocating = .init(aa); + var server = try Server.init(testing.allocator, testing.test_app, &out.writer); + defer server.deinit(); + // Session tools require the HTTP transport's parked-isolate discipline. + server.enableIsolateParking(); + + try router.handleMessage(server, aa, + \\{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"session_new","arguments":{"name":"a"}}} + ); + try testing.expect(std.mem.indexOf(u8, out.written(), "session a") != null); + try testing.expect(server.sessions.contains("a")); + + out.clearRetainingCapacity(); + try router.handleMessage(server, aa, + \\{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"session_list"}} + ); + // The listing is JSON nested in the tool-result text, so its quotes are + // escaped (\"default\"). + try testing.expect(std.mem.indexOf(u8, out.written(), "\\\"default\\\"") != null); + try testing.expect(std.mem.indexOf(u8, out.written(), "\\\"a\\\"") != null); + + // Routing a request to "a" (as the Mcp-Session-Id header does) and loading + // a page there leaves the default untouched, proving the two are isolated. + _ = try server.useSession("a"); + try router.handleMessage(server, aa, + \\{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"goto","arguments":{"url":"about:blank"}}} + ); + try testing.expect(server.sessions.get("a").?.session.currentFrame() != null); + try testing.expect(server.defaultSession().session.currentFrame() == null); + + // Route back to the default before closing "a" (the active session can't be closed). + _ = try server.useSession(null); + + out.clearRetainingCapacity(); + try router.handleMessage(server, aa, + \\{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"session_close","arguments":{"id":"default"}}} + ); + try testing.expect(std.mem.indexOf(u8, out.written(), "cannot be closed") != null); + + out.clearRetainingCapacity(); + try router.handleMessage(server, aa, + \\{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"session_close","arguments":{"id":"a"}}} + ); + try testing.expect(std.mem.indexOf(u8, out.written(), "closed session a") != null); + try testing.expect(!server.sessions.contains("a")); +} + fn testLoadPage(url: [:0]const u8, writer: *std.Io.Writer) !*Server { var server = try Server.init(testing.allocator, testing.test_app, writer); errdefer server.deinit(); - const page = try server.session.createPage(); + const page = try server.active_session.session.createPage(); try page.navigate(url, .{}); - var runner = server.session.runner(.{}); + var runner = server.active_session.session.runner(.{}); try runner.waitForFrame(page.frame_id, 2000, .{ .until = .done }); return server; } From 5abae593d861bcfa276a8dfcbaff228a0eb9c532 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Sun, 12 Jul 2026 22:09:25 +0200 Subject: [PATCH 019/218] fix(agent): keep streaming off for suppressed turns Streaming wrote assistant deltas straight to stdout regardless of `suppress_answer`, so `--save`/capture turns (which set it to hide the answer) leaked model text past the `runTurn` guard. Pass a null stream hook when the turn's answer is suppressed. --- src/agent/Agent.zig | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/agent/Agent.zig b/src/agent/Agent.zig index c3a321811..36401ed43 100644 --- a/src/agent/Agent.zig +++ b/src/agent/Agent.zig @@ -1703,7 +1703,9 @@ fn processUserMessage(self: *Agent, input: TurnInput) !?[]const u8 { // non-thinking models. .effort = self.effort, .cancel = .{ .context = @ptrCast(self), .checkFn = checkCancel }, - .stream = self.streamHook(), + // Suppressed turns (e.g. `--save` capture) must keep stdout clean; + // streaming would bypass the `suppress_answer` guard in `runTurn`. + .stream = if (input.suppress_answer) null else self.streamHook(), }, ) catch |err| { self.endStreamedText(); @@ -1790,7 +1792,7 @@ fn processUserMessage(self: *Agent, input: TurnInput) !?[]const u8 { // `.none` stays off to opt out on models that reject it. .effort = if (self.effort == .none) .none else .low, .cancel = .{ .context = @ptrCast(self), .checkFn = checkCancel }, - .stream = self.streamHook(), + .stream = if (input.suppress_answer) null else self.streamHook(), }, ) catch |err| { self.endStreamedText(); From a64ced0ccc9487d6e009a054e42c49aece512730 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Sun, 12 Jul 2026 22:09:48 +0200 Subject: [PATCH 020/218] fix(agent): stream assistant text only in the REPL Streaming defaulted on for every mode, so one-shot `--task` and script runs interleaved intermediate assistant commentary into stdout, which wrappers treat as the answer. Gate the stream hook on `Terminal.isRepl` so non-interactive modes keep the buffered final answer. --- src/agent/Agent.zig | 6 ++++-- src/agent/Terminal.zig | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/agent/Agent.zig b/src/agent/Agent.zig index 36401ed43..141253599 100644 --- a/src/agent/Agent.zig +++ b/src/agent/Agent.zig @@ -604,9 +604,11 @@ fn endStreamedText(self: *Agent) void { } /// The text-delta hook, or null when `/stream off` — a null hook makes -/// `runTools` fall back to a buffered response. +/// `runTools` fall back to a buffered response. Streaming is an interactive +/// REPL affordance; one-shot (`--task`) and script modes keep stdout to the +/// buffered final answer so wrappers can parse it cleanly. fn streamHook(self: *Agent) ?zenai.provider.Client.TextDeltaHook { - if (!self.stream_enabled) return null; + if (!self.stream_enabled or !self.terminal.isRepl()) return null; return .{ .context = @ptrCast(self), .onText = streamAssistantDelta }; } diff --git a/src/agent/Terminal.zig b/src/agent/Terminal.zig index e5c013e62..298b9a659 100644 --- a/src/agent/Terminal.zig +++ b/src/agent/Terminal.zig @@ -186,7 +186,7 @@ pub fn init(allocator: std.mem.Allocator, history_paths: ?HistoryPaths, verbosit }; } -fn isRepl(self: *const Terminal) bool { +pub fn isRepl(self: *const Terminal) bool { return self.repl_arena != null; } From 2e8e0d1a6fe9a907c5484ddbeb5b8b89b791c941 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Sun, 12 Jul 2026 22:10:11 +0200 Subject: [PATCH 021/218] docs(agent): note why the streamed_text guard is turn-wide safe Document the invariant that lets a single per-turn flag suppress the buffered reprint without risking a dropped final answer. --- src/agent/Agent.zig | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/agent/Agent.zig b/src/agent/Agent.zig index 141253599..8c340f3c8 100644 --- a/src/agent/Agent.zig +++ b/src/agent/Agent.zig @@ -683,7 +683,10 @@ fn runTurn(self: *Agent, input: TurnInput) bool { if (!input.suppress_answer) { if (text) |t| { // Streaming already emitted the text incrementally (plus a closing - // newline); only the buffered path needs to print it here. + // newline); only the buffered path needs to print it here. Safe as a + // turn-wide flag: the stream accumulator reconstructs `t` from the + // same deltas it emitted, and the synthesis fallback also streams, so + // `streamed_text` implies `t` was already shown in full. if (!self.streamed_text) self.terminal.printAssistant(t); } else if (self.last_turn_refused) self.terminal.printInfo("(model declined to respond — safety refusal)", .{}) From fa2dc027213e6a77ab35554286012e010aaeca9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Sun, 12 Jul 2026 22:10:29 +0200 Subject: [PATCH 022/218] fix(agent): reset stream_active at turn start `stream_active` relied on every runTools exit path balancing pause with `endStreamedText`. If one is ever missed, a stale-true flag skips the spinner pause on the next turn, interleaving frames with streamed text. Reset it alongside `streamed_text` so the invariant is local. --- src/agent/Agent.zig | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/agent/Agent.zig b/src/agent/Agent.zig index 8c340f3c8..6ca6c9b8b 100644 --- a/src/agent/Agent.zig +++ b/src/agent/Agent.zig @@ -1663,6 +1663,10 @@ fn processUserMessage(self: *Agent, input: TurnInput) !?[]const u8 { const ma = self.conversation.arena.allocator(); self.api_error_detail = null; self.streamed_text = false; + // Defensive: if an exit path ever missed `endStreamedText`, a stale-true + // `stream_active` would skip the spinner pause on the next turn's first + // delta and let frames interleave with the text. Reset it at the source. + self.stream_active = false; self.http_interrupt.reset(); try self.conversation.ensureSystemPrompt(); From 46be87f2ca40920de1c0af66cef8437f9c6619b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Sun, 12 Jul 2026 22:13:49 +0200 Subject: [PATCH 023/218] test(agent): cover resolveStream precedence and stream round-trip Verify the stream default is on, that a remembered value wins, and that the field parses from (and is absent-safe in) .lp-agent.zon. --- src/agent/settings.zig | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/agent/settings.zig b/src/agent/settings.zig index 0e59b54ed..1e17ffb57 100644 --- a/src/agent/settings.zig +++ b/src/agent/settings.zig @@ -338,4 +338,19 @@ test "parseRemembered: valid file round-trips" { defer std.zon.parse.free(testing.allocator, remembered); try testing.expect(remembered.provider == null); try testing.expectString("some-model", remembered.model); + // Absent `stream` is null so pre-streaming files still fall back to the default. + try testing.expect(remembered.stream == null); +} + +test "parseRemembered: stream field round-trips" { + const remembered = parseRemembered(testing.allocator, ".{ .model = \"m\", .stream = false }").?; + defer std.zon.parse.free(testing.allocator, remembered); + try testing.expect(remembered.stream == false); +} + +test "resolveStream: default on, remembered wins" { + try testing.expect(resolveStream(null)); + try testing.expect(resolveStream(.{ .model = "m", .stream = null })); + try testing.expect(resolveStream(.{ .model = "m", .stream = true })); + try testing.expect(!resolveStream(.{ .model = "m", .stream = false })); } From 70b5d400fa534a4500fae9297dc7a048f4b48d50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Sun, 12 Jul 2026 22:56:27 +0200 Subject: [PATCH 024/218] build: bump zenai dependency --- build.zig.zon | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 3dc79f054..fe35e87a2 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -35,8 +35,8 @@ .hash = "sqlite3-3.51.0-DMxLWssOAABZ8cAvU_LfBIbp0kZjm824PU8sSLXpEDdr", }, .zenai = .{ - .url = "git+https://github.com/lightpanda-io/zenai.git#60f075996d055ab3b6c8d90e118d54b21be186d1", - .hash = "zenai-0.0.0-iOY_VC11BQBteUIggVHlBII-KM9-76cTBHrL2qL-9cmB", + .url = "git+https://github.com/lightpanda-io/zenai.git#b6425afa98f01bcf555a291f570629bedcd26e2f", + .hash = "zenai-0.0.0-iOY_VAWeBQB79YlLt4KWWp_m-J7-Y9CLwFtjtxmkRIBb", }, .isocline = .{ .url = "git+https://github.com/arrufat/isocline?ref=lightpanda#832a9fe25f5f4458fcc47b5acc7c21db669c2f47", From a3dd3f254dd5e8ccbfae52d030068a01714ba3c6 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 13 Jul 2026 07:36:16 +0800 Subject: [PATCH 025/218] mem: Make Request, URLSearchParams finalized Also, fix potential UAF in FormData iterator --- src/browser/webapi/URL.zig | 7 ++- src/browser/webapi/net/Fetch.zig | 4 ++ src/browser/webapi/net/FormData.zig | 17 ++++-- src/browser/webapi/net/Request.zig | 44 +++++++++++--- src/browser/webapi/net/URLSearchParams.zig | 70 +++++++++++++++++----- 5 files changed, 111 insertions(+), 31 deletions(-) diff --git a/src/browser/webapi/URL.zig b/src/browser/webapi/URL.zig index ace7d6796..b130275e5 100644 --- a/src/browser/webapi/URL.zig +++ b/src/browser/webapi/URL.zig @@ -58,7 +58,10 @@ pub fn parse(url: []const u8, maybe_base: ?[]const u8, exec: *const Execution) ? return URL.init(url, maybe_base, exec) catch null; } -pub fn deinit(self: *URL, _: *Page) void { +pub fn deinit(self: *URL, page: *Page) void { + if (self._search_params) |search_params| { + search_params.releaseRef(page); + } // Not tracked by arena. U.url_free(self._url); } @@ -254,6 +257,8 @@ pub fn getSearchParams(self: *URL, exec: *const Execution) !*URLSearchParams { const search_value = if (U.url_get_query(self._url, &out, &len) == 0) (out - 1)[0 .. len + 1] else ""; const params = try URLSearchParams.init(.{ .query_string = search_value }, exec); + // Released in deinit; the cached params must outlive their JS wrapper. + params.acquireRef(); self._search_params = params; return params; } diff --git a/src/browser/webapi/net/Fetch.zig b/src/browser/webapi/net/Fetch.zig index 86e53ca4b..4ba7edcac 100644 --- a/src/browser/webapi/net/Fetch.zig +++ b/src/browser/webapi/net/Fetch.zig @@ -55,6 +55,10 @@ pub fn init(input: Input, options: ?InitOpts, exec: *const Execution) !js.Promis resolver.rejectError("fetch init error", .{ .type_error = "Failed to construct Request" }); return resolver.promise(); }; + // This Request is never exposed to JS. makeRequest dupes the url/body + // into the transfer, so nothing references it once we return. + request.acquireRef(); + defer request.releaseRef(exec.page); if (request._signal) |signal| { if (signal._aborted) { diff --git a/src/browser/webapi/net/FormData.zig b/src/browser/webapi/net/FormData.zig index f70a6545c..3cf0174c9 100644 --- a/src/browser/webapi/net/FormData.zig +++ b/src/browser/webapi/net/FormData.zig @@ -199,15 +199,15 @@ fn deleteByName(self: *FormData, name: String, exec: *Execution) void { } pub fn keys(self: *FormData, exec: *const js.Execution) !*KeyIterator { - return KeyIterator.init(.{ .fd = self, .list = self }, exec); + return KeyIterator.init(.{ .fd = self }, exec); } pub fn values(self: *FormData, exec: *const js.Execution) !*ValueIterator { - return ValueIterator.init(.{ .fd = self, .list = self }, exec); + return ValueIterator.init(.{ .fd = self }, exec); } pub fn entries(self: *FormData, exec: *const js.Execution) !*EntryIterator { - return EntryIterator.init(.{ .fd = self, .list = self }, exec); + return EntryIterator.init(.{ .fd = self }, exec); } pub fn forEach(self: *FormData, cb_: js.Function, js_this_: ?js.Object) !void { @@ -350,11 +350,16 @@ pub const Iterator = struct { index: u32 = 0, fd: *FormData, - // See KeyValueList.Iterator.list — required by the GenericIterator wrapper. - list: *anyopaque, - pub const Entry = struct { []const u8, []const u8 }; + pub fn acquireRef(self: *Iterator) void { + self.fd.acquireRef(); + } + + pub fn releaseRef(self: *Iterator, page: *Page) void { + self.fd.releaseRef(page); + } + pub fn next(self: *Iterator, _: *const Execution) ?Iterator.Entry { const index = self.index; const items = self.fd._entries.items; diff --git a/src/browser/webapi/net/Request.zig b/src/browser/webapi/net/Request.zig index 7faca42cc..f8c5ac6f9 100644 --- a/src/browser/webapi/net/Request.zig +++ b/src/browser/webapi/net/Request.zig @@ -17,11 +17,13 @@ // along with this program. If not, see . const std = @import("std"); +const lp = @import("lightpanda"); const js = @import("../../js/js.zig"); const http = @import("../../../network/http.zig"); const URL = @import("../URL.zig"); +const Page = @import("../../Page.zig"); const Blob = @import("../Blob.zig"); const AbortSignal = @import("../AbortSignal.zig"); @@ -34,6 +36,7 @@ const Allocator = std.mem.Allocator; const Request = @This(); +_rc: lp.RC(u8) = .{}, _url: [:0]const u8, _method: http.Method, _headers: ?*Headers, @@ -88,7 +91,9 @@ const Cache = enum { }; pub fn init(input: Input, opts_: ?InitOpts, exec: *const Execution) !*Request { - const arena = exec.arena; + const arena = try exec.getArena(.medium, "Request"); + errdefer exec.releaseArena(arena); + const url = switch (input) { .url => |u| try URL.resolve(arena, exec.base(), u, .{ .encoding = exec.charset.* }), .request => |r| try arena.dupeZ(u8, r._url), @@ -130,7 +135,9 @@ pub fn init(input: Input, opts_: ?InitOpts, exec: *const Execution) !*Request { break :blk extracted.bytes; } else switch (input) { .url => null, - .request => |r| r._body, + // Dupe: the source Request owns its body bytes and may be finalized + // before this one. + .request => |r| if (r._body) |b| try arena.dupe(u8, b) else null, }; const signal = if (opts.signal) |s| @@ -140,7 +147,8 @@ pub fn init(input: Input, opts_: ?InitOpts, exec: *const Execution) !*Request { .request => |r| r._signal, }; - return exec._factory.create(Request{ + const self = try arena.create(Request); + self.* = .{ ._url = url, ._arena = arena, ._method = method, @@ -150,7 +158,20 @@ pub fn init(input: Input, opts_: ?InitOpts, exec: *const Execution) !*Request { ._redirect = opts.redirect, ._body = body, ._signal = signal, - }); + }; + return self; +} + +pub fn deinit(self: *Request, page: *Page) void { + page.releaseArena(self._arena); +} + +pub fn releaseRef(self: *Request, page: *Page) void { + self._rc.release(self, page); +} + +pub fn acquireRef(self: *Request) void { + self._rc.acquire(); } fn parseMethod(method: []const u8, exec: *const Execution) !http.Method { @@ -278,17 +299,22 @@ pub fn bytes(self: *Request, exec: *const Execution) !js.Promise { } pub fn clone(self: *const Request, exec: *const Execution) !*Request { - return exec._factory.create(Request{ - ._url = self._url, - ._arena = self._arena, + const arena = try exec.getArena(if (self._body) |b| b.len else 512, "Request.clone"); + errdefer exec.releaseArena(arena); + + const request = try arena.create(Request); + request.* = .{ + ._url = try arena.dupeZ(u8, self._url), + ._arena = arena, ._method = self._method, ._headers = self._headers, ._cache = self._cache, ._credentials = self._credentials, ._redirect = self._redirect, - ._body = self._body, + ._body = if (self._body) |b| try arena.dupe(u8, b) else null, ._signal = self._signal, - }); + }; + return request; } pub const JsApi = struct { diff --git a/src/browser/webapi/net/URLSearchParams.zig b/src/browser/webapi/net/URLSearchParams.zig index f0dc14eca..dbb1e09ee 100644 --- a/src/browser/webapi/net/URLSearchParams.zig +++ b/src/browser/webapi/net/URLSearchParams.zig @@ -20,6 +20,7 @@ const std = @import("std"); const lp = @import("lightpanda"); const js = @import("../../js/js.zig"); +const Page = @import("../../Page.zig"); const FormData = @import("FormData.zig"); const KeyValueList = @import("../KeyValueList.zig"); @@ -29,8 +30,18 @@ const String = lp.String; const Execution = js.Execution; const Allocator = std.mem.Allocator; +pub fn registerTypes() []const type { + return &.{ + URLSearchParams, + KeyIterator, + ValueIterator, + EntryIterator, + }; +} + const URLSearchParams = @This(); +_rc: lp.RC(u8) = .{}, _arena: Allocator, _params: KeyValueList, @@ -41,7 +52,9 @@ const InitOpts = union(enum) { }; pub fn init(opts_: ?InitOpts, exec: *const Execution) !*URLSearchParams { - const arena = exec.arena; + const arena = try exec.getArena(.small, "URLSearchParams"); + errdefer exec.releaseArena(arena); + const params: KeyValueList = blk: { const opts = opts_ orelse break :blk .empty; switch (opts) { @@ -73,10 +86,24 @@ pub fn init(opts_: ?InitOpts, exec: *const Execution) !*URLSearchParams { } }; - return exec._factory.create(URLSearchParams{ + const self = try arena.create(URLSearchParams); + self.* = .{ ._arena = arena, ._params = params, - }); + }; + return self; +} + +pub fn deinit(self: *URLSearchParams, page: *Page) void { + page.releaseArena(self._arena); +} + +pub fn releaseRef(self: *URLSearchParams, page: *Page) void { + self._rc.release(self, page); +} + +pub fn acquireRef(self: *URLSearchParams) void { + self._rc.acquire(); } pub fn updateFromString(self: *URLSearchParams, query_string: []const u8, exec: *const Execution) !void { @@ -111,16 +138,16 @@ pub fn delete(self: *URLSearchParams, name: []const u8, value: ?[]const u8) void self._params.delete(name, value); } -pub fn keys(self: *URLSearchParams, exec: *const Execution) !*KeyValueList.KeyIterator { - return KeyValueList.KeyIterator.init(.{ .list = self, .kv = &self._params }, exec); +pub fn keys(self: *URLSearchParams, exec: *const Execution) !*KeyIterator { + return KeyIterator.init(.{ .list = self }, exec); } -pub fn values(self: *URLSearchParams, exec: *const Execution) !*KeyValueList.ValueIterator { - return KeyValueList.ValueIterator.init(.{ .list = self, .kv = &self._params }, exec); +pub fn values(self: *URLSearchParams, exec: *const Execution) !*ValueIterator { + return ValueIterator.init(.{ .list = self }, exec); } -pub fn entries(self: *URLSearchParams, exec: *const Execution) !*KeyValueList.EntryIterator { - return KeyValueList.EntryIterator.init(.{ .list = self, .kv = &self._params }, exec); +pub fn entries(self: *URLSearchParams, exec: *const Execution) !*EntryIterator { + return EntryIterator.init(.{ .list = self }, exec); } pub fn toString(self: *const URLSearchParams, writer: *std.Io.Writer) !void { @@ -295,23 +322,36 @@ inline fn decodeHex(char: u8) u8 { pub const Iterator = struct { index: u32 = 0, - list: *const URLSearchParams, + list: *URLSearchParams, - const Entry = struct { []const u8, []const u8 }; + pub const Entry = struct { []const u8, []const u8 }; - pub fn next(self: *Iterator, _: *const Execution) !?Iterator.Entry { + pub fn acquireRef(self: *Iterator) void { + self.list.acquireRef(); + } + + pub fn releaseRef(self: *Iterator, page: *Page) void { + self.list.releaseRef(page); + } + + pub fn next(self: *Iterator, _: *const Execution) ?Iterator.Entry { const index = self.index; - const items = self.list._params.items; - if (index >= items.len) { + const entries_ = self.list._params._entries.items; + if (index >= entries_.len) { return null; } self.index = index + 1; - const e = &items[index]; + const e = &entries_[index]; return .{ e.name.str(), e.value.str() }; } }; +const GenericIterator = @import("../collections/iterator.zig").Entry; +pub const KeyIterator = GenericIterator(Iterator, "0"); +pub const ValueIterator = GenericIterator(Iterator, "1"); +pub const EntryIterator = GenericIterator(Iterator, null); + pub const JsApi = struct { pub const bridge = js.Bridge(URLSearchParams); From 5e2615850d7d2124767700353776f8745f7f805f Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 13 Jul 2026 11:13:49 +0800 Subject: [PATCH 026/218] webapi: Allow CustomElements to extend any HTMLElement interfaces. Pervious, extension was limited to HtmlElement. CustomElements can now extend any html element interface. The implementation isn't great. This is a special case, and rather than coming up with some abstract way to define this in bridge, we make the Snapshot aware of this specific case directly. --- src/browser/js/Snapshot.zig | 42 ++++++++++++++++--- .../tests/custom_elements/built_in.html | 33 +++++++++++++++ src/browser/webapi/element/Html.zig | 12 +++++- 3 files changed, 81 insertions(+), 6 deletions(-) diff --git a/src/browser/js/Snapshot.zig b/src/browser/js/Snapshot.zig index 75ef4de46..581b1257e 100644 --- a/src/browser/js/Snapshot.zig +++ b/src/browser/js/Snapshot.zig @@ -22,6 +22,11 @@ const lp = @import("lightpanda"); const js = @import("js.zig"); const bridge = @import("bridge.zig"); +// Not ideal, but its children have special constructor rules (can be extended +// can't be instantiated). And rather than coming up with a generic definition +// for this one case, we just hardcode it. +const HtmlElement = @import("../webapi/element/Html.zig"); + const v8 = js.v8; const log = lp.log; const JsApis = bridge.JsApis; @@ -402,6 +407,9 @@ fn countExternalReferences() comptime_int { // +1 for the illegal constructor callback shared by various types count += 1; + // +1 for the upgrade constructor shared by built-in element interfaces + count += 1; + // +1 for the noop function shared by various types count += 1; @@ -474,6 +482,9 @@ fn collectExternalReferences() [countExternalReferences()]isize { references[idx] = @bitCast(@intFromPtr(&illegalConstructorCallback)); idx += 1; + references[idx] = @bitCast(@intFromPtr(HtmlElement.JsApi.upgrade_constructor.func)); + idx += 1; + references[idx] = @bitCast(@intFromPtr(&bridge.Function.noopFunction)); idx += 1; @@ -676,14 +687,15 @@ fn protoIndexLookup(comptime JsApi: type) ?u16 { // Generate a constructor template for a JsApi type (public for reuse) pub fn generateConstructor(comptime JsApi: type, isolate: *v8.Isolate) *const v8.FunctionTemplate { - const callback = blk: { + const callback, const arity = comptime blk: { if (@hasDecl(JsApi, "constructor")) { - break :blk JsApi.constructor.func; + break :blk .{ JsApi.constructor.func, JsApi.constructor.arity }; } - break :blk illegalConstructorCallback; + if (inheritsFromHtmlElement(JsApi)) { + break :blk .{ HtmlElement.JsApi.upgrade_constructor.func, @as(c_int, HtmlElement.JsApi.upgrade_constructor.arity) }; + } + break :blk .{ &illegalConstructorCallback, @as(c_int, 0) }; }; - - const arity: c_int = if (@hasDecl(JsApi, "constructor")) JsApi.constructor.arity else 0; const template = v8.v8__FunctionTemplate__New__Config(isolate, &.{ .length = arity, .callback = callback, @@ -704,6 +716,26 @@ pub fn generateConstructor(comptime JsApi: type, isolate: *v8.Isolate) *const v8 return template; } +// hard-coded special case for HtmlElement which can be extended but not +// instantiated. +fn inheritsFromHtmlElement(comptime JsApi: type) bool { + comptime { + var T = JsApi.bridge.type; + if (T == HtmlElement) { + return false; + } + + while (@hasField(T, "_proto")) { + const Parent = @typeInfo(std.meta.fieldInfo(T, ._proto).type).pointer.child; + if (Parent == HtmlElement) { + return true; + } + T = Parent; + } + return false; + } +} + // Attach JsApi members to a template (public for reuse). This is called on all // types. But, for globals (window, WGS) it's called twice. The first time, it's // called like any other interface. The 2nd time, it's called with flatten == true diff --git a/src/browser/tests/custom_elements/built_in.html b/src/browser/tests/custom_elements/built_in.html index 0b74acef1..63fcb801a 100644 --- a/src/browser/tests/custom_elements/built_in.html +++ b/src/browser/tests/custom_elements/built_in.html @@ -67,6 +67,39 @@ testing.expectEqual('DIV', div.tagName); } +{ + let constructorCalled = 0; + + class TypedButton extends HTMLButtonElement { + constructor() { + super(); + constructorCalled++; + this.custom = true; + } + } + + customElements.define('typed-button', TypedButton, { extends: 'button' }); + + const btn = document.createElement('button', { is: 'typed-button' }); + testing.expectEqual(1, constructorCalled); + testing.expectEqual(true, btn instanceof TypedButton); + testing.expectEqual(true, btn instanceof HTMLButtonElement); + testing.expectEqual('BUTTON', btn.tagName); + testing.expectEqual(true, btn.custom); +} + +// Directly constructing a built-in interface is unsupported and must throw a +// TypeError (not silently succeed). +{ + let threw = false; + try { + new HTMLDivElement(); + } catch (e) { + threw = e instanceof TypeError; + } + testing.expectEqual(true, threw); +} + { let connectedCount = 0; let disconnectedCount = 0; diff --git a/src/browser/webapi/element/Html.zig b/src/browser/webapi/element/Html.zig index 320537796..fead6c238 100644 --- a/src/browser/webapi/element/Html.zig +++ b/src/browser/webapi/element/Html.zig @@ -106,7 +106,7 @@ const HtmlElement = @This(); _type: Type, _proto: *Element, -// Special constructor for custom elements. +// Special constructor for custom elements (autonomous, `extends HTMLElement`). // Two paths: // - Upgrade path: customElements.define / createElement / upgrade set // `_upgrading_element` before calling newInstance, and we just return it. @@ -119,6 +119,15 @@ pub fn construct(new_target: js.Function, frame: *Frame) !*Element { return Frame.node_factory.constructCustomElement(frame, new_target); } +// Shared constructor callback for builtin html element interfaces. These types +// cannot be instantinated (e.g. new HTMLDivElement), but a custom element can +// be extended, so super() has to create them. All of these types have their +// constructors routed here. +pub fn upgradeConstruct(frame: *Frame) !*Element { + const node = frame._upgrading_element orelse return error.TypeError; + return node.is(Element) orelse return error.TypeError; +} + pub const Type = union(enum) { anchor: *Anchor, area: *Area, @@ -1643,6 +1652,7 @@ pub const JsApi = struct { }; pub const constructor = bridge.constructor(HtmlElement.construct, .{ .new_target = true }); + pub const upgrade_constructor = bridge.constructor(HtmlElement.upgradeConstruct, .{}); pub const innerText = bridge.accessor(_innerText, _setInnerText, .{ .ce_reactions = true }); fn _innerText(self: *HtmlElement, frame: *Frame) ![]const u8 { From 7b8ea3916448c9f983a0c76ed504e6aec2bfb6ae Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 13 Jul 2026 12:03:52 +0800 Subject: [PATCH 027/218] webapi: Improve DOMRect We had a basic DORect implementation (which probably covered more cases). This commit extends the implementation to be more complete: - Add DOMRectReadOnly (which DOMRect inherits from) - Add StructuredClone support to DOMRect and DOMRectReadOnly - Make DOMRect and DOMRectReadOnly usable from Worker DOMRect can be on the hot path with IntersectionObserver, so we maintain a value-based data object (DOMRect.Data) for internal use (things returned to js always have to be heap allocated (and always were, we're just more explicit about this part now)). --- src/browser/Factory.zig | 18 ++ src/browser/js/Value.zig | 2 + src/browser/js/bridge.zig | 3 + src/browser/tests/domrect.html | 155 ++++++++++++++++++ src/browser/webapi/DOMRect.zig | 98 ++++++----- src/browser/webapi/DOMRectReadOnly.zig | 172 ++++++++++++++++++++ src/browser/webapi/Element.zig | 42 ++--- src/browser/webapi/IntersectionObserver.zig | 34 ++-- src/browser/webapi/Range.zig | 8 +- src/browser/webapi/element/svg/Svg.zig | 2 +- src/cdp/domains/dom.zig | 28 ++-- 11 files changed, 462 insertions(+), 100 deletions(-) create mode 100644 src/browser/tests/domrect.html create mode 100644 src/browser/webapi/DOMRectReadOnly.zig diff --git a/src/browser/Factory.zig b/src/browser/Factory.zig index 9e1ee0685..00626ed12 100644 --- a/src/browser/Factory.zig +++ b/src/browser/Factory.zig @@ -35,6 +35,8 @@ const EventTarget = @import("webapi/EventTarget.zig"); const XMLHttpRequestEventTarget = @import("webapi/net/XMLHttpRequestEventTarget.zig"); const Blob = @import("webapi/Blob.zig"); const AbstractRange = @import("webapi/AbstractRange.zig"); +const DOMRect = @import("webapi/DOMRect.zig"); +const DOMRectReadOnly = @import("webapi/DOMRectReadOnly.zig"); const log = lp.log; const String = lp.String; @@ -292,6 +294,22 @@ pub fn abstractRange(_: *const Factory, arena: Allocator, child: anytype, frame: return chain.get(1); } +pub fn domRect(self: *Factory, rect: DOMRectReadOnly.Data) !*DOMRect { + const chain = try PrototypeChain(&.{ DOMRectReadOnly, DOMRect }).allocate(self._slab.allocator()); + + const base = chain.get(0); + base.* = .{ + ._type = .{ .mutable = chain.get(1) }, + ._x = rect.x, + ._y = rect.y, + ._width = rect.width, + ._height = rect.height, + }; + chain.setLeaf(1, DOMRect{ ._proto = base }); + + return chain.get(1); +} + pub fn node(self: *Factory, child: anytype) !*@TypeOf(child) { const allocator = self._slab.allocator(); return try AutoPrototypeChain( diff --git a/src/browser/js/Value.zig b/src/browser/js/Value.zig index 08bb1b8b4..95dc641dc 100644 --- a/src/browser/js/Value.zig +++ b/src/browser/js/Value.zig @@ -425,6 +425,8 @@ const cloneable_types = .{ @import("../webapi/ImageData.zig"), @import("../webapi/DOMPointReadOnly.zig"), @import("../webapi/DOMPoint.zig"), + @import("../webapi/DOMRectReadOnly.zig"), + @import("../webapi/DOMRect.zig"), }; // Passed to a type's structuredSerialize hook to write its payload into the diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index b4c0f4168..03bb0bbaf 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -907,6 +907,7 @@ pub const PageJsApis = flattenTypes(&.{ @import("../webapi/DOMImplementation.zig"), @import("../webapi/DOMTreeWalker.zig"), @import("../webapi/DOMNodeIterator.zig"), + @import("../webapi/DOMRectReadOnly.zig"), @import("../webapi/DOMRect.zig"), @import("../webapi/DOMMatrixReadOnly.zig"), @import("../webapi/DOMMatrix.zig"), @@ -1128,6 +1129,8 @@ pub const WorkerJsApis = flattenTypes(&.{ @import("../webapi/event/PromiseRejectionEvent.zig"), @import("../webapi/event/CloseEvent.zig"), @import("../webapi/DOMException.zig"), + @import("../webapi/DOMRectReadOnly.zig"), + @import("../webapi/DOMRect.zig"), @import("../webapi/DOMMatrixReadOnly.zig"), @import("../webapi/DOMMatrix.zig"), @import("../webapi/DOMPointReadOnly.zig"), diff --git a/src/browser/tests/domrect.html b/src/browser/tests/domrect.html new file mode 100644 index 000000000..e7af22990 --- /dev/null +++ b/src/browser/tests/domrect.html @@ -0,0 +1,155 @@ + + + DOMRect Test + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/webapi/DOMRect.zig b/src/browser/webapi/DOMRect.zig index e3b2502c6..00eee6b1c 100644 --- a/src/browser/webapi/DOMRect.zig +++ b/src/browser/webapi/DOMRect.zig @@ -16,55 +16,73 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const js = @import("../js/js.zig"); +const Page = @import("../Page.zig"); +const Factory = @import("../Factory.zig"); + +const RO = @import("DOMRectReadOnly.zig"); + +pub const Data = RO.Data; + const DOMRect = @This(); -const js = @import("../js/js.zig"); -const Frame = @import("../Frame.zig"); +_proto: *RO, -_x: f64, -_y: f64, -_width: f64, -_height: f64, - -pub fn init(x: f64, y: f64, width: f64, height: f64, frame: *Frame) !*DOMRect { - return frame._factory.create(DOMRect{ - ._x = x, - ._y = y, - ._width = width, - ._height = height, - }); +pub fn init(x_: ?f64, y_: ?f64, width_: ?f64, height_: ?f64, exec: *const js.Execution) !*DOMRect { + return create(.{ + .x = x_ orelse 0, + .y = y_ orelse 0, + .width = width_ orelse 0, + .height = height_ orelse 0, + }, exec._factory); } +pub fn create(rect: Data, factory: *Factory) !*DOMRect { + return factory.domRect(rect); +} + +pub fn fromRect(other_: ?Data, exec: *const js.Execution) !*DOMRect { + return create(other_ orelse .{}, exec._factory); +} + +pub fn structuredSerialize(self: *const DOMRect, writer: *js.StructuredWriter) !void { + try self._proto.structuredSerialize(writer); +} + +pub fn structuredDeserialize(reader: *js.StructuredReader, page: *Page) !*DOMRect { + return page.factory.domRect(try RO.readData(reader)); +} + +// DOMRect redeclares x/y/width/height as writable, so DOMRect.prototype needs its +// own read-write accessors distinct from the read-only ones on +// DOMRectReadOnly.prototype. The setters are the point; each accessor bundles a +// getter too, so we pair them with DOMRect-typed getters that read through +// `_proto`. top/right/bottom/left stay read-only and are inherited from the base. + pub fn getX(self: *const DOMRect) f64 { - return self._x; + return self._proto._x; } - pub fn getY(self: *const DOMRect) f64 { - return self._y; + return self._proto._y; } - pub fn getWidth(self: *const DOMRect) f64 { - return self._width; + return self._proto._width; } - pub fn getHeight(self: *const DOMRect) f64 { - return self._height; + return self._proto._height; } -pub fn getTop(self: *const DOMRect) f64 { - return @min(self._y, self._y + self._height); +pub fn setX(self: *DOMRect, v: f64) void { + self._proto._x = v; } - -pub fn getRight(self: *const DOMRect) f64 { - return @max(self._x, self._x + self._width); +pub fn setY(self: *DOMRect, v: f64) void { + self._proto._y = v; } - -pub fn getBottom(self: *const DOMRect) f64 { - return @max(self._y, self._y + self._height); +pub fn setWidth(self: *DOMRect, v: f64) void { + self._proto._width = v; } - -pub fn getLeft(self: *const DOMRect) f64 { - return @min(self._x, self._x + self._width); +pub fn setHeight(self: *DOMRect, v: f64) void { + self._proto._height = v; } pub const JsApi = struct { @@ -77,12 +95,12 @@ pub const JsApi = struct { }; pub const constructor = bridge.constructor(DOMRect.init, .{}); - pub const x = bridge.accessor(DOMRect.getX, null, .{}); - pub const y = bridge.accessor(DOMRect.getY, null, .{}); - pub const width = bridge.accessor(DOMRect.getWidth, null, .{}); - pub const height = bridge.accessor(DOMRect.getHeight, null, .{}); - pub const top = bridge.accessor(DOMRect.getTop, null, .{}); - pub const right = bridge.accessor(DOMRect.getRight, null, .{}); - pub const bottom = bridge.accessor(DOMRect.getBottom, null, .{}); - pub const left = bridge.accessor(DOMRect.getLeft, null, .{}); + pub const fromRect = bridge.function(DOMRect.fromRect, .{ .static = true }); + + // Writable components (the read-only top/right/bottom/left are inherited + // from DOMRectReadOnly.prototype). + pub const x = bridge.accessor(DOMRect.getX, DOMRect.setX, .{}); + pub const y = bridge.accessor(DOMRect.getY, DOMRect.setY, .{}); + pub const width = bridge.accessor(DOMRect.getWidth, DOMRect.setWidth, .{}); + pub const height = bridge.accessor(DOMRect.getHeight, DOMRect.setHeight, .{}); }; diff --git a/src/browser/webapi/DOMRectReadOnly.zig b/src/browser/webapi/DOMRectReadOnly.zig new file mode 100644 index 000000000..49ee7e54e --- /dev/null +++ b/src/browser/webapi/DOMRectReadOnly.zig @@ -0,0 +1,172 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const js = @import("../js/js.zig"); +const Page = @import("../Page.zig"); +const Factory = @import("../Factory.zig"); + +const DOMRect = @import("DOMRect.zig"); + +const DOMRectReadOnly = @This(); + +pub const _prototype_root = true; + +_type: Type, + +_x: f64, +_y: f64, +_width: f64, +_height: f64, + +pub const Type = union(enum) { + generic, + mutable: *DOMRect, +}; + +pub const Data = struct { + x: f64 = 0, + y: f64 = 0, + width: f64 = 0, + height: f64 = 0, +}; + +pub fn init(x_: ?f64, y_: ?f64, width_: ?f64, height_: ?f64, exec: *const js.Execution) !*DOMRectReadOnly { + return createBare(.{ + .x = x_ orelse 0, + .y = y_ orelse 0, + .width = width_ orelse 0, + .height = height_ orelse 0, + }, exec._factory); +} + +pub fn createBare(rect: Data, factory: *Factory) !*DOMRectReadOnly { + return factory.create(DOMRectReadOnly{ + ._type = .generic, + ._x = rect.x, + ._y = rect.y, + ._width = rect.width, + ._height = rect.height, + }); +} + +pub fn fromRect(other_: ?Data, exec: *const js.Execution) !*DOMRectReadOnly { + return createBare(other_ orelse .{}, exec._factory); +} + +pub fn structuredSerialize(self: *const DOMRectReadOnly, writer: *js.StructuredWriter) !void { + writer.writeUint64(@bitCast(self._x)); + writer.writeUint64(@bitCast(self._y)); + writer.writeUint64(@bitCast(self._width)); + writer.writeUint64(@bitCast(self._height)); +} + +pub fn readData(reader: *js.StructuredReader) !Data { + return .{ + .x = @bitCast(try reader.readUint64()), + .y = @bitCast(try reader.readUint64()), + .width = @bitCast(try reader.readUint64()), + .height = @bitCast(try reader.readUint64()), + }; +} + +pub fn structuredDeserialize(reader: *js.StructuredReader, page: *Page) !*DOMRectReadOnly { + return createBare(try readData(reader), &page.factory); +} + +pub fn getX(self: *const DOMRectReadOnly) f64 { + return self._x; +} + +pub fn getY(self: *const DOMRectReadOnly) f64 { + return self._y; +} + +pub fn getWidth(self: *const DOMRectReadOnly) f64 { + return self._width; +} + +pub fn getHeight(self: *const DOMRectReadOnly) f64 { + return self._height; +} + +pub fn getTop(self: *const DOMRectReadOnly) f64 { + return @min(self._y, self._y + self._height); +} + +pub fn getRight(self: *const DOMRectReadOnly) f64 { + return @max(self._x, self._x + self._width); +} + +pub fn getBottom(self: *const DOMRectReadOnly) f64 { + return @max(self._y, self._y + self._height); +} + +pub fn getLeft(self: *const DOMRectReadOnly) f64 { + return @min(self._x, self._x + self._width); +} + +pub fn toJSON(self: *const DOMRectReadOnly) struct { + x: f64, + y: f64, + width: f64, + height: f64, + top: f64, + right: f64, + bottom: f64, + left: f64, +} { + return .{ + .x = self._x, + .y = self._y, + .width = self._width, + .height = self._height, + .top = self.getTop(), + .right = self.getRight(), + .bottom = self.getBottom(), + .left = self.getLeft(), + }; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(DOMRectReadOnly); + + pub const Meta = struct { + pub const name = "DOMRectReadOnly"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const constructor = bridge.constructor(DOMRectReadOnly.init, .{}); + pub const fromRect = bridge.function(DOMRectReadOnly.fromRect, .{ .static = true }); + + pub const x = bridge.accessor(DOMRectReadOnly.getX, null, .{}); + pub const y = bridge.accessor(DOMRectReadOnly.getY, null, .{}); + pub const width = bridge.accessor(DOMRectReadOnly.getWidth, null, .{}); + pub const height = bridge.accessor(DOMRectReadOnly.getHeight, null, .{}); + pub const top = bridge.accessor(DOMRectReadOnly.getTop, null, .{}); + pub const right = bridge.accessor(DOMRectReadOnly.getRight, null, .{}); + pub const bottom = bridge.accessor(DOMRectReadOnly.getBottom, null, .{}); + pub const left = bridge.accessor(DOMRectReadOnly.getLeft, null, .{}); + + pub const toJSON = bridge.function(DOMRectReadOnly.toJSON, .{}); +}; + +const testing = @import("../../testing.zig"); +test "WebApi: DOMRect" { + try testing.htmlRunner("domrect.html", .{}); +} diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index bfb758281..7aa341e7c 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -1223,22 +1223,22 @@ pub fn getClientHeight(self: *Element, frame: *Frame) f64 { return dims.height; } -pub fn getBoundingClientRect(self: *Element, frame: *Frame) DOMRect { - if (!self.checkVisibilityCached(null, frame)) { - return .{ - ._x = 0.0, - ._y = 0.0, - ._width = 0.0, - ._height = 0.0, - }; - } - - return self.getBoundingClientRectForVisible(frame); +pub fn getBoundingClientRect(self: *Element, frame: *Frame) !*DOMRect { + return DOMRect.create(self.boundingClientRectValues(frame), frame._factory); } -// Some cases need a the BoundingClientRect but have already done the -// visibility check. -pub fn getBoundingClientRectForVisible(self: *Element, frame: *Frame) DOMRect { +// Plain rect values, no JS-backed allocation: the internal fast path shared by +// getBoundingClientRect, getClientRects, and IntersectionObserver. A DOMRect is +// only materialized at the JS boundary. +pub fn boundingClientRectValues(self: *Element, frame: *Frame) DOMRect.Data { + if (!self.checkVisibilityCached(null, frame)) { + return .{}; + } + return self.boundingClientRectValuesForVisible(frame); +} + +// Some cases need the bounding rect but have already done the visibility check. +pub fn boundingClientRectValuesForVisible(self: *Element, frame: *Frame) DOMRect.Data { const y = calculateDocumentPosition(self.asNode()); const dims = self.getElementDimensions(frame); @@ -1246,19 +1246,19 @@ pub fn getBoundingClientRectForVisible(self: *Element, frame: *Frame) DOMRect { const x = calculateSiblingPosition(self.asNode()); return .{ - ._x = x, - ._y = y, - ._width = dims.width, - ._height = dims.height, + .x = x, + .y = y, + .width = dims.width, + .height = dims.height, }; } -pub fn getClientRects(self: *Element, frame: *Frame) ![]DOMRect { +pub fn getClientRects(self: *Element, frame: *Frame) ![]*DOMRect { if (!self.checkVisibilityCached(null, frame)) { return &.{}; } - const rects = try frame.local_arena.alloc(DOMRect, 1); - rects[0] = self.getBoundingClientRectForVisible(frame); + const rects = try frame.local_arena.alloc(*DOMRect, 1); + rects[0] = try DOMRect.create(self.boundingClientRectValuesForVisible(frame), frame._factory); return rects; } diff --git a/src/browser/webapi/IntersectionObserver.zig b/src/browser/webapi/IntersectionObserver.zig index 8a70a7436..c6899c739 100644 --- a/src/browser/webapi/IntersectionObserver.zig +++ b/src/browser/webapi/IntersectionObserver.zig @@ -50,13 +50,9 @@ _pending_entries: std.ArrayList(*IntersectionObserverEntry) = .{}, // tracked targets that aren't reported yet _tracked: std.AutoHashMapUnmanaged(*Element, void) = .{}, -// Shared zero DOMRect to avoid repeated allocations for non-intersecting elements -var zero_rect: DOMRect = .{ - ._x = 0.0, - ._y = 0.0, - ._width = 0.0, - ._height = 0.0, -}; +// Shared zero rect (plain values) for non-intersecting elements. Materialized +// into a DOMRect only if it ends up on a delivered entry. +const zero_rect: DOMRect.Data = .{}; pub const ObserverInit = struct { root: ?*Node = null, @@ -202,18 +198,16 @@ fn calculateIntersection( has_parent: bool, frame: *Frame, ) !IntersectionData { - const target_rect = target.getBoundingClientRect(frame); + const target_rect = target.boundingClientRectValues(frame); // Use root element's rect or the faux-layout viewport. const root_rect = if (self._root) |root| - root.getBoundingClientRect(frame) + root.boundingClientRectValues(frame) else blk: { const viewport = frame._page.getViewport(); - break :blk DOMRect{ - ._x = 0.0, - ._y = 0.0, - ._width = @floatFromInt(viewport.width), - ._height = @floatFromInt(viewport.height), + break :blk DOMRect.Data{ + .width = @floatFromInt(viewport.width), + .height = @floatFromInt(viewport.height), }; }; @@ -238,9 +232,9 @@ fn calculateIntersection( const IntersectionData = struct { is_intersecting: bool, intersection_ratio: f64, - intersection_rect: DOMRect, - bounding_client_rect: DOMRect, - root_bounds: DOMRect, + intersection_rect: DOMRect.Data, + bounding_client_rect: DOMRect.Data, + root_bounds: DOMRect.Data, }; fn meetsThreshold(self: *IntersectionObserver, ratio: f64) bool { @@ -280,9 +274,9 @@ fn checkIntersection(self: *IntersectionObserver, target: *Element, frame: *Fram ._target = target, ._time = frame.window._performance.now(), ._is_intersecting = is_now_intersecting, - ._root_bounds = try frame._factory.create(data.root_bounds), - ._intersection_rect = try frame._factory.create(data.intersection_rect), - ._bounding_client_rect = try frame._factory.create(data.bounding_client_rect), + ._root_bounds = try DOMRect.create(data.root_bounds, frame._factory), + ._intersection_rect = try DOMRect.create(data.intersection_rect, frame._factory), + ._bounding_client_rect = try DOMRect.create(data.bounding_client_rect, frame._factory), ._intersection_ratio = data.intersection_ratio, }; try self._pending_entries.append(self._arena, entry); diff --git a/src/browser/webapi/Range.zig b/src/browser/webapi/Range.zig index 29a6d31f8..d0f23c649 100644 --- a/src/browser/webapi/Range.zig +++ b/src/browser/webapi/Range.zig @@ -664,17 +664,17 @@ fn nextAfterSubtree(node: *Node, root: *Node) ?*Node { return null; } -pub fn getBoundingClientRect(self: *const Range, frame: *Frame) DOMRect { +pub fn getBoundingClientRect(self: *const Range, frame: *Frame) !*DOMRect { if (self._proto.getCollapsed()) { - return .{ ._x = 0, ._y = 0, ._width = 0, ._height = 0 }; + return DOMRect.create(.{}, frame._factory); } const element = self.getContainerElement() orelse { - return .{ ._x = 0, ._y = 0, ._width = 0, ._height = 0 }; + return DOMRect.create(.{}, frame._factory); }; return element.getBoundingClientRect(frame); } -pub fn getClientRects(self: *const Range, frame: *Frame) ![]DOMRect { +pub fn getClientRects(self: *const Range, frame: *Frame) ![]*DOMRect { if (self._proto.getCollapsed()) { return &.{}; } diff --git a/src/browser/webapi/element/svg/Svg.zig b/src/browser/webapi/element/svg/Svg.zig index 920b25c7f..11b809b43 100644 --- a/src/browser/webapi/element/svg/Svg.zig +++ b/src/browser/webapi/element/svg/Svg.zig @@ -85,7 +85,7 @@ pub const JsApi = struct { pub const createSVGRect = bridge.function(_createSVGRect, .{}); fn _createSVGRect(_: *Svg, frame: *Frame) !*DOMRect { - return DOMRect.init(0, 0, 0, 0, frame); + return DOMRect.create(.{}, frame._factory); } pub const createSVGNumber = bridge.function(_createSVGNumber, .{}); diff --git a/src/cdp/domains/dom.zig b/src/cdp/domains/dom.zig index 37235ba20..6db73357f 100644 --- a/src/cdp/domains/dom.zig +++ b/src/cdp/domains/dom.zig @@ -429,16 +429,16 @@ const BoxModel = struct { // shapeOutside: ?ShapeOutsideInfo, }; -fn rectToQuad(rect: DOMNode.Element.DOMRect) Quad { +fn rectToQuad(rect: DOMNode.Element.DOMRect.Data) Quad { return Quad{ - rect._x, - rect._y, - rect._x + rect._width, - rect._y, - rect._x + rect._width, - rect._y + rect._height, - rect._x, - rect._y + rect._height, + rect.x, + rect.y, + rect.x + rect.width, + rect.y, + rect.x + rect.width, + rect.y + rect.height, + rect.x, + rect.y + rect.height, }; } @@ -447,7 +447,7 @@ fn scrollIntoViewIfNeeded(cmd: *CDP.Command) !void { nodeId: ?Node.Id = null, backendNodeId: ?u32 = null, objectId: ?[]const u8 = null, - rect: ?DOMNode.Element.DOMRect = null, + rect: ?DOMNode.Element.DOMRect.Data = null, })) orelse return error.InvalidParams; // Only 1 of nodeId, backendNodeId, objectId may be set, but chrome just takes the first non-null @@ -507,7 +507,7 @@ fn getContentQuads(cmd: *CDP.Command) !void { // Text may be tricky, multiple quads in case of multiple lines? empty quads of text = ""? // Elements like SVGElement may have multiple quads. - const quad = rectToQuad(element.getBoundingClientRect(frame)); + const quad = rectToQuad(element.boundingClientRectValues(frame)); return cmd.sendResult(.{ .quads = &.{quad} }, .{}); } @@ -526,7 +526,7 @@ fn getBoxModel(cmd: *CDP.Command) !void { // TODO implement for document or text const element = node.dom.is(DOMNode.Element) orelse return error.NodeIsNotAnElement; - const rect = element.getBoundingClientRect(frame); + const rect = element.boundingClientRectValues(frame); const quad = rectToQuad(rect); const zero = [_]f64{0.0} ** 8; @@ -535,8 +535,8 @@ fn getBoxModel(cmd: *CDP.Command) !void { .padding = zero, .border = zero, .margin = zero, - .width = @intFromFloat(rect._width), - .height = @intFromFloat(rect._height), + .width = @intFromFloat(rect.width), + .height = @intFromFloat(rect.height), } }, .{}); } From f7f8869ed337323de038f831531760b55a72d9b8 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 13 Jul 2026 12:28:59 +0800 Subject: [PATCH 028/218] webapi, minor: XHR Response-Type and invalid JSON parsing tweaks Add an explicit 'default' ResponseType, which mostly behaves like `text`, but can be used do discriminate between an explicit text and the default value. Also, on JSON parse error, return null, not an exception. --- src/browser/webapi/net/XMLHttpRequest.zig | 59 ++++++++++++++++------- 1 file changed, 42 insertions(+), 17 deletions(-) diff --git a/src/browser/webapi/net/XMLHttpRequest.zig b/src/browser/webapi/net/XMLHttpRequest.zig index 5d17730de..abda1a34b 100644 --- a/src/browser/webapi/net/XMLHttpRequest.zig +++ b/src/browser/webapi/net/XMLHttpRequest.zig @@ -68,7 +68,7 @@ _response_mime: ?Mime = null, _override_mime: ?Mime = null, _response_xml: ?*Node.Document = null, _response_headers: std.ArrayList([]const u8) = .empty, -_response_type: ResponseType = .text, +_response_type: ResponseType = .default, _ready_state: ReadyState = .unsent, _on_ready_state_change: ?js.Function.Global = null, @@ -83,7 +83,7 @@ const ReadyState = enum(u8) { done = 4, }; -const Response = union(ResponseType) { +const Response = union(enum) { text: []const u8, json: js.Value.Global, document: *Node.Document, @@ -91,11 +91,18 @@ const Response = union(ResponseType) { }; const ResponseType = enum { + default, // behaves like `text`, but its string representation is "" text, json, document, arraybuffer, - // TODO: other types to support + + pub fn toString(self: ResponseType) []const u8 { + return switch (self) { + .default => "", + else => @tagName(self), + }; + } }; pub fn init(exec: *const Execution) !*XMLHttpRequest { @@ -340,16 +347,24 @@ pub fn getAllResponseHeaders(self: *const XMLHttpRequest, exec: *const Execution return buf.written(); } -pub fn getResponseType(self: *const XMLHttpRequest) []const u8 { - if (self._ready_state != .done) { - return ""; - } - return @tagName(self._response_type); +pub fn getResponseType(self: *const XMLHttpRequest) ResponseType { + return self._response_type; } -pub fn setResponseType(self: *XMLHttpRequest, value: []const u8) void { +pub fn setResponseType(self: *XMLHttpRequest, value: []const u8) !void { + if (self._ready_state == .loading or self._ready_state == .done) { + return error.InvalidStateError; + } + + if (value.len == 0) { + self._response_type = .default; + return; + } + if (std.meta.stringToEnum(ResponseType, value)) |rt| { - self._response_type = rt; + if (rt != .default) { + self._response_type = rt; + } } } @@ -385,9 +400,21 @@ pub fn getResponse(self: *XMLHttpRequest, exec: *const Execution) !?Response { const data = self._response_data.items; const res: Response = switch (self._response_type) { - .text => .{ .text = data }, + .default, .text => .{ .text = data }, .json => blk: { - const value = try exec.js.local.?.parseJSON(data); + const local = exec.js.local.?; + + // per spec, we need to strip a JSON's body leading BOM + const json_text = if (std.mem.startsWith(u8, data, "\u{FEFF}")) data[3..] else data; + + var try_catch: js.TryCatch = undefined; + try_catch.init(local); + defer try_catch.deinit(); + const value = local.parseJSON(json_text) catch { + // if we fail to parse, we return null, not an error. + const null_value = js.Value{ .local = local, .handle = local.isolate.initNull() }; + break :blk .{ .json = try null_value.persist() }; + }; break :blk .{ .json = try value.persist() }; }, .document => blk: { @@ -429,11 +456,9 @@ pub fn getResponseXML(self: *XMLHttpRequest, exec: *const Execution) !?*Node.Doc }; } - // responseType="" (we map "" to .text — see setResponseType): lazily - // produce a Document when the final MIME type is XML, per WHATWG XHR - // "set a document response". For an HTML final MIME the spec returns - // null in this branch, so we only act on text/xml. - if (self._response_type != .text) return null; + if (self._response_type != .default) { + return null; + } if (self._response_xml) |document| { return document; From e4387bf7faa01716e9be87882c9a9c8feead2b81 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 13 Jul 2026 12:52:38 +0800 Subject: [PATCH 029/218] minor, webapi: Prevent outerHTML of document's element's from being written Captured by WPT test: /domparsing/outerhtml-01.html --- src/browser/webapi/Element.zig | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index bfb758281..8cf7ce2c9 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -466,6 +466,11 @@ pub fn setOuterHTML(self: *Element, html: []const u8, frame: *Frame) !void { const node = self.asNode(); const parent = node._parent orelse return; + // The parent of a documentElement is the Document, which cannot be modified. + if (parent._type == .document) { + return error.NoModificationAllowed; + } + frame.domChanged(); if (html.len > 0) { const fragment = (try Node.DocumentFragment.init(frame)).asNode(); From f4da23da2ea895db757c6b93005a2778c62115a3 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 13 Jul 2026 12:59:29 +0800 Subject: [PATCH 030/218] ops: Don't log TryCatchRethrow errors error.TryCatchRethrow is used as a control flow (sorry). It signals that an error has already been throw in v8 and that the method should exit (ultimately, it ends up being the return value passed to our v8 bridge, which knows to ignore it). We shouldn't log this as a WARN, it's completely normal that it happens AND it really contains no meaningful information as-is. --- src/browser/js/Function.zig | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/browser/js/Function.zig b/src/browser/js/Function.zig index 318e45f0f..ee9f2f606 100644 --- a/src/browser/js/Function.zig +++ b/src/browser/js/Function.zig @@ -95,7 +95,11 @@ pub fn call(self: *const Function, comptime T: type, args: anytype) !T { pub fn callRethrow(self: *const Function, comptime T: type, args: anytype) !T { var caught: js.TryCatch.Caught = undefined; return self._tryCallWithThis(T, self.getThis(), args, &caught, .{ .rethrow = true }) catch |err| { - log.warn(.js, "call caught", .{ .err = err, .caught = caught }); + if (err != error.TryCatchRethrow) { + // error.TryCatchRethrow is a control flow (sorry!), not an actual + // error we want to log + log.warn(.js, "call caught", .{ .err = err, .caught = caught }); + } return err; }; } From df0473420c9daf58105facb40782c27eabd73504 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 13 Jul 2026 13:17:53 +0800 Subject: [PATCH 031/218] ops: Reduce logging of JsException on event dispatch Event dispatch using a try/catch (tryCallWithThis) so that the EventManager can decide how to handle the error. Previously, using the `callWithThis` would result in always logging the error. Now, the EventManager can skip logging JsExceptions. --- src/browser/EventManager.zig | 10 ++++++---- src/browser/EventManagerBase.zig | 16 +++++++++------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/browser/EventManager.zig b/src/browser/EventManager.zig index dada38b5c..bd7994229 100644 --- a/src/browser/EventManager.zig +++ b/src/browser/EventManager.zig @@ -286,8 +286,9 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts // Inline handlers (e.g. onclick property) follow the same "report, // don't propagate" rule as addEventListener listeners — see Listener.run. - ls.toLocal(inline_handler).callWithThis(void, target_et, .{event}) catch |err| { - log.warn(.event, "inline handler", .{ .err = err }); + var caught: js.TryCatch.Caught = undefined; + ls.toLocal(inline_handler).tryCallWithThis(void, target_et, .{event}, &caught) catch |err| { + log.warn(.event, "inline handler", .{ .err = err, .caught = caught }); }; if (event._stop_propagation) { @@ -325,8 +326,9 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts event._target = getAdjustedTarget(original_target, current_target); } - ls.toLocal(inline_handler).callWithThis(void, current_target, .{event}) catch |err| { - log.warn(.event, "inline handler", .{ .err = err }); + var caught: js.TryCatch.Caught = undefined; + ls.toLocal(inline_handler).tryCallWithThis(void, current_target, .{event}, &caught) catch |err| { + log.warn(.event, "inline handler", .{ .err = err, .caught = caught }); }; if (event._needs_retargeting) { diff --git a/src/browser/EventManagerBase.zig b/src/browser/EventManagerBase.zig index 335a98dc0..60de091f6 100644 --- a/src/browser/EventManagerBase.zig +++ b/src/browser/EventManagerBase.zig @@ -287,11 +287,12 @@ pub fn dispatchDirect( // Call the property handler (e.g., onmessage) if present if (getFunction(handler, &ls.local)) |func| { event._current_target = target; - _ = func.callWithThis(void, target, .{event}) catch |err| { + var caught: js.TryCatch.Caught = undefined; + _ = func.tryCallWithThis(void, target, .{event}, &caught) catch |err| { if (err == error.JsException) { event._listeners_did_throw = true; } else { - log.warn(.event, opts.context, .{ .err = err }); + log.warn(.event, opts.context, .{ .err = err, .caught = caught }); } }; } @@ -433,12 +434,13 @@ pub const Listener = struct { event: *Event, comptime context: []const u8, ) error{OutOfMemory}!void { + var caught: js.TryCatch.Caught = undefined; switch (self.function) { - .value => |value| local.toLocal(value).callWithThis(void, event._current_target.?, .{event}) catch |err| { + .value => |value| local.toLocal(value).tryCallWithThis(void, event._current_target.?, .{event}, &caught) catch |err| { if (err == error.JsException) { event._listeners_did_throw = true; } else { - log.warn(.event, context, .{ .err = err }); + log.warn(.event, context, .{ .err = err, .caught = caught }); } }, .string => |string| { @@ -447,7 +449,7 @@ pub const Listener = struct { if (err == error.JsException) { event._listeners_did_throw = true; } else { - log.warn(.event, context, .{ .err = err }); + log.warn(.event, context, .{ .err = err, .caught = caught }); } }; }, @@ -463,11 +465,11 @@ pub const Listener = struct { break :blk null; }; if (handle_event) |handleEvent| { - handleEvent.callWithThis(void, obj, .{event}) catch |err| { + handleEvent.tryCallWithThis(void, obj, .{event}, &caught) catch |err| { if (err == error.JsException) { event._listeners_did_throw = true; } else { - log.warn(.event, context, .{ .err = err }); + log.warn(.event, context, .{ .err = err, .caught = caught }); } }; } else { From 6039bc82b8e5a3e4a4b3491cbbff68fb361ad4f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= <1671644+arrufat@users.noreply.github.com> Date: Mon, 13 Jul 2026 09:28:25 +0200 Subject: [PATCH 032/218] docs: correct --host description Co-authored-by: Karl Seguin --- src/help.zon | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/help.zon b/src/help.zon index a8db5cee9..e3d56c0eb 100644 --- a/src/help.zon +++ b/src/help.zon @@ -135,7 +135,7 @@ \\ Path to a JSON file to save cookies to on exit (write-only). \\ Defaults to no cookie saving. \\ --host - \\ Host to bind when --port is set (e.g. 0.0.0.0 for remote access). + \\ Host to bind when --port is set (e.g. 0.0.0.0 for all interfaces). \\ Defaults to "127.0.0.1". \\ --port \\ Serve MCP over HTTP on this port instead of stdio. Clients POST From 2c4803b1cfa7af769e461d273b46b04565202fca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 13 Jul 2026 10:07:37 +0200 Subject: [PATCH 033/218] mcp: ensure clean http server shutdown Track active connections and shut down sockets during deinit to unblock pending reads. Drain the job queue properly before stopping the worker. Also fix `--http-port` references to `--port` in docs and errors. --- README.md | 4 +- src/mcp/HttpServer.zig | 85 +++++++++++++++++++++++++++++++++++++----- src/mcp/tools.zig | 2 +- 3 files changed, 79 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 9ea1552c8..51fe66af8 100644 --- a/README.md +++ b/README.md @@ -203,8 +203,8 @@ Add to your MCP configuration: #### HTTP transport and independent sessions For serving several agents from one process, start the MCP server over HTTP -instead of stdio by giving it a port (add `--host 0.0.0.0` to accept remote -connections): +instead of stdio by giving it a port (add `--host x.x.x.x` to specify the +interface to listen on): ```bash lightpanda mcp --port 9223 diff --git a/src/mcp/HttpServer.zig b/src/mcp/HttpServer.zig index e1ff3d661..afadce161 100644 --- a/src/mcp/HttpServer.zig +++ b/src/mcp/HttpServer.zig @@ -84,6 +84,7 @@ const Queue = struct { cond: std.Thread.Condition = .{}, head: ?*Job = null, tail: ?*Job = null, + closed: std.atomic.Value(bool) = .init(false), fn push(self: *Queue, job: *Job) void { self.mutex.lock(); @@ -95,11 +96,13 @@ const Queue = struct { } /// Pop the next job, waiting at most `timeout_ms`. Returns null on timeout - /// (or spurious wakeup) so the worker can pump idle sessions and retry. + /// (or spurious wakeup) so the worker can pump idle sessions and retry; + /// check `closed` to tell shutdown from timeout. fn pop(self: *Queue, timeout_ms: u64) ?*Job { self.mutex.lock(); defer self.mutex.unlock(); if (self.head == null) { + if (timeout_ms == 0 or self.closed.load(.acquire)) return null; self.cond.timedWait(&self.mutex, timeout_ms * ns_per_ms) catch {}; } const job = self.head orelse return null; @@ -107,13 +110,26 @@ const Queue = struct { if (self.head == null) self.tail = null; return job; } + + fn close(self: *Queue) void { + self.mutex.lock(); + defer self.mutex.unlock(); + self.closed.store(true, .release); + self.cond.signal(); + } }; allocator: std.mem.Allocator, app: *App, queue: Queue = .{}, -stopping: std.atomic.Value(bool) = .init(false), + +// Registration happens in onAccept — the same (network) thread deinit runs +// on — so a connection is always counted and its socket registered before +// deinit can observe either. +active_conns: std.atomic.Value(u32) = .init(0), +conn_mutex: std.Thread.Mutex = .{}, +conns: std.ArrayList(posix.socket_t) = .{}, // The worker owns `server`; other threads must not touch it. worker_thread: std.Thread = undefined, @@ -139,10 +155,26 @@ pub fn init(allocator: std.mem.Allocator, app: *App) !*HttpServer { return self; } +/// Runs after the accept loop has stopped, so no new connection can arrive. +/// Shutting the sockets down unblocks the connection threads' pending reads; +/// the worker must outlive their drain because a connection thread may still +/// be blocked on `job.done`. pub fn deinit(self: *HttpServer) void { - self.stopping.store(true, .release); - self.queue.cond.signal(); // nudge the worker off its idle wait + { + self.conn_mutex.lock(); + defer self.conn_mutex.unlock(); + for (self.conns.items) |socket| { + posix.shutdown(socket, .both) catch {}; + } + } + while (self.active_conns.load(.monotonic) > 0) { + std.Thread.sleep(10 * ns_per_ms); + } + + self.queue.close(); self.worker_thread.join(); + + self.conns.deinit(self.allocator); self.allocator.destroy(self); } @@ -170,14 +202,37 @@ fn onAccept(ctx: *anyopaque, socket: posix.socket_t) void { return; }; + { + self.conn_mutex.lock(); + defer self.conn_mutex.unlock(); + self.conns.append(self.allocator, socket) catch { + posix.close(socket); + return; + }; + } + _ = self.active_conns.fetchAdd(1, .monotonic); + const thread = std.Thread.spawn(.{}, handleConn, .{ self, socket }) catch |err| { log.warn(.mcp, "mcp spawn", .{ .err = err }); + _ = self.active_conns.fetchSub(1, .monotonic); + self.unregister(socket); posix.close(socket); return; }; thread.detach(); } +fn unregister(self: *HttpServer, socket: posix.socket_t) void { + self.conn_mutex.lock(); + defer self.conn_mutex.unlock(); + for (self.conns.items, 0..) |s, i| { + if (s == socket) { + _ = self.conns.swapRemove(i); + break; + } + } +} + fn worker(self: *HttpServer) void { var placeholder: std.Io.Writer.Allocating = .init(self.allocator); defer placeholder.deinit(); @@ -197,12 +252,20 @@ fn worker(self: *HttpServer) void { var arena: std.heap.ArenaAllocator = .init(self.allocator); defer arena.deinit(); - while (!self.stopping.load(.acquire)) { - const wait_ms = server.idle(); - const job = self.queue.pop(wait_ms) orelse continue; + // Drain queued jobs before pumping idle work: idle() enters and ticks + // every live session (blocking up to 25ms each), so pumping between + // every two jobs would cap throughput at one job per full pass. + var wait_ms: u64 = 0; + while (true) { + const job = self.queue.pop(wait_ms) orelse { + if (self.queue.closed.load(.acquire)) break; + wait_ms = server.idle(); + continue; + }; _ = arena.reset(.retain_capacity); process(server, arena.allocator(), job); job.done.set(); + wait_ms = 0; } } @@ -254,8 +317,12 @@ fn isInitialize(arena: std.mem.Allocator, body: []const u8) bool { } fn handleConn(self: *HttpServer, socket: posix.socket_t) void { + defer _ = self.active_conns.fetchSub(1, .monotonic); const stream: std.net.Stream = .{ .handle = socket }; defer stream.close(); + // Runs before close (defers are LIFO): deinit's shutdown sweep must + // never see an fd that has been closed and possibly reused. + defer self.unregister(socket); var recv_buf: [16 * 1024]u8 = undefined; var send_buf: [16 * 1024]u8 = undefined; @@ -269,8 +336,8 @@ fn handleConn(self: *HttpServer, socket: posix.socket_t) void { var out: std.Io.Writer.Allocating = .init(self.allocator); defer out.deinit(); - while (!self.stopping.load(.acquire)) { - var request = http_server.receiveHead() catch return; // peer closed or bad head + while (true) { + var request = http_server.receiveHead() catch return; // peer closed, bad head, or shutdown _ = arena.reset(.retain_capacity); out.clearRetainingCapacity(); self.serve(&out.writer, arena.allocator(), &request) catch return; diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig index a795f708b..df3b7adc7 100644 --- a/src/mcp/tools.zig +++ b/src/mcp/tools.zig @@ -185,7 +185,7 @@ fn handleSave(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arg /// all unsupported, kept uniform so clients see one consistent rule. fn requireMultiSession(server: *Server, id: std.json.Value) !bool { if (server.park_isolates) return true; - try sendToolResultText(server, id, "multiple sessions require the HTTP transport (start with --http-port)", true); + try sendToolResultText(server, id, "multiple sessions require the HTTP transport (start with --port)", true); return false; } From 3e801fb83bfd25926f3002058d45ed93f669e79d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 13 Jul 2026 10:54:13 +0200 Subject: [PATCH 034/218] script: generate the PandaScript skill from the tool schemas The /save script documentation lived as a hand-maintained string in Agent.zig whose primitives table drifted whenever a tool changed. Move it to src/script/skill.zig and render the reference (signatures, option lists, enums, defaults, per-parameter descriptions) from Schema.all() at first use, keeping the curated per-tool notes behind an exhaustive switch on Tool so a new or renamed tool is a compile error until its doc entry exists. The rendered skill is shared by three consumers: - the /save and revision system prompts (built lazily in Agent.zig) - a new mcp://skill/pandascript resource - `zig build skills`, which writes zig-out/skills//SKILL.md with Claude Code frontmatter via a registry-based generator exe, so future Lightpanda skills are one registry entry each Schema.FieldEntry now retains per-parameter schema descriptions, which previously existed only in the raw JSON. --- build.zig | 32 ++++ src/agent/Agent.zig | 163 +++++--------------- src/lightpanda.zig | 1 + src/main_skills.zig | 61 ++++++++ src/mcp/resources.zig | 20 +++ src/script/Schema.zig | 12 ++ src/script/skill.zig | 341 ++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 501 insertions(+), 129 deletions(-) create mode 100644 src/main_skills.zig create mode 100644 src/script/skill.zig diff --git a/build.zig b/build.zig index c0dd98445..26f678679 100644 --- a/build.zig +++ b/build.zig @@ -176,6 +176,38 @@ pub fn build(b: *Build) !void { run_step.dependOn(&run_cmd.step); } + { + // skills generator + const exe = b.addExecutable(.{ + .name = "lightpanda-skills", + .use_llvm = true, + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main_skills.zig"), + .target = target, + .optimize = optimize, + .imports = &.{ + .{ .name = "lightpanda", .module = lightpanda_module }, + }, + }), + }); + + const exe_check = b.addLibrary(.{ + .name = "skills_check", + .root_module = exe.root_module, + }); + check.dependOn(&exe_check.step); + + const run_cmd = b.addRunArtifact(exe); + const out_dir = run_cmd.addOutputDirectoryArg("skills"); + const install = b.addInstallDirectory(.{ + .source_dir = out_dir, + .install_dir = .prefix, + .install_subdir = "skills", + }); + const skills_step = b.step("skills", "Generate LLM skill docs (zig-out/skills//SKILL.md)"); + skills_step.dependOn(&install.step); + } + { // test const tests = b.addTest(.{ diff --git a/src/agent/Agent.zig b/src/agent/Agent.zig index dc02488d1..e9f243879 100644 --- a/src/agent/Agent.zig +++ b/src/agent/Agent.zig @@ -77,133 +77,12 @@ const default_system_prompt = browser_tools.driver_guidance ++ \\ the Credentials section above) before reporting unavailable. ; -/// Skill-like documentation for writing Lightpanda agent script -/// Used in the system prompt of the `/save` command. -const script_skill = - \\# Writing Lightpanda agent scripts - \\ - \\Run with: - \\ - \\```console - \\./lightpanda agent script.js - \\``` - \\ - \\## Mental model (get this right first) - \\ - \\The script runs in its **own V8 context** — neither the page nor Node.js: - \\ - \\- `Page` is the only global. `new Page()` makes a page and `await page.goto(url)` navigates it; every other primitive is a **method on that page**: `const page = new Page(); await page.goto(url); page.extract({...}); page.click(sel);`. - \\- No `window`, `document`, DOM, `localStorage` — read pages with `page.extract(...)`, run page-side JS only via `page.evaluate("...")`. - \\- No `require`, `process`, `fs`, npm. Standard ECMAScript built-ins only (`JSON`, `Map`, template literals, …). - \\- `page.goto(...)` is **async — always `await` it**. Page methods are **synchronous**: `const data = page.extract({...})`, never `await page.extract(...)`. The script body runs as an async function, so top-level `await` is allowed. - \\- **Re-navigating reuses the same page**: `await page.goto(url2)` keeps `page` valid and points it at the new URL, discarding the old page — read it before navigating away. Independent URLs don't share a page: make a `new Page()` for each and load them in parallel (fan-out, best practice 2). - \\- Page `evaluate("...")` cannot see script variables — interpolate values into the string. Script code cannot see page variables. - \\- Variables persist across navigations within one run, so cross-page aggregation is plain JS. - \\- **`return ` is the script's output**, printed automatically (objects/arrays as JSON). End with `return page.extract({...});` or `return results;`. A bare trailing expression is NOT printed; neither is `console.log(JSON.stringify(...))`. - \\ - \\## Primitives - \\ - \\`Page` is the only global; `new Page()` makes a page and everything else is a method on it. - \\ - \\| Call | Notes | - \\|------|-------| - \\| `new Page()` | Makes a page object. No navigation yet — call `page.goto(url)` before any other method. Make several to navigate in parallel (fan-out, best practice 2). | - \\| `await page.goto(url[, { timeout }])` | **Async — must be `await`ed.** Navigates the page (re-navigating reuses the same object). Waits for `load`. Default timeout 10000 ms. Rejects on navigation failure; a **timeout does NOT reject** (the page may still be usable). | - \\| `page.close()` | Marks the page done; later method calls on it error. The page is otherwise reclaimed at script end. | - \\| `page.extract(schema)` | The only primitive returning a real JS value (object/array). See schema below. | - \\| `page.evaluate(script[, { url, timeout, save }])` | Page-side JS escape hatch; returns text (JSON for objects/arrays). | - \\| `page.click(sel)` / `page.hover(sel)` | | - \\| `page.fill(sel, value)` / `page.selectOption(sel, value)` | | - \\| `page.setChecked(sel[, checked])` | `checked` defaults to `true`. | - \\| `page.press(sel, key)` / `page.press(null, key)` / `page.press({ key })` | Selector first! `page.press("Enter")` binds "Enter" to `selector` and fails. | - \\| `page.scroll()` / `page.scroll({ x, y })` | | - \\| `page.waitForSelector(sel[, { timeout }])` | `waitFor*` default timeout 5000 ms. | - \\| `page.waitForScript(js[, { timeout }])` | Re-evaluates page JS until truthy. | - \\| `page.waitForState(state[, { timeout }])` | `"load"`, `"domcontentloaded"`, `"networkalmostidle"`, `"networkidle"`, `"done"`. | - \\ - \\Calling convention: leading positionals + optional trailing options object, or one object with everything (`page.waitForSelector("#row", { timeout: 2000 })` ≡ `page.waitForSelector({ selector: "#row", timeout: 2000 })`). A bare option positional (`page.waitForSelector("#row", 2000)`) and a field passed both ways are `invalid arguments`. `null` skips a positional. Arguments must be JSON-serializable. - \\ - \\CSS selectors only — `backendNodeId`s don't exist here. Standard CSS only: no jQuery `:contains()` or Playwright `:has-text()`. - \\ - \\## extract schema - \\ - \\Keys = output field names; values pick what to lift (not a JSON Schema): - \\ - \\```js - \\const { stories } = page.extract({ - \\ stories: [{ - \\ selector: "tr.athing", // one record per match - \\ limit: 5, - \\ fields: { // resolved relative to each match - \\ title: ".titleline > a", // first match's text (null if missing) - \\ url: { selector: ".titleline > a", attr: "href" }, - \\ text: "" // "" = the matched element's own text - \\ } - \\ }] - \\}); - \\``` - \\ - \\- `"sel"` → first match's text; `["sel"]` → all matches' text; `{ selector, attr }` / `[{ selector, attr }]` → attribute(s); `limit: N` caps any array form. - \\- Every value is a string or null — parse numbers in script logic. - \\- Empty arrays are valid results; if **every** field misses, extract throws ("no schema selector matched any element") → your selectors are wrong, not the page empty. - \\- An object schema always returns an object (destructure it); a bare array schema returns the array directly. - \\- No `save:` option in scripts — keep results in variables. - \\ - \\## Best practices - \\ - \\1. **Navigate, settle, read.** After `await page.goto` on a dynamic page (feeds, search results, comment threads), call `page.waitForState("networkidle")` or `page.waitForSelector(...)` before extracting. Most static pages are complete at `load` — don't wait blindly. - \\2. **List-to-detail — fan out independent pages.** Extract the list, then open one page per item and start every navigation together so the detail pages load in parallel instead of one-after-another: - \\ ```js - \\ const list = new Page(); - \\ await list.goto(listUrl); - \\ const { items } = list.extract({ items: [{ selector: "a.row", fields: { url: { attr: "href" } } }] }); - \\ - \\ const pages = items.map(() => new Page()); - \\ await Promise.all(pages.map((p, i) => p.goto(items[i].url))); // all in flight at once - \\ return pages.map((p, i) => ({ ...items[i], ...p.extract({ /* schema */ }) })); - \\ ``` - \\ - Concurrency is bounded by the HTTP connection pool (40 by default, `--http-max-concurrent`); extra navigations queue rather than fail. For long lists, fan out in batches and `page.close()` each page once read so its memory is reclaimed. - \\ - `Promise.all` rejects the whole batch if any `goto` *fails* (a timeout does not reject); use `Promise.allSettled` when partial results are fine. - \\ - Walk **serially** on one page (`for (const it of items) { await page.goto(it.url); … }`) only when the steps depend on each other — each page decides the next URL, or they share login/session state. - \\3. **`evaluate` is a last resort, not a reading tool.** A `querySelectorAll`-and-parse `page.evaluate` block is always wrong: lift the raw strings with `page.extract`, then trim/split/parse them in top-level JS. Reserve `page.evaluate` for behavior that must run inside the page and no builtin covers — and remember its state dies on every navigation/reload, while script variables persist. - \\4. **Credentials via `$LP_*` placeholders** in any string argument (`page.fill("#pw", "$LP_HN_PASSWORD")`). Never inline a real secret; placeholders resolve inside the Lightpanda process. - \\5. **Unique selectors.** Disambiguate with attributes/position: `input[type="submit"][value="login"]`, not `input[type="submit"]`. - \\6. **Let failures fail.** Primitives throw on error and stop the script — only `try/catch` where you have a real fallback (e.g. optional cookie banner: `try { page.click("#accept") } catch {}`). - \\7. **End with `return `.** `console.log` is for debug output only and doesn't JSON-format objects. - \\8. Modern, readable JS: `const`/`let`, `for (const x of xs)`, template literals, destructuring, 2-space indent. - \\9. **Comment the intent of each block.** Put a one-line `//` comment above each logical step describing what it accomplishes toward the goal (not restating the call). One comment per block, not per line — skip self-evident lines: - \\ ```js - \\ // Load the Hacker News front page - \\ const page = new Page(); - \\ await page.goto("https://news.ycombinator.com"); - \\ - \\ // Pull the top 5 stories (title + link) - \\ const { stories } = page.extract({ stories: [{ selector: "tr.athing", limit: 5, fields: { title: ".titleline > a", url: { selector: ".titleline > a", attr: "href" } } }] }); - \\ - \\ // Open each story page in parallel and read its text - \\ const pages = stories.map(() => new Page()); - \\ await Promise.all(pages.map((p, i) => p.goto(stories[i].url))); - \\ ``` - \\ - \\## Common errors - \\ - \\| Error | Cause / fix | - \\|-------|-------------| - \\| `extract is not defined` (or click/fill/…) | These are methods on the page object, not globals → `const page = new Page(); await page.goto(url); page.extract(...)` | - \\| `Page must be called with new` | `Page(...)` called without `new` → `const page = new Page();` | - \\| `page is not navigated or has been closed` | A method on a fresh `new Page()` (or a closed page) → `await page.goto(url)` first | - \\| `page handle is no longer valid` | Used a page after a later `goto` on the **same** page replaced it → read it before navigating away. Sibling pages from other `new Page()` calls stay valid. | - \\| `document is not defined` | DOM API in script context → use `page.extract` or `page.evaluate` | - \\| `require is not defined` | Not Node.js | - \\| `no page loaded - run page.goto(url) first` | Page method before navigation | - \\| `invalid arguments` | Wrong arity/shape, non-JSON value, or a field set both positionally and in options | - \\| `extract: no schema selector matched any element` | All schema fields missed → fix selectors | - \\| `press` fails with one string arg | Selector-first: use `page.press(null, "Enter")` or `page.press({ key: "Enter" })` | -; - -// Sytem prompt of the `/save` command -// With the save instructions and the skill-like agent script documentation. -const save_system_prompt = browser_tools.save_synthesis_prompt ++ "\n\n" ++ script_skill; +// System prompt of the `/save` command: the save instructions plus the +// script skill (`lp.skill`), whose primitives reference is rendered from +// the tool schemas at first use — hence lazy rather than comptime. +var save_prompts_once = std.once(initSavePrompts); +var save_system_prompt: []const u8 = undefined; +var save_revision_system_prompt: []const u8 = undefined; // Swapped in instead of `save_system_prompt` when the user message carries a // previously saved script: the "this session" framing above would otherwise @@ -216,7 +95,23 @@ const save_revision_note = \\a change, and add what this session contributes. Output the complete \\updated script — never a fragment, diff, or continuation. ; -const save_revision_system_prompt = browser_tools.save_synthesis_prompt ++ "\n" ++ save_revision_note ++ "\n" ++ script_skill; + +/// Panics on OOM like `Schema.all()` — the inputs are static, process-lifetime +/// strings, so failure is a build bug, not a runtime condition. +fn initSavePrompts() void { + const a = std.heap.page_allocator; + save_system_prompt = std.mem.concat(a, u8, &.{ + browser_tools.save_synthesis_prompt, "\n\n", lp.skill.text(), + }) catch @panic("OOM building save prompt"); + save_revision_system_prompt = std.mem.concat(a, u8, &.{ + browser_tools.save_synthesis_prompt, "\n", save_revision_note, "\n", lp.skill.text(), + }) catch @panic("OOM building save prompt"); +} + +fn savePrompt(revision: bool) []const u8 { + save_prompts_once.call(); + return if (revision) save_revision_system_prompt else save_system_prompt; +} const synthesis_prompt = \\You have used your tool budget or cannot finish the exploration. @@ -1212,7 +1107,7 @@ fn synthesizeSaveTo(self: *Agent, arena: std.mem.Allocator, path: []const u8, mo // regular turns keep the driver prompt. (`messages[0]` is the system // message — rollback and prune never touch it.) const plain_system = self.conversation.messages.items[0].content; - self.conversation.messages.items[0].content = if (previous_script != null) save_revision_system_prompt else save_system_prompt; + self.conversation.messages.items[0].content = savePrompt(previous_script != null); defer self.conversation.messages.items[0].content = plain_system; const ma = self.conversation.arena.allocator(); @@ -1934,6 +1829,16 @@ test { _ = settings; } +test "savePrompt: save instructions followed by the rendered script skill" { + const prompt = savePrompt(false); + try std.testing.expect(std.mem.startsWith(u8, prompt, browser_tools.save_synthesis_prompt)); + try std.testing.expect(std.mem.endsWith(u8, prompt, lp.skill.text())); + + const revision = savePrompt(true); + try std.testing.expect(std.mem.indexOf(u8, revision, save_revision_note) != null); + try std.testing.expect(std.mem.endsWith(u8, revision, lp.skill.text())); +} + test "capToolOutput: passes through when under cap" { const ta = std.testing.allocator; const out = capToolOutput(ta, "short"); diff --git a/src/lightpanda.zig b/src/lightpanda.zig index b9e9d9cc6..39e9dc2e9 100644 --- a/src/lightpanda.zig +++ b/src/lightpanda.zig @@ -51,6 +51,7 @@ pub const Command = @import("script/command.zig").Command; pub const Recorder = @import("script/Recorder.zig"); pub const Runtime = @import("script/Runtime.zig"); pub const Schema = @import("script/Schema.zig"); +pub const skill = @import("script/skill.zig"); pub const cookies = @import("cookies.zig"); pub const build_config = @import("build_config"); pub const crash_handler = @import("crash_handler.zig"); diff --git a/src/main_skills.zig b/src/main_skills.zig new file mode 100644 index 000000000..ad504ef16 --- /dev/null +++ b/src/main_skills.zig @@ -0,0 +1,61 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Generates the LLM skill tree (`//SKILL.md`), invoked by +//! `zig build skills`. The documents are rendered from the same code the +//! runtime uses, so they can't go stale. + +const std = @import("std"); +const lp = @import("lightpanda"); + +const Skill = struct { + name: []const u8, + write: *const fn (writer: *std.Io.Writer) std.Io.Writer.Error!void, +}; + +const skills = [_]Skill{ + .{ .name = lp.skill.name, .write = lp.skill.write }, +}; + +pub fn main() !void { + const allocator = std.heap.c_allocator; + + // usage: skills + var args = try std.process.argsWithAllocator(allocator); + _ = args.next(); // executable name + const out_path = args.next() orelse { + std.debug.print("usage: lightpanda-skills \n", .{}); + return error.MissingArgument; + }; + + var out_dir = try std.fs.cwd().makeOpenPath(out_path, .{}); + defer out_dir.close(); + + for (skills) |s| { + var skill_dir = try out_dir.makeOpenPath(s.name, .{}); + defer skill_dir.close(); + + const file = try skill_dir.createFile("SKILL.md", .{}); + defer file.close(); + + var buffer: [4096]u8 = undefined; + var writer = file.writer(&buffer); + try s.write(&writer.interface); + try writer.end(); + } +} diff --git a/src/mcp/resources.zig b/src/mcp/resources.zig index ed413c339..d3a5aafc4 100644 --- a/src/mcp/resources.zig +++ b/src/mcp/resources.zig @@ -19,6 +19,12 @@ pub const resource_list = [_]protocol.Resource{ .description = "The token-efficient markdown representation of the current page", .mimeType = "text/markdown", }, + .{ + .uri = "mcp://skill/pandascript", + .name = "PandaScript skill", + .description = lp.skill.description, + .mimeType = "text/markdown", + }, }; pub fn handleList(server: *Server, req: protocol.Request) !void { @@ -65,11 +71,13 @@ const ResourceStreamingResult = struct { const ResourceUri = enum { @"mcp://page/html", @"mcp://page/markdown", + @"mcp://skill/pandascript", }; const resource_map = std.StaticStringMap(ResourceUri).initComptime(.{ .{ "mcp://page/html", .@"mcp://page/html" }, .{ "mcp://page/markdown", .@"mcp://page/markdown" }, + .{ "mcp://skill/pandascript", .@"mcp://skill/pandascript" }, }); pub fn handleRead(server: *Server, arena: std.mem.Allocator, req: protocol.Request) !void { @@ -86,6 +94,16 @@ pub fn handleRead(server: *Server, arena: std.mem.Allocator, req: protocol.Reque return server.sendError(req_id, .InvalidRequest, "Resource not found"); }; + if (uri == .@"mcp://skill/pandascript") { + return server.sendResult(req_id, .{ + .contents = &.{.{ + .uri = params.uri, + .mimeType = "text/markdown", + .text = lp.skill.text(), + }}, + }); + } + const frame = server.session.currentFrame() orelse { return server.sendError(req_id, .FrameNotLoaded, "Page not loaded"); }; @@ -93,10 +111,12 @@ pub fn handleRead(server: *Server, arena: std.mem.Allocator, req: protocol.Reque const format: Format = switch (uri) { .@"mcp://page/html" => .html, .@"mcp://page/markdown" => .markdown, + .@"mcp://skill/pandascript" => unreachable, }; const mime_type: []const u8 = switch (uri) { .@"mcp://page/html" => "text/html", .@"mcp://page/markdown" => "text/markdown", + .@"mcp://skill/pandascript" => unreachable, }; const result: ResourceStreamingResult = .{ diff --git a/src/script/Schema.zig b/src/script/Schema.zig index 0fdc22a15..d2cf955de 100644 --- a/src/script/Schema.zig +++ b/src/script/Schema.zig @@ -50,6 +50,8 @@ pub const FieldEntry = struct { /// Allowed values when the schema constrains the field with `enum`; empty /// otherwise. The REPL completer/hinter offers these as value suggestions. enum_values: []const []const u8 = &.{}, + /// The schema's per-parameter `description` string; empty when absent. + description: []const u8 = "", /// `backendNodeId` is ephemeral, never replayable. Boolean fields /// matching the schema default are cosmetic noise. @@ -408,6 +410,7 @@ fn buildOne(arena: std.mem.Allocator, tool: BrowserTool, td: BrowserTool.Definit .field_type = fieldTypeOf(entry.value_ptr.*), .default_true = booleanDefaultTrue(entry.value_ptr.*), .enum_values = try enumValuesOf(arena, entry.value_ptr.*), + .description = descriptionOf(entry.value_ptr.*), }; } info.fields = fields; @@ -462,6 +465,13 @@ fn booleanDefaultTrue(value: std.json.Value) bool { return d == .bool and d.bool; } +fn descriptionOf(value: std.json.Value) []const u8 { + if (value != .object) return ""; + const d = value.object.get("description") orelse return ""; + if (d != .string) return ""; + return d.string; +} + fn enumValuesOf(arena: std.mem.Allocator, value: std.json.Value) ![]const []const u8 { if (value != .object) return &.{}; return jsonStringArray(arena, value.object.get("enum") orelse return &.{}); @@ -611,6 +621,8 @@ test "all: comptime tool defs reduce cleanly" { if (std.mem.eql(u8, f.name, "checked")) checked_default_true = f.default_true; } try testing.expect(checked_default_true); + const timeout = goto.findField("timeout").?; + try testing.expect(timeout.description.len > 0); } test "parseValue: single-required positional binds" { diff --git a/src/script/skill.zig b/src/script/skill.zig new file mode 100644 index 000000000..7427fc297 --- /dev/null +++ b/src/script/skill.zig @@ -0,0 +1,341 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Skill documentation for writing PandaScript agent scripts, assembled +//! from hand-written prose and a primitives reference generated from the +//! tool schemas (`Schema.all()`), so signatures, enums, and defaults +//! can't drift from the code. Consumed by the `/save` system prompt and +//! exported as `SKILL.md` by `zig build skills`. + +const std = @import("std"); +const lp = @import("lightpanda"); +const browser_tools = lp.tools; +const Schema = @import("Schema.zig"); + +pub const name = "pandascript"; +pub const description = "Write PandaScript agent scripts (.js) — Lightpanda's replayable browser-automation format, run token-free with `lightpanda agent script.js`."; + +/// The skill body (no frontmatter). Lazily rendered once, process lifetime. +pub fn text() []const u8 { + text_once.call(); + return text_buffer.written(); +} + +/// Frontmatter + body, the on-disk `SKILL.md` shape. +pub fn write(writer: *std.Io.Writer) std.Io.Writer.Error!void { + try writer.print("---\nname: {s}\ndescription: {s}\n---\n\n", .{ name, description }); + try writer.writeAll(text()); +} + +var text_buffer: std.Io.Writer.Allocating = undefined; +var text_once = std.once(initText); + +/// Panics on failure — the inputs are comptime tool defs, so any render +/// error is a build-time bug. +fn initText() void { + text_buffer = .init(std.heap.page_allocator); + render(&text_buffer.writer) catch |err| { + std.debug.panic("failed to render the {s} skill: {s}", .{ name, @errorName(err) }); + }; +} + +fn render(w: *std.Io.Writer) std.Io.Writer.Error!void { + try w.writeAll(prose_head ++ "\n\n"); + try writeReference(w); + try w.writeAll(prose_tail ++ "\n"); +} + +/// The generated primitives reference: one table row per recorded tool +/// plus an options list surfacing the per-parameter schema descriptions. +fn writeReference(w: *std.Io.Writer) std.Io.Writer.Error!void { + try w.writeAll(table_head ++ "\n"); + for (Schema.all()) |*s| { + if (!s.tool.isRecorded()) continue; + try writeRow(w, s); + } + + try w.writeAll("\nOptions (the trailing `{ … }` object; every option may be omitted):\n\n"); + for (Schema.all()) |*s| { + if (!s.tool.isRecorded() or s.tool == .extract) continue; + if (!hasOptions(s)) continue; + try w.print("- `{s}`:", .{s.tool_name}); + for (s.fields) |f| { + if (!isOption(s, f.name)) continue; + try w.print(" `{s}`", .{f.name}); + if (f.description.len > 0) try w.print(" — {s}", .{f.description}); + } + try w.writeAll("\n"); + } + try w.writeAll("\n"); +} + +fn writeRow(w: *std.Io.Writer, s: *const Schema) std.Io.Writer.Error!void { + try w.writeAll("| `"); + if (s.tool.isAsync()) try w.writeAll("await "); + try w.print("page.{s}(", .{s.tool_name}); + for (s.positional, 0..) |p, i| { + const optional = positionalOptional(s, p); + // A trailing optional positional is bracketed; a leading one + // (press's selector) renders plain and its note explains `null`. + if (i > 0) try w.writeAll(if (optional) "[, " else ", "); + try w.writeAll(p); + if (i > 0 and optional) try w.writeAll("]"); + } + // The script form of extract takes the schema as its only argument + // (`Runtime.extractArgs`); every other tool accepts the options object. + if (s.tool != .extract and hasOptions(s)) { + try w.writeAll(if (s.positional.len > 0) "[, { " else "[{ "); + var i: usize = 0; + for (s.fields) |f| { + if (!isOption(s, f.name)) continue; + if (i > 0) try w.writeAll(", "); + try w.writeAll(f.name); + i += 1; + } + try w.writeAll(" }]"); + } + try w.writeAll(")` | "); + + var wrote = false; + if (s.tool.isAsync()) { + try w.writeAll("**Async — must be `await`ed.**"); + wrote = true; + } + const n = note(s.tool); + if (n.len > 0) { + if (wrote) try w.writeAll(" "); + try w.writeAll(n); + wrote = true; + } + for (s.fields) |f| { + if (f.enum_values.len == 0 or std.mem.eql(u8, f.name, "backendNodeId")) continue; + if (wrote) try w.writeAll(" "); + try w.print("`{s}`: one of", .{f.name}); + for (f.enum_values, 0..) |v, i| { + try w.print("{s} `\"{s}\"`", .{ if (i == 0) "" else ",", v }); + } + try w.writeAll("."); + wrote = true; + } + for (s.positional) |p| { + const f = s.findField(p) orelse continue; + if (!f.default_true) continue; + if (wrote) try w.writeAll(" "); + try w.print("`{s}` defaults to `true`.", .{p}); + wrote = true; + } + try w.writeAll(" |\n"); +} + +/// Non-positional schema fields surfacing in the script signature. +/// `backendNodeId` is ephemeral and has no script form. +fn isOption(s: *const Schema, field_name: []const u8) bool { + if (std.mem.eql(u8, field_name, "backendNodeId")) return false; + for (s.positional) |p| { + if (std.mem.eql(u8, p, field_name)) return false; + } + return true; +} + +fn hasOptions(s: *const Schema) bool { + for (s.fields) |f| { + if (isOption(s, f.name)) return true; + } + return false; +} + +/// Whether a positional may be omitted in the script form. The schema marks +/// `selector` optional (backendNodeId alternative), but locator tools have +/// no such alternative in scripts, so their selector is effectively required. +fn positionalOptional(s: *const Schema, p: []const u8) bool { + if (s.findField(p)) |f| { + if (f.default_true) return true; + } + if (std.mem.eql(u8, p, "selector") and s.tool.needsLocator()) return false; + for (s.required) |r| { + if (std.mem.eql(u8, r, p)) return false; + } + return true; +} + +/// Curated per-tool script semantics the JSON schema can't express. +/// Exhaustive so adding or renaming a tool is a compile error until it +/// makes an explicit choice; non-recorded tools have no script form. +fn note(tool: browser_tools.Tool) []const u8 { + return switch (tool) { + .goto => "Navigates the page (re-navigating reuses the same object). Waits for `load`. Rejects on navigation failure; a **timeout does NOT reject** (the page may still be usable). Default timeout 10000 ms.", + .evaluate => "Page-side JS escape hatch; returns text (JSON for objects/arrays).", + .extract => "The only primitive returning a real JS value (object/array). The schema is its only argument. See schema below.", + .waitForSelector => "`waitFor*` default timeout 5000 ms.", + .waitForScript => "Re-evaluates page JS until truthy.", + .waitForState => "", + .press => "Selector first! `page.press(\"Enter\")` binds \"Enter\" to `selector` and fails — use `page.press(null, \"Enter\")` or `page.press({ key: \"Enter\" })`.", + .click, .fill, .scroll, .hover, .selectOption, .setChecked => "", + .search, .markdown, .html, .links, .tree, .nodeDetails, .interactiveElements, .structuredData, .detectForms, .findElement, .consoleLogs, .getUrl, .getCookies, .getEnv => "", + }; +} + +const prose_head = + \\# Writing Lightpanda agent scripts + \\ + \\Run with: + \\ + \\```console + \\./lightpanda agent script.js + \\``` + \\ + \\## Mental model (get this right first) + \\ + \\The script runs in its **own V8 context** — neither the page nor Node.js: + \\ + \\- `Page` is the only global. `new Page()` makes a page and `await page.goto(url)` navigates it; every other primitive is a **method on that page**: `const page = new Page(); await page.goto(url); page.extract({...}); page.click(sel);`. + \\- No `window`, `document`, DOM, `localStorage` — read pages with `page.extract(...)`, run page-side JS only via `page.evaluate("...")`. + \\- No `require`, `process`, `fs`, npm. Standard ECMAScript built-ins only (`JSON`, `Map`, template literals, …). + \\- `page.goto(...)` is **async — always `await` it**. Page methods are **synchronous**: `const data = page.extract({...})`, never `await page.extract(...)`. The script body runs as an async function, so top-level `await` is allowed. + \\- **Re-navigating reuses the same page**: `await page.goto(url2)` keeps `page` valid and points it at the new URL, discarding the old page — read it before navigating away. Independent URLs don't share a page: make a `new Page()` for each and load them in parallel (fan-out, best practice 2). + \\- Page `evaluate("...")` cannot see script variables — interpolate values into the string. Script code cannot see page variables. + \\- Variables persist across navigations within one run, so cross-page aggregation is plain JS. + \\- **`return ` is the script's output**, printed automatically (objects/arrays as JSON). End with `return page.extract({...});` or `return results;`. A bare trailing expression is NOT printed; neither is `console.log(JSON.stringify(...))`. + \\ + \\## Primitives + \\ + \\`Page` is the only global; `new Page()` makes a page and everything else is a method on it. +; + +const table_head = + \\| Call | Notes | + \\|------|-------| + \\| `new Page()` | Makes a page object. No navigation yet — call `page.goto(url)` before any other method. Make several to navigate in parallel (fan-out, best practice 2). | + \\| `page.close()` | Marks the page done; later method calls on it error. The page is otherwise reclaimed at script end. | +; + +const prose_tail = + \\Calling convention: leading positionals + optional trailing options object, or one object with everything (`page.waitForSelector("#row", { timeout: 2000 })` ≡ `page.waitForSelector({ selector: "#row", timeout: 2000 })`). A bare option positional (`page.waitForSelector("#row", 2000)`) and a field passed both ways are `invalid arguments`. `null` skips a positional. Arguments must be JSON-serializable. + \\ + \\CSS selectors only — `backendNodeId`s don't exist here. Standard CSS only: no jQuery `:contains()` or Playwright `:has-text()`. + \\ + \\## extract schema + \\ + \\Keys = output field names; values pick what to lift (not a JSON Schema): + \\ + \\```js + \\const { stories } = page.extract({ + \\ stories: [{ + \\ selector: "tr.athing", // one record per match + \\ limit: 5, + \\ fields: { // resolved relative to each match + \\ title: ".titleline > a", // first match's text (null if missing) + \\ url: { selector: ".titleline > a", attr: "href" }, + \\ text: "" // "" = the matched element's own text + \\ } + \\ }] + \\}); + \\``` + \\ + \\- `"sel"` → first match's text; `["sel"]` → all matches' text; `{ selector, attr }` / `[{ selector, attr }]` → attribute(s); `limit: N` caps any array form. + \\- Every value is a string or null — parse numbers in script logic. + \\- Empty arrays are valid results; if **every** field misses, extract throws ("no schema selector matched any element") → your selectors are wrong, not the page empty. + \\- An object schema always returns an object (destructure it); a bare array schema returns the array directly. + \\- No `save:` option in scripts — keep results in variables. + \\ + \\## Best practices + \\ + \\1. **Navigate, settle, read.** After `await page.goto` on a dynamic page (feeds, search results, comment threads), call `page.waitForState("networkidle")` or `page.waitForSelector(...)` before extracting. Most static pages are complete at `load` — don't wait blindly. + \\2. **List-to-detail — fan out independent pages.** Extract the list, then open one page per item and start every navigation together so the detail pages load in parallel instead of one-after-another: + \\ ```js + \\ const list = new Page(); + \\ await list.goto(listUrl); + \\ const { items } = list.extract({ items: [{ selector: "a.row", fields: { url: { attr: "href" } } }] }); + \\ + \\ const pages = items.map(() => new Page()); + \\ await Promise.all(pages.map((p, i) => p.goto(items[i].url))); // all in flight at once + \\ return pages.map((p, i) => ({ ...items[i], ...p.extract({ /* schema */ }) })); + \\ ``` + \\ - Concurrency is bounded by the HTTP connection pool (40 by default, `--http-max-concurrent`); extra navigations queue rather than fail. For long lists, fan out in batches and `page.close()` each page once read so its memory is reclaimed. + \\ - `Promise.all` rejects the whole batch if any `goto` *fails* (a timeout does not reject); use `Promise.allSettled` when partial results are fine. + \\ - Walk **serially** on one page (`for (const it of items) { await page.goto(it.url); … }`) only when the steps depend on each other — each page decides the next URL, or they share login/session state. + \\3. **`evaluate` is a last resort, not a reading tool.** A `querySelectorAll`-and-parse `page.evaluate` block is always wrong: lift the raw strings with `page.extract`, then trim/split/parse them in top-level JS. Reserve `page.evaluate` for behavior that must run inside the page and no builtin covers — and remember its state dies on every navigation/reload, while script variables persist. + \\4. **Credentials via `$LP_*` placeholders** in any string argument (`page.fill("#pw", "$LP_HN_PASSWORD")`). Never inline a real secret; placeholders resolve inside the Lightpanda process. + \\5. **Unique selectors.** Disambiguate with attributes/position: `input[type="submit"][value="login"]`, not `input[type="submit"]`. + \\6. **Let failures fail.** Primitives throw on error and stop the script — only `try/catch` where you have a real fallback (e.g. optional cookie banner: `try { page.click("#accept") } catch {}`). + \\7. **End with `return `.** `console.log` is for debug output only and doesn't JSON-format objects. + \\8. Modern, readable JS: `const`/`let`, `for (const x of xs)`, template literals, destructuring, 2-space indent. + \\9. **Comment the intent of each block.** Put a one-line `//` comment above each logical step describing what it accomplishes toward the goal (not restating the call). One comment per block, not per line — skip self-evident lines: + \\ ```js + \\ // Load the Hacker News front page + \\ const page = new Page(); + \\ await page.goto("https://news.ycombinator.com"); + \\ + \\ // Pull the top 5 stories (title + link) + \\ const { stories } = page.extract({ stories: [{ selector: "tr.athing", limit: 5, fields: { title: ".titleline > a", url: { selector: ".titleline > a", attr: "href" } } }] }); + \\ + \\ // Open each story page in parallel and read its text + \\ const pages = stories.map(() => new Page()); + \\ await Promise.all(pages.map((p, i) => p.goto(stories[i].url))); + \\ ``` + \\ + \\## Common errors + \\ + \\| Error | Cause / fix | + \\|-------|-------------| + \\| `extract is not defined` (or click/fill/…) | These are methods on the page object, not globals → `const page = new Page(); await page.goto(url); page.extract(...)` | + \\| `Page must be called with new` | `Page(...)` called without `new` → `const page = new Page();` | + \\| `page is not navigated or has been closed` | A method on a fresh `new Page()` (or a closed page) → `await page.goto(url)` first | + \\| `page handle is no longer valid` | Used a page after a later `goto` on the **same** page replaced it → read it before navigating away. Sibling pages from other `new Page()` calls stay valid. | + \\| `document is not defined` | DOM API in script context → use `page.extract` or `page.evaluate` | + \\| `require is not defined` | Not Node.js | + \\| `no page loaded - run page.goto(url) first` | Page method before navigation | + \\| `invalid arguments` | Wrong arity/shape, non-JSON value, or a field set both positionally and in options | + \\| `extract: no schema selector matched any element` | All schema fields missed → fix selectors | + \\| `press` fails with one string arg | Selector-first: use `page.press(null, "Enter")` or `page.press({ key: "Enter" })` | +; + +const testing = @import("../testing.zig"); + +test "skill: every recorded tool is documented, no non-recorded one is" { + const body = text(); + inline for (comptime std.meta.tags(browser_tools.Tool)) |tool| { + const call = "page." ++ @tagName(tool) ++ "("; + const documented = std.mem.indexOf(u8, body, call) != null; + try testing.expect(documented == tool.isRecorded()); + } + try testing.expect(std.mem.indexOf(u8, body, "await page.goto(") != null); +} + +test "skill: golden fragments track the schemas" { + const body = text(); + // waitForState's enum list tracks Config.WaitUntil. + inline for (comptime std.meta.tags(lp.Config.WaitUntil)) |state| { + try testing.expect(std.mem.indexOf(u8, body, "\"" ++ @tagName(state) ++ "\"") != null); + } + try testing.expect(std.mem.indexOf(u8, body, "`checked` defaults to `true`.") != null); + // extract's script form takes the schema as its only argument. + try testing.expect(std.mem.indexOf(u8, body, "page.extract(schema)") != null); + try testing.expect(std.mem.indexOf(u8, body, "page.extract(schema[, ") == null); + try testing.expect(std.mem.indexOf(u8, body, "{ url, timeout, save }") != null); + // backendNodeId has no script form. + try testing.expect(std.mem.indexOf(u8, body, "backendNodeId }") == null); +} + +test "skill: write emits frontmatter" { + var aw: std.Io.Writer.Allocating = .init(testing.allocator); + defer aw.deinit(); + try write(&aw.writer); + try testing.expect(std.mem.startsWith(u8, aw.written(), "---\nname: pandascript\ndescription: ")); + try testing.expect(std.mem.indexOf(u8, aw.written(), "\n---\n\n# Writing Lightpanda agent scripts\n") != null); +} From 23f0d7e988f0028466a2afd4371ae52f10abc461 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 13 Jul 2026 18:46:27 +0800 Subject: [PATCH 035/218] webapi: Improve popup window When deciding what type of window to return, we compare the origin of the popup to its opener. If they match, we return a full Window. For this to work properly we need to store the origin as soon as the window is opened, and not wait until the async response is received. Also, expand CrossOriginWindow to expose more methods, most notably: close. --- src/browser/Frame.zig | 8 ++++++++ src/browser/webapi/Window.zig | 37 +++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index 8fdabe441..5bb3f29da 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -1826,6 +1826,7 @@ pub fn openPopup(self: *Frame, opts: OpenPopupOpts) !*Frame { errdefer popup.deinit(); popup.window._opener = opts.opener; + if (opts.name.len > 0 and !std.ascii.eqlIgnoreCase(opts.name, "_blank") and !std.ascii.eqlIgnoreCase(opts.name, "_self") and @@ -1845,6 +1846,13 @@ pub fn openPopup(self: *Frame, opts: OpenPopupOpts) !*Frame { return err; }; + // A bit hacky. The type of window we return depends on whether or not its + // origin is the same as the opener. I believe that we're supposed to do + // this check lazily, per function invocation. But we don't have that in + // place right now. So the best we can do is set the origin now, before the + // async navigation completed. + try popup.js.setOrigin(popup.origin); + return popup; } diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index ecbb3e182..2cb803324 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -695,6 +695,9 @@ pub fn close(self: *Window) void { page.closed_frames.append(page.frame_arena, frame) catch @panic("OOM"); } +pub fn focus(_: *Window) void {} +pub fn blur(_: *Window) void {} + pub fn postMessage(self: *Window, message: js.Value, target_origin: ?[]const u8, transfer: ?[]const *MessagePort, frame: *Frame) !void { // For now, we ignore targetOrigin checking and just dispatch the message // In a full implementation, we would validate the origin @@ -1131,6 +1134,8 @@ pub const JsApi = struct { pub const name = bridge.accessor(Window.getName, Window.setName, .{}); pub const open = bridge.function(Window.open, .{}); pub const close = bridge.function(Window.close, .{}); + pub const focus = bridge.function(Window.focus, .{}); + pub const blur = bridge.function(Window.blur, .{}); pub const alert = bridge.function(struct { fn alert(_: *const Window, message: ?[]const u8, frame: *Frame) void { @@ -1198,6 +1203,30 @@ const CrossOriginWindow = struct { return self.window.getFramesLength(); } + pub fn getWindow(self: *CrossOriginWindow, frame: *Frame) Access { + return Access.init(frame.window, self.window); + } + + pub fn getOpener(self: *CrossOriginWindow, frame: *Frame) ?Access { + return self.window.getOpener(frame); + } + + pub fn getClosed(self: *const CrossOriginWindow) bool { + return self.window.getClosed(); + } + + pub fn close(self: *CrossOriginWindow) void { + self.window.close(); + } + + pub fn focus(self: *CrossOriginWindow) void { + self.window.focus(); + } + + pub fn blur(self: *CrossOriginWindow) void { + self.window.blur(); + } + pub const JsApi = struct { pub const bridge = js.Bridge(CrossOriginWindow); @@ -1208,8 +1237,16 @@ const CrossOriginWindow = struct { }; pub const postMessage = bridge.function(CrossOriginWindow.postMessage, .{}); + pub const close = bridge.function(CrossOriginWindow.close, .{}); + pub const focus = bridge.function(CrossOriginWindow.focus, .{}); + pub const blur = bridge.function(CrossOriginWindow.blur, .{}); + pub const window = bridge.accessor(CrossOriginWindow.getWindow, null, .{}); + pub const self = bridge.accessor(CrossOriginWindow.getWindow, null, .{}); + pub const frames = bridge.accessor(CrossOriginWindow.getWindow, null, .{}); pub const top = bridge.accessor(CrossOriginWindow.getTop, null, .{}); pub const parent = bridge.accessor(CrossOriginWindow.getParent, null, .{}); + pub const opener = bridge.accessor(CrossOriginWindow.getOpener, null, .{}); + pub const closed = bridge.accessor(CrossOriginWindow.getClosed, null, .{}); pub const length = bridge.accessor(CrossOriginWindow.getFramesLength, null, .{}); }; }; From 43b55b801364a4d8d70d7b93151aac6314bd43c9 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 13 Jul 2026 12:44:10 +0800 Subject: [PATCH 036/218] http: Change default max http size to 1GB (from unlimited) --- src/help.zon | 2 +- src/network/HttpClient.zig | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/help.zon b/src/help.zon index e3d56c0eb..0ac6e3de6 100644 --- a/src/help.zon +++ b/src/help.zon @@ -314,7 +314,7 @@ \\ --http-max-response-size \\ Limits the acceptable response size for any request \\ e.g. XHR, fetch, script loading. - \\ Defaults to no limit. + \\ Defaults to 1GB. \\ --http-proxy \\ HTTP proxy for all HTTP requests. \\ username:password may be included for basic auth. diff --git a/src/network/HttpClient.zig b/src/network/HttpClient.zig index 1a60a2966..efc69b615 100644 --- a/src/network/HttpClient.zig +++ b/src/network/HttpClient.zig @@ -215,7 +215,7 @@ pub fn init(self: *Client, allocator: Allocator, network: *Network, cdp: ?*CDP) .use_proxy = http_proxy != null, .http_proxy = http_proxy, .tls_verify = network.config.tlsVerifyHost(), - .max_response_size = network.config.httpMaxResponseSize() orelse std.math.maxInt(u32), + .max_response_size = network.config.httpMaxResponseSize() orelse 1 * 1024 * 1024 * 1024, // 1GB .serve_mode = network.config.mode == .serve, .obey_robots = network.config.obeyRobots(), From 9f47b286662ea6c981e557e665cd5ed16928b4f6 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 13 Jul 2026 20:30:43 +0800 Subject: [PATCH 037/218] webapi: ResizeObserver.observer box option default This should default to "content-box". Not that the value is important, but without the default, we fail if an empty object is passed. --- src/browser/webapi/ResizeObserver.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/browser/webapi/ResizeObserver.zig b/src/browser/webapi/ResizeObserver.zig index 184fc3c6d..29c99003f 100644 --- a/src/browser/webapi/ResizeObserver.zig +++ b/src/browser/webapi/ResizeObserver.zig @@ -30,7 +30,7 @@ fn init(cbk: js.Function) ResizeObserver { } const Options = struct { - box: []const u8, + box: []const u8 = "content-box", }; pub fn observe(self: *const ResizeObserver, element: *Element, options_: ?Options) void { _ = self; From f06681587d735a3bcbe36b429769289a6104b57d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 13 Jul 2026 14:47:09 +0200 Subject: [PATCH 038/218] build: update zenai dependency --- build.zig.zon | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index fe35e87a2..64aff55d1 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -35,8 +35,8 @@ .hash = "sqlite3-3.51.0-DMxLWssOAABZ8cAvU_LfBIbp0kZjm824PU8sSLXpEDdr", }, .zenai = .{ - .url = "git+https://github.com/lightpanda-io/zenai.git#b6425afa98f01bcf555a291f570629bedcd26e2f", - .hash = "zenai-0.0.0-iOY_VAWeBQB79YlLt4KWWp_m-J7-Y9CLwFtjtxmkRIBb", + .url = "git+https://github.com/lightpanda-io/zenai.git#1df180de89ef72535d973af7854bf24dc8ab94a7", + .hash = "zenai-0.0.0-iOY_VPyeBQDnDr5NOUWDK7HWpFTJR8JXZ5UYcwNGEMOu", }, .isocline = .{ .url = "git+https://github.com/arrufat/isocline?ref=lightpanda#832a9fe25f5f4458fcc47b5acc7c21db669c2f47", From dd1fa4135cd99145eb726ad0202a1065f5058870 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 13 Jul 2026 15:37:01 +0200 Subject: [PATCH 039/218] agent: render basic markdown in REPL output Style the assistant's answer in the interactive agent REPL with ANSI escapes: headings, bullet/numbered lists, bold/italic/inline-code/strike, and links. Rendering is gated to the REPL on a real terminal, so `--task`/piped output stays verbatim for programmatic consumption. Adds src/agent/md_term.zig (markdown -> ANSI renderer) and wires it into Terminal.printAssistant. --- src/agent/Terminal.zig | 23 +++++- src/agent/md_term.zig | 176 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 197 insertions(+), 2 deletions(-) create mode 100644 src/agent/md_term.zig diff --git a/src/agent/Terminal.zig b/src/agent/Terminal.zig index c260fc447..b94e5ff95 100644 --- a/src/agent/Terminal.zig +++ b/src/agent/Terminal.zig @@ -24,6 +24,7 @@ const Command = lp.Command; const Schema = lp.Schema; const SlashCommand = @import("SlashCommand.zig"); const Spinner = @import("Spinner.zig"); +const md_term = @import("md_term.zig"); const c = @cImport({ @cInclude("isocline.h"); }); @@ -47,6 +48,8 @@ pub const ansi = struct { pub const bold = "\x1b[1m"; pub const dim = "\x1b[2m"; pub const italic = "\x1b[3m"; + pub const underline = "\x1b[4m"; + pub const strike = "\x1b[9m"; pub const cyan = "\x1b[36m"; pub const green = "\x1b[32m"; pub const yellow = "\x1b[33m"; @@ -70,6 +73,7 @@ verbosity: Verbosity, /// only gates non-interactive runs. repl_arena: ?std.heap.ArenaAllocator, stderr_is_tty: bool, +stdout_is_tty: bool, spinner: Spinner, completion_source: ?CompletionSource = null, /// True while the REPL is in JS mode; set by isocline's mode callback. @@ -181,6 +185,7 @@ pub fn init(allocator: std.mem.Allocator, history_paths: ?HistoryPaths, verbosit .verbosity = verbosity, .repl_arena = if (is_repl) std.heap.ArenaAllocator.init(allocator) else null, .stderr_is_tty = stderr_is_tty, + .stdout_is_tty = std.posix.isatty(std.posix.STDOUT_FILENO), .spinner = .init(is_repl, stderr_is_tty), .history_paths = history_paths, }; @@ -1346,10 +1351,24 @@ test "renderSchemaHint: ghosts enum value once typing, keeps template when empty try std.testing.expectEqualStrings("load", hintStr(renderSchemaHint(schema, "state=", false)).?); } -pub fn printAssistant(_: *Terminal, text: []const u8) void { +test { + _ = md_term; +} + +pub fn printAssistant(self: *Terminal, text: []const u8) void { if (text.len == 0) return; const fd = std.posix.STDOUT_FILENO; - _ = std.posix.write(fd, text) catch {}; + + // Style only for the interactive REPL on a real terminal; `--task`/piped + // output stays verbatim so it can be consumed programmatically. + var arena = std.heap.ArenaAllocator.init(self.allocator); + defer arena.deinit(); + const out = if (self.isRepl() and self.stdout_is_tty) + md_term.render(arena.allocator(), text) catch text + else + text; + + _ = std.posix.write(fd, out) catch {}; _ = std.posix.write(fd, "\n") catch {}; } diff --git a/src/agent/md_term.zig b/src/agent/md_term.zig new file mode 100644 index 000000000..79393c6b0 --- /dev/null +++ b/src/agent/md_term.zig @@ -0,0 +1,176 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); +const ansi = @import("Terminal.zig").ansi; + +/// Render markdown `src` to an ANSI-styled terminal string owned by `arena`. +pub fn render(arena: std.mem.Allocator, src: []const u8) ![]const u8 { + // Output is the input plus a bounded number of short escape sequences. + var aw: std.Io.Writer.Allocating = try .initCapacity(arena, src.len + src.len / 4); + const w = &aw.writer; + + var in_fence = false; + var wrote_any = false; + var it = std.mem.splitScalar(u8, src, '\n'); + while (it.next()) |line| { + // Drop the ``` delimiter entirely: no line, no separator. + if (std.mem.startsWith(u8, std.mem.trimLeft(u8, line, " \t"), "```")) { + in_fence = !in_fence; + continue; + } + if (wrote_any) try w.writeByte('\n'); + wrote_any = true; + try renderLine(w, line, in_fence); + } + return aw.written(); +} + +fn renderLine(w: *std.Io.Writer, line: []const u8, in_fence: bool) !void { + if (in_fence) { + try styled(w, line, ansi.dim); + return; + } + + const indent_len = line.len - std.mem.trimLeft(u8, line, " \t").len; + const indent = line[0..indent_len]; + const trimmed = line[indent_len..]; + + var hashes: usize = 0; + while (hashes < trimmed.len and trimmed[hashes] == '#') hashes += 1; + if (hashes >= 1 and hashes <= 6 and hashes < trimmed.len and trimmed[hashes] == ' ') { + try w.writeAll(ansi.bold); + try renderInline(w, std.mem.trimLeft(u8, trimmed[hashes..], " ")); + try w.writeAll(ansi.reset); + return; + } + + if (trimmed.len >= 2 and (trimmed[0] == '-' or trimmed[0] == '*' or trimmed[0] == '+') and trimmed[1] == ' ') { + try w.writeAll(indent); + try styled(w, "•", ansi.dim); + try w.writeByte(' '); + try renderInline(w, trimmed[2..]); + return; + } + + // Numbered list keeps its marker verbatim; a bullet is normalized to '•'. + var digits: usize = 0; + while (digits < trimmed.len and std.ascii.isDigit(trimmed[digits])) digits += 1; + if (digits > 0 and digits + 1 < trimmed.len and + (trimmed[digits] == '.' or trimmed[digits] == ')') and trimmed[digits + 1] == ' ') + { + try w.writeAll(indent); + try w.writeAll(trimmed[0 .. digits + 2]); + try renderInline(w, trimmed[digits + 2 ..]); + return; + } + + try renderInline(w, line); +} + +fn renderInline(w: *std.Io.Writer, text: []const u8) !void { + var i: usize = 0; + while (i < text.len) { + switch (text[i]) { + '`' => if (std.mem.indexOfPos(u8, text, i + 1, "`")) |end| { + try styled(w, text[i + 1 .. end], ansi.cyan); + i = end + 1; + continue; + }, + '*', '_' => |ch| { + const double = [2]u8{ ch, ch }; + if (i + 1 < text.len and text[i + 1] == ch) { + if (std.mem.indexOfPos(u8, text, i + 2, &double)) |end| { + try styled(w, text[i + 2 .. end], ansi.bold); + i = end + 2; + continue; + } + } else if (std.mem.indexOfScalarPos(u8, text, i + 1, ch)) |end| { + try styled(w, text[i + 1 .. end], ansi.italic); + i = end + 1; + continue; + } + }, + '~' => if (i + 1 < text.len and text[i + 1] == '~') { + if (std.mem.indexOfPos(u8, text, i + 2, "~~")) |end| { + try styled(w, text[i + 2 .. end], ansi.strike); + i = end + 2; + continue; + } + }, + '[' => if (std.mem.indexOfPos(u8, text, i + 1, "](")) |mid| { + if (std.mem.indexOfScalarPos(u8, text, mid + 2, ')')) |end| { + try renderLink(w, text[i + 1 .. mid], text[mid + 2 .. end]); + i = end + 1; + continue; + } + }, + else => {}, + } + try w.writeByte(text[i]); + i += 1; + } +} + +fn styled(w: *std.Io.Writer, inner: []const u8, style: []const u8) !void { + try w.writeAll(style); + try w.writeAll(inner); + try w.writeAll(ansi.reset); +} + +fn renderLink(w: *std.Io.Writer, label: []const u8, url: []const u8) !void { + try styled(w, label, ansi.underline); + try w.print(" {s}({s}){s}", .{ ansi.dim, url, ansi.reset }); +} + +const testing = std.testing; + +fn expectRender(expected: []const u8, src: []const u8) !void { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + try testing.expectEqualStrings(expected, try render(arena.allocator(), src)); +} + +test "md_term: inline styles" { + try expectRender("say \x1b[1mhi\x1b[0m now", "say **hi** now"); + try expectRender("say \x1b[3mhi\x1b[0m now", "say *hi* now"); + try expectRender("say \x1b[3mhi\x1b[0m now", "say _hi_ now"); + try expectRender("run \x1b[36mls\x1b[0m ok", "run `ls` ok"); + try expectRender("no \x1b[9mway\x1b[0m", "no ~~way~~"); +} + +test "md_term: blocks" { + try expectRender("\x1b[1mTitle\x1b[0m", "# Title"); + try expectRender("\x1b[1mSub\x1b[0m", "### Sub"); + try expectRender("\x1b[2m•\x1b[0m item", "- item"); + try expectRender("1. item", "1. item"); +} + +test "md_term: fenced code block" { + try expectRender("\x1b[2mlet x = 1;\x1b[0m", "```\nlet x = 1;\n```"); +} + +test "md_term: link" { + try expectRender("\x1b[4mLP\x1b[0m \x1b[2m(https://x.io)\x1b[0m", "[LP](https://x.io)"); +} + +test "md_term: unterminated markers stay literal" { + try expectRender("say **hi now", "say **hi now"); + try expectRender("a * b", "a * b"); + try expectRender("see [x](y", "see [x](y"); +} From 98b89250aa61207a64548dcac246e2dacd1f4fed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 13 Jul 2026 15:46:38 +0200 Subject: [PATCH 040/218] agent: add OSC 8 links, blockquotes, rules, escapes to REPL markdown - Links now emit OSC 8 hyperlinks (clickable where supported) with a dim url fallback; a bare link (label == url) drops the redundant suffix. - Blockquote lines (`> `) render with a dim bar. - Horizontal rules (`---`/`***`/`___`) render as a dim line. - Backslash escapes unescape only markdown-special chars, leaving e.g. C:\\Users intact. --- src/agent/md_term.zig | 73 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 71 insertions(+), 2 deletions(-) diff --git a/src/agent/md_term.zig b/src/agent/md_term.zig index 79393c6b0..7c7124b09 100644 --- a/src/agent/md_term.zig +++ b/src/agent/md_term.zig @@ -51,6 +51,18 @@ fn renderLine(w: *std.Io.Writer, line: []const u8, in_fence: bool) !void { const indent = line[0..indent_len]; const trimmed = line[indent_len..]; + if (trimmed.len >= 1 and trimmed[0] == '>') { + try styled(w, "│", ansi.dim); + try w.writeByte(' '); + try renderInline(w, std.mem.trimLeft(u8, trimmed[1..], " ")); + return; + } + + if (isHorizontalRule(trimmed)) { + try styled(w, "─" ** 24, ansi.dim); + return; + } + var hashes: usize = 0; while (hashes < trimmed.len and trimmed[hashes] == '#') hashes += 1; if (hashes >= 1 and hashes <= 6 and hashes < trimmed.len and trimmed[hashes] == ' ') { @@ -87,6 +99,12 @@ fn renderInline(w: *std.Io.Writer, text: []const u8) !void { var i: usize = 0; while (i < text.len) { switch (text[i]) { + // Only unescape markdown-special chars; leave e.g. `C:\Users` intact. + '\\' => if (i + 1 < text.len and isEscapable(text[i + 1])) { + try w.writeByte(text[i + 1]); + i += 2; + continue; + }, '`' => if (std.mem.indexOfPos(u8, text, i + 1, "`")) |end| { try styled(w, text[i + 1 .. end], ansi.cyan); i = end + 1; @@ -133,9 +151,37 @@ fn styled(w: *std.Io.Writer, inner: []const u8, style: []const u8) !void { try w.writeAll(ansi.reset); } +fn isEscapable(c: u8) bool { + return switch (c) { + '*', '_', '`', '~', '[', ']', '(', ')', '\\' => true, + else => false, + }; +} + +/// A line of 3+ identical `-`, `*` or `_` markers (spaces ignored). +fn isHorizontalRule(s: []const u8) bool { + var marker: ?u8 = null; + var count: usize = 0; + for (s) |c| switch (c) { + ' ', '\t' => {}, + '-', '*', '_' => { + if (marker) |m| { + if (c != m) return false; + } else marker = c; + count += 1; + }, + else => return false, + }; + return count >= 3; +} + fn renderLink(w: *std.Io.Writer, label: []const u8, url: []const u8) !void { + // OSC 8 makes the label clickable where supported and is ignored elsewhere; + // the trailing dim url is the fallback for terminals without OSC 8. + try w.print("\x1b]8;;{s}\x1b\\", .{url}); try styled(w, label, ansi.underline); - try w.print(" {s}({s}){s}", .{ ansi.dim, url, ansi.reset }); + try w.writeAll("\x1b]8;;\x1b\\"); + if (!std.mem.eql(u8, label, url)) try w.print(" {s}({s}){s}", .{ ansi.dim, url, ansi.reset }); } const testing = std.testing; @@ -166,7 +212,30 @@ test "md_term: fenced code block" { } test "md_term: link" { - try expectRender("\x1b[4mLP\x1b[0m \x1b[2m(https://x.io)\x1b[0m", "[LP](https://x.io)"); + // OSC 8 hyperlink around the label, plus a dim fallback url. + try expectRender( + "\x1b]8;;https://x.io\x1b\\\x1b[4mLP\x1b[0m\x1b]8;;\x1b\\ \x1b[2m(https://x.io)\x1b[0m", + "[LP](https://x.io)", + ); + // A bare link (label == url) omits the redundant suffix. + try expectRender( + "\x1b]8;;https://x.io\x1b\\\x1b[4mhttps://x.io\x1b[0m\x1b]8;;\x1b\\", + "[https://x.io](https://x.io)", + ); +} + +test "md_term: blockquote" { + try expectRender("\x1b[2m│\x1b[0m quoted \x1b[1mnote\x1b[0m", "> quoted **note**"); +} + +test "md_term: horizontal rule" { + try expectRender("\x1b[2m" ++ "─" ** 24 ++ "\x1b[0m", "---"); + try expectRender("\x1b[2m" ++ "─" ** 24 ++ "\x1b[0m", "***"); +} + +test "md_term: backslash escapes" { + try expectRender("a * b", "a \\* b"); + try expectRender("keep \\d and C:\\Users", "keep \\d and C:\\Users"); } test "md_term: unterminated markers stay literal" { From ca1e80a397ab24d5bac6458a508da7c2f4858c70 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 13 Jul 2026 23:23:00 +0800 Subject: [PATCH 041/218] fix: Custom-element constructor parser endless recursion In a custom element, when this.innerHTML = '....' is called, we need to be careful to prevent endless recursion. The html5ever callback used to determine the context element should not invoke the custom-element constructor, else we'll enter an endless loop. This also fixes an ungating problem added with the new HttpClient when a waitForImport can block forever. Both issues were see on a WooCommerce site - though the HttpClient is only due to an earlier HttpClient refactor. --- src/browser/Frame.zig | 6 ++ src/browser/frame/node_factory.zig | 6 ++ src/browser/parser/Parser.zig | 20 ++++++ src/browser/parser/html5ever.zig | 1 + .../context_element_not_reconstructed.html | 69 +++++++++++++++++++ src/html5ever/lib.rs | 27 ++++++-- src/network/HttpClient.zig | 14 +++- 7 files changed, 136 insertions(+), 7 deletions(-) create mode 100644 src/browser/tests/custom_elements/context_element_not_reconstructed.html diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index 16a03f23f..4e3a6de81 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -211,6 +211,12 @@ _customized_builtin_disconnected_callback_invoked: std.AutoHashMapUnmanaged(*Ele // The constructor can access this to get the element being upgraded. _upgrading_element: ?*Node = null, +// Set when materializing the fragment parser's context element. The element +// is never inserted into the tree so if its a custom element ,we must not run +// its constructor (else we'll end up in an endless loop if the constructor +// sets this.innerHTML = '...', which happens). +_skip_custom_element_upgrade: bool = false, + // List of custom elements that were created before their definition was registered _undefined_custom_elements: std.ArrayList(*Element.Html.Custom) = .{}, diff --git a/src/browser/frame/node_factory.zig b/src/browser/frame/node_factory.zig index 95dd36ec9..b93d4a36a 100644 --- a/src/browser/frame/node_factory.zig +++ b/src/browser/frame/node_factory.zig @@ -829,6 +829,12 @@ pub fn createElementNS(frame: *Frame, namespace: Element.Namespace, name: []cons ._definition = definition, }); + // Fragment-parse context element. It will not be inserted and + // we should not run the custom element's constructor. + if (frame._skip_custom_element_upgrade) { + return node; + } + const def = definition orelse { const element = node.as(Element); const custom = element.is(Element.Html.Custom).?; diff --git a/src/browser/parser/Parser.zig b/src/browser/parser/Parser.zig index 700dc5377..159dbe372 100644 --- a/src/browser/parser/Parser.zig +++ b/src/browser/parser/Parser.zig @@ -272,6 +272,7 @@ pub fn parseFragment(self: *Parser, html: []const u8) void { &self.container, self, createElementCallback, + createContextElementCallback, getDataCallback, appendCallback, parseErrorCallback, @@ -439,6 +440,25 @@ fn createXMLElementCallback(ctx: *anyopaque, data: *anyopaque, qname: h5e.QualNa return _createElementCallbackWithDefaultnamespace(ctx, data, qname, attributes, .xml); } +// html5ever_parse_fragment materializes the fragment's context element through +// this dedicated callback, never through createElementCallback. The context +// element is a throwaway: html5ever only queries its name (and, for a +//