Merge branch 'main' into agent

This commit is contained in:
Adrià Arrufat
2026-04-25 09:49:47 +02:00
20 changed files with 464 additions and 253 deletions

View File

@@ -13,7 +13,7 @@ inputs:
zig-v8:
description: 'zig v8 version to install'
required: false
default: 'v0.4.0'
default: 'v0.4.1'
v8:
description: 'v8 version to install'
required: false

View File

@@ -1,4 +1,4 @@
name: nightly build
name: release build
env:
AWS_ACCESS_KEY_ID: ${{ vars.NIGHTLY_BUILD_AWS_ACCESS_ID }}
@@ -23,164 +23,181 @@ permissions:
contents: write
jobs:
build-linux-x86_64:
build-linux:
strategy:
fail-fast: false
matrix:
include:
- arch: x86_64
runner: ubuntu-22.04
cpu_flag: -Dcpu=x86_64
- arch: aarch64
runner: ubuntu-22.04-arm
cpu_flag: -Dcpu=generic
env:
ARCH: x86_64
ARCH: ${{ matrix.arch }}
OS: linux
runs-on: ${{ matrix.runner }}
timeout-minutes: 20
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: ./.github/actions/install
with:
os: ${{env.OS}}
arch: ${{env.ARCH}}
- name: v8 snapshot
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
- name: zig build
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast ${{ matrix.cpu_flag }} ${{ env.VERSION_FLAG }}
- name: Rename binary
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
- name: upload on s3
run: |
export DIR=`git show --no-patch --no-notes --pretty='%cs_%h'`
aws s3 cp --storage-class=GLACIER_IR lightpanda-${{ env.ARCH }}-${{ env.OS }} s3://lpd-nightly-build/${DIR}/lightpanda-${{ env.ARCH }}-${{ env.OS }}
- name: Upload the build
uses: ncipollo/release-action@v1
with:
allowUpdates: true
artifacts: lightpanda-${{ env.ARCH }}-${{ env.OS }}
tag: ${{ env.RELEASE }}
makeLatest: true
- name: Share binary with packaging jobs
uses: actions/upload-artifact@v4
with:
name: lightpanda-${{ env.ARCH }}-${{ env.OS }}
path: lightpanda-${{ env.ARCH }}-${{ env.OS }}
retention-days: 1
build-macos:
strategy:
fail-fast: false
matrix:
include:
# macos-14 runs on arm CPU. see
# https://github.com/actions/runner-images?tab=readme-ov-file
- arch: aarch64
runner: macos-14
- arch: x86_64
runner: macos-14-large
env:
ARCH: ${{ matrix.arch }}
OS: macos
runs-on: ${{ matrix.runner }}
timeout-minutes: 20
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: ./.github/actions/install
with:
os: ${{env.OS}}
arch: ${{env.ARCH}}
- name: v8 snapshot
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
- name: zig build
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast ${{ env.VERSION_FLAG }}
- name: Rename binary
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
- name: upload on s3
run: |
export DIR=`git show --no-patch --no-notes --pretty='%cs_%h'`
aws s3 cp --storage-class=GLACIER_IR lightpanda-${{ env.ARCH }}-${{ env.OS }} s3://lpd-nightly-build/${DIR}/lightpanda-${{ env.ARCH }}-${{ env.OS }}
- name: Upload the build
uses: ncipollo/release-action@v1
with:
allowUpdates: true
artifacts: lightpanda-${{ env.ARCH }}-${{ env.OS }}
tag: ${{ env.RELEASE }}
makeLatest: true
package-archlinux:
if: github.ref_type == 'tag'
strategy:
fail-fast: false
matrix:
arch: [x86_64, aarch64]
env:
ARCH: ${{ matrix.arch }}
OS: linux
needs: build-linux
runs-on: ubuntu-22.04
timeout-minutes: 20
container: archlinux:latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- name: Install packaging deps
run: pacman -Syu --noconfirm --needed base-devel sudo
- name: Download linux binary
uses: actions/download-artifact@v4
with:
fetch-depth: 0
name: lightpanda-${{ env.ARCH }}-${{ env.OS }}
path: .
- uses: ./.github/actions/install
with:
os: ${{env.OS}}
arch: ${{env.ARCH}}
- name: v8 snapshot
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
- name: zig build
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64 ${{ env.VERSION_FLAG }}
- name: Rename binary
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
- name: upload on s3
- name: Build Arch package
run: |
export DIR=`git show --no-patch --no-notes --pretty='%cs_%h'`
aws s3 cp --storage-class=GLACIER_IR lightpanda-${{ env.ARCH }}-${{ env.OS }} s3://lpd-nightly-build/${DIR}/lightpanda-${{ env.ARCH }}-${{ env.OS }}
useradd -m builder
echo "builder ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers
- name: Upload the build
RAW_VERSION="${{ env.RELEASE }}"
PKGVER="${RAW_VERSION#v}"
PKGREL="1"
echo "PKGVER=${PKGVER}" >> "$GITHUB_ENV"
echo "PKGREL=${PKGREL}" >> "$GITHUB_ENV"
mkdir -p pkg
cp lightpanda-${{ env.ARCH }}-${{ env.OS }} pkg/
cp LICENSE pkg/
cat > pkg/PKGBUILD <<EOF
pkgname=lightpanda
pkgver=${PKGVER}
pkgrel=${PKGREL}
pkgdesc="Lightpanda, headless browser built for AI and automation"
arch=('x86_64' 'aarch64')
url="https://lightpanda.io"
license=('AGPL-3.0-or-later')
options=(!strip !debug)
package() {
install -Dm755 "\$startdir/lightpanda-\$CARCH-linux" "\$pkgdir/usr/bin/lightpanda"
install -Dm644 "\$startdir/LICENSE" "\$pkgdir/usr/share/licenses/\$pkgname/LICENSE"
}
EOF
chown -R builder:builder pkg
cd pkg
sudo -u builder env CARCH=${{ env.ARCH }} makepkg -f --noconfirm -A
- name: Upload Arch package to release
uses: ncipollo/release-action@v1
with:
allowUpdates: true
artifacts: lightpanda-${{ env.ARCH }}-${{ env.OS }}
tag: ${{ env.RELEASE }}
makeLatest: true
build-linux-aarch64:
env:
ARCH: aarch64
OS: linux
runs-on: ubuntu-22.04-arm
timeout-minutes: 20
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: ./.github/actions/install
with:
os: ${{env.OS}}
arch: ${{env.ARCH}}
- name: v8 snapshot
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
- name: zig build
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=generic ${{ env.VERSION_FLAG }}
- name: Rename binary
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
- name: upload on s3
run: |
export DIR=`git show --no-patch --no-notes --pretty='%cs_%h'`
aws s3 cp --storage-class=GLACIER_IR lightpanda-${{ env.ARCH }}-${{ env.OS }} s3://lpd-nightly-build/${DIR}/lightpanda-${{ env.ARCH }}-${{ env.OS }}
- name: Upload the build
uses: ncipollo/release-action@v1
with:
allowUpdates: true
artifacts: lightpanda-${{ env.ARCH }}-${{ env.OS }}
tag: ${{ env.RELEASE }}
makeLatest: true
build-macos-aarch64:
env:
ARCH: aarch64
OS: macos
# macos-14 runs on arm CPU. see
# https://github.com/actions/runner-images?tab=readme-ov-file
runs-on: macos-14
timeout-minutes: 20
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: ./.github/actions/install
with:
os: ${{env.OS}}
arch: ${{env.ARCH}}
- name: v8 snapshot
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
- name: zig build
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast ${{ env.VERSION_FLAG }}
- name: Rename binary
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
- name: upload on s3
run: |
export DIR=`git show --no-patch --no-notes --pretty='%cs_%h'`
aws s3 cp --storage-class=GLACIER_IR lightpanda-${{ env.ARCH }}-${{ env.OS }} s3://lpd-nightly-build/${DIR}/lightpanda-${{ env.ARCH }}-${{ env.OS }}
- name: Upload the build
uses: ncipollo/release-action@v1
with:
allowUpdates: true
artifacts: lightpanda-${{ env.ARCH }}-${{ env.OS }}
tag: ${{ env.RELEASE }}
makeLatest: true
build-macos-x86_64:
env:
ARCH: x86_64
OS: macos
runs-on: macos-14-large
timeout-minutes: 20
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: ./.github/actions/install
with:
os: ${{env.OS}}
arch: ${{env.ARCH}}
- name: v8 snapshot
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
- name: zig build
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast ${{ env.VERSION_FLAG }}
- name: Rename binary
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
- name: upload on s3
run: |
export DIR=`git show --no-patch --no-notes --pretty='%cs_%h'`
aws s3 cp --storage-class=GLACIER_IR lightpanda-${{ env.ARCH }}-${{ env.OS }} s3://lpd-nightly-build/${DIR}/lightpanda-${{ env.ARCH }}-${{ env.OS }}
- name: Upload the build
uses: ncipollo/release-action@v1
with:
allowUpdates: true
artifacts: lightpanda-${{ env.ARCH }}-${{ env.OS }}
artifacts: pkg/lightpanda-${{ env.PKGVER }}-${{ env.PKGREL }}-${{ env.ARCH }}.pkg.tar.zst
tag: ${{ env.RELEASE }}
makeLatest: true

