37 Commits

Author SHA1 Message Date
Adrià Arrufat
3aeba97fc9 build: add check step to verify compilation 2026-03-27 14:25:17 +09:00
Karl Seguin
0065677273 Merge pull request #2011 from lightpanda-io/mcp-fixes
MCP fixes
2026-03-27 13:02:59 +08:00
Karl Seguin
2e65ae632e Merge pull request #2009 from lightpanda-io/fix/issue-1960
mcp: improve argument parsing error handling
2026-03-27 12:46:34 +08:00
Adrià Arrufat
1d54e6944b mcp: send error response when message is too long 2026-03-27 11:36:18 +09:00
Adrià Arrufat
de32e5cf34 mcp: handle missing request IDs safely 2026-03-27 11:34:06 +09:00
Adrià Arrufat
c8d8ca5e94 mcp: improve error handling in resources and tools
- Handle failures during HTML, Markdown, and link serialization.
- Return MCP internal errors when result serialization fails.
- Refactor resource reading logic for better clarity and consistency.
2026-03-27 11:28:47 +09:00
Adrià Arrufat
da0828620f mcp: improve argument parsing error handling
Closes #1960
2026-03-27 10:04:45 +09:00
Adrià Arrufat
cdd33621e3 Merge pull request #2005 from lightpanda-io/mcp-lp-node-registry
MCP/CDP: unify node registration
2026-03-27 09:36:08 +09:00
Karl Seguin
8001709506 Merge pull request #2002 from lightpanda-io/nikneym/form-data-event
Support `FormDataEvent`
2026-03-27 08:16:32 +08:00
Karl Seguin
a0ae6b4c92 Merge pull request #2008 from buley/feature/fix-scanner-warnings
chore: fix dead code and error swallowing warnings
2026-03-27 08:10:31 +08:00
Karl Seguin
fdf7f5267a Merge pull request #2001 from lightpanda-io/refactor/mcp-tools-dedup
mcp: extract parseOptionalAndGetPage helper
2026-03-27 07:58:18 +08:00
Taylor
88e0b39d6b chore: fix dead code and error swallowing warnings
Fixes issues reported by polyglot-scanner:
- Removed explicit `return` keywords and trailing semicolons to resolve DEAD_CODE/DEAD_BRANCH warnings.
- Replaced `epoch::advance().unwrap()` and `stats::resident::read().unwrap()` with safer alternatives (`drop` and `unwrap_or(0)`) to resolve ERROR_SWALLOW warnings.
- Replaced `let _ = Box::from_raw(...)` with `drop(Box::from_raw(...))` to correctly drop the box while fixing the ERROR_SWALLOW warning.
2026-03-26 09:58:49 -07:00
Pierre Tachoire
f95396a487 Merge pull request #1998 from lightpanda-io/url_origin_fix
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / wba-demo-scripts (push) Has been cancelled
e2e-test / wba-test (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig fmt (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / zig build release (push) Has been cancelled
wpt / build wpt runner (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Improve authority parsing
2026-03-26 17:32:40 +01:00
Pierre Tachoire
d02d05b246 Merge pull request #2004 from lightpanda-io/nikneym/resize-unobserver
`ResizeObserver`: make `unobserve` available in JS context
2026-03-26 16:48:42 +01:00
Pierre Tachoire
7b2d817d0e Merge pull request #2003 from lightpanda-io/nikneym/canvas-access-canvas
`CanvasRenderingContext2D`: make canvas able to access canvas element
2026-03-26 16:48:11 +01:00
Adrià Arrufat
7e778a17d6 MCP/CDP: unify node registration
This fixes a bug in MCP where interactive elements were not assigned
a backendNodeId, preventing agents from clicking or filling them. Also
extracts link collection to a shared browser module.
2026-03-26 23:51:43 +09:00
Pierre Tachoire
a0dd14aaad Merge pull request #1999 from lightpanda-io/wait_until_default
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / wba-demo-scripts (push) Has been cancelled
e2e-test / wba-test (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig fmt (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Fix --wait-until default value.
2026-03-26 15:03:59 +01:00
Halil Durak
d447d1e3c7 ResizeObserver: make unobserve available in JS context 2026-03-26 16:37:17 +03:00
Halil Durak
8684d35394 add tests 2026-03-26 16:35:23 +03:00
Halil Durak
e243f96988 CanvasRenderingContext2D: make canvas able to access canvas element 2026-03-26 16:35:13 +03:00
Pierre Tachoire
7ea8f3f766 Merge pull request #2000 from lightpanda-io/add-pre-version
add a -Dpre_version build flag for custom pre version
2026-03-26 12:06:38 +01:00
Halil Durak
5e6082b5e9 FormDataEvent: add tests 2026-03-26 14:04:03 +03:00
Halil Durak
1befd9a5e8 make comment on SubmitEvent doc-comment 2026-03-26 14:03:51 +03:00
Halil Durak
e103ce0f39 FormDataEvent: initial support 2026-03-26 14:03:33 +03:00
Adrià Arrufat
14fa2da2ad mcp: remove duplicate code in testLoadPage 2026-03-26 19:57:14 +09:00
Pierre Tachoire
28cc60adb0 add a -Dpre_version build flag for custom pre version 2026-03-26 11:52:16 +01:00
Adrià Arrufat
96d24b5dc6 mcp: extract parseOptionalAndGetPage helper
Deduplicate the repeated "parse optional URL, maybe navigate, get page"
pattern across 6 MCP tool handlers (markdown, links, semantic_tree,
interactiveElements, structuredData, detectForms).
2026-03-26 19:44:44 +09:00
Karl Seguin
c14a9ad986 Merge pull request #1992 from navidemad/cdp-page-reload
CDP: implement Page.reload
2026-03-26 18:14:49 +08:00
Karl Seguin
679f2104f4 Fix --wait-until default value.
This was `load`, but it should have been (and was documented as `done`). This
is my fault. Sorry.

Should help with: https://github.com/lightpanda-io/browser/issues/1947#issuecomment-4120597764
2026-03-26 18:06:14 +08:00
Navid EMAD
c6b0c75106 Address review: use arena.dupeZ for URL copy, add try to testing.context()
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 10:09:48 +01:00
Navid EMAD
93485c1ef3 CDP: implement Page.reload
Add `Page.reload` to the CDP Page domain dispatch. Reuses the existing
`page.navigate()` path with `NavigationKind.reload`, matching what
`Location.reload` already does for the JS `location.reload()` API.

Accepts the standard CDP params (`ignoreCache`, `scriptToEvaluateOnLoad`)
per the Chrome DevTools Protocol spec.

The current page URL is copied to the stack before `replacePage()` to
avoid a use-after-free when the old page's arena is freed.

This unblocks CDP clients (Puppeteer, capybara-lightpanda, etc.) that
call `Page.reload` and currently get `UnknownMethod`.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 10:09:48 +01:00
Karl Seguin
0324d5c232 Merge pull request #1997 from lightpanda-io/update-zig-v8
build: bump zig-v8 to v0.3.7
2026-03-26 16:01:40 +08:00
Karl Seguin
0588cc374d Improve authority parsing
Only look for @ within the first part of the url (up to the first possible
separator, i.e /, # or ?). This fixes potentially incorrect (and insecure)
getOrigin and getHost, both of which use the new helper.

Also make port parsing IPv6-aware.
2026-03-26 13:22:56 +08:00
Adrià Arrufat
a75c0cf08d build: bump zig-v8 to v0.3.7 2026-03-26 12:34:10 +09:00
Karl Seguin
2812b8f07c Merge pull request #1991 from lightpanda-io/v8_signature
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / wba-demo-scripts (push) Has been cancelled
e2e-test / wba-test (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig fmt (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Set v8::Signature on FunctionTemplates
2026-03-26 09:27:22 +08:00
Karl Seguin
e2afbec29d update v8 dep 2026-03-26 09:17:32 +08:00
Karl Seguin
a45f9cb810 Set v8::Signature on FunctionTemplates
This causes v8 to verify the receiver of a function, and prevents calling an
accessor or function with the wrong receiver, e.g.:

```
const g = Object.getOwnPropertyDescriptor(Window.prototype, 'document').get;
g.call(null);
```

A few other cleanups in this commit:
1 - Define any accessor with a getter as ReadOnly
2 - Ability to define an accessor with the DontDelete attribute
    (window.document and window.location)
3 - Replace v8__ObjectTemplate__SetAccessorProperty__DEFAULTX overloads with
    new v8__ObjectTemplate__SetAccessorProperty__Config
4 - Remove unnecessary @constCast for FunctionTemplate which can be const
    everywhere.
2026-03-26 09:15:33 +08:00
32 changed files with 796 additions and 252 deletions

View File

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

View File

@@ -7,7 +7,7 @@ env:
AWS_REGION: ${{ vars.NIGHTLY_BUILD_AWS_REGION }}
RELEASE: ${{ github.ref_type == 'tag' && github.ref_name || 'nightly' }}
VERSION: ${{ github.ref_type == 'tag' && github.ref_name || '1.0.0-nightly' }}
VERSION_FLAG: ${{ github.ref_type == 'tag' && format('-Dversion_string={0}', github.ref_name) || format('-Dpre_version={0}', 'nightly') }}
on:
push:
@@ -45,7 +45,7 @@ jobs:
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 -Dversion_string=${{ env.VERSION }}
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 }}
@@ -85,7 +85,7 @@ jobs:
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 -Dversion_string=${{ env.VERSION }}
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 }}
@@ -127,7 +127,7 @@ jobs:
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 -Dversion_string=${{ env.VERSION }}
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 }}
@@ -167,7 +167,7 @@ jobs:
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 -Dversion_string=${{ env.VERSION }}
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 }}

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.3.4
ARG ZIG_V8=v0.3.7
ARG TARGETPLATFORM
RUN apt-get update -yq && \

View File

@@ -85,6 +85,15 @@ pub fn build(b: *Build) !void {
break :blk mod;
};
// Check compilation
const check = b.step("check", "Check if lightpanda compiles");
const check_lib = b.addLibrary(.{
.name = "lightpanda_check",
.root_module = lightpanda_module,
});
check.dependOn(&check_lib.step);
{
// browser
const exe = b.addExecutable(.{
@@ -103,6 +112,12 @@ pub fn build(b: *Build) !void {
});
b.installArtifact(exe);
const exe_check = b.addLibrary(.{
.name = "lightpanda_exe_check",
.root_module = exe.root_module,
});
check.dependOn(&exe_check.step);
const run_cmd = b.addRunArtifact(exe);
if (b.args) |args| {
run_cmd.addArgs(args);
@@ -132,6 +147,12 @@ pub fn build(b: *Build) !void {
});
b.installArtifact(exe);
const exe_check = b.addLibrary(.{
.name = "snapshot_creator_check",
.root_module = exe.root_module,
});
check.dependOn(&exe_check.step);
const run_cmd = b.addRunArtifact(exe);
if (b.args) |args| {
run_cmd.addArgs(args);
@@ -170,6 +191,12 @@ pub fn build(b: *Build) !void {
});
b.installArtifact(exe);
const exe_check = b.addLibrary(.{
.name = "legacy_test_check",
.root_module = exe.root_module,
});
check.dependOn(&exe_check.step);
const run_cmd = b.addRunArtifact(exe);
if (b.args) |args| {
run_cmd.addArgs(args);
@@ -728,8 +755,17 @@ fn resolveVersion(b: *std.Build) std.SemanticVersion {
};
}
const pre_version = b.option([]const u8, "pre_version", "Override the pre version of this build");
const pre = blk: {
if (pre_version) |pre| {
break :blk pre;
}
break :blk lightpanda_version.pre;
};
// If it's a stable release (no pre or build metadata in build.zig.zon), use it as is
if (lightpanda_version.pre == null and lightpanda_version.build == null) return lightpanda_version;
if (pre == null and lightpanda_version.build == null) return lightpanda_version;
// For dev/nightly versions, calculate the commit count and hash
const git_hash_raw = runGit(b, &.{ "rev-parse", "--short", "HEAD" }) catch return lightpanda_version;
@@ -742,7 +778,7 @@ fn resolveVersion(b: *std.Build) std.SemanticVersion {
.major = lightpanda_version.major,
.minor = lightpanda_version.minor,
.patch = lightpanda_version.patch,
.pre = b.fmt("{s}.{s}", .{ lightpanda_version.pre.?, commit_count }),
.pre = b.fmt("{s}.{s}", .{ pre.?, commit_count }),
.build = commit_hash,
};
}

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.3.4.tar.gz",
.hash = "v8-0.0.0-xddH6_F3BAAiFvKY6R1H-gkuQlk19BkDQ0--uZuTrSup",
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.3.7.tar.gz",
.hash = "v8-0.0.0-xddH67uBBAD95hWsPQz3Ni1PlZjdywtPXrGUAp8rSKco",
},
// .v8 = .{ .path = "../zig-v8-fork" },
.brotli = .{

View File

@@ -247,7 +247,7 @@ pub const Fetch = struct {
with_frames: bool = false,
strip: dump.Opts.Strip = .{},
wait_ms: u32 = 5000,
wait_until: WaitUntil = .load,
wait_until: WaitUntil = .done,
};
pub const Common = struct {
@@ -665,7 +665,7 @@ fn parseFetchArgs(
var common: Common = .{};
var strip: dump.Opts.Strip = .{};
var wait_ms: u32 = 5000;
var wait_until: WaitUntil = .load;
var wait_until: WaitUntil = .done;
while (args.next()) |opt| {
if (std.mem.eql(u8, "--wait-ms", opt) or std.mem.eql(u8, "--wait_ms", opt)) {

View File

@@ -357,25 +357,38 @@ pub fn isHTTPS(raw: [:0]const u8) bool {
pub fn getHostname(raw: [:0]const u8) []const u8 {
const host = getHost(raw);
const pos = std.mem.lastIndexOfScalar(u8, host, ':') orelse return host;
return host[0..pos];
const port_sep = findPortSeparator(host) orelse return host;
return host[0..port_sep];
}
pub fn getPort(raw: [:0]const u8) []const u8 {
const host = getHost(raw);
const pos = std.mem.lastIndexOfScalar(u8, host, ':') orelse return "";
if (pos + 1 >= host.len) {
return "";
const port_sep = findPortSeparator(host) orelse return "";
return host[port_sep + 1 ..];
}
// Finds the colon separating host from port, handling IPv6 bracket notation.
// For IPv6 like "[::1]:8080", returns position of ":" after "]".
// For IPv6 like "[::1]" (no port), returns null.
// For regular hosts, returns position of last ":" if followed by digits.
fn findPortSeparator(host: []const u8) ?usize {
if (host.len > 0 and host[0] == '[') {
// IPv6: find closing bracket, port separator must be after it
const bracket_end = std.mem.indexOfScalar(u8, host, ']') orelse return null;
if (bracket_end + 1 < host.len and host[bracket_end + 1] == ':') {
return bracket_end + 1;
}
return null;
}
// Regular host: find last colon and verify it's followed by digits
const pos = std.mem.lastIndexOfScalar(u8, host, ':') orelse return null;
if (pos + 1 >= host.len) return null;
for (host[pos + 1 ..]) |c| {
if (c < '0' or c > '9') {
return "";
if (c < '0' or c > '9') return null;
}
}
return host[pos + 1 ..];
return pos;
}
pub fn getSearch(raw: [:0]const u8) []const u8 {
@@ -403,21 +416,12 @@ pub fn getOrigin(allocator: Allocator, raw: [:0]const u8) !?[]const u8 {
return null;
}
var authority_start = scheme_end + 3;
const has_user_info = if (std.mem.indexOf(u8, raw[authority_start..], "@")) |pos| blk: {
authority_start += pos + 1;
break :blk true;
} else false;
// Find end of authority (start of path/query/fragment or end of string)
const authority_end_relative = std.mem.indexOfAny(u8, raw[authority_start..], "/?#");
const authority_end = if (authority_end_relative) |end|
authority_start + end
else
raw.len;
const auth = parseAuthority(raw) orelse return null;
const has_user_info = auth.has_user_info;
const authority_end = auth.host_end;
// Check for port in the host:port section
const host_part = raw[authority_start..authority_end];
const host_part = auth.getHost(raw);
if (std.mem.lastIndexOfScalar(u8, host_part, ':')) |colon_pos_in_host| {
const port = host_part[colon_pos_in_host + 1 ..];
@@ -458,31 +462,18 @@ pub fn getOrigin(allocator: Allocator, raw: [:0]const u8) !?[]const u8 {
}
fn getUserInfo(raw: [:0]const u8) ?[]const u8 {
const scheme_end = std.mem.indexOf(u8, raw, "://") orelse return null;
const auth = parseAuthority(raw) orelse return null;
if (!auth.has_user_info) return null;
// User info is from authority_start to host_start - 1 (excluding the @)
const scheme_end = std.mem.indexOf(u8, raw, "://").?;
const authority_start = scheme_end + 3;
const pos = std.mem.indexOfScalar(u8, raw[authority_start..], '@') orelse return null;
const path_start = std.mem.indexOfScalarPos(u8, raw, authority_start, '/') orelse raw.len;
const full_pos = authority_start + pos;
if (full_pos < path_start) {
return raw[authority_start..full_pos];
}
return null;
return raw[authority_start .. auth.host_start - 1];
}
pub fn getHost(raw: [:0]const u8) []const u8 {
const scheme_end = std.mem.indexOf(u8, raw, "://") orelse return "";
var authority_start = scheme_end + 3;
if (std.mem.indexOf(u8, raw[authority_start..], "@")) |pos| {
authority_start += pos + 1;
}
const authority = raw[authority_start..];
const path_start = std.mem.indexOfAny(u8, authority, "/?#") orelse return authority;
return authority[0..path_start];
const auth = parseAuthority(raw) orelse return "";
return auth.getHost(raw);
}
// Returns true if these two URLs point to the same document.
@@ -761,6 +752,47 @@ pub fn unescape(arena: Allocator, input: []const u8) ![]const u8 {
return result.items;
}
const AuthorityInfo = struct {
host_start: usize,
host_end: usize,
has_user_info: bool,
fn getHost(self: AuthorityInfo, raw: []const u8) []const u8 {
return raw[self.host_start..self.host_end];
}
};
// Parses the authority component of a URL, correctly handling userinfo.
// Returns null if the URL doesn't have a valid scheme (no "://").
// SECURITY: Only looks for @ within the authority portion (before /?#)
// to prevent path-based @ injection attacks.
fn parseAuthority(raw: []const u8) ?AuthorityInfo {
const scheme_end = std.mem.indexOf(u8, raw, "://") orelse return null;
const authority_start = scheme_end + 3;
// Find end of authority FIRST (start of path/query/fragment or end of string)
const authority_end = if (std.mem.indexOfAny(u8, raw[authority_start..], "/?#")) |end|
authority_start + end
else
raw.len;
// Only look for @ within the authority portion, not in path/query/fragment
const authority_portion = raw[authority_start..authority_end];
if (std.mem.indexOf(u8, authority_portion, "@")) |pos| {
return .{
.host_start = authority_start + pos + 1,
.host_end = authority_end,
.has_user_info = true,
};
}
return .{
.host_start = authority_start,
.host_end = authority_end,
.has_user_info = false,
};
}
const testing = @import("../testing.zig");
test "URL: isCompleteHTTPUrl" {
try testing.expectEqual(true, isCompleteHTTPUrl("http://example.com/about"));
@@ -1429,6 +1461,42 @@ test "URL: getHost" {
try testing.expectEqualSlices(u8, "example.com", getHost("https://user:pass@example.com/page"));
try testing.expectEqualSlices(u8, "example.com:8080", getHost("https://user:pass@example.com:8080/page"));
try testing.expectEqualSlices(u8, "", getHost("not-a-url"));
// SECURITY: @ in path must NOT be treated as userinfo separator
try testing.expectEqualSlices(u8, "evil.example.com", getHost("http://evil.example.com/@victim.example.com/"));
try testing.expectEqualSlices(u8, "evil.example.com", getHost("https://evil.example.com/path/@victim.example.com"));
// IPv6 addresses
try testing.expectEqualSlices(u8, "[::1]:8080", getHost("http://[::1]:8080/path"));
try testing.expectEqualSlices(u8, "[::1]", getHost("http://[::1]/path"));
try testing.expectEqualSlices(u8, "[2001:db8::1]", getHost("https://[2001:db8::1]/"));
}
test "URL: getHostname" {
// Regular hosts
try testing.expectEqualSlices(u8, "example.com", getHostname("https://example.com:8080/path"));
try testing.expectEqualSlices(u8, "example.com", getHostname("https://example.com/path"));
// IPv6 with port
try testing.expectEqualSlices(u8, "[::1]", getHostname("http://[::1]:8080/path"));
// IPv6 without port - must return full bracket notation
try testing.expectEqualSlices(u8, "[::1]", getHostname("http://[::1]/path"));
try testing.expectEqualSlices(u8, "[2001:db8::1]", getHostname("https://[2001:db8::1]/"));
}
test "URL: getPort" {
// Regular hosts
try testing.expectEqualSlices(u8, "8080", getPort("https://example.com:8080/path"));
try testing.expectEqualSlices(u8, "", getPort("https://example.com/path"));
// IPv6 with port
try testing.expectEqualSlices(u8, "8080", getPort("http://[::1]:8080/path"));
try testing.expectEqualSlices(u8, "3000", getPort("http://[2001:db8::1]:3000/"));
// IPv6 without port - colons inside brackets must not be treated as port separator
try testing.expectEqualSlices(u8, "", getPort("http://[::1]/path"));
try testing.expectEqualSlices(u8, "", getPort("https://[2001:db8::1]/"));
}
test "URL: setPathname percent-encodes" {
@@ -1449,3 +1517,56 @@ test "URL: setPathname percent-encodes" {
const result3 = try setPathname("https://example.com/path?a=b#hash", "/new path", allocator);
try testing.expectEqualSlices(u8, "https://example.com/new%20path?a=b#hash", result3);
}
test "URL: getOrigin" {
defer testing.reset();
const Case = struct {
url: [:0]const u8,
expected: ?[]const u8,
};
const cases = [_]Case{
// Basic HTTP/HTTPS origins
.{ .url = "http://example.com/path", .expected = "http://example.com" },
.{ .url = "https://example.com/path", .expected = "https://example.com" },
.{ .url = "https://example.com:8080/path", .expected = "https://example.com:8080" },
// Default ports should be stripped
.{ .url = "http://example.com:80/path", .expected = "http://example.com" },
.{ .url = "https://example.com:443/path", .expected = "https://example.com" },
// User info should be stripped from origin
.{ .url = "http://user:pass@example.com/path", .expected = "http://example.com" },
.{ .url = "https://user@example.com:8080/path", .expected = "https://example.com:8080" },
// Non-HTTP schemes return null
.{ .url = "ftp://example.com/path", .expected = null },
.{ .url = "file:///path/to/file", .expected = null },
.{ .url = "about:blank", .expected = null },
// Query and fragment should not affect origin
.{ .url = "https://example.com?query=1", .expected = "https://example.com" },
.{ .url = "https://example.com#fragment", .expected = "https://example.com" },
.{ .url = "https://example.com/path?q=1#frag", .expected = "https://example.com" },
// SECURITY: @ in path must NOT be treated as userinfo separator
// This would be a Same-Origin Policy bypass if mishandled
.{ .url = "http://evil.example.com/@victim.example.com/", .expected = "http://evil.example.com" },
.{ .url = "https://evil.example.com/path/@victim.example.com/steal", .expected = "https://evil.example.com" },
.{ .url = "http://evil.example.com/@victim.example.com:443/", .expected = "http://evil.example.com" },
// @ in query/fragment must also not affect origin
.{ .url = "https://example.com/path?user=foo@bar.com", .expected = "https://example.com" },
.{ .url = "https://example.com/path#user@host", .expected = "https://example.com" },
};
for (cases) |case| {
const result = try getOrigin(testing.arena_allocator, case.url);
if (case.expected) |expected| {
try testing.expectString(expected, result.?);
} else {
try testing.expectEqual(null, result);
}
}
}

View File

@@ -36,6 +36,7 @@ pub const InteractivityType = enum {
};
pub const InteractiveElement = struct {
backendNodeId: ?u32 = null,
node: *Node,
tag_name: []const u8,
role: ?[]const u8,
@@ -55,6 +56,11 @@ pub const InteractiveElement = struct {
pub fn jsonStringify(self: *const InteractiveElement, jw: anytype) !void {
try jw.beginObject();
if (self.backendNodeId) |id| {
try jw.objectField("backendNodeId");
try jw.write(id);
}
try jw.objectField("tagName");
try jw.write(self.tag_name);
@@ -123,6 +129,15 @@ pub const InteractiveElement = struct {
}
};
/// Populate backendNodeId on each interactive element by registering
/// their nodes in the given registry. Works with both CDP and MCP registries.
pub fn registerNodes(elements: []InteractiveElement, registry: anytype) !void {
for (elements) |*el| {
const registered = try registry.register(el.node);
el.backendNodeId = registered.id;
}
}
/// Collect all interactive elements under `root`.
pub fn collectInteractiveElements(
root: *Node,

View File

@@ -505,6 +505,7 @@ pub const Function = struct {
pub const Opts = struct {
noop: bool = false,
static: bool = false,
deletable: bool = true,
dom_exception: bool = false,
as_typed_array: bool = false,
null_as_undefined: bool = false,

View File

@@ -137,7 +137,7 @@ pub fn create() !Snapshot {
defer v8.v8__HandleScope__DESTRUCT(&handle_scope);
// Create templates (constructors only) FIRST
var templates: [JsApis.len]*v8.FunctionTemplate = undefined;
var templates: [JsApis.len]*const v8.FunctionTemplate = undefined;
inline for (JsApis, 0..) |JsApi, i| {
@setEvalBranchQuota(10_000);
templates[i] = generateConstructor(JsApi, isolate);
@@ -419,7 +419,7 @@ fn collectExternalReferences() [countExternalReferences()]isize {
// via `new ClassName()` - but they could, for example, be created in
// Zig and returned from a function call, which is why we need the
// FunctionTemplate.
fn generateConstructor(comptime JsApi: type, isolate: *v8.Isolate) *v8.FunctionTemplate {
fn generateConstructor(comptime JsApi: type, isolate: *v8.Isolate) *const v8.FunctionTemplate {
const callback = blk: {
if (@hasDecl(JsApi, "constructor")) {
break :blk JsApi.constructor.func;
@@ -429,7 +429,7 @@ fn generateConstructor(comptime JsApi: type, isolate: *v8.Isolate) *v8.FunctionT
break :blk illegalConstructorCallback;
};
const template = @constCast(v8.v8__FunctionTemplate__New__DEFAULT2(isolate, callback).?);
const template = v8.v8__FunctionTemplate__New__DEFAULT2(isolate, callback).?;
{
const internal_field_count = comptime countInternalFields(JsApi);
if (internal_field_count > 0) {
@@ -482,10 +482,15 @@ pub fn countInternalFields(comptime JsApi: type) u8 {
}
// Attaches JsApi members to the prototype template (normal case)
fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *v8.FunctionTemplate) void {
fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *const v8.FunctionTemplate) void {
const instance = v8.v8__FunctionTemplate__InstanceTemplate(template);
const prototype = v8.v8__FunctionTemplate__PrototypeTemplate(template);
// Create a signature that validates the receiver is an instance of this template.
// This prevents crashes when JavaScript extracts a getter/method and calls it
// with the wrong `this` (e.g., documentGetter.call(null)).
const signature = v8.v8__Signature__New(isolate, template);
const declarations = @typeInfo(JsApi).@"struct".decls;
var has_named_index_getter = false;
@@ -497,23 +502,47 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *v8.Functio
switch (definition) {
bridge.Accessor => {
const js_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len));
const getter_callback = @constCast(v8.v8__FunctionTemplate__New__Config(isolate, &.{ .callback = value.getter }).?);
const getter_signature = if (value.static) null else signature;
const getter_callback = v8.v8__FunctionTemplate__New__Config(isolate, &.{
.callback = value.getter,
.signature = getter_signature,
}).?;
const setter_callback = if (value.setter) |setter|
v8.v8__FunctionTemplate__New__Config(isolate, &.{
.callback = setter,
.signature = getter_signature,
}).?
else
null;
var attribute: v8.PropertyAttribute = 0;
if (value.setter == null) {
attribute |= v8.ReadOnly;
}
if (value.deletable == false) {
attribute |= v8.DontDelete;
}
if (value.static) {
v8.v8__Template__SetAccessorProperty__DEFAULT(@ptrCast(template), js_name, getter_callback);
// Static accessors: use Template's SetAccessorProperty
v8.v8__Template__SetAccessorProperty(@ptrCast(template), js_name, getter_callback, setter_callback, attribute);
} else {
v8.v8__ObjectTemplate__SetAccessorProperty__DEFAULT(prototype, js_name, getter_callback);
}
} else {
if (comptime IS_DEBUG) {
std.debug.assert(value.static == false);
}
const setter_callback = @constCast(v8.v8__FunctionTemplate__New__Config(isolate, &.{ .callback = value.setter.? }).?);
v8.v8__ObjectTemplate__SetAccessorProperty__DEFAULT2(prototype, js_name, getter_callback, setter_callback);
v8.v8__ObjectTemplate__SetAccessorProperty__Config(prototype, &.{
.key = js_name,
.getter = getter_callback,
.setter = setter_callback,
.attribute = attribute,
});
}
},
bridge.Function => {
const function_template = @constCast(v8.v8__FunctionTemplate__New__Config(isolate, &.{ .callback = value.func, .length = value.arity }).?);
// For non-static functions, use the signature to validate the receiver
const func_signature = if (value.static) null else signature;
const function_template = v8.v8__FunctionTemplate__New__Config(isolate, &.{
.callback = value.func,
.length = value.arity,
.signature = func_signature,
}).?;
const js_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len));
if (value.static) {
v8.v8__Template__Set(@ptrCast(template), js_name, @ptrCast(function_template), v8.None);
@@ -551,7 +580,7 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *v8.Functio
has_named_index_getter = true;
},
bridge.Iterator => {
const function_template = @constCast(v8.v8__FunctionTemplate__New__Config(isolate, &.{ .callback = value.func }).?);
const function_template = v8.v8__FunctionTemplate__New__Config(isolate, &.{ .callback = value.func }).?;
const js_name = if (value.async)
v8.v8__Symbol__GetAsyncIterator(isolate)
else

View File

@@ -198,6 +198,7 @@ pub const Function = struct {
pub const Accessor = struct {
static: bool = false,
deletable: bool = true,
cache: ?Caller.Function.Opts.Caching = null,
getter: ?*const fn (?*const v8.FunctionCallbackInfo) callconv(.c) void = null,
setter: ?*const fn (?*const v8.FunctionCallbackInfo) callconv(.c) void = null,
@@ -206,6 +207,7 @@ pub const Accessor = struct {
var accessor = Accessor{
.cache = opts.cache,
.static = opts.static,
.deletable = opts.deletable,
};
if (@typeInfo(@TypeOf(getter)) != .null) {
@@ -851,6 +853,7 @@ pub const JsApis = flattenTypes(&.{
@import("../webapi/event/InputEvent.zig"),
@import("../webapi/event/PromiseRejectionEvent.zig"),
@import("../webapi/event/SubmitEvent.zig"),
@import("../webapi/event/FormDataEvent.zig"),
@import("../webapi/MessageChannel.zig"),
@import("../webapi/MessagePort.zig"),
@import("../webapi/media/MediaError.zig"),

54
src/browser/links.zig Normal file
View File

@@ -0,0 +1,54 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
const Element = @import("webapi/Element.zig");
const Node = @import("webapi/Node.zig");
const Page = @import("Page.zig");
const Selector = @import("webapi/selector/Selector.zig");
const Allocator = std.mem.Allocator;
/// Collect all links (href attributes from anchor tags) under `root`.
/// Returns a slice of strings allocated with `arena`.
pub fn collectLinks(arena: Allocator, root: *Node, page: *Page) ![]const []const u8 {
var links: std.ArrayList([]const u8) = .empty;
if (Selector.querySelectorAll(root, "a[href]", page)) |list| {
defer list.deinit(page._session);
for (list._nodes) |node| {
if (node.is(Element.Html.Anchor)) |anchor| {
const href = anchor.getHref(page) catch |err| {
@import("../lightpanda.zig").log.err(.app, "resolve href failed", .{ .err = err });
continue;
};
if (href.len > 0) {
try links.append(arena, href);
}
}
}
} else |err| {
@import("../lightpanda.zig").log.err(.app, "query links failed", .{ .err = err });
return err;
}
return links.items;
}

View File

@@ -125,6 +125,19 @@
</script>
<script id="CanvasRenderingContext2D#canvas">
{
const element = document.createElement("canvas");
const ctx = element.getContext("2d");
testing.expectEqual(ctx.canvas, element);
// Setting dimensions via ctx.canvas should update the element.
ctx.canvas.width = 40;
ctx.canvas.height = 25;
testing.expectEqual(element.width, 40);
testing.expectEqual(element.height, 25);
}
</script>
<script id="getter">
{
const element = document.createElement("canvas");

View File

@@ -734,3 +734,101 @@
testing.expectEqual([['field', 'data'], ['x', '0'], ['y', '0']], entries);
}
</script>
<script id=formDataEventFires>
{
// formdata event fires on the form when FormData is constructed with a form
const form = document.createElement('form');
const input = document.createElement('input');
input.name = 'field';
input.value = 'hello';
form.appendChild(input);
let eventFired = false;
let receivedFormData = null;
form.addEventListener('formdata', (e) => {
eventFired = true;
receivedFormData = e.formData;
});
const fd = new FormData(form);
testing.expectEqual(true, eventFired);
testing.expectEqual(fd, receivedFormData);
}
</script>
<script id=formDataEventNotFiredWithoutForm>
{
// formdata event should NOT fire when FormData is constructed without a form
const fd = new FormData();
fd.append('a', '1');
testing.expectEqual('1', fd.get('a'));
}
</script>
<script id=formDataEventBubbles>
{
// formdata event should bubble
const container = document.createElement('div');
const form = document.createElement('form');
container.appendChild(form);
document.body.appendChild(container);
const input = document.createElement('input');
input.name = 'x';
input.value = '1';
form.appendChild(input);
let bubbled = false;
container.addEventListener('formdata', () => {
bubbled = true;
});
const fd = new FormData(form);
testing.expectEqual(true, bubbled);
document.body.removeChild(container);
}
</script>
<script id=formDataEventNotCancelable>
{
// formdata event should not be cancelable
const form = document.createElement('form');
const input = document.createElement('input');
input.name = 'key';
input.value = 'val';
form.appendChild(input);
let cancelable = null;
form.addEventListener('formdata', (e) => {
cancelable = e.cancelable;
});
const fd = new FormData(form);
testing.expectEqual(false, cancelable);
}
</script>
<script id=formDataEventModifyFormData>
{
// Listeners can modify formData during the event
const form = document.createElement('form');
const input = document.createElement('input');
input.name = 'original';
input.value = 'data';
form.appendChild(input);
form.addEventListener('formdata', (e) => {
e.formData.append('added', 'by-listener');
});
const fd = new FormData(form);
testing.expectEqual('data', fd.get('original'));
testing.expectEqual('by-listener', fd.get('added'));
}
</script>

View File

@@ -262,6 +262,31 @@
}
</script>
<script id=cached_getter_wrong_this>
// Test that extracting a cached property getter and calling it with wrong `this`
// doesn't crash (V8 internal field out of bounds). V8's Signature validation
// should throw "Illegal invocation" for wrong receiver types.
const documentGetter = Object.getOwnPropertyDescriptor(Window.prototype, 'document').get;
// Verify we get an error with wrong this values
let errorCount = 0;
const testValues = [{}, null, undefined, 42, 'string', [], () => {}];
for (const val of testValues) {
try {
documentGetter.call(val);
} catch (e) {
if (e.message.includes('Illegal invocation')) {
errorCount++;
}
}
}
// At least some should throw (null/undefined/primitives get coerced in sloppy mode)
testing.expectEqual(true, errorCount > 0);
// Calling with correct this should still work
testing.expectEqual(document, documentGetter.call(window));
</script>
<script id=unhandled_rejection>
{
let unhandledCalled = 0;

View File

@@ -77,6 +77,7 @@ pub const Type = union(enum) {
ui_event: *@import("event/UIEvent.zig"),
promise_rejection_event: *@import("event/PromiseRejectionEvent.zig"),
submit_event: *@import("event/SubmitEvent.zig"),
form_data_event: *@import("event/FormDataEvent.zig"),
};
pub const Options = struct {
@@ -176,6 +177,7 @@ pub fn is(self: *Event, comptime T: type) ?*T {
.pop_state_event => |e| return if (T == @import("event/PopStateEvent.zig")) e else null,
.promise_rejection_event => |e| return if (T == @import("event/PromiseRejectionEvent.zig")) e else null,
.submit_event => |e| return if (T == @import("event/SubmitEvent.zig")) e else null,
.form_data_event => |e| return if (T == @import("event/FormDataEvent.zig")) e else null,
.ui_event => |e| {
if (T == @import("event/UIEvent.zig")) {
return e;

View File

@@ -62,5 +62,6 @@ pub const JsApi = struct {
pub const constructor = bridge.constructor(ResizeObserver.init, .{});
pub const observe = bridge.function(ResizeObserver.observe, .{});
pub const unobserve = bridge.function(ResizeObserver.unobserve, .{});
pub const disconnect = bridge.function(ResizeObserver.disconnect, .{});
};

View File

@@ -829,7 +829,7 @@ pub const JsApi = struct {
pub var class_id: bridge.ClassId = undefined;
};
pub const document = bridge.accessor(Window.getDocument, null, .{ .cache = .{ .internal = 1 } });
pub const document = bridge.accessor(Window.getDocument, null, .{ .cache = .{ .internal = 1 }, .deletable = false });
pub const console = bridge.accessor(Window.getConsole, null, .{ .cache = .{ .internal = 2 } });
pub const top = bridge.accessor(Window.getTop, null, .{});
@@ -842,7 +842,7 @@ pub const JsApi = struct {
pub const performance = bridge.accessor(Window.getPerformance, null, .{});
pub const localStorage = bridge.accessor(Window.getLocalStorage, null, .{});
pub const sessionStorage = bridge.accessor(Window.getSessionStorage, null, .{});
pub const location = bridge.accessor(Window.getLocation, Window.setLocation, .{});
pub const location = bridge.accessor(Window.getLocation, Window.setLocation, .{ .deletable = false });
pub const history = bridge.accessor(Window.getHistory, null, .{});
pub const navigation = bridge.accessor(Window.getNavigation, null, .{});
pub const crypto = bridge.accessor(Window.getCrypto, null, .{});

View File

@@ -23,16 +23,24 @@ const js = @import("../../js/js.zig");
const color = @import("../../color.zig");
const Page = @import("../../Page.zig");
const Canvas = @import("../element/html/Canvas.zig");
const ImageData = @import("../ImageData.zig");
/// This class doesn't implement a `constructor`.
/// It can be obtained with a call to `HTMLCanvasElement#getContext`.
/// https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D
const CanvasRenderingContext2D = @This();
/// Reference to the parent canvas element.
/// https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/canvas
_canvas: *Canvas,
/// Fill color.
/// TODO: Add support for `CanvasGradient` and `CanvasPattern`.
_fill_style: color.RGBA = color.RGBA.Named.black,
pub fn getCanvas(self: *const CanvasRenderingContext2D) *Canvas {
return self._canvas;
}
pub fn getFillStyle(self: *const CanvasRenderingContext2D, page: *Page) ![]const u8 {
var w = std.Io.Writer.Allocating.init(page.call_arena);
try self._fill_style.format(&w.writer);
@@ -125,6 +133,7 @@ pub const JsApi = struct {
pub var class_id: bridge.ClassId = undefined;
};
pub const canvas = bridge.accessor(CanvasRenderingContext2D.getCanvas, null, .{});
pub const font = bridge.property("10px sans-serif", .{ .template = false, .readonly = false });
pub const globalAlpha = bridge.property(1.0, .{ .template = false, .readonly = false });
pub const globalCompositeOperation = bridge.property("source-over", .{ .template = false, .readonly = false });

View File

@@ -67,9 +67,9 @@ const DrawingContext = union(enum) {
webgl: *WebGLRenderingContext,
};
pub fn getContext(_: *Canvas, context_type: []const u8, page: *Page) !?DrawingContext {
pub fn getContext(self: *Canvas, context_type: []const u8, page: *Page) !?DrawingContext {
if (std.mem.eql(u8, context_type, "2d")) {
const ctx = try page._factory.create(CanvasRenderingContext2D{});
const ctx = try page._factory.create(CanvasRenderingContext2D{ ._canvas = self });
return .{ .@"2d" = ctx };
}

View File

@@ -0,0 +1,95 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
const Allocator = std.mem.Allocator;
const String = @import("../../../string.zig").String;
const Page = @import("../../Page.zig");
const Session = @import("../../Session.zig");
const js = @import("../../js/js.zig");
const Event = @import("../Event.zig");
const UIEvent = @import("UIEvent.zig");
const FormData = @import("../net/FormData.zig");
/// https://developer.mozilla.org/en-US/docs/Web/API/FormDataEvent
const FormDataEvent = @This();
_proto: *Event,
_form_data: ?*FormData = null,
const Options = Event.inheritOptions(FormDataEvent, struct {
formData: ?*FormData = null,
});
pub fn init(typ: []const u8, maybe_options: Options, page: *Page) !*FormDataEvent {
const arena = try page.getArena(.{ .debug = "FormDataEvent" });
errdefer page.releaseArena(arena);
const type_string = try String.init(arena, typ, .{});
return initWithTrusted(arena, type_string, maybe_options, false, page);
}
pub fn initTrusted(typ: String, _opts: ?Options, page: *Page) !*FormDataEvent {
const arena = try page.getArena(.{ .debug = "FormDataEvent.trusted" });
errdefer page.releaseArena(arena);
return initWithTrusted(arena, typ, _opts, true, page);
}
fn initWithTrusted(arena: Allocator, typ: String, maybe_options: ?Options, trusted: bool, page: *Page) !*FormDataEvent {
const options = maybe_options orelse Options{};
const event = try page._factory.event(
arena,
typ,
FormDataEvent{
._proto = undefined,
._form_data = options.formData,
},
);
Event.populatePrototypes(event, options, trusted);
return event;
}
pub fn deinit(self: *FormDataEvent, shutdown: bool, session: *Session) void {
self._proto.deinit(shutdown, session);
}
pub fn asEvent(self: *FormDataEvent) *Event {
return self._proto;
}
pub fn getFormData(self: *const FormDataEvent) ?*FormData {
return self._form_data;
}
pub const JsApi = struct {
pub const bridge = js.Bridge(FormDataEvent);
pub const Meta = struct {
pub const name = "FormDataEvent";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const weak = true;
pub const finalizer = bridge.finalizer(FormDataEvent.deinit);
};
pub const constructor = bridge.constructor(FormDataEvent.init, .{});
pub const formData = bridge.accessor(FormDataEvent.getFormData, null, .{});
};

View File

@@ -26,7 +26,7 @@ const Event = @import("../Event.zig");
const HtmlElement = @import("../element/Html.zig");
const Allocator = std.mem.Allocator;
// https://developer.mozilla.org/en-US/docs/Web/API/SubmitEvent
/// https://developer.mozilla.org/en-US/docs/Web/API/SubmitEvent
const SubmitEvent = @This();
_proto: *Event,

View File

@@ -35,10 +35,22 @@ _arena: Allocator,
_list: KeyValueList,
pub fn init(form: ?*Form, submitter: ?*Element, page: *Page) !*FormData {
return page._factory.create(FormData{
const form_data = try page._factory.create(FormData{
._arena = page.arena,
._list = try collectForm(page.arena, form, submitter, page),
});
// Dispatch `formdata` event if form provided.
if (form) |_form| {
const form_data_event = try (@import("../event/FormDataEvent.zig")).initTrusted(
comptime .wrap("formdata"),
.{ .bubbles = true, .cancelable = false, .formData = form_data },
page,
);
try page._event_manager.dispatch(_form.asNode().asEventTarget(), form_data_event.asEvent());
}
return form_data;
}
pub fn get(self: *const FormData, name: []const u8) ?[]const u8 {

View File

@@ -135,17 +135,10 @@ fn getInteractiveElements(cmd: anytype) !void {
page.document.asNode();
const elements = try interactive.collectInteractiveElements(root, cmd.arena, page);
// Register nodes so nodeIds are valid for subsequent CDP calls.
var node_ids: std.ArrayList(Node.Id) = try .initCapacity(cmd.arena, elements.len);
for (elements) |el| {
const registered = try bc.node_registry.register(el.node);
node_ids.appendAssumeCapacity(registered.id);
}
try interactive.registerNodes(elements, &bc.node_registry);
return cmd.sendResult(.{
.elements = elements,
.nodeIds = node_ids.items,
}, .{});
}
@@ -308,7 +301,6 @@ test "cdp.lp: getInteractiveElements" {
const result = (try ctx.getSentMessage(0)).?.object.get("result").?.object;
try testing.expect(result.get("elements") != null);
try testing.expect(result.get("nodeIds") != null);
}
test "cdp.lp: getStructuredData" {

View File

@@ -39,6 +39,7 @@ pub fn processMessage(cmd: anytype) !void {
addScriptToEvaluateOnNewDocument,
createIsolatedWorld,
navigate,
reload,
stopLoading,
close,
captureScreenshot,
@@ -52,6 +53,7 @@ pub fn processMessage(cmd: anytype) !void {
.addScriptToEvaluateOnNewDocument => return addScriptToEvaluateOnNewDocument(cmd),
.createIsolatedWorld => return createIsolatedWorld(cmd),
.navigate => return navigate(cmd),
.reload => return doReload(cmd),
.stopLoading => return cmd.sendResult(null, .{}),
.close => return close(cmd),
.captureScreenshot => return captureScreenshot(cmd),
@@ -252,6 +254,36 @@ fn navigate(cmd: anytype) !void {
});
}
fn doReload(cmd: anytype) !void {
const params = try cmd.params(struct {
ignoreCache: ?bool = null,
scriptToEvaluateOnLoad: ?[]const u8 = null,
});
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
if (bc.session_id == null) {
return error.SessionIdNotLoaded;
}
const session = bc.session;
var page = session.currentPage() orelse return error.PageNotLoaded;
// Dupe URL before replacePage() frees the old page's arena.
const reload_url = try cmd.arena.dupeZ(u8, page.url);
if (page._load_state != .waiting) {
page = try session.replacePage();
}
try page.navigate(reload_url, .{
.reason = .address_bar,
.cdp_id = cmd.input.id,
.kind = .reload,
.force = if (params) |p| p.ignoreCache orelse false else false,
});
}
pub fn pageNavigate(bc: anytype, event: *const Notification.PageNavigate) !void {
// detachTarget could be called, in which case, we still have a page doing
// things, but no session.
@@ -784,3 +816,27 @@ test "cdp.page: getLayoutMetrics" {
},
}, .{ .id = 12 });
}
test "cdp.page: reload" {
var ctx = try testing.context();
defer ctx.deinit();
{
// reload without browser context — should error
try ctx.processMessage(.{ .id = 30, .method = "Page.reload" });
try ctx.expectSentError(-31998, "BrowserContextNotLoaded", .{ .id = 30 });
}
_ = try ctx.loadBrowserContext(.{ .id = "BID-9", .url = "hi.html", .target_id = "FID-000000000X".* });
{
// reload with no params — should not error (navigation is async,
// so no result is sent synchronously; we just verify no error)
try ctx.processMessage(.{ .id = 31, .method = "Page.reload" });
}
{
// reload with ignoreCache param
try ctx.processMessage(.{ .id = 32, .method = "Page.reload", .params = .{ .ignoreCache = true } });
}
}

View File

@@ -158,13 +158,13 @@ pub extern "C" fn html5ever_attribute_iterator_next(
let attr = &iter.vec[pos];
iter.pos += 1;
return CNullable::<CAttribute>::some(CAttribute {
CNullable::<CAttribute>::some(CAttribute {
name: CQualName::create(&attr.name),
value: StringSlice {
ptr: attr.value.as_ptr(),
len: attr.value.len(),
},
});
})
}
#[no_mangle]
@@ -186,12 +186,12 @@ pub extern "C" fn html5ever_get_memory_usage() -> Memory {
use tikv_jemalloc_ctl::{epoch, stats};
// many statistics are cached and only updated when the epoch is advanced.
epoch::advance().unwrap();
drop(epoch::advance());
return Memory {
resident: stats::resident::read().unwrap(),
allocated: stats::allocated::read().unwrap(),
};
Memory {
resident: stats::resident::read().unwrap_or(0),
allocated: stats::allocated::read().unwrap_or(0),
}
}
// Streaming parser API
@@ -325,7 +325,7 @@ pub extern "C" fn html5ever_streaming_parser_destroy(parser_ptr: *mut c_void) {
// Drop the parser box without finishing
// This is for cases where you want to cancel parsing
unsafe {
let _ = Box::from_raw(parser_ptr as *mut StreamingParser);
drop(Box::from_raw(parser_ptr as *mut StreamingParser));
}
}

View File

@@ -36,10 +36,10 @@ pub struct ElementData {
}
impl ElementData {
fn new(qname: QualName, flags: ElementFlags) -> Self {
return Self {
Self {
qname: qname,
mathml_annotation_xml_integration_point: flags.mathml_annotation_xml_integration_point,
};
}
}
}
@@ -130,12 +130,12 @@ impl<'arena> TreeSink for Sink<'arena> {
unsafe {
let mut attribute_iterator = CAttributeIterator { vec: attrs, pos: 0 };
return (self.create_element_callback)(
(self.create_element_callback)(
self.ctx,
data as *mut _ as *mut c_void,
CQualName::create(&name),
&mut attribute_iterator as *mut _ as *mut c_void,
);
)
}
}

View File

@@ -126,21 +126,21 @@ impl CQualName {
None => CNullable::<StringSlice>::none(),
Some(prefix) => CNullable::<StringSlice>::some(StringSlice { ptr: prefix.as_ptr(), len: prefix.len()}),
};
return CQualName{
CQualName{
// inner: q as *const _ as *const c_void,
ns: ns,
local: local,
prefix: prefix,
};
}
}
}
impl Default for CQualName {
fn default() -> Self {
return Self{
Self{
prefix: CNullable::<StringSlice>::none(),
ns: StringSlice::default(),
local: StringSlice::default(),
};
}
}
}

View File

@@ -35,6 +35,7 @@ pub const markdown = @import("browser/markdown.zig");
pub const SemanticTree = @import("SemanticTree.zig");
pub const CDPNode = @import("cdp/Node.zig");
pub const interactive = @import("browser/interactive.zig");
pub const links = @import("browser/links.zig");
pub const forms = @import("browser/forms.zig");
pub const actions = @import("browser/actions.zig");
pub const structured_data = @import("browser/structured_data.zig");

View File

@@ -22,12 +22,14 @@ pub const resource_list = [_]protocol.Resource{
};
pub fn handleList(server: *Server, req: protocol.Request) !void {
try server.sendResult(req.id.?, .{ .resources = &resource_list });
const id = req.id orelse return;
try server.sendResult(id, .{ .resources = &resource_list });
}
const ReadParams = struct {
uri: []const u8,
};
const Format = enum { html, markdown };
const ResourceStreamingResult = struct {
contents: []const struct {
@@ -38,7 +40,7 @@ const ResourceStreamingResult = struct {
const StreamingText = struct {
page: *lp.Page,
format: enum { html, markdown },
format: Format,
pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) !void {
try jw.beginWriteRaw();
@@ -47,9 +49,11 @@ const ResourceStreamingResult = struct {
switch (self.format) {
.html => lp.dump.root(self.page.document, .{}, &escaped.writer, self.page) catch |err| {
log.err(.mcp, "html dump failed", .{ .err = err });
return error.WriteFailed;
},
.markdown => lp.markdown.dump(self.page.document.asNode(), .{}, &escaped.writer, self.page) catch |err| {
log.err(.mcp, "markdown dump failed", .{ .err = err });
return error.WriteFailed;
},
}
try jw.writer.writeByte('"');
@@ -86,28 +90,25 @@ pub fn handleRead(server: *Server, arena: std.mem.Allocator, req: protocol.Reque
return server.sendError(req_id, .PageNotLoaded, "Page not loaded");
};
switch (uri) {
.@"mcp://page/html" => {
const format: Format = switch (uri) {
.@"mcp://page/html" => .html,
.@"mcp://page/markdown" => .markdown,
};
const mime_type: []const u8 = switch (uri) {
.@"mcp://page/html" => "text/html",
.@"mcp://page/markdown" => "text/markdown",
};
const result: ResourceStreamingResult = .{
.contents = &.{.{
.uri = params.uri,
.mimeType = "text/html",
.text = .{ .page = page, .format = .html },
.mimeType = mime_type,
.text = .{ .page = page, .format = format },
}},
};
try server.sendResult(req_id, result);
},
.@"mcp://page/markdown" => {
const result: ResourceStreamingResult = .{
.contents = &.{.{
.uri = params.uri,
.mimeType = "text/markdown",
.text = .{ .page = page, .format = .markdown },
}},
server.sendResult(req_id, result) catch {
return server.sendError(req_id, .InternalError, "Failed to serialize resource content");
};
try server.sendResult(req_id, result);
},
}
}
const testing = @import("../testing.zig");

View File

@@ -16,6 +16,7 @@ pub fn processRequests(server: *Server, reader: *std.io.Reader) !void {
const buffered_line = reader.takeDelimiter('\n') catch |err| switch (err) {
error.StreamTooLong => {
log.err(.mcp, "Message too long", .{});
try server.sendError(.null, .InvalidRequest, "Message too long");
continue;
},
else => return err,
@@ -80,6 +81,7 @@ pub fn handleMessage(server: *Server, arena: std.mem.Allocator, msg: []const u8)
}
fn handleInitialize(server: *Server, req: protocol.Request) !void {
const id = req.id orelse return;
const result = protocol.InitializeResult{
.protocolVersion = "2025-11-25",
.capabilities = .{
@@ -92,7 +94,7 @@ fn handleInitialize(server: *Server, req: protocol.Request) !void {
},
};
try server.sendResult(req.id.?, result);
try server.sendResult(id, result);
}
fn handlePing(server: *Server, req: protocol.Request) !void {

View File

@@ -172,13 +172,18 @@ pub const tool_list = [_]protocol.Tool{
pub fn handleList(server: *Server, arena: std.mem.Allocator, req: protocol.Request) !void {
_ = arena;
try server.sendResult(req.id.?, .{ .tools = &tool_list });
const id = req.id orelse return;
try server.sendResult(id, .{ .tools = &tool_list });
}
const GotoParams = struct {
url: [:0]const u8,
};
const UrlParams = struct {
url: ?[:0]const u8 = null,
};
const EvaluateParams = struct {
script: [:0]const u8,
url: ?[:0]const u8 = null,
@@ -201,29 +206,19 @@ const ToolStreamingText = struct {
switch (self.action) {
.markdown => lp.markdown.dump(self.page.document.asNode(), .{}, w, self.page) catch |err| {
log.err(.mcp, "markdown dump failed", .{ .err = err });
return error.WriteFailed;
},
.links => {
if (Selector.querySelectorAll(self.page.document.asNode(), "a[href]", self.page)) |list| {
defer list.deinit(self.page._session);
var first = true;
for (list._nodes) |node| {
if (node.is(Element.Html.Anchor)) |anchor| {
const href = anchor.getHref(self.page) catch |err| {
log.err(.mcp, "resolve href failed", .{ .err = err });
continue;
const links = lp.links.collectLinks(self.page.call_arena, self.page.document.asNode(), self.page) catch |err| {
log.err(.mcp, "query links failed", .{ .err = err });
return error.WriteFailed;
};
if (href.len > 0) {
var first = true;
for (links) |href| {
if (!first) try w.writeByte('\n');
try w.writeAll(href);
first = false;
}
}
}
} else |err| {
log.err(.mcp, "query links failed", .{ .err = err });
}
},
.semantic_tree => {
var root_node = self.page.document.asNode();
@@ -248,6 +243,7 @@ const ToolStreamingText = struct {
st.textStringify(w) catch |err| {
log.err(.mcp, "semantic tree dump failed", .{ .err = err });
return error.WriteFailed;
};
},
}
@@ -324,7 +320,7 @@ pub fn handleCall(server: *Server, arena: std.mem.Allocator, req: protocol.Reque
}
fn handleGoto(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const args = try parseArguments(GotoParams, arena, arguments, server, id, "goto");
const args = try parseArgs(GotoParams, arena, arguments, server, id, "goto");
try performGoto(server, args.url, id);
const content = [_]protocol.TextContent([]const u8){.{ .text = "Navigated successfully." }};
@@ -332,45 +328,27 @@ fn handleGoto(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arg
}
fn handleMarkdown(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const MarkdownParams = struct {
url: ?[:0]const u8 = null,
};
if (arguments) |args_raw| {
if (std.json.parseFromValueLeaky(MarkdownParams, arena, args_raw, .{ .ignore_unknown_fields = true })) |args| {
if (args.url) |u| {
try performGoto(server, u, id);
}
} else |_| {}
}
const page = server.session.currentPage() orelse {
return server.sendError(id, .PageNotLoaded, "Page not loaded");
};
const args = try parseArgsOrDefault(UrlParams, arena, arguments, server, id);
const page = try ensurePage(server, id, args.url);
const content = [_]protocol.TextContent(ToolStreamingText){.{
.text = .{ .page = page, .action = .markdown },
}};
try server.sendResult(id, protocol.CallToolResult(ToolStreamingText){ .content = &content });
server.sendResult(id, protocol.CallToolResult(ToolStreamingText){ .content = &content }) catch {
return server.sendError(id, .InternalError, "Failed to serialize markdown content");
};
}
fn handleLinks(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const LinksParams = struct {
url: ?[:0]const u8 = null,
};
if (arguments) |args_raw| {
if (std.json.parseFromValueLeaky(LinksParams, arena, args_raw, .{ .ignore_unknown_fields = true })) |args| {
if (args.url) |u| {
try performGoto(server, u, id);
}
} else |_| {}
}
const page = server.session.currentPage() orelse {
return server.sendError(id, .PageNotLoaded, "Page not loaded");
};
const args = try parseArgsOrDefault(UrlParams, arena, arguments, server, id);
const page = try ensurePage(server, id, args.url);
const content = [_]protocol.TextContent(ToolStreamingText){.{
.text = .{ .page = page, .action = .links },
}};
try server.sendResult(id, protocol.CallToolResult(ToolStreamingText){ .content = &content });
server.sendResult(id, protocol.CallToolResult(ToolStreamingText){ .content = &content }) catch {
return server.sendError(id, .InternalError, "Failed to serialize links content");
};
}
fn handleSemanticTree(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
@@ -379,44 +357,38 @@ fn handleSemanticTree(server: *Server, arena: std.mem.Allocator, id: std.json.Va
backendNodeId: ?u32 = null,
maxDepth: ?u32 = null,
};
var tree_args: TreeParams = .{};
if (arguments) |args_raw| {
if (std.json.parseFromValueLeaky(TreeParams, arena, args_raw, .{ .ignore_unknown_fields = true })) |args| {
tree_args = args;
if (args.url) |u| {
try performGoto(server, u, id);
}
} else |_| {}
}
const page = server.session.currentPage() orelse {
return server.sendError(id, .PageNotLoaded, "Page not loaded");
};
const args = try parseArgsOrDefault(TreeParams, arena, arguments, server, id);
const page = try ensurePage(server, id, args.url);
const content = [_]protocol.TextContent(ToolStreamingText){.{
.text = .{ .page = page, .action = .semantic_tree, .registry = &server.node_registry, .arena = arena, .backendNodeId = tree_args.backendNodeId, .maxDepth = tree_args.maxDepth },
.text = .{
.page = page,
.action = .semantic_tree,
.registry = &server.node_registry,
.arena = arena,
.backendNodeId = args.backendNodeId,
.maxDepth = args.maxDepth,
},
}};
try server.sendResult(id, protocol.CallToolResult(ToolStreamingText){ .content = &content });
server.sendResult(id, protocol.CallToolResult(ToolStreamingText){ .content = &content }) catch {
return server.sendError(id, .InternalError, "Failed to serialize semantic tree content");
};
}
fn handleInteractiveElements(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const Params = struct {
url: ?[:0]const u8 = null,
};
if (arguments) |args_raw| {
if (std.json.parseFromValueLeaky(Params, arena, args_raw, .{ .ignore_unknown_fields = true })) |args| {
if (args.url) |u| {
try performGoto(server, u, id);
}
} else |_| {}
}
const page = server.session.currentPage() orelse {
return server.sendError(id, .PageNotLoaded, "Page not loaded");
};
const args = try parseArgsOrDefault(UrlParams, arena, arguments, server, id);
const page = try ensurePage(server, id, args.url);
const elements = lp.interactive.collectInteractiveElements(page.document.asNode(), arena, page) catch |err| {
log.err(.mcp, "elements collection failed", .{ .err = err });
return server.sendError(id, .InternalError, "Failed to collect interactive elements");
};
lp.interactive.registerNodes(elements, &server.node_registry) catch |err| {
log.err(.mcp, "node registration failed", .{ .err = err });
return server.sendError(id, .InternalError, "Failed to register element nodes");
};
var aw: std.Io.Writer.Allocating = .init(arena);
try std.json.Stringify.value(elements, .{}, &aw.writer);
@@ -425,19 +397,8 @@ fn handleInteractiveElements(server: *Server, arena: std.mem.Allocator, id: std.
}
fn handleStructuredData(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const Params = struct {
url: ?[:0]const u8 = null,
};
if (arguments) |args_raw| {
if (std.json.parseFromValueLeaky(Params, arena, args_raw, .{ .ignore_unknown_fields = true })) |args| {
if (args.url) |u| {
try performGoto(server, u, id);
}
} else |_| {}
}
const page = server.session.currentPage() orelse {
return server.sendError(id, .PageNotLoaded, "Page not loaded");
};
const args = try parseArgsOrDefault(UrlParams, arena, arguments, server, id);
const page = try ensurePage(server, id, args.url);
const data = lp.structured_data.collectStructuredData(page.document.asNode(), arena, page) catch |err| {
log.err(.mcp, "struct data collection failed", .{ .err = err });
@@ -451,20 +412,8 @@ fn handleStructuredData(server: *Server, arena: std.mem.Allocator, id: std.json.
}
fn handleDetectForms(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const Params = struct {
url: ?[:0]const u8 = null,
};
if (arguments) |args_raw| {
const args = std.json.parseFromValueLeaky(Params, arena, args_raw, .{ .ignore_unknown_fields = true }) catch {
return server.sendError(id, .InvalidParams, "Invalid arguments for detectForms");
};
if (args.url) |u| {
try performGoto(server, u, id);
}
}
const page = server.session.currentPage() orelse {
return server.sendError(id, .PageNotLoaded, "Page not loaded");
};
const args = try parseArgsOrDefault(UrlParams, arena, arguments, server, id);
const page = try ensurePage(server, id, args.url);
const forms_data = lp.forms.collectForms(arena, page.document.asNode(), page) catch |err| {
log.err(.mcp, "form collection failed", .{ .err = err });
@@ -484,14 +433,8 @@ fn handleDetectForms(server: *Server, arena: std.mem.Allocator, id: std.json.Val
}
fn handleEvaluate(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const args = try parseArguments(EvaluateParams, arena, arguments, server, id, "evaluate");
if (args.url) |url| {
try performGoto(server, url, id);
}
const page = server.session.currentPage() orelse {
return server.sendError(id, .PageNotLoaded, "Page not loaded");
};
const args = try parseArgs(EvaluateParams, arena, arguments, server, id, "evaluate");
const page = try ensurePage(server, id, args.url);
var ls: js.Local.Scope = undefined;
page.js.localScope(&ls);
@@ -520,7 +463,7 @@ fn handleClick(server: *Server, arena: std.mem.Allocator, id: std.json.Value, ar
const ClickParams = struct {
backendNodeId: CDPNode.Id,
};
const args = try parseArguments(ClickParams, arena, arguments, server, id, "click");
const args = try parseArgs(ClickParams, arena, arguments, server, id, "click");
const page = server.session.currentPage() orelse {
return server.sendError(id, .PageNotLoaded, "Page not loaded");
@@ -552,7 +495,7 @@ fn handleFill(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arg
backendNodeId: CDPNode.Id,
text: []const u8,
};
const args = try parseArguments(FillParams, arena, arguments, server, id, "fill");
const args = try parseArgs(FillParams, arena, arguments, server, id, "fill");
const page = server.session.currentPage() orelse {
return server.sendError(id, .PageNotLoaded, "Page not loaded");
@@ -586,7 +529,7 @@ fn handleScroll(server: *Server, arena: std.mem.Allocator, id: std.json.Value, a
x: ?i32 = null,
y: ?i32 = null,
};
const args = try parseArguments(ScrollParams, arena, arguments, server, id, "scroll");
const args = try parseArgs(ScrollParams, arena, arguments, server, id, "scroll");
const page = server.session.currentPage() orelse {
return server.sendError(id, .PageNotLoaded, "Page not loaded");
@@ -623,7 +566,7 @@ fn handleWaitForSelector(server: *Server, arena: std.mem.Allocator, id: std.json
selector: [:0]const u8,
timeout: ?u32 = null,
};
const args = try parseArguments(WaitParams, arena, arguments, server, id, "waitForSelector");
const args = try parseArgs(WaitParams, arena, arguments, server, id, "waitForSelector");
_ = server.session.currentPage() orelse {
return server.sendError(id, .PageNotLoaded, "Page not loaded");
@@ -647,12 +590,38 @@ fn handleWaitForSelector(server: *Server, arena: std.mem.Allocator, id: std.json
return server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content });
}
fn parseArguments(comptime T: type, arena: std.mem.Allocator, arguments: ?std.json.Value, server: *Server, id: std.json.Value, tool_name: []const u8) !T {
if (arguments == null) {
fn ensurePage(server: *Server, id: std.json.Value, url: ?[:0]const u8) !*lp.Page {
if (url) |u| {
try performGoto(server, u, id);
}
return server.session.currentPage() orelse {
try server.sendError(id, .PageNotLoaded, "Page not loaded");
return error.PageNotLoaded;
};
}
/// Parses JSON arguments into a given struct type `T`.
/// If the arguments are missing, it returns a default-initialized `T` (e.g., `.{}`).
/// If the arguments are present but invalid, it sends an MCP error response and returns `error.InvalidParams`.
/// Use this for tools where all arguments are optional.
fn parseArgsOrDefault(comptime T: type, arena: std.mem.Allocator, arguments: ?std.json.Value, server: *Server, id: std.json.Value) !T {
const args_raw = arguments orelse return .{};
return std.json.parseFromValueLeaky(T, arena, args_raw, .{ .ignore_unknown_fields = true }) catch {
try server.sendError(id, .InvalidParams, "Invalid arguments");
return error.InvalidParams;
};
}
/// Parses JSON arguments into a given struct type `T`.
/// If the arguments are missing or invalid, it automatically sends an MCP error response to the client
/// and returns an `error.InvalidParams`.
/// Use this for tools that require strict validation or mandatory arguments.
fn parseArgs(comptime T: type, arena: std.mem.Allocator, arguments: ?std.json.Value, server: *Server, id: std.json.Value, tool_name: []const u8) !T {
const args_raw = arguments orelse {
try server.sendError(id, .InvalidParams, "Missing arguments");
return error.InvalidParams;
}
return std.json.parseFromValueLeaky(T, arena, arguments.?, .{ .ignore_unknown_fields = true }) catch {
};
return std.json.parseFromValueLeaky(T, arena, args_raw, .{ .ignore_unknown_fields = true }) catch {
const msg = std.fmt.allocPrint(arena, "Invalid arguments for {s}", .{tool_name}) catch "Invalid arguments";
try server.sendError(id, .InvalidParams, msg);
return error.InvalidParams;
@@ -664,7 +633,10 @@ fn performGoto(server: *Server, url: [:0]const u8, id: std.json.Value) !void {
if (session.page != null) {
session.removePage();
}
const page = try session.createPage();
const page = session.createPage() catch {
try server.sendError(id, .InternalError, "Failed to create page");
return error.NavigationFailed;
};
page.navigate(url, .{
.reason = .address_bar,
.kind = .{ .push = null },
@@ -673,8 +645,14 @@ fn performGoto(server: *Server, url: [:0]const u8, id: std.json.Value) !void {
return error.NavigationFailed;
};
var runner = try session.runner(.{});
try runner.wait(.{ .ms = 2000 });
var runner = session.runner(.{}) catch {
try server.sendError(id, .InternalError, "Failed to start page runner");
return error.NavigationFailed;
};
runner.wait(.{ .ms = 2000 }) catch {
try server.sendError(id, .InternalError, "Timeout waiting for page load");
return error.NavigationFailed;
};
}
const router = @import("router.zig");