View File

@@ -3,7 +3,7 @@ FROM debian:stable-slim
ARG MINISIG=0.12
ARG ZIG_MINISIG=RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U
ARG V8=14.0.365.4
ARG ZIG_V8=v0.4.0
ARG ZIG_V8=v0.4.1
ARG TARGETPLATFORM
RUN apt-get update -yq && \

View File

@@ -5,8 +5,8 @@
.minimum_zig_version = "0.15.2",
.dependencies = .{
.v8 = .{
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.4.0.tar.gz",
.hash = "v8-0.0.0-xddH61yIBAD04dV4CHW0qIFiqbOGvkN_-amGdmgbQ3dU",
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.4.1.tar.gz",
.hash = "v8-0.0.0-xddH672HBAA1hQIa2Uv4mzs_qHC9-Py-M5ssqSSVhWtK",
},
// .v8 = .{ .path = "../zig-v8-fork" },
.brotli = .{

View File

@@ -37,6 +37,12 @@ pub const CDP_MAX_HTTP_REQUEST_SIZE = 4096;
// +140 for the max control packet that might be interleaved in a message
pub const CDP_MAX_MESSAGE_SIZE = 512 * 1024 + 14 + 140;
// TCP keepalive parameters applied to accepted CDP connections.
// Detection window ≈ IDLE + CNT * INTVL = 4 + 3*2 = 10s.
pub const CDP_KEEPALIVE_IDLE_S: c_int = 4;
pub const CDP_KEEPALIVE_INTVL_S: c_int = 2;
pub const CDP_KEEPALIVE_CNT: c_int = 3;
const Config = @This();
fn logFilterScopesValidator(allocator: Allocator, args: *std.process.ArgIterator, list: *std.ArrayList(log.Scope)) !void {
@@ -115,7 +121,7 @@ const Commands = cli.Builder(.{
.{ .name = "host", .type = []const u8, .default = "127.0.0.1" },
.{ .name = "port", .type = u16, .default = 9222 },
.{ .name = "advertise_host", .type = ?[]const u8 },
.{ .name = "timeout", .type = u31, .default = 10 },
.{ .name = "timeout", .type = ?u31 },
.{ .name = "cdp_max_connections", .type = u16, .default = 16 },
.{ .name = "cdp_max_pending_connections", .type = u16, .default = 128 },
},
@@ -319,14 +325,6 @@ pub fn cookieJarFile(self: *const Config) ?[]const u8 {
};
}
pub fn cdpTimeout(self: *const Config) usize {
return switch (self.mode) {
.serve => |opts| if (opts.timeout > 604_800) 604_800_000 else @as(usize, opts.timeout) * 1_000,
.mcp => 10_000, // Default timeout for MCP-CDP
else => unreachable,
};
}
pub fn port(self: *const Config) u16 {
return switch (self.mode) {
.serve => |opts| opts.port,
@@ -665,9 +663,6 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void {
\\ Useful, for example, when --host is 0.0.0.0.
\\ Defaults to --host value
\\
\\--timeout Inactivity timeout in seconds before disconnecting clients
\\ Defaults to 10 (seconds). Limited to 604800 (1 week).
\\
\\--cdp-max-connections
\\ Maximum number of simultaneous CDP connections.
\\ Defaults to 16.
@@ -757,6 +752,9 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void {
pub fn parseArgs(allocator: Allocator) !Config {
const exec_name, const command = try Commands.parse(allocator);
if (command == .serve and command.serve.timeout != null) {
log.warn(.app, "--timeout is deprecated", .{});
}
return .init(allocator, exec_name, command);
}

View File

@@ -83,16 +83,47 @@ pub fn deinit(self: *Server) void {
fn onAccept(ctx: *anyopaque, socket: posix.socket_t) void {
const self: *Server = @ptrCast(@alignCast(ctx));
const timeout_ms: u32 = @intCast(self.app.config.cdpTimeout());
self.spawnWorker(socket, timeout_ms) catch |err| {
self.spawnWorker(socket) catch |err| {
log.err(.app, "CDP spawn", .{ .err = err });
posix.close(socket);
};
}
fn handleConnection(self: *Server, socket: posix.socket_t, timeout_ms: u32) void {
// Liveness is enforced at the TCP layer via keepalive probes sent by the
// kernel. This is transparent to CDP clients — unlike a WebSocket ping, which
// go-rod panics on and chromedp logs as "malformed". Tunables in Config.zig.
fn setTcpKeepalive(socket: posix.socket_t) void {
posix.setsockopt(socket, posix.SOL.SOCKET, posix.SO.KEEPALIVE, &std.mem.toBytes(@as(c_int, 1))) catch |err| {
log.warn(.app, "SO_KEEPALIVE", .{ .err = err });
return;
};
const option = switch (@import("builtin").os.tag) {
.macos, .ios => posix.TCP.KEEPALIVE,
else => posix.TCP.KEEPIDLE,
};
posix.setsockopt(socket, posix.IPPROTO.TCP, option, &std.mem.toBytes(Config.CDP_KEEPALIVE_IDLE_S)) catch |err| {
log.warn(.app, "TCP_KEEPIDLE", .{ .err = err });
};
if (@hasDecl(posix.TCP, "KEEPINTVL")) {
posix.setsockopt(socket, posix.IPPROTO.TCP, posix.TCP.KEEPINTVL, &std.mem.toBytes(Config.CDP_KEEPALIVE_INTVL_S)) catch |err| {
log.warn(.app, "TCP_KEEPINTVL", .{ .err = err });
};
}
if (@hasDecl(posix.TCP, "KEEPCNT")) {
posix.setsockopt(socket, posix.IPPROTO.TCP, posix.TCP.KEEPCNT, &std.mem.toBytes(Config.CDP_KEEPALIVE_CNT)) catch |err| {
log.warn(.app, "TCP_KEEPCNT", .{ .err = err });
};
}
}
fn handleConnection(self: *Server, socket: posix.socket_t) void {
defer posix.close(socket);
setTcpKeepalive(socket);
// Client is HUGE (> 512KB) because it has a large read buffer.
// V8 crashes if this is on the stack (likely related to its size).
const client = self.getClient() catch |err| {
@@ -106,7 +137,6 @@ fn handleConnection(self: *Server, socket: posix.socket_t, timeout_ms: u32) void
self.allocator,
self.app,
self.json_version_response,
timeout_ms,
) catch |err| {
log.err(.app, "CDP client init", .{ .err = err });
return;
@@ -155,7 +185,7 @@ fn unregisterClient(self: *Server, client: *Client) void {
}
}
fn spawnWorker(self: *Server, socket: posix.socket_t, timeout_ms: u32) !void {
fn spawnWorker(self: *Server, socket: posix.socket_t) !void {
if (self.app.shutdown()) {
return error.ShuttingDown;
}
@@ -182,13 +212,13 @@ fn spawnWorker(self: *Server, socket: posix.socket_t, timeout_ms: u32) !void {
}
errdefer _ = self.active_threads.fetchSub(1, .monotonic);
const thread = try std.Thread.spawn(.{}, runWorker, .{ self, socket, timeout_ms });
const thread = try std.Thread.spawn(.{}, runWorker, .{ self, socket });
thread.detach();
}
fn runWorker(self: *Server, socket: posix.socket_t, timeout_ms: u32) void {
fn runWorker(self: *Server, socket: posix.socket_t) void {
defer _ = self.active_threads.fetchSub(1, .monotonic);
handleConnection(self, socket, timeout_ms);
handleConnection(self, socket);
}
fn joinThreads(self: *Server) void {
@@ -216,9 +246,8 @@ pub const Client = struct {
allocator: Allocator,
app: *App,
json_version_response: []const u8,
timeout_ms: u32,
) !Client {
var ws = try Net.WsConnection.init(socket, allocator, json_version_response, timeout_ms);
var ws = try Net.WsConnection.init(socket, allocator, json_version_response);
errdefer ws.deinit();
if (log.enabled(.app, .info)) {
@@ -277,15 +306,19 @@ pub const Client = struct {
fn httpLoop(self: *Client, http: *HttpClient) !void {
lp.assert(self.mode == .http, "Client.httpLoop invalid mode", .{});
// Liveness is enforced by TCP keepalive configured in
// Server.setTcpKeepalive; the kernel closes dead sockets, which
// surfaces as EOF/error from readSocket. The loop blocks for ~24 days
// on each poll rather than tracking app-level timeouts. Capped at
// i32-max because HttpClient.tick narrows to c_int.
const wait_ms: u32 = std.math.maxInt(i32);
while (true) {
const status = http.tick(self.ws.timeout_ms) catch |err| {
const status = http.tick(wait_ms) catch |err| {
log.err(.app, "http tick", .{ .err = err });
return;
};
if (status != .cdp_socket) {
log.info(.app, "CDP timeout", .{});
return;
}
if (status != .cdp_socket) continue;
if (self.readSocket() == false) {
return;
@@ -297,19 +330,15 @@ pub const Client = struct {
}
var cdp = &self.mode.cdp;
const timeout_ms = self.ws.timeout_ms;
while (true) {
const result = cdp.pageWait(timeout_ms) catch |wait_err| switch (wait_err) {
const result = cdp.pageWait(wait_ms) catch |wait_err| switch (wait_err) {
error.NoPage => {
const status = http.tick(timeout_ms) catch |err| {
const status = http.tick(wait_ms) catch |err| {
log.err(.app, "http tick", .{ .err = err });
return;
};
if (status != .cdp_socket) {
log.info(.app, "CDP timeout", .{});
return;
}
if (status != .cdp_socket) continue;
if (self.readSocket() == false) {
return;
}
@@ -324,10 +353,7 @@ pub const Client = struct {
return;
}
},
.done => {
log.info(.app, "CDP timeout", .{});
return;
},
.done => {},
}
}
}

View File

@@ -1668,11 +1668,17 @@ pub fn setNodeOwnerDocument(self: *Frame, node: *Node, owner: *Document) !void {
}
// Recursively sets the owner document for a node and all its descendants
pub fn adoptNodeTree(self: *Frame, node: *Node, new_owner: *Document) !void {
pub fn adoptNodeTree(self: *Frame, node: *Node, old_owner: *Document, new_owner: *Document) !void {
try self.setNodeOwnerDocument(node, new_owner);
// Per spec, adopted steps run on each element after its document is set.
if (node.is(Element)) |el| {
Element.Html.Custom.invokeAdoptedCallbackOnElement(el, old_owner, new_owner, self);
}
var it = node.childrenIterator();
while (it.next()) |child| {
try self.adoptNodeTree(child, new_owner);
try self.adoptNodeTree(child, old_owner, new_owner);
}
}
@@ -2374,6 +2380,7 @@ pub fn createElementNS(self: *Frame, namespace: Element.Namespace, name: []const
attr._name,
null, // old_value is null for initial attributes
attr._value,
null,
self,
);
}
@@ -2470,6 +2477,35 @@ fn populateElementAttributes(self: *Frame, element: *Element, list: anytype) !vo
}
}
// Called when `new MyElement()` is invoked directly in JS (not via the
// customElements.define/upgrade path). `new_target` is the constructor
// function that was used with `new`. We find the matching definition in the
// registry by function identity and allocate a detached Custom element with
// the registered tag name.
pub fn constructCustomElement(self: *Frame, new_target: JS.Function) !*Element {
var it = self.window._custom_elements._definitions.iterator();
const definition = while (it.next()) |entry| {
if (entry.value_ptr.*.constructor.isEqual(new_target)) {
break entry.value_ptr.*;
}
} else return error.IllegalConstructor;
// Customized built-ins (`class Foo extends HTMLDivElement`, etc.) would
// need to allocate the extended HTML type rather than Custom. Not yet
// supported via direct `new` — upgrade path still works for those.
if (definition.isCustomizedBuiltIn()) {
return error.IllegalConstructor;
}
const tag_name = try String.init(self.arena, definition.name, .{});
const node = try self.createHtmlElementT(Element.Html.Custom, .html, @as(?*Element.Attribute.List, null), .{
._proto = undefined,
._tag_name = tag_name,
._definition = definition,
});
return node.as(Element);
}
pub fn createTextNode(self: *Frame, text: []const u8) !*Node {
const cd = try self._factory.node(CData{
._proto = undefined,
@@ -2935,7 +2971,10 @@ pub fn _insertNodeRelative(self: *Frame, comptime from_parser: bool, parent: *No
}
if (opts.child_already_connected and !opts.adopting_to_new_document) {
// The child is already connected in the same document, we don't have to reconnect it
// The child is already connected in the same document, we don't have to reconnect it.
// On cross-document adoption the child has already fired
// disconnectedCallback against the old tree and must re-fire
// connectedCallback for the new tree, so we fall through.
return;
}
@@ -2953,7 +2992,10 @@ pub fn _insertNodeRelative(self: *Frame, comptime from_parser: bool, parent: *No
// Only invoke connectedCallback if the root child is transitioning from
// disconnected to connected. When that happens, all descendants should also
// get connectedCallback invoked (they're becoming connected as a group).
const should_invoke_connected = parent_is_connected and !opts.child_already_connected;
// Cross-document adoption also counts as a transition: the element fired
// disconnectedCallback against the old tree during removeNode and must
// now fire connectedCallback against the new tree.
const should_invoke_connected = parent_is_connected and (!opts.child_already_connected or opts.adopting_to_new_document);
var tw = @import("webapi/TreeWalker.zig").Full.Elements.init(child, .{});
while (tw.next()) |el| {
@@ -2972,7 +3014,7 @@ pub fn attributeChange(self: *Frame, element: *Element, name: String, value: Str
log.err(.bug, "build.attributeChange", .{ .tag = element.getTag(), .name = name, .value = value, .err = err, .type = self._type, .url = self.url });
};
Element.Html.Custom.invokeAttributeChangedCallbackOnElement(element, name, old_value, value, self);
Element.Html.Custom.invokeAttributeChangedCallbackOnElement(element, name, old_value, value, null, self);
var it: ?*std.DoublyLinkedList.Node = self._mutation_observers.first;
while (it) |node| : (it = node.next) {
@@ -2998,7 +3040,7 @@ pub fn attributeRemove(self: *Frame, element: *Element, name: String, old_value:
log.err(.bug, "build.attributeRemove", .{ .tag = element.getTag(), .name = name, .err = err, .type = self._type, .url = self.url });
};
Element.Html.Custom.invokeAttributeChangedCallbackOnElement(element, name, old_value, null, self);
Element.Html.Custom.invokeAttributeChangedCallbackOnElement(element, name, old_value, null, null, self);
var it: ?*std.DoublyLinkedList.Node = self._mutation_observers.first;
while (it) |node| : (it = node.next) {

View File

@@ -1564,10 +1564,18 @@ pub const Transfer = struct {
return error.LocationNotFound;
};
const base_url = try conn.getEffectiveUrl();
const url = try URL.resolve(arena, std.mem.span(base_url), location.value, .{});
try transfer.updateURL(url);
const url: [:0]const u8 = blk: {
if (location.value.len == 0) {
// Might seem silly, but URL.resovle will return location.value as-is
// if empty, and location.value is memory owned by libcurl.
break :blk "";
}
const base_url = try conn.getEffectiveUrl();
break :blk try URL.resolve(arena, std.mem.span(base_url), location.value, .{});
};
try transfer.updateURL(url);
// 301, 302, 303 → change to GET, drop body.
// 307, 308 → keep method and body.
const status = try conn.getResponseCode();

View File

@@ -72,7 +72,21 @@ fn _wait(self: *Runner, comptime is_cdp: bool, opts: WaitOpts) !CDPWaitResult {
.ms = 200,
.until = opts.until,
};
// Periodic V8 GC hint during long waits. V8 is otherwise only nudged on
// session/page teardown (Browser.zig, Page.zig), so a page that stays
// alive for seconds while running heavy JS accumulates wrappers and
// external-ref'd Zig allocations V8 has no reason to drop. `.moderate`
// speeds up incremental GC without stalling the tick.
const gc_hint_period_ns: u64 = std.time.ns_per_s;
var gc_hint_timer = std.time.Timer.start() catch unreachable;
while (true) {
if (gc_hint_timer.read() >= gc_hint_period_ns) {
gc_hint_timer.reset();
self.session.browser.env.memoryPressureNotification(.moderate);
}
const tick_result = self._tick(is_cdp, tick_opts) catch |err| {
switch (err) {
error.JsError => {}, // already logged (with hopefully more context)

View File

@@ -110,6 +110,10 @@ pub const CallOpts = struct {
dom_exception: bool = false,
null_as_undefined: bool = false,
as_typed_array: bool = false,
// Constructor-only. When true, `new.target` is pulled from the
// FunctionCallbackInfo and passed as the first argument to the Zig
// function (as a js.Function). See bridge.Constructor.Opts.
new_target: bool = false,
};
pub fn constructor(self: *Caller, comptime T: type, func: anytype, handle: *const v8.FunctionCallbackInfo, comptime opts: CallOpts) void {
@@ -126,15 +130,20 @@ pub fn constructor(self: *Caller, comptime T: type, func: anytype, handle: *cons
return;
}
self._constructor(func, info) catch |err| {
self._constructor(func, info, opts) catch |err| {
handleError(T, @TypeOf(func), local, err, info, opts);
};
}
fn _constructor(self: *Caller, func: anytype, info: FunctionCallbackInfo) !void {
fn _constructor(self: *Caller, func: anytype, info: FunctionCallbackInfo, comptime opts: CallOpts) !void {
const F = @TypeOf(func);
const local = &self.local;
const args = try getArgs(F, 0, local, info);
const offset: comptime_int = if (opts.new_target) 1 else 0;
var args = try getArgs(F, offset, local, info);
if (comptime opts.new_target) {
const new_target_handle = v8.v8__FunctionCallbackInfo__NewTarget(info.handle).?;
@field(args, "0") = js.Function{ .local = local, .handle = @ptrCast(new_target_handle) };
}
const res = @call(.auto, func, args);
const ReturnType = @typeInfo(F).@"fn".return_type orelse {

View File

@@ -111,6 +111,10 @@ pub const Constructor = struct {
const Opts = struct {
dom_exception: bool = false,
// When true, the constructor function receives `new.target` (as a
// js.Function) as its first parameter. Used by HTMLElement to support
// direct instantiation of custom elements via `new MyElement()`.
new_target: bool = false,
};
fn init(comptime T: type, comptime func: anytype, comptime opts: Opts) Constructor {
@@ -125,6 +129,7 @@ pub const Constructor = struct {
caller.constructor(T, func, handle.?, .{
.dom_exception = opts.dom_exception,
.new_target = opts.new_target,
});
}
}.wrap };

View File

@@ -317,9 +317,12 @@ fn collectLink(
const testing = @import("../testing.zig");
// Caller is responsible for `defer testing.test_session.removePage()` after a
// successful call — the returned StructuredData's slices live in the page's
// call_arena, which is released when the page is removed.
fn testStructuredData(html: []const u8) !StructuredData {
const frame = try testing.test_session.createPage();
defer testing.test_session.removePage();
errdefer testing.test_session.removePage();
const doc = frame.window._document;
const div = try doc.createElement("div", null, frame);
@@ -341,6 +344,7 @@ test "structured_data: json-ld" {
\\{"@context":"https://schema.org","@type":"Article","headline":"Test"}
\\</script>
);
defer testing.test_session.removePage();
try testing.expectEqual(1, data.json_ld.len);
try testing.expect(std.mem.indexOf(u8, data.json_ld[0], "Article") != null);
}
@@ -351,6 +355,7 @@ test "structured_data: multiple json-ld" {
\\<script type="application/ld+json">{"@type":"BreadcrumbList"}</script>
\\<script type="text/javascript">var x = 1;</script>
);
defer testing.test_session.removePage();
try testing.expectEqual(2, data.json_ld.len);
}
@@ -363,6 +368,7 @@ test "structured_data: open graph" {
\\<meta property="og:type" content="article">
\\<meta property="article:published_time" content="2026-03-10">
);
defer testing.test_session.removePage();
try testing.expectEqual(6, data.open_graph.len);
try testing.expectEqual("My Page", findProperty(data.open_graph, "title").?);
try testing.expectEqual("article", findProperty(data.open_graph, "type").?);
@@ -376,6 +382,7 @@ test "structured_data: open graph duplicate keys" {
\\<meta property="og:image" content="https://example.com/img2.jpg">
\\<meta property="og:image" content="https://example.com/img3.jpg">
);
defer testing.test_session.removePage();
// Duplicate keys are preserved as separate Property entries.
try testing.expectEqual(4, data.open_graph.len);
@@ -404,6 +411,7 @@ test "structured_data: twitter card" {
\\<meta name="twitter:site" content="@example">
\\<meta name="twitter:title" content="My Page">
);
defer testing.test_session.removePage();
try testing.expectEqual(3, data.twitter_card.len);
try testing.expectEqual("summary_large_image", findProperty(data.twitter_card, "card").?);
try testing.expectEqual("@example", findProperty(data.twitter_card, "site").?);
@@ -417,6 +425,7 @@ test "structured_data: meta tags" {
\\<meta name="keywords" content="test, example">
\\<meta name="robots" content="index, follow">
);
defer testing.test_session.removePage();
try testing.expectEqual("Page Title", findProperty(data.meta, "title").?);
try testing.expectEqual("A test page", findProperty(data.meta, "description").?);
try testing.expectEqual("Test Author", findProperty(data.meta, "author").?);
@@ -431,6 +440,7 @@ test "structured_data: link elements" {
\\<link rel="manifest" href="/manifest.json">
\\<link rel="stylesheet" href="/style.css">
);
defer testing.test_session.removePage();
try testing.expectEqual(3, data.links.len);
try testing.expectEqual("https://example.com/page", findProperty(data.links, "canonical").?);
// stylesheet should be filtered out
@@ -442,6 +452,7 @@ test "structured_data: alternate links" {
\\<link rel="alternate" href="https://example.com/fr" hreflang="fr" title="French">
\\<link rel="alternate" href="https://example.com/de" hreflang="de">
);
defer testing.test_session.removePage();
try testing.expectEqual(2, data.alternate.len);
try testing.expectEqual("fr", data.alternate[0].hreflang.?);
try testing.expectEqual("French", data.alternate[0].title.?);
@@ -455,6 +466,7 @@ test "structured_data: non-metadata elements ignored" {
\\<p>More text</p>
\\<a href="/link">Link</a>
);
defer testing.test_session.removePage();
try testing.expectEqual(0, data.json_ld.len);
try testing.expectEqual(0, data.open_graph.len);
try testing.expectEqual(0, data.twitter_card.len);
@@ -467,6 +479,7 @@ test "structured_data: charset and http-equiv" {
\\<meta charset="utf-8">
\\<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
);
defer testing.test_session.removePage();
try testing.expectEqual("utf-8", findProperty(data.meta, "charset").?);
try testing.expectEqual("text/html; charset=utf-8", findProperty(data.meta, "Content-Type").?);
}
@@ -480,6 +493,7 @@ test "structured_data: mixed content" {
\\<link rel="canonical" href="https://example.com">
\\<script type="application/ld+json">{"@type":"WebSite"}</script>
);
defer testing.test_session.removePage();
try testing.expectEqual(1, data.json_ld.len);
try testing.expectEqual(1, data.open_graph.len);
try testing.expectEqual(1, data.twitter_card.len);

View File

@@ -0,0 +1,48 @@
<!DOCTYPE html>
<body>
<script src="../testing.js"></script>
<script id="adopted">
{
let calls = [];
class MyElement extends HTMLElement {
connectedCallback() { calls.push('connected'); }
adoptedCallback(oldDoc, newDoc) {
calls.push('adopted');
calls.push(oldDoc === document);
calls.push(newDoc === otherDoc);
}
disconnectedCallback() { calls.push('disconnected'); }
}
customElements.define('my-adopted-element', MyElement);
const otherDoc = document.implementation.createHTMLDocument('other');
// Case 1: adopting a detached element into another document
{
const el = document.createElement('my-adopted-element');
calls = [];
otherDoc.body.appendChild(el);
testing.expectEqual('adopted,true,true,connected', calls.join(','));
}
// Case 2: moving a connected element into another document fires
// disconnected -> adopted -> connected in order.
{
const el = document.createElement('my-adopted-element');
document.body.appendChild(el);
calls = [];
otherDoc.body.appendChild(el);
testing.expectEqual('disconnected,adopted,true,true,connected', calls.join(','));
}
// Case 3: appending into the same document does NOT fire adopted.
{
const el = document.createElement('my-adopted-element');
calls = [];
document.body.appendChild(el);
testing.expectEqual('connected', calls.join(','));
}
}
</script>
</body>

View File

@@ -52,6 +52,40 @@
const el = document.createElement('no-constructor-element');
testing.expectEqual('NO-CONSTRUCTOR-ELEMENT', el.tagName);
}
{
// Direct instantiation: `new MyElement()` should work for a registered
// autonomous custom element.
class DirectInstantiation extends HTMLElement {
constructor() {
super();
this.init = 'direct';
}
}
customElements.define('direct-instantiation', DirectInstantiation);
const el = new DirectInstantiation();
testing.expectEqual(true, el instanceof DirectInstantiation);
testing.expectEqual(true, el instanceof HTMLElement);
testing.expectEqual('direct-instantiation', el.localName);
testing.expectEqual('DIRECT-INSTANTIATION', el.tagName);
testing.expectEqual('direct', el.init);
}
{
// `new HTMLElement()` directly is illegal (no registered constructor).
let threw = false;
try { new HTMLElement(); } catch (e) { threw = true; }
testing.expectEqual(true, threw);
}
{
// Unregistered subclass of HTMLElement is also illegal.
class Unregistered extends HTMLElement {}
let threw = false;
try { new Unregistered(); } catch (e) { threw = true; }
testing.expectEqual(true, threw);
}
</script>
<div id=clone_container></div>

View File

@@ -195,7 +195,7 @@ pub fn upgradeCustomElement(custom: *Custom, definition: *CustomElementDefinitio
while (attr_it.next()) |attr| {
const name = attr._name;
if (definition.isAttributeObserved(name)) {
custom.invokeAttributeChangedCallback(name, null, attr._value, frame);
custom.invokeAttributeChangedCallback(name, null, attr._value, null, frame);
}
}

View File

@@ -241,12 +241,16 @@ pub fn appendChild(self: *Node, child: *Node, frame: *Frame) !*Node {
if (child._parent) |parent| {
// we can signal removeNode that the child will remain connected
// (when it's appended to self) so that it can be a bit more efficient.
frame.removeNode(parent, child, .{ .will_be_reconnected = self.isConnected() });
// But on cross-document moves the child must fully disconnect from the
// source document (firing disconnectedCallback) before adoption.
frame.removeNode(parent, child, .{
.will_be_reconnected = self.isConnected() and !adopting_to_new_document,
});
}
// Adopt the node tree if moving between documents
if (adopting_to_new_document) {
try frame.adoptNodeTree(child, parent_owner);
try frame.adoptNodeTree(child, child_owner.?, parent_owner);
}
try frame.appendNode(self, child, .{
@@ -591,14 +595,14 @@ pub fn insertBefore(self: *Node, new_node: *Node, ref_node_: ?*Node, frame: *Fra
const adopting_to_new_document = child_owner != null and child_owner.? != parent_owner;
frame.domChanged();
const will_be_reconnected = self.isConnected();
const will_be_reconnected = self.isConnected() and !adopting_to_new_document;
if (new_node._parent) |parent| {
frame.removeNode(parent, new_node, .{ .will_be_reconnected = will_be_reconnected });
}
// Adopt the node tree if moving between documents
if (adopting_to_new_document) {
try frame.adoptNodeTree(new_node, parent_owner);
try frame.adoptNodeTree(new_node, child_owner.?, parent_owner);
}
try frame.insertNodeRelative(

View File

@@ -103,10 +103,17 @@ const HtmlElement = @This();
_type: Type,
_proto: *Element,
// Special constructor for custom elements
pub fn construct(frame: *Frame) !*Element {
const node = frame._upgrading_element orelse return error.IllegalConstructor;
return node.is(Element) orelse return error.IllegalConstructor;
// Special constructor for custom elements.
// Two paths:
// - Upgrade path: customElements.define / createElement / upgrade set
// `_upgrading_element` before calling newInstance, and we just return it.
// - Direct path: `new MyElement()` from user code. `new.target` tells us
// which custom element class was invoked; look it up in the registry.
pub fn construct(new_target: js.Function, frame: *Frame) !*Element {
if (frame._upgrading_element) |node| {
return node.is(Element) orelse return error.IllegalConstructor;
}
return frame.constructCustomElement(new_target);
}
pub const Type = union(enum) {
@@ -286,42 +293,15 @@ pub fn insertAdjacentHTML(
html: []const u8,
frame: *Frame,
) !void {
// Create a new HTMLDocument.
const doc = try frame._factory.document(@import("../HTMLDocument.zig"){
._proto = undefined,
});
const doc_node = doc.asNode();
const arena = try frame.getArena(.medium, "HTML.insertAdjacentHTML");
defer frame.releaseArena(arena);
const Parser = @import("../../parser/Parser.zig");
var parser = Parser.init(arena, doc_node, frame);
parser.parse(html);
// Check if there's parsing error.
if (parser.err) |_| {
return error.Invalid;
}
// The parser wraps content in a document structure:
// - Typical: <html><head>...</head><body>...</body></html>
// - Head-only: <html><head><meta></head></html> (no body)
// - Empty/comments: May have no <html> element at all
const html_node = doc_node.firstChild() orelse return;
const DocumentFragment = @import("../DocumentFragment.zig");
const fragment = (try DocumentFragment.init(frame)).asNode();
try frame.parseHtmlAsChildren(fragment, html);
const target_node, const prev_node = try self.asElement().asNode().findAdjacentNodes(position);
// Iterate through all children of <html> (typically <head> and/or <body>)
// and insert their children (not the containers themselves) into the target.
// This handles both body content AND head-only elements like <meta>, <title>, etc.
var html_children = html_node.childrenIterator();
while (html_children.next()) |container| {
var iter = container.childrenIterator();
while (iter.next()) |child_node| {
_ = try target_node.insertBefore(child_node, prev_node, frame);
}
var iter = fragment.childrenIterator();
while (iter.next()) |child_node| {
_ = try target_node.insertBefore(child_node, prev_node, frame);
}
}
@@ -1225,7 +1205,7 @@ pub const JsApi = struct {
pub var class_id: bridge.ClassId = undefined;
};
pub const constructor = bridge.constructor(HtmlElement.construct, .{});
pub const constructor = bridge.constructor(HtmlElement.construct, .{ .new_target = true });
pub const innerText = bridge.accessor(_innerText, HtmlElement.setInnerText, .{});
fn _innerText(self: *HtmlElement, frame: *const Frame) ![]const u8 {

View File

@@ -24,6 +24,7 @@ const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
const Document = @import("../../Document.zig");
const HtmlElement = @import("../Html.zig");
const CustomElementDefinition = @import("../../CustomElementDefinition.zig");
@@ -66,12 +67,25 @@ pub fn invokeDisconnectedCallback(self: *Custom, frame: *Frame) void {
self.invokeCallback("disconnectedCallback", .{}, frame);
}
pub fn invokeAttributeChangedCallback(self: *Custom, name: String, old_value: ?String, new_value: ?String, frame: *Frame) void {
pub fn invokeAttributeChangedCallback(self: *Custom, name: String, old_value: ?String, new_value: ?String, namespace: ?String, frame: *Frame) void {
const definition = self._definition orelse return;
if (!definition.isAttributeObserved(name)) {
return;
}
self.invokeCallback("attributeChangedCallback", .{ name, old_value, new_value }, frame);
self.invokeCallback("attributeChangedCallback", .{ name, old_value, new_value, namespace }, frame);
}
pub fn invokeAdoptedCallback(self: *Custom, old_document: *Document, new_document: *Document, frame: *Frame) void {
self.invokeCallback("adoptedCallback", .{ old_document, new_document }, frame);
}
pub fn invokeAdoptedCallbackOnElement(element: *Element, old_document: *Document, new_document: *Document, frame: *Frame) void {
if (element.is(Custom)) |custom| {
custom.invokeAdoptedCallback(old_document, new_document, frame);
return;
}
const definition = frame.getCustomizedBuiltInDefinition(element) orelse return;
invokeCallbackOnElement(element, definition, "adoptedCallback", .{ old_document, new_document }, frame);
}
pub fn invokeConnectedCallbackOnElement(comptime from_parser: bool, element: *Element, frame: *Frame) !void {
@@ -146,17 +160,17 @@ pub fn invokeDisconnectedCallbackOnElement(element: *Element, frame: *Frame) voi
invokeCallbackOnElement(element, definition, "disconnectedCallback", .{}, frame);
}
pub fn invokeAttributeChangedCallbackOnElement(element: *Element, name: String, old_value: ?String, new_value: ?String, frame: *Frame) void {
pub fn invokeAttributeChangedCallbackOnElement(element: *Element, name: String, old_value: ?String, new_value: ?String, namespace: ?String, frame: *Frame) void {
// Autonomous custom element
if (element.is(Custom)) |custom| {
custom.invokeAttributeChangedCallback(name, old_value, new_value, frame);
custom.invokeAttributeChangedCallback(name, old_value, new_value, namespace, frame);
return;
}
// Customized built-in element - check if attribute is observed
const definition = frame.getCustomizedBuiltInDefinition(element) orelse return;
if (!definition.isAttributeObserved(name)) return;
invokeCallbackOnElement(element, definition, "attributeChangedCallback", .{ name, old_value, new_value }, frame);
invokeCallbackOnElement(element, definition, "attributeChangedCallback", .{ name, old_value, new_value, namespace }, frame);
}
fn invokeCallbackOnElement(element: *Element, definition: *CustomElementDefinition, comptime callback_name: [:0]const u8, args: anytype, frame: *Frame) void {

View File

@@ -315,7 +315,7 @@ pub fn context() !TestContext {
try posix.setsockopt(pair[1], posix.SOL.SOCKET, posix.SO.RCVBUF, &std.mem.toBytes(@as(c_int, 32_768)));
try posix.setsockopt(pair[1], posix.SOL.SOCKET, posix.SO.SNDBUF, &std.mem.toBytes(@as(c_int, 32_768)));
const client = try Server.Client.init(pair[1], base.arena_allocator, base.test_app, "json-version", 2000);
const client = try Server.Client.init(pair[1], base.arena_allocator, base.test_app, "json-version");
return .{
.client = client,

View File

@@ -319,9 +319,8 @@ pub const WsConnection = struct {
reader: Reader(true),
send_arena: ArenaAllocator,
json_version_response: []const u8,
timeout_ms: u32,
pub fn init(socket: posix.socket_t, allocator: Allocator, json_version_response: []const u8, timeout_ms: u32) !WsConnection {
pub fn init(socket: posix.socket_t, allocator: Allocator, json_version_response: []const u8) !WsConnection {
const socket_flags = try posix.fcntl(socket, posix.F.GETFL, 0);
const nonblocking = @as(u32, @bitCast(posix.O{ .NONBLOCK = true }));
if (builtin.is_test == false) {
@@ -337,7 +336,6 @@ pub const WsConnection = struct {
.reader = reader,
.send_arena = ArenaAllocator.init(allocator),
.json_version_response = json_version_response,
.timeout_ms = timeout_ms,
};
}