mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-03-29 16:10:04 +00:00
Compare commits
67 Commits
0.2.7
...
mcp-protoc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81d4bdb157 | ||
|
|
cf5e4d7d1e | ||
|
|
03ed45637a | ||
|
|
9068fe718e | ||
|
|
5369d25213 | ||
|
|
649d8d1024 | ||
|
|
15d60d845a | ||
|
|
c4b837b598 | ||
|
|
54391238c9 | ||
|
|
d33edc5697 | ||
|
|
16ca8d4b14 | ||
|
|
707ffb4893 | ||
|
|
4782b37216 | ||
|
|
ce197256dd | ||
|
|
e6d644998a | ||
|
|
67bd555e75 | ||
|
|
a10e533701 | ||
|
|
0065677273 | ||
|
|
226d9bfc6f | ||
|
|
2e65ae632e | ||
|
|
ea422075c7 | ||
|
|
1d54e6944b | ||
|
|
de32e5cf34 | ||
|
|
c8d8ca5e94 | ||
|
|
7f2139f612 | ||
|
|
da0828620f | ||
|
|
cdd33621e3 | ||
|
|
8001709506 | ||
|
|
a0ae6b4c92 | ||
|
|
fdf7f5267a | ||
|
|
88e0b39d6b | ||
|
|
f95396a487 | ||
|
|
d02d05b246 | ||
|
|
7b2d817d0e | ||
|
|
7e778a17d6 | ||
|
|
a0dd14aaad | ||
|
|
d447d1e3c7 | ||
|
|
8684d35394 | ||
|
|
e243f96988 | ||
|
|
7ea8f3f766 | ||
|
|
5e6082b5e9 | ||
|
|
1befd9a5e8 | ||
|
|
e103ce0f39 | ||
|
|
14fa2da2ad | ||
|
|
28cc60adb0 | ||
|
|
96d24b5dc6 | ||
|
|
c14a9ad986 | ||
|
|
679f2104f4 | ||
|
|
c6b0c75106 | ||
|
|
93485c1ef3 | ||
|
|
0324d5c232 | ||
|
|
0588cc374d | ||
|
|
a75c0cf08d | ||
|
|
2812b8f07c | ||
|
|
e2afbec29d | ||
|
|
a45f9cb810 | ||
|
|
cf641ed458 | ||
|
|
0fc959dcc5 | ||
|
|
077376ea04 | ||
|
|
6ed8d1d201 | ||
|
|
5207bd4202 | ||
|
|
11ed95290b | ||
|
|
ca41bb5fa2 | ||
|
|
0dd0495ab8 | ||
|
|
ae080f32eb | ||
|
|
b19f30d865 | ||
|
|
35be9f897f |
2
.github/actions/install/action.yml
vendored
2
.github/actions/install/action.yml
vendored
@@ -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
|
||||
|
||||
10
.github/workflows/nightly.yml
vendored
10
.github/workflows/nightly.yml
vendored
@@ -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={0}', github.ref_name) || '-Dversion=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 }}
|
||||
|
||||
@@ -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 && \
|
||||
|
||||
@@ -170,6 +170,7 @@ You may still encounter errors or crashes. Please open an issue with specifics i
|
||||
|
||||
Here are the key features we have implemented:
|
||||
|
||||
- [ ] CORS [#2015](https://github.com/lightpanda-io/browser/issues/2015)
|
||||
- [x] HTTP loader ([Libcurl](https://curl.se/libcurl/))
|
||||
- [x] HTML parser ([html5ever](https://github.com/servo/html5ever))
|
||||
- [x] DOM tree
|
||||
|
||||
45
build.zig
45
build.zig
@@ -719,30 +719,45 @@ fn buildCurl(
|
||||
return lib;
|
||||
}
|
||||
|
||||
/// Returns `MAJOR.MINOR.PATCH-dev` when `git describe` fails.
|
||||
/// Resolves the semantic version of the build.
|
||||
///
|
||||
/// The base version is read from `build.zig.zon`. This can be overridden
|
||||
/// using the `-Dversion` command-line flag:
|
||||
/// - If the flag contains a full semantic version (e.g., `1.2.3`), it replaces
|
||||
/// the base version entirely.
|
||||
/// - If the flag contains a simple string (e.g., `nightly`), it replaces only
|
||||
/// the pre-release tag of the base version (e.g., `1.0.0-dev` -> `1.0.0-nightly`).
|
||||
///
|
||||
/// For versions that have a pre-release tag and no explicit build metadata,
|
||||
/// this function automatically enriches the version with the git commit count
|
||||
/// and short hash (e.g., `1.0.0-dev.5243+dbe45229`).
|
||||
fn resolveVersion(b: *std.Build) std.SemanticVersion {
|
||||
const version_string = b.option([]const u8, "version_string", "Override the version of this build");
|
||||
if (version_string) |semver_string| {
|
||||
return std.SemanticVersion.parse(semver_string) catch |err| {
|
||||
std.debug.panic("Expected -Dversion-string={s} to be a semantic version: {}", .{ semver_string, err });
|
||||
};
|
||||
}
|
||||
const opt_version = b.option([]const u8, "version", "Override the version of this build");
|
||||
|
||||
// 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;
|
||||
const version = if (opt_version) |v|
|
||||
std.SemanticVersion.parse(v) catch blk: {
|
||||
var fallback = lightpanda_version;
|
||||
fallback.pre = v;
|
||||
break :blk fallback;
|
||||
}
|
||||
else
|
||||
lightpanda_version;
|
||||
|
||||
// Only enrich versions that have a pre-release field and no explicit build metadata.
|
||||
if (version.pre == null or version.build != null) return 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;
|
||||
const git_hash_raw = runGit(b, &.{ "rev-parse", "--short", "HEAD" }) catch return version;
|
||||
const commit_hash = std.mem.trim(u8, git_hash_raw, " \n\r");
|
||||
|
||||
const git_count_raw = runGit(b, &.{ "rev-list", "--count", "HEAD" }) catch return lightpanda_version;
|
||||
const git_count_raw = runGit(b, &.{ "rev-list", "--count", "HEAD" }) catch return version;
|
||||
const commit_count = std.mem.trim(u8, git_count_raw, " \n\r");
|
||||
|
||||
return .{
|
||||
.major = lightpanda_version.major,
|
||||
.minor = lightpanda_version.minor,
|
||||
.patch = lightpanda_version.patch,
|
||||
.pre = b.fmt("{s}.{s}", .{ lightpanda_version.pre.?, commit_count }),
|
||||
.major = version.major,
|
||||
.minor = version.minor,
|
||||
.patch = version.patch,
|
||||
.pre = b.fmt("{s}.{s}", .{ version.pre.?, commit_count }),
|
||||
.build = commit_hash,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 = .{
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
const std = @import("std");
|
||||
const builtin = @import("builtin");
|
||||
const log = @import("log.zig");
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
const ArenaAllocator = std.heap.ArenaAllocator;
|
||||
@@ -62,7 +63,7 @@ pub fn deinit(self: *ArenaPool) void {
|
||||
var it = self._leak_track.iterator();
|
||||
while (it.next()) |kv| {
|
||||
if (kv.value_ptr.* != 0) {
|
||||
std.debug.print("ArenaPool leak detected: '{s}' count={d}\n", .{ kv.key_ptr.*, kv.value_ptr.* });
|
||||
log.err(.bug, "ArenaPool leak", .{ .name = kv.key_ptr.*, .count = kv.value_ptr.* });
|
||||
has_leaks = true;
|
||||
}
|
||||
}
|
||||
@@ -129,11 +130,11 @@ pub fn release(self: *ArenaPool, allocator: Allocator) void {
|
||||
if (self._leak_track.getPtr(entry.debug)) |count| {
|
||||
count.* -= 1;
|
||||
if (count.* < 0) {
|
||||
std.debug.print("ArenaPool double-free detected: '{s}'\n", .{entry.debug});
|
||||
log.err(.bug, "ArenaPool double-free", .{ .name = entry.debug });
|
||||
@panic("ArenaPool: double-free detected");
|
||||
}
|
||||
} else {
|
||||
std.debug.print("ArenaPool release of untracked arena: '{s}'\n", .{entry.debug});
|
||||
log.err(.bug, "ArenaPool release unknown", .{ .name = entry.debug });
|
||||
@panic("ArenaPool: release of untracked arena");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ const log = @import("log.zig");
|
||||
const dump = @import("browser/dump.zig");
|
||||
|
||||
const WebBotAuthConfig = @import("network/WebBotAuth.zig").Config;
|
||||
const mcp = @import("mcp.zig");
|
||||
|
||||
pub const RunMode = enum {
|
||||
help,
|
||||
@@ -188,6 +189,13 @@ pub fn webBotAuth(self: *const Config) ?WebBotAuthConfig {
|
||||
};
|
||||
}
|
||||
|
||||
pub fn mcpVersion(self: *const Config) []const u8 {
|
||||
return switch (self.mode) {
|
||||
.mcp => |opts| @tagName(opts.version),
|
||||
else => @tagName(mcp.Version.default),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn maxConnections(self: *const Config) u16 {
|
||||
return switch (self.mode) {
|
||||
.serve => |opts| opts.cdp_max_connections,
|
||||
@@ -222,6 +230,7 @@ pub const Serve = struct {
|
||||
|
||||
pub const Mcp = struct {
|
||||
common: Common = .{},
|
||||
version: mcp.Version = .default,
|
||||
};
|
||||
|
||||
pub const DumpFormat = enum {
|
||||
@@ -247,7 +256,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 {
|
||||
@@ -453,6 +462,13 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void {
|
||||
\\Starts an MCP (Model Context Protocol) server over stdio
|
||||
\\Example: {s} mcp
|
||||
\\
|
||||
\\Options:
|
||||
\\--version
|
||||
\\ Override the reported MCP version.
|
||||
\\ Valid: 2024-11-05, 2025-03-26, 2025-06-18, 2025-11-25.
|
||||
\\ Can also be set via LIGHTPANDA_MCP_VERSION env var.
|
||||
\\ Defaults to "2024-11-05".
|
||||
\\
|
||||
++ common_options ++
|
||||
\\
|
||||
\\version command
|
||||
@@ -640,10 +656,25 @@ fn parseMcpArgs(
|
||||
allocator: Allocator,
|
||||
args: *std.process.ArgIterator,
|
||||
) !Mcp {
|
||||
var mcp: Mcp = .{};
|
||||
var result: Mcp = .{};
|
||||
var env_ver: ?[]const u8 = null;
|
||||
var arg_ver: ?[]const u8 = null;
|
||||
|
||||
if (std.posix.getenv("LIGHTPANDA_MCP_VERSION")) |ver| {
|
||||
env_ver = ver;
|
||||
}
|
||||
|
||||
while (args.next()) |opt| {
|
||||
if (try parseCommonArg(allocator, opt, args, &mcp.common)) {
|
||||
if (std.mem.eql(u8, "--version", opt)) {
|
||||
const str = args.next() orelse {
|
||||
log.fatal(.mcp, "missing argument value", .{ .arg = opt });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
arg_ver = str;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (try parseCommonArg(allocator, opt, args, &result.common)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -651,7 +682,15 @@ fn parseMcpArgs(
|
||||
return error.UnkownOption;
|
||||
}
|
||||
|
||||
return mcp;
|
||||
const final_ver = arg_ver orelse env_ver;
|
||||
if (final_ver) |ver| {
|
||||
result.version = std.meta.stringToEnum(mcp.Version, ver) orelse {
|
||||
log.fatal(.mcp, "invalid protocol version", .{ .value = ver });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
fn parseFetchArgs(
|
||||
@@ -665,7 +704,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)) {
|
||||
|
||||
@@ -22,12 +22,11 @@ const net = std.net;
|
||||
const posix = std.posix;
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
const ArenaAllocator = std.heap.ArenaAllocator;
|
||||
|
||||
const log = @import("log.zig");
|
||||
const App = @import("App.zig");
|
||||
const Config = @import("Config.zig");
|
||||
const CDP = @import("cdp/cdp.zig").CDP;
|
||||
const CDP = @import("cdp/CDP.zig");
|
||||
const Net = @import("network/websocket.zig");
|
||||
const HttpClient = @import("browser/HttpClient.zig");
|
||||
|
||||
@@ -212,7 +211,7 @@ pub const Client = struct {
|
||||
http: *HttpClient,
|
||||
ws: Net.WsConnection,
|
||||
|
||||
fn init(
|
||||
pub fn init(
|
||||
socket: posix.socket_t,
|
||||
allocator: Allocator,
|
||||
app: *App,
|
||||
@@ -250,7 +249,7 @@ pub const Client = struct {
|
||||
self.ws.shutdown();
|
||||
}
|
||||
|
||||
fn deinit(self: *Client) void {
|
||||
pub fn deinit(self: *Client) void {
|
||||
switch (self.mode) {
|
||||
.cdp => |*cdp| cdp.deinit(),
|
||||
.http => {},
|
||||
@@ -461,7 +460,7 @@ pub const Client = struct {
|
||||
|
||||
fn upgradeConnection(self: *Client, request: []u8) !void {
|
||||
try self.ws.upgrade(request);
|
||||
self.mode = .{ .cdp = try CDP.init(self.app, self.http, self) };
|
||||
self.mode = .{ .cdp = try CDP.init(self) };
|
||||
}
|
||||
|
||||
fn writeHTTPErrorResponse(self: *Client, comptime status: u16, comptime body: []const u8) void {
|
||||
|
||||
@@ -19,17 +19,13 @@
|
||||
const std = @import("std");
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
const ArenaAllocator = std.heap.ArenaAllocator;
|
||||
|
||||
const js = @import("js/js.zig");
|
||||
const log = @import("../log.zig");
|
||||
const App = @import("../App.zig");
|
||||
const HttpClient = @import("HttpClient.zig");
|
||||
|
||||
const ArenaPool = App.ArenaPool;
|
||||
|
||||
const IS_DEBUG = @import("builtin").mode == .Debug;
|
||||
|
||||
const Session = @import("Session.zig");
|
||||
const Notification = @import("../Notification.zig");
|
||||
|
||||
|
||||
@@ -425,7 +425,7 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts
|
||||
ls.deinit();
|
||||
}
|
||||
|
||||
const activation_state = ActivationState.create(event, target, page);
|
||||
const activation_state = try ActivationState.create(event, target, page);
|
||||
|
||||
// Defer runs even on early return - ensures event phase is reset
|
||||
// and default actions execute (unless prevented)
|
||||
@@ -820,7 +820,7 @@ const ActivationState = struct {
|
||||
|
||||
const Input = Element.Html.Input;
|
||||
|
||||
fn create(event: *const Event, target: *Node, page: *Page) ?ActivationState {
|
||||
fn create(event: *const Event, target: *Node, page: *Page) !?ActivationState {
|
||||
if (event._type_string.eql(comptime .wrap("click")) == false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -27,7 +27,6 @@ const IS_DEBUG = builtin.mode == .Debug;
|
||||
|
||||
const log = @import("../log.zig");
|
||||
|
||||
const App = @import("../App.zig");
|
||||
const String = @import("../string.zig").String;
|
||||
|
||||
const Mime = @import("Mime.zig");
|
||||
@@ -43,7 +42,6 @@ const URL = @import("URL.zig");
|
||||
const Blob = @import("webapi/Blob.zig");
|
||||
const Node = @import("webapi/Node.zig");
|
||||
const Event = @import("webapi/Event.zig");
|
||||
const EventTarget = @import("webapi/EventTarget.zig");
|
||||
const CData = @import("webapi/CData.zig");
|
||||
const Element = @import("webapi/Element.zig");
|
||||
const HtmlElement = @import("webapi/element/Html.zig");
|
||||
@@ -59,7 +57,6 @@ const AbstractRange = @import("webapi/AbstractRange.zig");
|
||||
const MutationObserver = @import("webapi/MutationObserver.zig");
|
||||
const IntersectionObserver = @import("webapi/IntersectionObserver.zig");
|
||||
const CustomElementDefinition = @import("webapi/CustomElementDefinition.zig");
|
||||
const storage = @import("webapi/storage/storage.zig");
|
||||
const PageTransitionEvent = @import("webapi/event/PageTransitionEvent.zig");
|
||||
const SubmitEvent = @import("webapi/event/SubmitEvent.zig");
|
||||
const NavigationKind = @import("webapi/navigation/root.zig").NavigationKind;
|
||||
@@ -67,7 +64,6 @@ const KeyboardEvent = @import("webapi/event/KeyboardEvent.zig");
|
||||
const MouseEvent = @import("webapi/event/MouseEvent.zig");
|
||||
|
||||
const HttpClient = @import("HttpClient.zig");
|
||||
const ArenaPool = App.ArenaPool;
|
||||
|
||||
const timestamp = @import("../datetime.zig").timestamp;
|
||||
const milliTimestamp = @import("../datetime.zig").milliTimestamp;
|
||||
@@ -300,7 +296,9 @@ pub fn init(self: *Page, frame_id: u32, session: *Session, parent: ?*Page) !void
|
||||
._performance = Performance.init(),
|
||||
._screen = screen,
|
||||
._visual_viewport = visual_viewport,
|
||||
._cross_origin_wrapper = undefined,
|
||||
});
|
||||
self.window._cross_origin_wrapper = .{ .window = self.window };
|
||||
|
||||
self._style_manager = try StyleManager.init(self);
|
||||
errdefer self._style_manager.deinit();
|
||||
@@ -383,12 +381,9 @@ pub fn getTitle(self: *Page) !?[]const u8 {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Add comon headers for a request:
|
||||
// * cookies
|
||||
// Add common headers for a request:
|
||||
// * referer
|
||||
pub fn headersForRequest(self: *Page, temp: Allocator, url: [:0]const u8, headers: *HttpClient.Headers) !void {
|
||||
try self.requestCookie(.{}).headersForRequest(temp, url, headers);
|
||||
|
||||
pub fn headersForRequest(self: *Page, headers: *HttpClient.Headers) !void {
|
||||
// Build the referer
|
||||
const referer = blk: {
|
||||
if (self.referer_header == null) {
|
||||
@@ -543,8 +538,6 @@ pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !voi
|
||||
if (opts.header) |hdr| {
|
||||
try headers.add(hdr);
|
||||
}
|
||||
try self.requestCookie(.{ .is_navigation = true }).headersForRequest(self.arena, self.url, &headers);
|
||||
|
||||
// We dispatch page_navigate event before sending the request.
|
||||
// It ensures the event page_navigated is not dispatched before this one.
|
||||
session.notification.dispatch(.page_navigate, &.{
|
||||
@@ -571,6 +564,7 @@ pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !voi
|
||||
.headers = headers,
|
||||
.body = opts.body,
|
||||
.cookie_jar = &session.cookie_jar,
|
||||
.cookie_origin = self.url,
|
||||
.resource_type = .document,
|
||||
.notification = self._session.notification,
|
||||
.header_callback = pageHeaderDoneCallback,
|
||||
@@ -1034,6 +1028,7 @@ fn pageDoneCallback(ctx: *anyopaque) !void {
|
||||
});
|
||||
|
||||
parser.parse(html);
|
||||
self._parse_state = .complete;
|
||||
self.documentIsComplete();
|
||||
},
|
||||
else => unreachable,
|
||||
@@ -3552,19 +3547,6 @@ pub fn insertText(self: *Page, v: []const u8) !void {
|
||||
}
|
||||
}
|
||||
|
||||
const RequestCookieOpts = struct {
|
||||
is_http: bool = true,
|
||||
is_navigation: bool = false,
|
||||
};
|
||||
pub fn requestCookie(self: *const Page, opts: RequestCookieOpts) HttpClient.RequestCookie {
|
||||
return .{
|
||||
.jar = &self._session.cookie_jar,
|
||||
.origin = self.url,
|
||||
.is_http = opts.is_http,
|
||||
.is_navigation = opts.is_navigation,
|
||||
};
|
||||
}
|
||||
|
||||
fn asUint(comptime string: anytype) std.meta.Int(
|
||||
.unsigned,
|
||||
@bitSizeOf(@TypeOf(string.*)) - 8, // (- 8) to exclude sentinel 0
|
||||
@@ -3587,12 +3569,7 @@ test "WebApi: Page" {
|
||||
}
|
||||
|
||||
test "WebApi: Frames" {
|
||||
// TOO FLAKY, disabled for now
|
||||
|
||||
// const filter: testing.LogFilter = .init(&.{.js});
|
||||
// defer filter.deinit();
|
||||
|
||||
// try testing.htmlRunner("frames", .{});
|
||||
try testing.htmlRunner("frames", .{});
|
||||
}
|
||||
|
||||
test "WebApi: Integration" {
|
||||
|
||||
@@ -21,12 +21,9 @@ const lp = @import("lightpanda");
|
||||
const builtin = @import("builtin");
|
||||
|
||||
const log = @import("../log.zig");
|
||||
const App = @import("../App.zig");
|
||||
|
||||
const Page = @import("Page.zig");
|
||||
const Session = @import("Session.zig");
|
||||
const Browser = @import("Browser.zig");
|
||||
const Factory = @import("Factory.zig");
|
||||
const HttpClient = @import("HttpClient.zig");
|
||||
|
||||
const IS_DEBUG = builtin.mode == .Debug;
|
||||
|
||||
@@ -28,12 +28,10 @@ const String = @import("../string.zig").String;
|
||||
const js = @import("js/js.zig");
|
||||
const URL = @import("URL.zig");
|
||||
const Page = @import("Page.zig");
|
||||
const Browser = @import("Browser.zig");
|
||||
|
||||
const Element = @import("webapi/Element.zig");
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
const ArrayList = std.ArrayList;
|
||||
|
||||
const IS_DEBUG = builtin.mode == .Debug;
|
||||
|
||||
@@ -138,9 +136,9 @@ fn clearList(list: *std.DoublyLinkedList) void {
|
||||
}
|
||||
}
|
||||
|
||||
fn getHeaders(self: *ScriptManager, arena: Allocator, url: [:0]const u8) !net_http.Headers {
|
||||
fn getHeaders(self: *ScriptManager) !net_http.Headers {
|
||||
var headers = try self.client.newHeaders();
|
||||
try self.page.headersForRequest(arena, url, &headers);
|
||||
try self.page.headersForRequest(&headers);
|
||||
return headers;
|
||||
}
|
||||
|
||||
@@ -280,9 +278,10 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
|
||||
.ctx = script,
|
||||
.method = .GET,
|
||||
.frame_id = page._frame_id,
|
||||
.headers = try self.getHeaders(arena, url),
|
||||
.headers = try self.getHeaders(),
|
||||
.blocking = is_blocking,
|
||||
.cookie_jar = &page._session.cookie_jar,
|
||||
.cookie_origin = page.url,
|
||||
.resource_type = .script,
|
||||
.notification = page._session.notification,
|
||||
.start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null,
|
||||
@@ -405,8 +404,9 @@ pub fn preloadImport(self: *ScriptManager, url: [:0]const u8, referrer: []const
|
||||
.ctx = script,
|
||||
.method = .GET,
|
||||
.frame_id = page._frame_id,
|
||||
.headers = try self.getHeaders(arena, url),
|
||||
.headers = try self.getHeaders(),
|
||||
.cookie_jar = &page._session.cookie_jar,
|
||||
.cookie_origin = page.url,
|
||||
.resource_type = .script,
|
||||
.notification = page._session.notification,
|
||||
.start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null,
|
||||
@@ -508,10 +508,11 @@ pub fn getAsyncImport(self: *ScriptManager, url: [:0]const u8, cb: ImportAsync.C
|
||||
.url = url,
|
||||
.method = .GET,
|
||||
.frame_id = page._frame_id,
|
||||
.headers = try self.getHeaders(arena, url),
|
||||
.headers = try self.getHeaders(),
|
||||
.ctx = script,
|
||||
.resource_type = .script,
|
||||
.cookie_jar = &page._session.cookie_jar,
|
||||
.cookie_origin = page.url,
|
||||
.notification = page._session.notification,
|
||||
.start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null,
|
||||
.header_callback = Script.headerCallback,
|
||||
@@ -654,7 +655,6 @@ pub const Script = struct {
|
||||
debug_transfer_aborted: bool = false,
|
||||
debug_transfer_bytes_received: usize = 0,
|
||||
debug_transfer_notified_fail: bool = false,
|
||||
debug_transfer_redirecting: bool = false,
|
||||
debug_transfer_intercept_state: u8 = 0,
|
||||
debug_transfer_auth_challenge: bool = false,
|
||||
debug_transfer_easy_id: usize = 0,
|
||||
@@ -730,7 +730,6 @@ pub const Script = struct {
|
||||
.a3 = self.debug_transfer_aborted,
|
||||
.a4 = self.debug_transfer_bytes_received,
|
||||
.a5 = self.debug_transfer_notified_fail,
|
||||
.a6 = self.debug_transfer_redirecting,
|
||||
.a7 = self.debug_transfer_intercept_state,
|
||||
.a8 = self.debug_transfer_auth_challenge,
|
||||
.a9 = self.debug_transfer_easy_id,
|
||||
@@ -739,10 +738,9 @@ pub const Script = struct {
|
||||
.b3 = transfer.aborted,
|
||||
.b4 = transfer.bytes_received,
|
||||
.b5 = transfer._notified_fail,
|
||||
.b6 = transfer._redirecting,
|
||||
.b7 = @intFromEnum(transfer._intercept_state),
|
||||
.b8 = transfer._auth_challenge != null,
|
||||
.b9 = if (transfer._conn) |c| @intFromPtr(c.easy) else 0,
|
||||
.b9 = if (transfer._conn) |c| @intFromPtr(c._easy) else 0,
|
||||
});
|
||||
self.header_callback_called = true;
|
||||
self.debug_transfer_id = transfer.id;
|
||||
@@ -750,10 +748,9 @@ pub const Script = struct {
|
||||
self.debug_transfer_aborted = transfer.aborted;
|
||||
self.debug_transfer_bytes_received = transfer.bytes_received;
|
||||
self.debug_transfer_notified_fail = transfer._notified_fail;
|
||||
self.debug_transfer_redirecting = transfer._redirecting;
|
||||
self.debug_transfer_intercept_state = @intFromEnum(transfer._intercept_state);
|
||||
self.debug_transfer_auth_challenge = transfer._auth_challenge != null;
|
||||
self.debug_transfer_easy_id = if (transfer._conn) |c| @intFromPtr(c.easy) else 0;
|
||||
self.debug_transfer_easy_id = if (transfer._conn) |c| @intFromPtr(c._easy) else 0;
|
||||
}
|
||||
|
||||
lp.assert(self.source.remote.capacity == 0, "ScriptManager.Header buffer", .{ .capacity = self.source.remote.capacity });
|
||||
|
||||
@@ -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 "";
|
||||
const port_sep = findPortSeparator(host) orelse return "";
|
||||
return host[port_sep + 1 ..];
|
||||
}
|
||||
|
||||
if (pos + 1 >= host.len) {
|
||||
return "";
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -128,7 +128,7 @@ fn _constructor(self: *Caller, func: anytype, info: FunctionCallbackInfo) !void
|
||||
const new_this_handle = info.getThis();
|
||||
var this = js.Object{ .local = local, .handle = new_this_handle };
|
||||
if (@typeInfo(ReturnType) == .error_union) {
|
||||
const non_error_res = res catch |err| return err;
|
||||
const non_error_res = try res;
|
||||
this = try local.mapZigInstanceToJs(new_this_handle, non_error_res);
|
||||
} else {
|
||||
this = try local.mapZigInstanceToJs(new_this_handle, res);
|
||||
@@ -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,
|
||||
|
||||
@@ -22,7 +22,6 @@ const log = @import("../../log.zig");
|
||||
|
||||
const js = @import("js.zig");
|
||||
const Env = @import("Env.zig");
|
||||
const bridge = @import("bridge.zig");
|
||||
const Origin = @import("Origin.zig");
|
||||
const Scheduler = @import("Scheduler.zig");
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ const App = @import("../../App.zig");
|
||||
const log = @import("../../log.zig");
|
||||
|
||||
const bridge = @import("bridge.zig");
|
||||
const Origin = @import("Origin.zig");
|
||||
const Context = @import("Context.zig");
|
||||
const Isolate = @import("Isolate.zig");
|
||||
const Platform = @import("Platform.zig");
|
||||
@@ -34,7 +33,6 @@ const Snapshot = @import("Snapshot.zig");
|
||||
const Inspector = @import("Inspector.zig");
|
||||
|
||||
const Page = @import("../Page.zig");
|
||||
const Session = @import("../Session.zig");
|
||||
const Window = @import("../webapi/Window.zig");
|
||||
|
||||
const JsApis = bridge.JsApis;
|
||||
|
||||
@@ -21,7 +21,6 @@ const js = @import("js.zig");
|
||||
const v8 = js.v8;
|
||||
|
||||
const log = @import("../../log.zig");
|
||||
const Session = @import("../Session.zig");
|
||||
|
||||
const Function = @This();
|
||||
|
||||
|
||||
@@ -32,7 +32,6 @@ const js = @import("js.zig");
|
||||
const Session = @import("../Session.zig");
|
||||
|
||||
const v8 = js.v8;
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const Identity = @This();
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
const Page = @import("../Page.zig");
|
||||
const Session = @import("../Session.zig");
|
||||
const log = @import("../../log.zig");
|
||||
const string = @import("../../string.zig");
|
||||
@@ -33,7 +32,6 @@ const IS_DEBUG = @import("builtin").mode == .Debug;
|
||||
|
||||
const v8 = js.v8;
|
||||
const CallOpts = Caller.CallOpts;
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
// Where js.Context has a lifetime tied to the page, and holds the
|
||||
// v8::Global<v8::Context>, this has a much shorter lifetime and holds a
|
||||
|
||||
@@ -20,8 +20,6 @@ const std = @import("std");
|
||||
const js = @import("js.zig");
|
||||
const v8 = js.v8;
|
||||
|
||||
const Session = @import("../Session.zig");
|
||||
|
||||
const Promise = @This();
|
||||
|
||||
local: *const js.Local,
|
||||
|
||||
@@ -25,7 +25,6 @@ const IS_DEBUG = @import("builtin").mode == .Debug;
|
||||
|
||||
const v8 = js.v8;
|
||||
const JsApis = bridge.JsApis;
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const Snapshot = @This();
|
||||
|
||||
@@ -137,7 +136,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 +418,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 +428,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 +481,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 +501,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) {
|
||||
if (value.static) {
|
||||
v8.v8__Template__SetAccessorProperty__DEFAULT(@ptrCast(template), js_name, getter_callback);
|
||||
} else {
|
||||
v8.v8__ObjectTemplate__SetAccessorProperty__DEFAULT(prototype, js_name, getter_callback);
|
||||
}
|
||||
attribute |= v8.ReadOnly;
|
||||
}
|
||||
if (value.deletable == false) {
|
||||
attribute |= v8.DontDelete;
|
||||
}
|
||||
|
||||
if (value.static) {
|
||||
// Static accessors: use Template's SetAccessorProperty
|
||||
v8.v8__Template__SetAccessorProperty(@ptrCast(template), js_name, getter_callback, setter_callback, attribute);
|
||||
} 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 +579,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
|
||||
|
||||
@@ -25,7 +25,6 @@ const v8 = js.v8;
|
||||
const IS_DEBUG = @import("builtin").mode == .Debug;
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
const Session = @import("../Session.zig");
|
||||
|
||||
const Value = @This();
|
||||
|
||||
|
||||
@@ -18,15 +18,12 @@
|
||||
|
||||
const std = @import("std");
|
||||
const js = @import("js.zig");
|
||||
const lp = @import("lightpanda");
|
||||
const log = @import("../../log.zig");
|
||||
const Page = @import("../Page.zig");
|
||||
const Session = @import("../Session.zig");
|
||||
|
||||
const v8 = js.v8;
|
||||
|
||||
const Caller = @import("Caller.zig");
|
||||
const Context = @import("Context.zig");
|
||||
|
||||
const IS_DEBUG = @import("builtin").mode == .Debug;
|
||||
|
||||
@@ -198,6 +195,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 +204,7 @@ pub const Accessor = struct {
|
||||
var accessor = Accessor{
|
||||
.cache = opts.cache,
|
||||
.static = opts.static,
|
||||
.deletable = opts.deletable,
|
||||
};
|
||||
|
||||
if (@typeInfo(@TypeOf(getter)) != .null) {
|
||||
@@ -851,6 +850,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
54
src/browser/links.zig
Normal 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;
|
||||
}
|
||||
@@ -21,7 +21,6 @@ const std = @import("std");
|
||||
const Page = @import("Page.zig");
|
||||
const URL = @import("URL.zig");
|
||||
const TreeWalker = @import("webapi/TreeWalker.zig");
|
||||
const CData = @import("webapi/CData.zig");
|
||||
const Element = @import("webapi/Element.zig");
|
||||
const Node = @import("webapi/Node.zig");
|
||||
const isAllWhitespace = @import("../string.zig").isAllWhitespace;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -118,24 +118,24 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=link_click>
|
||||
testing.async(async (restore) => {
|
||||
let f6;
|
||||
await new Promise((resolve) => {
|
||||
let count = 0;
|
||||
f6 = document.createElement('iframe');
|
||||
f6.id = 'f6';
|
||||
f6.addEventListener('load', () => {
|
||||
if (++count == 2) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
f6.contentDocument.querySelector('#link').click();
|
||||
});
|
||||
f6.src = "support/with_link.html";
|
||||
document.documentElement.appendChild(f6);
|
||||
});
|
||||
restore();
|
||||
<script id=link_click type=module>
|
||||
const state = await testing.async();
|
||||
|
||||
let count = 0;
|
||||
let f6 = document.createElement('iframe');
|
||||
f6.id = 'f6';
|
||||
f6.addEventListener('load', () => {
|
||||
if (++count == 2) {
|
||||
state.resolve();
|
||||
return;
|
||||
}
|
||||
f6.contentDocument.querySelector('#link').click();
|
||||
});
|
||||
|
||||
f6.src = 'support/with_link.html';
|
||||
document.documentElement.appendChild(f6);
|
||||
|
||||
await state.done(() => {
|
||||
testing.expectEqual("<html><head></head><body>It was clicked!\n</body></html>", f6.contentDocument.documentElement.outerHTML);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
{
|
||||
let reply = null;
|
||||
window.addEventListener('message', (e) => {
|
||||
console.warn('reply')
|
||||
reply = e.data;
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<!DOCTYPE html>
|
||||
<script>
|
||||
window.addEventListener('message', (e) => {
|
||||
console.warn('Frame Message', e.data);
|
||||
if (e.data === 'ping') {
|
||||
window.top.postMessage({data: 'pong', origin: e.origin}, '*');
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
let eventuallies = [];
|
||||
let async_capture = null;
|
||||
let current_script_id = null;
|
||||
let async_pending = 0;
|
||||
|
||||
function expectTrue(actual) {
|
||||
expectEqual(true, actual);
|
||||
@@ -64,6 +65,25 @@
|
||||
}
|
||||
|
||||
async function async(cb) {
|
||||
if (cb == undefined) {
|
||||
let resolve = null
|
||||
const promise = new Promise((r) => { resolve = r});
|
||||
async_pending += 1;
|
||||
|
||||
return {
|
||||
promise: promise,
|
||||
resolve: resolve,
|
||||
capture: {script_id: document.currentScript.id, stack: new Error().stack},
|
||||
done: async function(cb) {
|
||||
await this.promise;
|
||||
async_pending -= 1;
|
||||
async_capture = this.capture;
|
||||
cb();
|
||||
async_capture = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let capture = {script_id: document.currentScript.id, stack: new Error().stack};
|
||||
await cb(() => { async_capture = capture; });
|
||||
async_capture = null;
|
||||
@@ -74,6 +94,10 @@
|
||||
throw new Error('Failed');
|
||||
}
|
||||
|
||||
if (async_pending > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let e of eventuallies) {
|
||||
current_script_id = e.script_id;
|
||||
e.callback();
|
||||
@@ -97,6 +121,8 @@
|
||||
throw new Error(`script id: '${script_id}' failed: ${status || 'no assertions'}`);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const IS_TEST_RUNNER = window.navigator.userAgent.startsWith("Lightpanda/");
|
||||
|
||||
51
src/browser/tests/window/cross_origin.html
Normal file
51
src/browser/tests/window/cross_origin.html
Normal file
@@ -0,0 +1,51 @@
|
||||
<!DOCTYPE html>
|
||||
<script src="../testing.js"></script>
|
||||
|
||||
<iframe src=support/frame1.html></iframe>
|
||||
|
||||
<script id=post_message type=module>
|
||||
const state = await testing.async();
|
||||
|
||||
{
|
||||
const ALT_BASE = testing.BASE_URL.replace('127.0.0.1', 'localhost');
|
||||
|
||||
{
|
||||
let iframe2 = document.createElement('iframe');
|
||||
iframe2.src = ALT_BASE + 'window/support/frame1.html';
|
||||
document.documentElement.appendChild(iframe2);
|
||||
}
|
||||
|
||||
{
|
||||
let iframe3 = document.createElement('iframe');
|
||||
iframe3.src = ALT_BASE + 'window/support/frame2.html';
|
||||
document.documentElement.appendChild(iframe3);
|
||||
}
|
||||
|
||||
let captures = [];
|
||||
window.addEventListener('message', (e) => {
|
||||
captures.push(e.data);
|
||||
if (captures.length == 3) {
|
||||
state.resolve();
|
||||
}
|
||||
});
|
||||
|
||||
await state.done(() => {
|
||||
const expected_urls = [
|
||||
testing.BASE_URL + 'window/support/frame1.html',
|
||||
ALT_BASE + 'window/support/frame1.html',
|
||||
ALT_BASE + 'window/support/frame2.html',
|
||||
];
|
||||
|
||||
// No strong order guarantee for messaages, and we don't care about the order
|
||||
// so long as it's the correct data.
|
||||
testing.expectEqual(expected_urls.sort(), captures.map((c) => {return c.url}).sort());
|
||||
captures.forEach((c) => {
|
||||
if (c.url.includes(testing.BASE_URL)) {
|
||||
testing.expectEqual(false, c.document_is_undefined);
|
||||
} else {
|
||||
testing.expectEqual(true, c.document_is_undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
7
src/browser/tests/window/support/frame1.html
Normal file
7
src/browser/tests/window/support/frame1.html
Normal file
@@ -0,0 +1,7 @@
|
||||
<!DOCTYPE html>
|
||||
<script>
|
||||
window.parent.postMessage({
|
||||
url: location.toString(),
|
||||
document_is_undefined: window.parent.document === undefined,
|
||||
}, '*')
|
||||
</script>
|
||||
7
src/browser/tests/window/support/frame2.html
Normal file
7
src/browser/tests/window/support/frame2.html
Normal file
@@ -0,0 +1,7 @@
|
||||
<!DOCTYPE html>
|
||||
<script>
|
||||
window.top.postMessage({
|
||||
url: location.toString(),
|
||||
document_is_undefined: window.top.document === undefined,
|
||||
}, '*')
|
||||
</script>
|
||||
@@ -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;
|
||||
|
||||
@@ -24,7 +24,6 @@ const Page = @import("../Page.zig");
|
||||
|
||||
const Node = @import("Node.zig");
|
||||
const Element = @import("Element.zig");
|
||||
const DOMException = @import("DOMException.zig");
|
||||
const Custom = @import("element/html/Custom.zig");
|
||||
const CustomElementDefinition = @import("CustomElementDefinition.zig");
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
|
||||
const std = @import("std");
|
||||
const js = @import("../js/js.zig");
|
||||
const String = @import("../../string.zig").String;
|
||||
|
||||
const Page = @import("../Page.zig");
|
||||
const Node = @import("Node.zig");
|
||||
|
||||
@@ -19,10 +19,8 @@
|
||||
const std = @import("std");
|
||||
|
||||
const String = @import("../../string.zig").String;
|
||||
const log = @import("../../log.zig");
|
||||
|
||||
const js = @import("../js/js.zig");
|
||||
const color = @import("../color.zig");
|
||||
const Page = @import("../Page.zig");
|
||||
|
||||
/// https://developer.mozilla.org/en-US/docs/Web/API/ImageData/ImageData
|
||||
|
||||
@@ -25,6 +25,8 @@ const Allocator = std.mem.Allocator;
|
||||
|
||||
const Page = @import("../Page.zig");
|
||||
const Session = @import("../Session.zig");
|
||||
|
||||
const Node = @import("Node.zig");
|
||||
const Element = @import("Element.zig");
|
||||
const DOMRect = @import("DOMRect.zig");
|
||||
|
||||
@@ -55,7 +57,7 @@ var zero_rect: DOMRect = .{
|
||||
};
|
||||
|
||||
pub const ObserverInit = struct {
|
||||
root: ?*Element = null,
|
||||
root: ?*Node = null,
|
||||
rootMargin: ?[]const u8 = null,
|
||||
threshold: Threshold = .{ .scalar = 0.0 },
|
||||
|
||||
@@ -81,11 +83,25 @@ pub fn init(callback: js.Function.Temp, options: ?ObserverInit, page: *Page) !*I
|
||||
.array => |arr| try arena.dupe(f64, arr),
|
||||
};
|
||||
|
||||
const root: ?*Element = blk: {
|
||||
const root_opt = opts.root orelse break :blk null;
|
||||
switch (root_opt._type) {
|
||||
.element => |el| break :blk el,
|
||||
.document => {
|
||||
// not strictly correct, `null` means the viewport, not the
|
||||
// entire document, but since we don't render anything, this
|
||||
// should be fine.
|
||||
break :blk null;
|
||||
},
|
||||
else => return error.TypeError,
|
||||
}
|
||||
};
|
||||
|
||||
const self = try arena.create(IntersectionObserver);
|
||||
self.* = .{
|
||||
._arena = arena,
|
||||
._callback = callback,
|
||||
._root = opts.root,
|
||||
._root = root,
|
||||
._root_margin = root_margin,
|
||||
._threshold = threshold,
|
||||
};
|
||||
|
||||
@@ -28,8 +28,6 @@ const DocumentFragment = @import("DocumentFragment.zig");
|
||||
const AbstractRange = @import("AbstractRange.zig");
|
||||
const DOMRect = @import("DOMRect.zig");
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const Range = @This();
|
||||
|
||||
_proto: *AbstractRange,
|
||||
|
||||
@@ -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, .{});
|
||||
};
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
const log = @import("../../log.zig");
|
||||
|
||||
const js = @import("../js/js.zig");
|
||||
const Page = @import("../Page.zig");
|
||||
@@ -27,7 +26,6 @@ const Range = @import("Range.zig");
|
||||
const AbstractRange = @import("AbstractRange.zig");
|
||||
const Node = @import("Node.zig");
|
||||
const Event = @import("Event.zig");
|
||||
const Document = @import("Document.zig");
|
||||
|
||||
/// https://w3c.github.io/selection-api/
|
||||
const Selection = @This();
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
const js = @import("../js/js.zig");
|
||||
const Page = @import("../Page.zig");
|
||||
const EventTarget = @import("EventTarget.zig");
|
||||
const Window = @import("Window.zig");
|
||||
|
||||
const VisualViewport = @This();
|
||||
|
||||
|
||||
@@ -49,6 +49,10 @@ const IS_DEBUG = builtin.mode == .Debug;
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
pub fn registerTypes() []const type {
|
||||
return &.{ Window, CrossOriginWindow };
|
||||
}
|
||||
|
||||
const Window = @This();
|
||||
|
||||
_proto: *EventTarget,
|
||||
@@ -87,6 +91,8 @@ _scroll_pos: struct {
|
||||
.y = 0,
|
||||
.state = .done,
|
||||
},
|
||||
// A cross origin wrapper for this window
|
||||
_cross_origin_wrapper: CrossOriginWindow,
|
||||
|
||||
pub fn asEventTarget(self: *Window) *EventTarget {
|
||||
return self._proto;
|
||||
@@ -104,19 +110,19 @@ pub fn getWindow(self: *Window) *Window {
|
||||
return self;
|
||||
}
|
||||
|
||||
pub fn getTop(self: *Window) *Window {
|
||||
pub fn getTop(self: *Window, page: *Page) Access {
|
||||
var p = self._page;
|
||||
while (p.parent) |parent| {
|
||||
p = parent;
|
||||
}
|
||||
return p.window;
|
||||
return Access.init(page.window, p.window);
|
||||
}
|
||||
|
||||
pub fn getParent(self: *Window) *Window {
|
||||
pub fn getParent(self: *Window, page: *Page) Access {
|
||||
if (self._page.parent) |p| {
|
||||
return p.window;
|
||||
return Access.init(page.window, p.window);
|
||||
}
|
||||
return self;
|
||||
return .{ .window = self };
|
||||
}
|
||||
|
||||
pub fn getDocument(self: *Window) *Document {
|
||||
@@ -606,6 +612,25 @@ pub fn unhandledPromiseRejection(self: *Window, no_handler: bool, rejection: js.
|
||||
}
|
||||
}
|
||||
|
||||
pub const Access = union(enum) {
|
||||
window: *Window,
|
||||
cross_origin: *CrossOriginWindow,
|
||||
|
||||
pub fn init(callee: *Window, accessing: *Window) Access {
|
||||
if (callee == accessing) {
|
||||
// common enough that it's worth the check
|
||||
return .{ .window = accessing };
|
||||
}
|
||||
|
||||
if (callee._page.js.origin == accessing._page.js.origin) {
|
||||
// two different windows, but same origin, return the full window
|
||||
return .{ .window = accessing };
|
||||
}
|
||||
|
||||
return .{ .cross_origin = &accessing._cross_origin_wrapper };
|
||||
}
|
||||
};
|
||||
|
||||
const ScheduleOpts = struct {
|
||||
repeat: bool,
|
||||
params: []js.Value.Temp,
|
||||
@@ -804,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, .{});
|
||||
@@ -817,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, .{});
|
||||
@@ -892,6 +917,41 @@ pub const JsApi = struct {
|
||||
}.prompt, .{});
|
||||
};
|
||||
|
||||
const CrossOriginWindow = struct {
|
||||
window: *Window,
|
||||
|
||||
pub fn postMessage(self: *CrossOriginWindow, message: js.Value.Temp, target_origin: ?[]const u8, page: *Page) !void {
|
||||
return self.window.postMessage(message, target_origin, page);
|
||||
}
|
||||
|
||||
pub fn getTop(self: *CrossOriginWindow, page: *Page) Access {
|
||||
return self.window.getParent(page);
|
||||
}
|
||||
|
||||
pub fn getParent(self: *CrossOriginWindow, page: *Page) Access {
|
||||
return self.window.getParent(page);
|
||||
}
|
||||
|
||||
pub fn getFramesLength(self: *const CrossOriginWindow) u32 {
|
||||
return self.window.getFramesLength();
|
||||
}
|
||||
|
||||
pub const JsApi = struct {
|
||||
pub const bridge = js.Bridge(CrossOriginWindow);
|
||||
|
||||
pub const Meta = struct {
|
||||
pub const name = "CrossOriginWindow";
|
||||
pub const prototype_chain = bridge.prototypeChain();
|
||||
pub var class_id: bridge.ClassId = undefined;
|
||||
};
|
||||
|
||||
pub const postMessage = bridge.function(CrossOriginWindow.postMessage, .{});
|
||||
pub const top = bridge.accessor(CrossOriginWindow.getTop, null, .{});
|
||||
pub const parent = bridge.accessor(CrossOriginWindow.getParent, null, .{});
|
||||
pub const length = bridge.accessor(CrossOriginWindow.getFramesLength, null, .{});
|
||||
};
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "WebApi: Window" {
|
||||
try testing.htmlRunner("window", .{});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
// 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 String = @import("../../../string.zig").String;
|
||||
const js = @import("../../js/js.zig");
|
||||
const Page = @import("../../Page.zig");
|
||||
const CData = @import("../CData.zig");
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
|
||||
const std = @import("std");
|
||||
const lp = @import("lightpanda");
|
||||
const log = @import("../../../log.zig");
|
||||
const crypto = @import("../../../sys/libcrypto.zig");
|
||||
|
||||
const Page = @import("../../Page.zig");
|
||||
|
||||
@@ -20,12 +20,10 @@
|
||||
|
||||
const std = @import("std");
|
||||
const lp = @import("lightpanda");
|
||||
const log = @import("../../../log.zig");
|
||||
const crypto = @import("../../../sys/libcrypto.zig");
|
||||
|
||||
const Page = @import("../../Page.zig");
|
||||
const js = @import("../../js/js.zig");
|
||||
const Algorithm = @import("algorithm.zig").Algorithm;
|
||||
|
||||
const CryptoKey = @import("../CryptoKey.zig");
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ const reflect = @import("../../reflect.zig");
|
||||
const log = @import("../../../log.zig");
|
||||
|
||||
const global_event_handlers = @import("../global_event_handlers.zig");
|
||||
const GlobalEventHandlersLookup = global_event_handlers.Lookup;
|
||||
const GlobalEventHandler = global_event_handlers.Handler;
|
||||
|
||||
const Page = @import("../../Page.zig");
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
// 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 log = @import("../../../../log.zig");
|
||||
const js = @import("../../../js/js.zig");
|
||||
const Page = @import("../../../Page.zig");
|
||||
const Window = @import("../../Window.zig");
|
||||
@@ -39,8 +38,9 @@ pub fn asNode(self: *IFrame) *Node {
|
||||
return self.asElement().asNode();
|
||||
}
|
||||
|
||||
pub fn getContentWindow(self: *const IFrame) ?*Window {
|
||||
return self._window;
|
||||
pub fn getContentWindow(self: *const IFrame, page: *Page) ?Window.Access {
|
||||
const frame_window = self._window orelse return null;
|
||||
return Window.Access.init(page.window, frame_window);
|
||||
}
|
||||
|
||||
pub fn getContentDocument(self: *const IFrame) ?*Document {
|
||||
|
||||
@@ -5,10 +5,6 @@ const URL = @import("../../../URL.zig");
|
||||
const Node = @import("../../Node.zig");
|
||||
const Element = @import("../../Element.zig");
|
||||
const HtmlElement = @import("../Html.zig");
|
||||
const Event = @import("../../Event.zig");
|
||||
const log = @import("../../../../log.zig");
|
||||
|
||||
const IS_DEBUG = @import("builtin").mode == .Debug;
|
||||
|
||||
const Image = @This();
|
||||
_proto: *HtmlElement,
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
const std = @import("std");
|
||||
|
||||
const log = @import("../../../../log.zig");
|
||||
const js = @import("../../../js/js.zig");
|
||||
const Page = @import("../../../Page.zig");
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ const js = @import("../../js/js.zig");
|
||||
const Page = @import("../../Page.zig");
|
||||
const Session = @import("../../Session.zig");
|
||||
const Event = @import("../Event.zig");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const CompositionEvent = @This();
|
||||
|
||||
|
||||
94
src/browser/webapi/event/FormDataEvent.zig
Normal file
94
src/browser/webapi/event/FormDataEvent.zig
Normal file
@@ -0,0 +1,94 @@
|
||||
// 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 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, .{});
|
||||
};
|
||||
@@ -22,7 +22,6 @@ const js = @import("../../js/js.zig");
|
||||
const Page = @import("../../Page.zig");
|
||||
const Session = @import("../../Session.zig");
|
||||
const Event = @import("../Event.zig");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const PromiseRejectionEvent = @This();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -27,8 +27,6 @@ const Page = @import("../../Page.zig");
|
||||
const Event = @import("../Event.zig");
|
||||
const EventTarget = @import("../EventTarget.zig");
|
||||
|
||||
const IS_DEBUG = @import("builtin").mode == .Debug;
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Navigation
|
||||
const Navigation = @This();
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
|
||||
const std = @import("std");
|
||||
const URL = @import("../URL.zig");
|
||||
const EventTarget = @import("../EventTarget.zig");
|
||||
const NavigationState = @import("root.zig").NavigationState;
|
||||
const Page = @import("../../Page.zig");
|
||||
const js = @import("../../js/js.zig");
|
||||
|
||||
@@ -80,7 +80,7 @@ pub fn init(input: Input, options: ?InitOpts, page: *Page) !js.Promise {
|
||||
if (request._headers) |h| {
|
||||
try h.populateHttpHeader(page.call_arena, &headers);
|
||||
}
|
||||
try page.headersForRequest(page.arena, request._url, &headers);
|
||||
try page.headersForRequest(&headers);
|
||||
|
||||
if (comptime IS_DEBUG) {
|
||||
log.debug(.http, "fetch", .{ .url = request._url });
|
||||
@@ -95,6 +95,7 @@ pub fn init(input: Input, options: ?InitOpts, page: *Page) !js.Promise {
|
||||
.headers = headers,
|
||||
.resource_type = .fetch,
|
||||
.cookie_jar = &page._session.cookie_jar,
|
||||
.cookie_origin = page.url,
|
||||
.notification = page._session.notification,
|
||||
.start_callback = httpStartCallback,
|
||||
.header_callback = httpHeaderDoneCallback,
|
||||
|
||||
@@ -22,7 +22,6 @@ const log = @import("../../../log.zig");
|
||||
|
||||
const js = @import("../../js/js.zig");
|
||||
const Page = @import("../../Page.zig");
|
||||
const Node = @import("../Node.zig");
|
||||
const Form = @import("../element/html/Form.zig");
|
||||
const Element = @import("../Element.zig");
|
||||
const KeyValueList = @import("../KeyValueList.zig");
|
||||
@@ -35,10 +34,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 {
|
||||
|
||||
@@ -29,7 +29,6 @@ const Page = @import("../../Page.zig");
|
||||
const Session = @import("../../Session.zig");
|
||||
|
||||
const Node = @import("../Node.zig");
|
||||
const Blob = @import("../Blob.zig");
|
||||
const Event = @import("../Event.zig");
|
||||
const Headers = @import("Headers.zig");
|
||||
const EventTarget = @import("../EventTarget.zig");
|
||||
@@ -225,7 +224,7 @@ pub fn send(self: *XMLHttpRequest, body_: ?[]const u8) !void {
|
||||
|
||||
try self._request_headers.populateHttpHeader(page.call_arena, &headers);
|
||||
if (cookie_support) {
|
||||
try page.headersForRequest(self._arena, self._url, &headers);
|
||||
try page.headersForRequest(&headers);
|
||||
}
|
||||
|
||||
try http_client.request(.{
|
||||
@@ -236,6 +235,7 @@ pub fn send(self: *XMLHttpRequest, body_: ?[]const u8) !void {
|
||||
.frame_id = page._frame_id,
|
||||
.body = self._request_body,
|
||||
.cookie_jar = if (cookie_support) &page._session.cookie_jar else null,
|
||||
.cookie_origin = page.url,
|
||||
.resource_type = .xhr,
|
||||
.notification = page._session.notification,
|
||||
.start_callback = httpStartCallback,
|
||||
|
||||
@@ -22,297 +22,292 @@ const lp = @import("lightpanda");
|
||||
const Allocator = std.mem.Allocator;
|
||||
const json = std.json;
|
||||
|
||||
const log = @import("../log.zig");
|
||||
const js = @import("../browser/js/js.zig");
|
||||
const Incrementing = @import("id.zig").Incrementing;
|
||||
|
||||
const App = @import("../App.zig");
|
||||
const log = @import("../log.zig");
|
||||
const Notification = @import("../Notification.zig");
|
||||
|
||||
const Client = @import("../Server.zig").Client;
|
||||
|
||||
const js = @import("../browser/js/js.zig");
|
||||
const Browser = @import("../browser/Browser.zig");
|
||||
const Session = @import("../browser/Session.zig");
|
||||
const HttpClient = @import("../browser/HttpClient.zig");
|
||||
const Page = @import("../browser/Page.zig");
|
||||
const Incrementing = @import("id.zig").Incrementing;
|
||||
const Notification = @import("../Notification.zig");
|
||||
const InterceptState = @import("domains/fetch.zig").InterceptState;
|
||||
const Mime = @import("../browser/Mime.zig");
|
||||
|
||||
const InterceptState = @import("domains/fetch.zig").InterceptState;
|
||||
|
||||
pub const URL_BASE = "chrome://newtab/";
|
||||
|
||||
const IS_DEBUG = @import("builtin").mode == .Debug;
|
||||
|
||||
pub const CDP = CDPT(struct {
|
||||
const Client = *@import("../Server.zig").Client;
|
||||
});
|
||||
|
||||
const SessionIdGen = Incrementing(u32, "SID");
|
||||
const TargetIdGen = Incrementing(u32, "TID");
|
||||
const SessionIdGen = Incrementing(u32, "SID");
|
||||
const BrowserContextIdGen = Incrementing(u32, "BID");
|
||||
|
||||
// Generic so that we can inject mocks into it.
|
||||
pub fn CDPT(comptime TypeProvider: type) type {
|
||||
return struct {
|
||||
// Used for sending message to the client and closing on error
|
||||
client: TypeProvider.Client,
|
||||
const CDP = @This();
|
||||
|
||||
allocator: Allocator,
|
||||
// Used for sending message to the client and closing on error
|
||||
client: *Client,
|
||||
|
||||
// The active browser
|
||||
browser: Browser,
|
||||
allocator: Allocator,
|
||||
|
||||
// when true, any target creation must be attached.
|
||||
target_auto_attach: bool = false,
|
||||
// The active browser
|
||||
browser: Browser,
|
||||
|
||||
target_id_gen: TargetIdGen = .{},
|
||||
session_id_gen: SessionIdGen = .{},
|
||||
browser_context_id_gen: BrowserContextIdGen = .{},
|
||||
// when true, any target creation must be attached.
|
||||
target_auto_attach: bool = false,
|
||||
|
||||
browser_context: ?BrowserContext(Self),
|
||||
target_id_gen: TargetIdGen = .{},
|
||||
session_id_gen: SessionIdGen = .{},
|
||||
browser_context_id_gen: BrowserContextIdGen = .{},
|
||||
|
||||
// Re-used arena for processing a message. We're assuming that we're getting
|
||||
// 1 message at a time.
|
||||
message_arena: std.heap.ArenaAllocator,
|
||||
browser_context: ?BrowserContext(CDP),
|
||||
|
||||
// Used for processing notifications within a browser context.
|
||||
notification_arena: std.heap.ArenaAllocator,
|
||||
// Re-used arena for processing a message. We're assuming that we're getting
|
||||
// 1 message at a time.
|
||||
message_arena: std.heap.ArenaAllocator,
|
||||
|
||||
// Valid for 1 page navigation (what CDP calls a "renderer")
|
||||
page_arena: std.heap.ArenaAllocator,
|
||||
// Used for processing notifications within a browser context.
|
||||
notification_arena: std.heap.ArenaAllocator,
|
||||
|
||||
// Valid for the entire lifetime of the BrowserContext. Should minimize
|
||||
// (or altogether elimiate) our use of this.
|
||||
browser_context_arena: std.heap.ArenaAllocator,
|
||||
// Valid for 1 page navigation (what CDP calls a "renderer")
|
||||
page_arena: std.heap.ArenaAllocator,
|
||||
|
||||
const Self = @This();
|
||||
// Valid for the entire lifetime of the BrowserContext. Should minimize
|
||||
// (or altogether elimiate) our use of this.
|
||||
browser_context_arena: std.heap.ArenaAllocator,
|
||||
|
||||
pub fn init(app: *App, http_client: *HttpClient, client: TypeProvider.Client) !Self {
|
||||
const allocator = app.allocator;
|
||||
const browser = try Browser.init(app, .{
|
||||
.env = .{ .with_inspector = true },
|
||||
.http_client = http_client,
|
||||
});
|
||||
errdefer browser.deinit();
|
||||
pub fn init(client: *Client) !CDP {
|
||||
const app = client.app;
|
||||
const allocator = app.allocator;
|
||||
const browser = try Browser.init(app, .{
|
||||
.env = .{ .with_inspector = true },
|
||||
.http_client = client.http,
|
||||
});
|
||||
errdefer browser.deinit();
|
||||
|
||||
return .{
|
||||
.client = client,
|
||||
.browser = browser,
|
||||
.allocator = allocator,
|
||||
.browser_context = null,
|
||||
.page_arena = std.heap.ArenaAllocator.init(allocator),
|
||||
.message_arena = std.heap.ArenaAllocator.init(allocator),
|
||||
.notification_arena = std.heap.ArenaAllocator.init(allocator),
|
||||
.browser_context_arena = std.heap.ArenaAllocator.init(allocator),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Self) void {
|
||||
if (self.browser_context) |*bc| {
|
||||
bc.deinit();
|
||||
}
|
||||
self.browser.deinit();
|
||||
self.page_arena.deinit();
|
||||
self.message_arena.deinit();
|
||||
self.notification_arena.deinit();
|
||||
self.browser_context_arena.deinit();
|
||||
}
|
||||
|
||||
pub fn handleMessage(self: *Self, msg: []const u8) bool {
|
||||
// if there's an error, it's already been logged
|
||||
self.processMessage(msg) catch return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
pub fn processMessage(self: *Self, msg: []const u8) !void {
|
||||
const arena = &self.message_arena;
|
||||
defer _ = arena.reset(.{ .retain_with_limit = 1024 * 16 });
|
||||
return self.dispatch(arena.allocator(), self, msg);
|
||||
}
|
||||
|
||||
// @newhttp
|
||||
// A bit hacky right now. The main server loop doesn't unblock for
|
||||
// scheduled task. So we run this directly in order to process any
|
||||
// timeouts (or http events) which are ready to be processed.
|
||||
pub fn pageWait(self: *Self, ms: u32) !Session.Runner.CDPWaitResult {
|
||||
const session = &(self.browser.session orelse return error.NoPage);
|
||||
var runner = try session.runner(.{});
|
||||
return runner.waitCDP(.{ .ms = ms });
|
||||
}
|
||||
|
||||
// Called from above, in processMessage which handles client messages
|
||||
// but can also be called internally. For example, Target.sendMessageToTarget
|
||||
// calls back into dispatch to capture the response.
|
||||
pub fn dispatch(self: *Self, arena: Allocator, sender: anytype, str: []const u8) !void {
|
||||
const input = json.parseFromSliceLeaky(InputMessage, arena, str, .{
|
||||
.ignore_unknown_fields = true,
|
||||
}) catch return error.InvalidJSON;
|
||||
|
||||
var command = Command(Self, @TypeOf(sender)){
|
||||
.input = .{
|
||||
.json = str,
|
||||
.id = input.id,
|
||||
.action = "",
|
||||
.params = input.params,
|
||||
.session_id = input.sessionId,
|
||||
},
|
||||
.cdp = self,
|
||||
.arena = arena,
|
||||
.sender = sender,
|
||||
.browser_context = if (self.browser_context) |*bc| bc else null,
|
||||
};
|
||||
|
||||
// See dispatchStartupCommand for more info on this.
|
||||
var is_startup = false;
|
||||
if (input.sessionId) |input_session_id| {
|
||||
if (std.mem.eql(u8, input_session_id, "STARTUP")) {
|
||||
is_startup = true;
|
||||
} else if (self.isValidSessionId(input_session_id) == false) {
|
||||
return command.sendError(-32001, "Unknown sessionId", .{});
|
||||
}
|
||||
}
|
||||
|
||||
if (is_startup) {
|
||||
dispatchStartupCommand(&command, input.method) catch |err| {
|
||||
command.sendError(-31999, @errorName(err), .{}) catch return err;
|
||||
};
|
||||
} else {
|
||||
dispatchCommand(&command, input.method) catch |err| {
|
||||
command.sendError(-31998, @errorName(err), .{}) catch return err;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// A CDP session isn't 100% fully driven by the driver. There's are
|
||||
// independent actions that the browser is expected to take. For example
|
||||
// Puppeteer expects the browser to startup a tab and thus have existing
|
||||
// targets.
|
||||
// To this end, we create a [very] dummy BrowserContext, Target and
|
||||
// Session. There isn't actually a BrowserContext, just a special id.
|
||||
// When messages are received with the "STARTUP" sessionId, we do
|
||||
// "special" handling - the bare minimum we need to do until the driver
|
||||
// switches to a real BrowserContext.
|
||||
// (I can imagine this logic will become driver-specific)
|
||||
fn dispatchStartupCommand(command: anytype, method: []const u8) !void {
|
||||
// Stagehand parses the response and error if we don't return a
|
||||
// correct one for Page.getFrameTree on startup call.
|
||||
if (std.mem.eql(u8, method, "Page.getFrameTree")) {
|
||||
// The Page.getFrameTree handles startup response gracefully.
|
||||
return dispatchCommand(command, method);
|
||||
}
|
||||
|
||||
return command.sendResult(null, .{});
|
||||
}
|
||||
|
||||
fn dispatchCommand(command: anytype, method: []const u8) !void {
|
||||
const domain = blk: {
|
||||
const i = std.mem.indexOfScalarPos(u8, method, 0, '.') orelse {
|
||||
return error.InvalidMethod;
|
||||
};
|
||||
command.input.action = method[i + 1 ..];
|
||||
break :blk method[0..i];
|
||||
};
|
||||
|
||||
switch (domain.len) {
|
||||
2 => switch (@as(u16, @bitCast(domain[0..2].*))) {
|
||||
asUint(u16, "LP") => return @import("domains/lp.zig").processMessage(command),
|
||||
else => {},
|
||||
},
|
||||
3 => switch (@as(u24, @bitCast(domain[0..3].*))) {
|
||||
asUint(u24, "DOM") => return @import("domains/dom.zig").processMessage(command),
|
||||
asUint(u24, "Log") => return @import("domains/log.zig").processMessage(command),
|
||||
asUint(u24, "CSS") => return @import("domains/css.zig").processMessage(command),
|
||||
else => {},
|
||||
},
|
||||
4 => switch (@as(u32, @bitCast(domain[0..4].*))) {
|
||||
asUint(u32, "Page") => return @import("domains/page.zig").processMessage(command),
|
||||
else => {},
|
||||
},
|
||||
5 => switch (@as(u40, @bitCast(domain[0..5].*))) {
|
||||
asUint(u40, "Fetch") => return @import("domains/fetch.zig").processMessage(command),
|
||||
asUint(u40, "Input") => return @import("domains/input.zig").processMessage(command),
|
||||
else => {},
|
||||
},
|
||||
6 => switch (@as(u48, @bitCast(domain[0..6].*))) {
|
||||
asUint(u48, "Target") => return @import("domains/target.zig").processMessage(command),
|
||||
else => {},
|
||||
},
|
||||
7 => switch (@as(u56, @bitCast(domain[0..7].*))) {
|
||||
asUint(u56, "Browser") => return @import("domains/browser.zig").processMessage(command),
|
||||
asUint(u56, "Runtime") => return @import("domains/runtime.zig").processMessage(command),
|
||||
asUint(u56, "Network") => return @import("domains/network.zig").processMessage(command),
|
||||
asUint(u56, "Storage") => return @import("domains/storage.zig").processMessage(command),
|
||||
else => {},
|
||||
},
|
||||
8 => switch (@as(u64, @bitCast(domain[0..8].*))) {
|
||||
asUint(u64, "Security") => return @import("domains/security.zig").processMessage(command),
|
||||
else => {},
|
||||
},
|
||||
9 => switch (@as(u72, @bitCast(domain[0..9].*))) {
|
||||
asUint(u72, "Emulation") => return @import("domains/emulation.zig").processMessage(command),
|
||||
asUint(u72, "Inspector") => return @import("domains/inspector.zig").processMessage(command),
|
||||
else => {},
|
||||
},
|
||||
11 => switch (@as(u88, @bitCast(domain[0..11].*))) {
|
||||
asUint(u88, "Performance") => return @import("domains/performance.zig").processMessage(command),
|
||||
else => {},
|
||||
},
|
||||
13 => switch (@as(u104, @bitCast(domain[0..13].*))) {
|
||||
asUint(u104, "Accessibility") => return @import("domains/accessibility.zig").processMessage(command),
|
||||
else => {},
|
||||
},
|
||||
|
||||
else => {},
|
||||
}
|
||||
|
||||
return error.UnknownDomain;
|
||||
}
|
||||
|
||||
fn isValidSessionId(self: *const Self, input_session_id: []const u8) bool {
|
||||
const browser_context = &(self.browser_context orelse return false);
|
||||
const session_id = browser_context.session_id orelse return false;
|
||||
return std.mem.eql(u8, session_id, input_session_id);
|
||||
}
|
||||
|
||||
pub fn createBrowserContext(self: *Self) ![]const u8 {
|
||||
if (self.browser_context != null) {
|
||||
return error.AlreadyExists;
|
||||
}
|
||||
const id = self.browser_context_id_gen.next();
|
||||
|
||||
self.browser_context = @as(BrowserContext(Self), undefined);
|
||||
const browser_context = &self.browser_context.?;
|
||||
|
||||
try BrowserContext(Self).init(browser_context, id, self);
|
||||
return id;
|
||||
}
|
||||
|
||||
pub fn disposeBrowserContext(self: *Self, browser_context_id: []const u8) bool {
|
||||
const bc = &(self.browser_context orelse return false);
|
||||
if (std.mem.eql(u8, bc.id, browser_context_id) == false) {
|
||||
return false;
|
||||
}
|
||||
bc.deinit();
|
||||
self.browser.closeSession();
|
||||
self.browser_context = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
const SendEventOpts = struct {
|
||||
session_id: ?[]const u8 = null,
|
||||
};
|
||||
pub fn sendEvent(self: *Self, method: []const u8, p: anytype, opts: SendEventOpts) !void {
|
||||
return self.sendJSON(.{
|
||||
.method = method,
|
||||
.params = if (comptime @typeInfo(@TypeOf(p)) == .null) struct {}{} else p,
|
||||
.sessionId = opts.session_id,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn sendJSON(self: *Self, message: anytype) !void {
|
||||
return self.client.sendJSON(message, .{
|
||||
.emit_null_optional_fields = false,
|
||||
});
|
||||
}
|
||||
return .{
|
||||
.client = client,
|
||||
.browser = browser,
|
||||
.allocator = allocator,
|
||||
.browser_context = null,
|
||||
.page_arena = std.heap.ArenaAllocator.init(allocator),
|
||||
.message_arena = std.heap.ArenaAllocator.init(allocator),
|
||||
.notification_arena = std.heap.ArenaAllocator.init(allocator),
|
||||
.browser_context_arena = std.heap.ArenaAllocator.init(allocator),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *CDP) void {
|
||||
if (self.browser_context) |*bc| {
|
||||
bc.deinit();
|
||||
}
|
||||
self.browser.deinit();
|
||||
self.page_arena.deinit();
|
||||
self.message_arena.deinit();
|
||||
self.notification_arena.deinit();
|
||||
self.browser_context_arena.deinit();
|
||||
}
|
||||
|
||||
pub fn handleMessage(self: *CDP, msg: []const u8) bool {
|
||||
// if there's an error, it's already been logged
|
||||
self.processMessage(msg) catch return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
pub fn processMessage(self: *CDP, msg: []const u8) !void {
|
||||
const arena = &self.message_arena;
|
||||
defer _ = arena.reset(.{ .retain_with_limit = 1024 * 16 });
|
||||
return self.dispatch(arena.allocator(), self, msg);
|
||||
}
|
||||
|
||||
// @newhttp
|
||||
// A bit hacky right now. The main server loop doesn't unblock for
|
||||
// scheduled task. So we run this directly in order to process any
|
||||
// timeouts (or http events) which are ready to be processed.
|
||||
pub fn pageWait(self: *CDP, ms: u32) !Session.Runner.CDPWaitResult {
|
||||
const session = &(self.browser.session orelse return error.NoPage);
|
||||
var runner = try session.runner(.{});
|
||||
return runner.waitCDP(.{ .ms = ms });
|
||||
}
|
||||
|
||||
// Called from above, in processMessage which handles client messages
|
||||
// but can also be called internally. For example, Target.sendMessageToTarget
|
||||
// calls back into dispatch to capture the response.
|
||||
pub fn dispatch(self: *CDP, arena: Allocator, sender: anytype, str: []const u8) !void {
|
||||
const input = json.parseFromSliceLeaky(InputMessage, arena, str, .{
|
||||
.ignore_unknown_fields = true,
|
||||
}) catch return error.InvalidJSON;
|
||||
|
||||
var command = Command(CDP, @TypeOf(sender)){
|
||||
.input = .{
|
||||
.json = str,
|
||||
.id = input.id,
|
||||
.action = "",
|
||||
.params = input.params,
|
||||
.session_id = input.sessionId,
|
||||
},
|
||||
.cdp = self,
|
||||
.arena = arena,
|
||||
.sender = sender,
|
||||
.browser_context = if (self.browser_context) |*bc| bc else null,
|
||||
};
|
||||
|
||||
// See dispatchStartupCommand for more info on this.
|
||||
var is_startup = false;
|
||||
if (input.sessionId) |input_session_id| {
|
||||
if (std.mem.eql(u8, input_session_id, "STARTUP")) {
|
||||
is_startup = true;
|
||||
} else if (self.isValidSessionId(input_session_id) == false) {
|
||||
return command.sendError(-32001, "Unknown sessionId", .{});
|
||||
}
|
||||
}
|
||||
|
||||
if (is_startup) {
|
||||
dispatchStartupCommand(&command, input.method) catch |err| {
|
||||
command.sendError(-31999, @errorName(err), .{}) catch return err;
|
||||
};
|
||||
} else {
|
||||
dispatchCommand(&command, input.method) catch |err| {
|
||||
command.sendError(-31998, @errorName(err), .{}) catch return err;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// A CDP session isn't 100% fully driven by the driver. There's are
|
||||
// independent actions that the browser is expected to take. For example
|
||||
// Puppeteer expects the browser to startup a tab and thus have existing
|
||||
// targets.
|
||||
// To this end, we create a [very] dummy BrowserContext, Target and
|
||||
// Session. There isn't actually a BrowserContext, just a special id.
|
||||
// When messages are received with the "STARTUP" sessionId, we do
|
||||
// "special" handling - the bare minimum we need to do until the driver
|
||||
// switches to a real BrowserContext.
|
||||
// (I can imagine this logic will become driver-specific)
|
||||
fn dispatchStartupCommand(command: anytype, method: []const u8) !void {
|
||||
// Stagehand parses the response and error if we don't return a
|
||||
// correct one for Page.getFrameTree on startup call.
|
||||
if (std.mem.eql(u8, method, "Page.getFrameTree")) {
|
||||
// The Page.getFrameTree handles startup response gracefully.
|
||||
return dispatchCommand(command, method);
|
||||
}
|
||||
|
||||
return command.sendResult(null, .{});
|
||||
}
|
||||
|
||||
fn dispatchCommand(command: anytype, method: []const u8) !void {
|
||||
const domain = blk: {
|
||||
const i = std.mem.indexOfScalarPos(u8, method, 0, '.') orelse {
|
||||
return error.InvalidMethod;
|
||||
};
|
||||
command.input.action = method[i + 1 ..];
|
||||
break :blk method[0..i];
|
||||
};
|
||||
|
||||
switch (domain.len) {
|
||||
2 => switch (@as(u16, @bitCast(domain[0..2].*))) {
|
||||
asUint(u16, "LP") => return @import("domains/lp.zig").processMessage(command),
|
||||
else => {},
|
||||
},
|
||||
3 => switch (@as(u24, @bitCast(domain[0..3].*))) {
|
||||
asUint(u24, "DOM") => return @import("domains/dom.zig").processMessage(command),
|
||||
asUint(u24, "Log") => return @import("domains/log.zig").processMessage(command),
|
||||
asUint(u24, "CSS") => return @import("domains/css.zig").processMessage(command),
|
||||
else => {},
|
||||
},
|
||||
4 => switch (@as(u32, @bitCast(domain[0..4].*))) {
|
||||
asUint(u32, "Page") => return @import("domains/page.zig").processMessage(command),
|
||||
else => {},
|
||||
},
|
||||
5 => switch (@as(u40, @bitCast(domain[0..5].*))) {
|
||||
asUint(u40, "Fetch") => return @import("domains/fetch.zig").processMessage(command),
|
||||
asUint(u40, "Input") => return @import("domains/input.zig").processMessage(command),
|
||||
else => {},
|
||||
},
|
||||
6 => switch (@as(u48, @bitCast(domain[0..6].*))) {
|
||||
asUint(u48, "Target") => return @import("domains/target.zig").processMessage(command),
|
||||
else => {},
|
||||
},
|
||||
7 => switch (@as(u56, @bitCast(domain[0..7].*))) {
|
||||
asUint(u56, "Browser") => return @import("domains/browser.zig").processMessage(command),
|
||||
asUint(u56, "Runtime") => return @import("domains/runtime.zig").processMessage(command),
|
||||
asUint(u56, "Network") => return @import("domains/network.zig").processMessage(command),
|
||||
asUint(u56, "Storage") => return @import("domains/storage.zig").processMessage(command),
|
||||
else => {},
|
||||
},
|
||||
8 => switch (@as(u64, @bitCast(domain[0..8].*))) {
|
||||
asUint(u64, "Security") => return @import("domains/security.zig").processMessage(command),
|
||||
else => {},
|
||||
},
|
||||
9 => switch (@as(u72, @bitCast(domain[0..9].*))) {
|
||||
asUint(u72, "Emulation") => return @import("domains/emulation.zig").processMessage(command),
|
||||
asUint(u72, "Inspector") => return @import("domains/inspector.zig").processMessage(command),
|
||||
else => {},
|
||||
},
|
||||
11 => switch (@as(u88, @bitCast(domain[0..11].*))) {
|
||||
asUint(u88, "Performance") => return @import("domains/performance.zig").processMessage(command),
|
||||
else => {},
|
||||
},
|
||||
13 => switch (@as(u104, @bitCast(domain[0..13].*))) {
|
||||
asUint(u104, "Accessibility") => return @import("domains/accessibility.zig").processMessage(command),
|
||||
else => {},
|
||||
},
|
||||
|
||||
else => {},
|
||||
}
|
||||
|
||||
return error.UnknownDomain;
|
||||
}
|
||||
|
||||
fn isValidSessionId(self: *const CDP, input_session_id: []const u8) bool {
|
||||
const browser_context = &(self.browser_context orelse return false);
|
||||
const session_id = browser_context.session_id orelse return false;
|
||||
return std.mem.eql(u8, session_id, input_session_id);
|
||||
}
|
||||
|
||||
pub fn createBrowserContext(self: *CDP) ![]const u8 {
|
||||
if (self.browser_context != null) {
|
||||
return error.AlreadyExists;
|
||||
}
|
||||
const id = self.browser_context_id_gen.next();
|
||||
|
||||
self.browser_context = @as(BrowserContext(CDP), undefined);
|
||||
const browser_context = &self.browser_context.?;
|
||||
|
||||
try BrowserContext(CDP).init(browser_context, id, self);
|
||||
return id;
|
||||
}
|
||||
|
||||
pub fn disposeBrowserContext(self: *CDP, browser_context_id: []const u8) bool {
|
||||
const bc = &(self.browser_context orelse return false);
|
||||
if (std.mem.eql(u8, bc.id, browser_context_id) == false) {
|
||||
return false;
|
||||
}
|
||||
bc.deinit();
|
||||
self.browser.closeSession();
|
||||
self.browser_context = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
const SendEventOpts = struct {
|
||||
session_id: ?[]const u8 = null,
|
||||
};
|
||||
pub fn sendEvent(self: *CDP, method: []const u8, p: anytype, opts: SendEventOpts) !void {
|
||||
return self.sendJSON(.{
|
||||
.method = method,
|
||||
.params = if (comptime @typeInfo(@TypeOf(p)) == .null) struct {}{} else p,
|
||||
.sessionId = opts.session_id,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn sendJSON(self: *CDP, message: anytype) !void {
|
||||
return self.client.sendJSON(message, .{
|
||||
.emit_null_optional_fields = false,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn BrowserContext(comptime CDP_T: type) type {
|
||||
const Node = @import("Node.zig");
|
||||
const AXNode = @import("AXNode.zig");
|
||||
@@ -473,8 +468,8 @@ pub fn BrowserContext(comptime CDP_T: type) type {
|
||||
if (self.http_proxy_changed) {
|
||||
// has to be called after browser.closeSession, since it won't
|
||||
// work if there are active connections.
|
||||
browser.http_client.restoreOriginalProxy() catch |err| {
|
||||
log.warn(.http, "restoreOriginalProxy", .{ .err = err });
|
||||
browser.http_client.changeProxy(null) catch |err| {
|
||||
log.warn(.http, "changeProxy", .{ .err = err });
|
||||
};
|
||||
}
|
||||
self.intercept_state.deinit();
|
||||
@@ -958,7 +953,7 @@ fn asUint(comptime T: type, comptime string: []const u8) T {
|
||||
|
||||
const testing = @import("testing.zig");
|
||||
test "cdp: invalid json" {
|
||||
var ctx = testing.context();
|
||||
var ctx = try testing.context();
|
||||
defer ctx.deinit();
|
||||
|
||||
try testing.expectError(error.InvalidJSON, ctx.processMessage("invalid"));
|
||||
@@ -983,7 +978,7 @@ test "cdp: invalid json" {
|
||||
}
|
||||
|
||||
test "cdp: invalid sessionId" {
|
||||
var ctx = testing.context();
|
||||
var ctx = try testing.context();
|
||||
defer ctx.deinit();
|
||||
|
||||
{
|
||||
@@ -1008,7 +1003,7 @@ test "cdp: invalid sessionId" {
|
||||
}
|
||||
|
||||
test "cdp: STARTUP sessionId" {
|
||||
var ctx = testing.context();
|
||||
var ctx = try testing.context();
|
||||
defer ctx.deinit();
|
||||
|
||||
{
|
||||
@@ -1021,13 +1016,13 @@ test "cdp: STARTUP sessionId" {
|
||||
// we have a brower context but no session_id
|
||||
_ = try ctx.loadBrowserContext(.{});
|
||||
try ctx.processMessage(.{ .id = 3, .method = "Hi", .sessionId = "STARTUP" });
|
||||
try ctx.expectSentResult(null, .{ .id = 3, .index = 0, .session_id = "STARTUP" });
|
||||
try ctx.expectSentResult(null, .{ .id = 3, .index = 1, .session_id = "STARTUP" });
|
||||
}
|
||||
|
||||
{
|
||||
// we have a brower context with a different session_id
|
||||
_ = try ctx.loadBrowserContext(.{ .session_id = "SESS-2" });
|
||||
try ctx.processMessage(.{ .id = 4, .method = "Hi", .sessionId = "STARTUP" });
|
||||
try ctx.expectSentResult(null, .{ .id = 4, .index = 0, .session_id = "STARTUP" });
|
||||
try ctx.expectSentResult(null, .{ .id = 4, .index = 2, .session_id = "STARTUP" });
|
||||
}
|
||||
}
|
||||
@@ -112,7 +112,7 @@ fn resetPermissions(cmd: anytype) !void {
|
||||
|
||||
const testing = @import("../testing.zig");
|
||||
test "cdp.browser: getVersion" {
|
||||
var ctx = testing.context();
|
||||
var ctx = try testing.context();
|
||||
defer ctx.deinit();
|
||||
|
||||
try ctx.processMessage(.{
|
||||
@@ -131,7 +131,7 @@ test "cdp.browser: getVersion" {
|
||||
}
|
||||
|
||||
test "cdp.browser: getWindowForTarget" {
|
||||
var ctx = testing.context();
|
||||
var ctx = try testing.context();
|
||||
defer ctx.deinit();
|
||||
|
||||
try ctx.processMessage(.{
|
||||
|
||||
@@ -547,7 +547,7 @@ fn requestNode(cmd: anytype) !void {
|
||||
|
||||
const testing = @import("../testing.zig");
|
||||
test "cdp.dom: getSearchResults unknown search id" {
|
||||
var ctx = testing.context();
|
||||
var ctx = try testing.context();
|
||||
defer ctx.deinit();
|
||||
|
||||
try ctx.processMessage(.{
|
||||
@@ -559,7 +559,7 @@ test "cdp.dom: getSearchResults unknown search id" {
|
||||
}
|
||||
|
||||
test "cdp.dom: search flow" {
|
||||
var ctx = testing.context();
|
||||
var ctx = try testing.context();
|
||||
defer ctx.deinit();
|
||||
|
||||
_ = try ctx.loadBrowserContext(.{ .id = "BID-A", .url = "cdp/dom1.html" });
|
||||
@@ -614,7 +614,7 @@ test "cdp.dom: search flow" {
|
||||
}
|
||||
|
||||
test "cdp.dom: querySelector unknown search id" {
|
||||
var ctx = testing.context();
|
||||
var ctx = try testing.context();
|
||||
defer ctx.deinit();
|
||||
|
||||
_ = try ctx.loadBrowserContext(.{ .id = "BID-A", .url = "cdp/dom1.html" });
|
||||
@@ -635,7 +635,7 @@ test "cdp.dom: querySelector unknown search id" {
|
||||
}
|
||||
|
||||
test "cdp.dom: querySelector Node not found" {
|
||||
var ctx = testing.context();
|
||||
var ctx = try testing.context();
|
||||
defer ctx.deinit();
|
||||
|
||||
_ = try ctx.loadBrowserContext(.{ .id = "BID-A", .url = "cdp/dom1.html" });
|
||||
@@ -663,7 +663,7 @@ test "cdp.dom: querySelector Node not found" {
|
||||
}
|
||||
|
||||
test "cdp.dom: querySelector Nodes found" {
|
||||
var ctx = testing.context();
|
||||
var ctx = try testing.context();
|
||||
defer ctx.deinit();
|
||||
|
||||
_ = try ctx.loadBrowserContext(.{ .id = "BID-A", .url = "cdp/dom2.html" });
|
||||
@@ -693,7 +693,7 @@ test "cdp.dom: querySelector Nodes found" {
|
||||
}
|
||||
|
||||
test "cdp.dom: getBoxModel" {
|
||||
var ctx = testing.context();
|
||||
var ctx = try testing.context();
|
||||
defer ctx.deinit();
|
||||
|
||||
_ = try ctx.loadBrowserContext(.{ .id = "BID-A", .url = "cdp/dom2.html" });
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
|
||||
const std = @import("std");
|
||||
const lp = @import("lightpanda");
|
||||
const log = @import("../../log.zig");
|
||||
const markdown = lp.markdown;
|
||||
const SemanticTree = lp.SemanticTree;
|
||||
const interactive = lp.interactive;
|
||||
@@ -135,17 +134,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,
|
||||
}, .{});
|
||||
}
|
||||
|
||||
@@ -279,7 +271,7 @@ fn waitForSelector(cmd: anytype) !void {
|
||||
|
||||
const testing = @import("../testing.zig");
|
||||
test "cdp.lp: getMarkdown" {
|
||||
var ctx = testing.context();
|
||||
var ctx = try testing.context();
|
||||
defer ctx.deinit();
|
||||
|
||||
const bc = try ctx.loadBrowserContext(.{});
|
||||
@@ -290,12 +282,12 @@ test "cdp.lp: getMarkdown" {
|
||||
.method = "LP.getMarkdown",
|
||||
});
|
||||
|
||||
const result = ctx.client.?.sent.items[0].object.get("result").?.object;
|
||||
const result = (try ctx.getSentMessage(0)).?.object.get("result").?.object;
|
||||
try testing.expect(result.get("markdown") != null);
|
||||
}
|
||||
|
||||
test "cdp.lp: getInteractiveElements" {
|
||||
var ctx = testing.context();
|
||||
var ctx = try testing.context();
|
||||
defer ctx.deinit();
|
||||
|
||||
const bc = try ctx.loadBrowserContext(.{});
|
||||
@@ -306,13 +298,12 @@ test "cdp.lp: getInteractiveElements" {
|
||||
.method = "LP.getInteractiveElements",
|
||||
});
|
||||
|
||||
const result = ctx.client.?.sent.items[0].object.get("result").?.object;
|
||||
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" {
|
||||
var ctx = testing.context();
|
||||
var ctx = try testing.context();
|
||||
defer ctx.deinit();
|
||||
|
||||
const bc = try ctx.loadBrowserContext(.{});
|
||||
@@ -323,12 +314,12 @@ test "cdp.lp: getStructuredData" {
|
||||
.method = "LP.getStructuredData",
|
||||
});
|
||||
|
||||
const result = ctx.client.?.sent.items[0].object.get("result").?.object;
|
||||
const result = (try ctx.getSentMessage(0)).?.object.get("result").?.object;
|
||||
try testing.expect(result.get("structuredData") != null);
|
||||
}
|
||||
|
||||
test "cdp.lp: action tools" {
|
||||
var ctx = testing.context();
|
||||
var ctx = try testing.context();
|
||||
defer ctx.deinit();
|
||||
|
||||
const bc = try ctx.loadBrowserContext(.{});
|
||||
@@ -389,7 +380,7 @@ test "cdp.lp: action tools" {
|
||||
}
|
||||
|
||||
test "cdp.lp: waitForSelector" {
|
||||
var ctx = testing.context();
|
||||
var ctx = try testing.context();
|
||||
defer ctx.deinit();
|
||||
|
||||
const bc = try ctx.loadBrowserContext(.{});
|
||||
@@ -405,9 +396,8 @@ test "cdp.lp: waitForSelector" {
|
||||
.method = "LP.waitForSelector",
|
||||
.params = .{ .selector = "#existing", .timeout = 2000 },
|
||||
});
|
||||
var result = ctx.client.?.sent.items[0].object.get("result").?.object;
|
||||
var result = (try ctx.getSentMessage(0)).?.object.get("result").?.object;
|
||||
try testing.expect(result.get("backendNodeId") != null);
|
||||
ctx.client.?.sent.clearRetainingCapacity();
|
||||
|
||||
// 2. Delayed element
|
||||
try ctx.processMessage(.{
|
||||
@@ -415,9 +405,8 @@ test "cdp.lp: waitForSelector" {
|
||||
.method = "LP.waitForSelector",
|
||||
.params = .{ .selector = "#delayed", .timeout = 5000 },
|
||||
});
|
||||
result = ctx.client.?.sent.items[0].object.get("result").?.object;
|
||||
result = (try ctx.getSentMessage(1)).?.object.get("result").?.object;
|
||||
try testing.expect(result.get("backendNodeId") != null);
|
||||
ctx.client.?.sent.clearRetainingCapacity();
|
||||
|
||||
// 3. Timeout error
|
||||
try ctx.processMessage(.{
|
||||
@@ -425,6 +414,6 @@ test "cdp.lp: waitForSelector" {
|
||||
.method = "LP.waitForSelector",
|
||||
.params = .{ .selector = "#nonexistent", .timeout = 100 },
|
||||
});
|
||||
const err_obj = ctx.client.?.sent.items[0].object.get("error").?.object;
|
||||
const err_obj = (try ctx.getSentMessage(2)).?.object.get("error").?.object;
|
||||
try testing.expect(err_obj.get("code") != null);
|
||||
}
|
||||
|
||||
@@ -351,6 +351,10 @@ pub const TransferAsRequestWriter = struct {
|
||||
try jws.objectField(hdr.name);
|
||||
try jws.write(hdr.value);
|
||||
}
|
||||
if (try transfer.getCookieString()) |cookies| {
|
||||
try jws.objectField("Cookie");
|
||||
try jws.write(cookies[0 .. cookies.len - 1]);
|
||||
}
|
||||
try jws.endObject();
|
||||
}
|
||||
try jws.endObject();
|
||||
@@ -439,7 +443,7 @@ fn idFromRequestId(request_id: []const u8) !u64 {
|
||||
|
||||
const testing = @import("../testing.zig");
|
||||
test "cdp.network setExtraHTTPHeaders" {
|
||||
var ctx = testing.context();
|
||||
var ctx = try testing.context();
|
||||
defer ctx.deinit();
|
||||
|
||||
_ = try ctx.loadBrowserContext(.{ .id = "NID-A", .session_id = "NESI-A" });
|
||||
@@ -465,7 +469,7 @@ test "cdp.Network: cookies" {
|
||||
const ResCookie = CdpStorage.ResCookie;
|
||||
const CdpCookie = CdpStorage.CdpCookie;
|
||||
|
||||
var ctx = testing.context();
|
||||
var ctx = try testing.context();
|
||||
defer ctx.deinit();
|
||||
_ = try ctx.loadBrowserContext(.{ .id = "BID-S" });
|
||||
|
||||
|
||||
@@ -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),
|
||||
@@ -82,7 +84,7 @@ fn getFrameTree(cmd: anytype) !void {
|
||||
.frame = .{
|
||||
.id = "TID-STARTUP",
|
||||
.loaderId = "LID-STARTUP",
|
||||
.securityOrigin = @import("../cdp.zig").URL_BASE,
|
||||
.securityOrigin = @import("../CDP.zig").URL_BASE,
|
||||
.url = "about:blank",
|
||||
.secureContextType = "Secure",
|
||||
},
|
||||
@@ -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.
|
||||
@@ -642,7 +674,7 @@ fn getLayoutMetrics(cmd: anytype) !void {
|
||||
|
||||
const testing = @import("../testing.zig");
|
||||
test "cdp.page: getFrameTree" {
|
||||
var ctx = testing.context();
|
||||
var ctx = try testing.context();
|
||||
defer ctx.deinit();
|
||||
|
||||
{
|
||||
@@ -712,7 +744,7 @@ test "cdp.page: captureScreenshot" {
|
||||
const filter: LogFilter = .init(&.{.not_implemented});
|
||||
defer filter.deinit();
|
||||
|
||||
var ctx = testing.context();
|
||||
var ctx = try testing.context();
|
||||
defer ctx.deinit();
|
||||
{
|
||||
try ctx.processMessage(.{ .id = 10, .method = "Page.captureScreenshot", .params = .{ .format = "jpg" } });
|
||||
@@ -728,7 +760,7 @@ test "cdp.page: captureScreenshot" {
|
||||
}
|
||||
|
||||
test "cdp.page: getLayoutMetrics" {
|
||||
var ctx = testing.context();
|
||||
var ctx = try testing.context();
|
||||
defer ctx.deinit();
|
||||
|
||||
_ = try ctx.loadBrowserContext(.{ .id = "BID-9", .url = "hi.html", .target_id = "FID-000000000X".* });
|
||||
@@ -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 } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ fn setIgnoreCertificateErrors(cmd: anytype) !void {
|
||||
const testing = @import("../testing.zig");
|
||||
|
||||
test "cdp.Security: setIgnoreCertificateErrors" {
|
||||
var ctx = testing.context();
|
||||
var ctx = try testing.context();
|
||||
defer ctx.deinit();
|
||||
|
||||
_ = try ctx.loadBrowserContext(.{ .id = "BID-9" });
|
||||
|
||||
@@ -243,7 +243,7 @@ pub fn writeCookie(cookie: *const Cookie, w: anytype) !void {
|
||||
const testing = @import("../testing.zig");
|
||||
|
||||
test "cdp.Storage: cookies" {
|
||||
var ctx = testing.context();
|
||||
var ctx = try testing.context();
|
||||
defer ctx.deinit();
|
||||
_ = try ctx.loadBrowserContext(.{ .id = "BID-S" });
|
||||
|
||||
|
||||
@@ -24,9 +24,6 @@ const log = @import("../../log.zig");
|
||||
const URL = @import("../../browser/URL.zig");
|
||||
const js = @import("../../browser/js/js.zig");
|
||||
|
||||
// TODO: hard coded IDs
|
||||
const LOADER_ID = "LOADERID42AA389647D702B4D805F49A";
|
||||
|
||||
pub fn processMessage(cmd: anytype) !void {
|
||||
const action = std.meta.stringToEnum(enum {
|
||||
getTargets,
|
||||
@@ -512,7 +509,7 @@ const TargetInfo = struct {
|
||||
|
||||
const testing = @import("../testing.zig");
|
||||
test "cdp.target: getBrowserContexts" {
|
||||
var ctx = testing.context();
|
||||
var ctx = try testing.context();
|
||||
defer ctx.deinit();
|
||||
|
||||
// {
|
||||
@@ -536,7 +533,7 @@ test "cdp.target: getBrowserContexts" {
|
||||
}
|
||||
|
||||
test "cdp.target: createBrowserContext" {
|
||||
var ctx = testing.context();
|
||||
var ctx = try testing.context();
|
||||
defer ctx.deinit();
|
||||
|
||||
{
|
||||
@@ -554,7 +551,7 @@ test "cdp.target: createBrowserContext" {
|
||||
}
|
||||
|
||||
test "cdp.target: disposeBrowserContext" {
|
||||
var ctx = testing.context();
|
||||
var ctx = try testing.context();
|
||||
defer ctx.deinit();
|
||||
|
||||
{
|
||||
@@ -585,7 +582,7 @@ test "cdp.target: disposeBrowserContext" {
|
||||
|
||||
test "cdp.target: createTarget" {
|
||||
{
|
||||
var ctx = testing.context();
|
||||
var ctx = try testing.context();
|
||||
defer ctx.deinit();
|
||||
try ctx.processMessage(.{ .id = 10, .method = "Target.createTarget", .params = .{ .url = "about:blank" } });
|
||||
|
||||
@@ -595,7 +592,7 @@ test "cdp.target: createTarget" {
|
||||
}
|
||||
|
||||
{
|
||||
var ctx = testing.context();
|
||||
var ctx = try testing.context();
|
||||
defer ctx.deinit();
|
||||
// active auto attach to get the Target.attachedToTarget event.
|
||||
try ctx.processMessage(.{ .id = 9, .method = "Target.setAutoAttach", .params = .{ .autoAttach = true, .waitForDebuggerOnStart = false } });
|
||||
@@ -607,7 +604,7 @@ test "cdp.target: createTarget" {
|
||||
try ctx.expectSentEvent("Target.attachedToTarget", .{ .sessionId = bc.session_id.?, .targetInfo = .{ .url = "about:blank", .title = "", .attached = true, .type = "page", .canAccessOpener = false, .browserContextId = bc.id, .targetId = bc.target_id.? } }, .{});
|
||||
}
|
||||
|
||||
var ctx = testing.context();
|
||||
var ctx = try testing.context();
|
||||
defer ctx.deinit();
|
||||
const bc = try ctx.loadBrowserContext(.{ .id = "BID-9" });
|
||||
{
|
||||
@@ -624,7 +621,7 @@ test "cdp.target: createTarget" {
|
||||
}
|
||||
|
||||
test "cdp.target: closeTarget" {
|
||||
var ctx = testing.context();
|
||||
var ctx = try testing.context();
|
||||
defer ctx.deinit();
|
||||
|
||||
{
|
||||
@@ -655,7 +652,7 @@ test "cdp.target: closeTarget" {
|
||||
}
|
||||
|
||||
test "cdp.target: attachToTarget" {
|
||||
var ctx = testing.context();
|
||||
var ctx = try testing.context();
|
||||
defer ctx.deinit();
|
||||
|
||||
{
|
||||
@@ -686,7 +683,7 @@ test "cdp.target: attachToTarget" {
|
||||
}
|
||||
|
||||
test "cdp.target: getTargetInfo" {
|
||||
var ctx = testing.context();
|
||||
var ctx = try testing.context();
|
||||
defer ctx.deinit();
|
||||
|
||||
{
|
||||
@@ -737,7 +734,7 @@ test "cdp.target: getTargetInfo" {
|
||||
}
|
||||
|
||||
test "cdp.target: issue#474: attach to just created target" {
|
||||
var ctx = testing.context();
|
||||
var ctx = try testing.context();
|
||||
defer ctx.deinit();
|
||||
const bc = try ctx.loadBrowserContext(.{ .id = "BID-9" });
|
||||
{
|
||||
@@ -752,7 +749,7 @@ test "cdp.target: issue#474: attach to just created target" {
|
||||
}
|
||||
|
||||
test "cdp.target: detachFromTarget" {
|
||||
var ctx = testing.context();
|
||||
var ctx = try testing.context();
|
||||
defer ctx.deinit();
|
||||
const bc = try ctx.loadBrowserContext(.{ .id = "BID-9" });
|
||||
{
|
||||
@@ -775,19 +772,19 @@ test "cdp.target: detachFromTarget" {
|
||||
}
|
||||
|
||||
test "cdp.target: detachFromTarget without session" {
|
||||
var ctx = testing.context();
|
||||
var ctx = try testing.context();
|
||||
defer ctx.deinit();
|
||||
_ = try ctx.loadBrowserContext(.{ .id = "BID-9" });
|
||||
{
|
||||
// detach when no session is attached should not send event
|
||||
try ctx.processMessage(.{ .id = 10, .method = "Target.detachFromTarget" });
|
||||
try ctx.expectSentResult(null, .{ .id = 10 });
|
||||
try ctx.expectSentCount(0);
|
||||
try ctx.expectSentCount(1);
|
||||
}
|
||||
}
|
||||
|
||||
test "cdp.target: setAutoAttach false sends detachedFromTarget" {
|
||||
var ctx = testing.context();
|
||||
var ctx = try testing.context();
|
||||
defer ctx.deinit();
|
||||
const bc = try ctx.loadBrowserContext(.{ .id = "BID-9" });
|
||||
{
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
const IS_DEBUG = @import("builtin").mode == .Debug;
|
||||
|
||||
pub fn toPageId(comptime id_type: enum { frame_id, loader_id }, input: []const u8) !u32 {
|
||||
const err = switch (comptime id_type) {
|
||||
|
||||
@@ -18,12 +18,10 @@
|
||||
|
||||
const std = @import("std");
|
||||
const json = std.json;
|
||||
const Allocator = std.mem.Allocator;
|
||||
const ArenaAllocator = std.heap.ArenaAllocator;
|
||||
const posix = std.posix;
|
||||
|
||||
const Testing = @This();
|
||||
|
||||
const main = @import("cdp.zig");
|
||||
const CDP = @import("CDP.zig");
|
||||
const Server = @import("../Server.zig");
|
||||
|
||||
const base = @import("../testing.zig");
|
||||
pub const allocator = base.allocator;
|
||||
@@ -35,61 +33,27 @@ pub const expectEqualSlices = base.expectEqualSlices;
|
||||
pub const pageTest = base.pageTest;
|
||||
pub const newString = base.newString;
|
||||
|
||||
const Client = struct {
|
||||
allocator: Allocator,
|
||||
send_arena: ArenaAllocator,
|
||||
sent: std.ArrayList(json.Value) = .{},
|
||||
serialized: std.ArrayList([]const u8) = .{},
|
||||
|
||||
fn init(alloc: Allocator) Client {
|
||||
return .{
|
||||
.allocator = alloc,
|
||||
.send_arena = ArenaAllocator.init(alloc),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn sendAllocator(self: *Client) Allocator {
|
||||
return self.send_arena.allocator();
|
||||
}
|
||||
|
||||
pub fn sendJSON(self: *Client, message: anytype, opts: json.Stringify.Options) !void {
|
||||
var opts_copy = opts;
|
||||
opts_copy.whitespace = .indent_2;
|
||||
const serialized = try json.Stringify.valueAlloc(self.allocator, message, opts_copy);
|
||||
try self.serialized.append(self.allocator, serialized);
|
||||
|
||||
const value = try json.parseFromSliceLeaky(json.Value, self.allocator, serialized, .{});
|
||||
try self.sent.append(self.allocator, value);
|
||||
}
|
||||
|
||||
pub fn sendJSONRaw(self: *Client, buf: std.ArrayList(u8)) !void {
|
||||
const value = try json.parseFromSliceLeaky(json.Value, self.allocator, buf.items, .{});
|
||||
try self.sent.append(self.allocator, value);
|
||||
}
|
||||
};
|
||||
|
||||
const TestCDP = main.CDPT(struct {
|
||||
pub const Client = *Testing.Client;
|
||||
});
|
||||
|
||||
const TestContext = struct {
|
||||
client: ?Client = null,
|
||||
cdp_: ?TestCDP = null,
|
||||
arena: ArenaAllocator,
|
||||
read_at: usize = 0,
|
||||
read_buf: [1024 * 32]u8 = undefined,
|
||||
cdp_: ?CDP = null,
|
||||
client: Server.Client,
|
||||
socket: posix.socket_t,
|
||||
received: std.ArrayList(json.Value) = .empty,
|
||||
received_raw: std.ArrayList([]const u8) = .empty,
|
||||
|
||||
pub fn deinit(self: *TestContext) void {
|
||||
if (self.cdp_) |*c| {
|
||||
c.deinit();
|
||||
}
|
||||
self.arena.deinit();
|
||||
self.client.deinit();
|
||||
posix.close(self.socket);
|
||||
base.reset();
|
||||
}
|
||||
|
||||
pub fn cdp(self: *TestContext) *TestCDP {
|
||||
pub fn cdp(self: *TestContext) *CDP {
|
||||
if (self.cdp_ == null) {
|
||||
self.client = Client.init(self.arena.allocator());
|
||||
// Don't use the arena here. We want to detect leaks in CDP.
|
||||
// The arena is only for test-specific stuff
|
||||
self.cdp_ = TestCDP.init(base.test_app, base.test_http, &self.client.?) catch unreachable;
|
||||
self.cdp_ = CDP.init(&self.client) catch |err| @panic(@errorName(err));
|
||||
}
|
||||
return &self.cdp_.?;
|
||||
}
|
||||
@@ -100,7 +64,7 @@ const TestContext = struct {
|
||||
session_id: ?[]const u8 = null,
|
||||
url: ?[:0]const u8 = null,
|
||||
};
|
||||
pub fn loadBrowserContext(self: *TestContext, opts: BrowserContextOpts) !*main.BrowserContext(TestCDP) {
|
||||
pub fn loadBrowserContext(self: *TestContext, opts: BrowserContextOpts) !*CDP.BrowserContext(CDP) {
|
||||
var c = self.cdp();
|
||||
if (c.browser_context) |bc| {
|
||||
_ = c.disposeBrowserContext(bc.id);
|
||||
@@ -130,7 +94,7 @@ const TestContext = struct {
|
||||
}
|
||||
const page = try bc.session.createPage();
|
||||
const full_url = try std.fmt.allocPrintSentinel(
|
||||
self.arena.allocator(),
|
||||
base.arena_allocator,
|
||||
"http://127.0.0.1:9582/src/browser/tests/{s}",
|
||||
.{url},
|
||||
0,
|
||||
@@ -143,19 +107,20 @@ const TestContext = struct {
|
||||
}
|
||||
|
||||
pub fn processMessage(self: *TestContext, msg: anytype) !void {
|
||||
var json_message: []const u8 = undefined;
|
||||
if (@typeInfo(@TypeOf(msg)) != .pointer) {
|
||||
json_message = try std.json.Stringify.valueAlloc(self.arena.allocator(), msg, .{});
|
||||
} else {
|
||||
const json_message: []const u8 = blk: {
|
||||
if (@typeInfo(@TypeOf(msg)) != .pointer) {
|
||||
break :blk try std.json.Stringify.valueAlloc(base.arena_allocator, msg, .{});
|
||||
}
|
||||
// assume this is a string we want to send as-is, if it isn't, we'll
|
||||
// get a compile error, so no big deal.
|
||||
json_message = msg;
|
||||
}
|
||||
break :blk msg;
|
||||
};
|
||||
return self.cdp().processMessage(json_message);
|
||||
}
|
||||
|
||||
pub fn expectSentCount(self: *TestContext, expected: usize) !void {
|
||||
try expectEqual(expected, self.client.?.sent.items.len);
|
||||
try self.read();
|
||||
try expectEqual(expected, self.received.items.len);
|
||||
}
|
||||
|
||||
const ExpectResultOpts = struct {
|
||||
@@ -203,37 +168,135 @@ const TestContext = struct {
|
||||
index: ?usize = null,
|
||||
};
|
||||
pub fn expectSent(self: *TestContext, expected: anytype, opts: SentOpts) !void {
|
||||
const serialized = try json.Stringify.valueAlloc(self.arena.allocator(), expected, .{
|
||||
const serialized = try json.Stringify.valueAlloc(base.arena_allocator, expected, .{
|
||||
.whitespace = .indent_2,
|
||||
.emit_null_optional_fields = false,
|
||||
});
|
||||
|
||||
for (self.client.?.sent.items, 0..) |sent, i| {
|
||||
if (try compareExpectedToSent(serialized, sent) == false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (opts.index) |expected_index| {
|
||||
if (expected_index != i) {
|
||||
return error.ErrorAtWrongIndex;
|
||||
for (0..5) |_| {
|
||||
for (self.received.items, 0..) |received, i| {
|
||||
if (try compareExpectedToSent(serialized, received) == false) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
_ = self.client.?.sent.orderedRemove(i);
|
||||
_ = self.client.?.serialized.orderedRemove(i);
|
||||
return;
|
||||
}
|
||||
|
||||
std.debug.print("Error not found. Expecting:\n{s}\n\nGot:\n", .{serialized});
|
||||
for (self.client.?.serialized.items, 0..) |sent, i| {
|
||||
std.debug.print("#{d}\n{s}\n\n", .{ i, sent });
|
||||
if (opts.index) |expected_index| {
|
||||
if (expected_index != i) {
|
||||
std.debug.print("Expected message at index: {d}, was at index: {d}\n", .{ expected_index, i });
|
||||
self.dumpReceived();
|
||||
return error.ErrorAtWrongIndex;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
std.Thread.sleep(5 * std.time.ns_per_ms);
|
||||
try self.read();
|
||||
}
|
||||
self.dumpReceived();
|
||||
return error.ErrorNotFound;
|
||||
}
|
||||
|
||||
fn dumpReceived(self: *const TestContext) void {
|
||||
std.debug.print("CDP Message Received ({d})\n", .{self.received_raw.items.len});
|
||||
for (self.received_raw.items, 0..) |received, i| {
|
||||
std.debug.print("===Message: {d}===\n{s}\n\n", .{ i, received });
|
||||
}
|
||||
}
|
||||
|
||||
pub fn getSentMessage(self: *TestContext, index: usize) !?json.Value {
|
||||
for (0..5) |_| {
|
||||
if (index < self.received.items.len) {
|
||||
return self.received.items[index];
|
||||
}
|
||||
std.Thread.sleep(5 * std.time.ns_per_ms);
|
||||
try self.read();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
fn read(self: *TestContext) !void {
|
||||
while (true) {
|
||||
const n = posix.read(self.socket, self.read_buf[self.read_at..]) catch |err| switch (err) {
|
||||
error.WouldBlock => return,
|
||||
else => return err,
|
||||
};
|
||||
|
||||
if (n == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.read_at += n;
|
||||
|
||||
// Try to parse complete WebSocket frames
|
||||
var pos: usize = 0;
|
||||
while (pos < self.read_at) {
|
||||
// Need at least 2 bytes for header
|
||||
if (self.read_at - pos < 2) break;
|
||||
|
||||
const opcode = self.read_buf[pos] & 0x0F;
|
||||
const payload_len_byte = self.read_buf[pos + 1] & 0x7F;
|
||||
|
||||
var header_size: usize = 2;
|
||||
var payload_len: usize = payload_len_byte;
|
||||
|
||||
if (payload_len_byte == 126) {
|
||||
if (self.read_at - pos < 4) break;
|
||||
payload_len = std.mem.readInt(u16, self.read_buf[pos + 2 ..][0..2], .big);
|
||||
header_size = 4;
|
||||
}
|
||||
// Skip 8-byte length case (127) - not needed
|
||||
|
||||
const frame_size = header_size + payload_len;
|
||||
if (self.read_at - pos < frame_size) break;
|
||||
|
||||
// We have a complete frame - process text (1) or binary (2), skip others
|
||||
if (opcode == 1 or opcode == 2) {
|
||||
const payload = self.read_buf[pos + header_size ..][0..payload_len];
|
||||
const parsed = try std.json.parseFromSliceLeaky(json.Value, base.arena_allocator, payload, .{});
|
||||
try self.received.append(base.arena_allocator, parsed);
|
||||
try self.received_raw.append(base.arena_allocator, try base.arena_allocator.dupe(u8, payload));
|
||||
}
|
||||
|
||||
pos += frame_size;
|
||||
}
|
||||
|
||||
// Move remaining partial data to beginning of buffer
|
||||
if (pos > 0 and pos < self.read_at) {
|
||||
std.mem.copyForwards(u8, &self.read_buf, self.read_buf[pos..self.read_at]);
|
||||
self.read_at -= pos;
|
||||
} else if (pos == self.read_at) {
|
||||
self.read_at = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
pub fn context() TestContext {
|
||||
pub fn context() !TestContext {
|
||||
var pair: [2]posix.socket_t = undefined;
|
||||
const rc = std.c.socketpair(posix.AF.LOCAL, posix.SOCK.STREAM, 0, &pair);
|
||||
if (rc != 0) {
|
||||
return error.SocketPairFailed;
|
||||
}
|
||||
|
||||
errdefer {
|
||||
posix.close(pair[0]);
|
||||
posix.close(pair[1]);
|
||||
}
|
||||
|
||||
const timeout = std.mem.toBytes(posix.timeval{ .sec = 0, .usec = 5_000 });
|
||||
try posix.setsockopt(pair[0], posix.SOL.SOCKET, posix.SO.RCVTIMEO, &timeout);
|
||||
try posix.setsockopt(pair[0], posix.SOL.SOCKET, posix.SO.SNDTIMEO, &timeout);
|
||||
try posix.setsockopt(pair[1], posix.SOL.SOCKET, posix.SO.RCVTIMEO, &timeout);
|
||||
try posix.setsockopt(pair[1], posix.SOL.SOCKET, posix.SO.SNDTIMEO, &timeout);
|
||||
|
||||
try posix.setsockopt(pair[0], posix.SOL.SOCKET, posix.SO.RCVBUF, &std.mem.toBytes(@as(c_int, 32_768)));
|
||||
try posix.setsockopt(pair[0], posix.SOL.SOCKET, posix.SO.SNDBUF, &std.mem.toBytes(@as(c_int, 32_768)));
|
||||
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);
|
||||
|
||||
return .{
|
||||
.arena = ArenaAllocator.init(std.testing.allocator),
|
||||
.client = client,
|
||||
.socket = pair[0],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -49,9 +49,6 @@ const Opts = struct {
|
||||
|
||||
pub var opts = Opts{};
|
||||
|
||||
// synchronizes writes to the output
|
||||
var out_lock: Thread.Mutex = .{};
|
||||
|
||||
// synchronizes access to last_log
|
||||
var last_log_lock: Thread.Mutex = .{};
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const std = @import("std");
|
||||
|
||||
pub const protocol = @import("mcp/protocol.zig");
|
||||
pub const Version = protocol.Version;
|
||||
pub const router = @import("mcp/router.zig");
|
||||
pub const Server = @import("mcp/Server.zig");
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ test "MCP.Server - Integration: synchronous smoke test" {
|
||||
|
||||
try router.processRequests(server, &in_reader);
|
||||
|
||||
try testing.expectJson(.{ .jsonrpc = "2.0", .id = 1 }, out_alloc.writer.buffered());
|
||||
try testing.expectJson(.{ .jsonrpc = "2.0", .id = 1, .result = .{ .protocolVersion = "2024-11-05" } }, out_alloc.writer.buffered());
|
||||
}
|
||||
|
||||
test "MCP.Server - Integration: ping request returns an empty result" {
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
const std = @import("std");
|
||||
|
||||
pub const Version = enum {
|
||||
@"2024-11-05",
|
||||
@"2025-03-26",
|
||||
@"2025-06-18",
|
||||
@"2025-11-25",
|
||||
|
||||
pub const default: Version = .@"2024-11-05";
|
||||
};
|
||||
|
||||
pub const Request = struct {
|
||||
jsonrpc: []const u8 = "2.0",
|
||||
id: ?std.json.Value = null,
|
||||
|
||||
@@ -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,23 @@ 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 result: ResourceStreamingResult = .{
|
||||
.contents = &.{.{
|
||||
.uri = params.uri,
|
||||
.mimeType = "text/html",
|
||||
.text = .{ .page = page, .format = .html },
|
||||
}},
|
||||
};
|
||||
try server.sendResult(req_id, result);
|
||||
},
|
||||
.@"mcp://page/markdown" => {
|
||||
const result: ResourceStreamingResult = .{
|
||||
.contents = &.{.{
|
||||
.uri = params.uri,
|
||||
.mimeType = "text/markdown",
|
||||
.text = .{ .page = page, .format = .markdown },
|
||||
}},
|
||||
};
|
||||
try server.sendResult(req_id, result);
|
||||
},
|
||||
}
|
||||
}
|
||||
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 testing = @import("../testing.zig");
|
||||
const result: ResourceStreamingResult = .{
|
||||
.contents = &.{.{
|
||||
.uri = params.uri,
|
||||
.mimeType = mime_type,
|
||||
.text = .{ .page = page, .format = format },
|
||||
}},
|
||||
};
|
||||
server.sendResult(req_id, result) catch {
|
||||
return server.sendError(req_id, .InternalError, "Failed to serialize resource content");
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
const std = @import("std");
|
||||
const lp = @import("lightpanda");
|
||||
const protocol = @import("protocol.zig");
|
||||
const resources = @import("resources.zig");
|
||||
const Server = @import("Server.zig");
|
||||
@@ -16,6 +15,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,8 +80,9 @@ pub fn handleMessage(server: *Server, arena: std.mem.Allocator, msg: []const u8)
|
||||
}
|
||||
|
||||
fn handleInitialize(server: *Server, req: protocol.Request) !void {
|
||||
const result = protocol.InitializeResult{
|
||||
.protocolVersion = "2025-11-25",
|
||||
const id = req.id orelse return;
|
||||
const result: protocol.InitializeResult = .{
|
||||
.protocolVersion = server.app.config.mcpVersion(),
|
||||
.capabilities = .{
|
||||
.resources = .{},
|
||||
.tools = .{},
|
||||
@@ -92,7 +93,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 {
|
||||
@@ -120,7 +121,7 @@ test "MCP.router - handleMessage - synchronous unit tests" {
|
||||
\\{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test-client","version":"1.0.0"}}}
|
||||
);
|
||||
try testing.expectJson(
|
||||
\\{ "jsonrpc": "2.0", "id": 1, "result": { "capabilities": { "tools": {} } } }
|
||||
\\{ "jsonrpc": "2.0", "id": 1, "result": { "protocolVersion": "2024-11-05", "capabilities": { "tools": {} } } }
|
||||
, out_alloc.writer.buffered());
|
||||
out_alloc.writer.end = 0;
|
||||
|
||||
|
||||
@@ -4,9 +4,7 @@ const lp = @import("lightpanda");
|
||||
const log = lp.log;
|
||||
const js = lp.js;
|
||||
|
||||
const Element = @import("../browser/webapi/Element.zig");
|
||||
const DOMNode = @import("../browser/webapi/Node.zig");
|
||||
const Selector = @import("../browser/webapi/selector/Selector.zig");
|
||||
const protocol = @import("protocol.zig");
|
||||
const Server = @import("Server.zig");
|
||||
const CDPNode = @import("../cdp/Node.zig");
|
||||
@@ -172,13 +170,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,28 +204,18 @@ 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;
|
||||
};
|
||||
|
||||
if (href.len > 0) {
|
||||
if (!first) try w.writeByte('\n');
|
||||
try w.writeAll(href);
|
||||
first = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else |err| {
|
||||
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;
|
||||
};
|
||||
var first = true;
|
||||
for (links) |href| {
|
||||
if (!first) try w.writeByte('\n');
|
||||
try w.writeAll(href);
|
||||
first = false;
|
||||
}
|
||||
},
|
||||
.semantic_tree => {
|
||||
@@ -248,6 +241,7 @@ const ToolStreamingText = struct {
|
||||
|
||||
st.textStringify(w) catch |err| {
|
||||
log.err(.mcp, "semantic tree dump failed", .{ .err = err });
|
||||
return error.WriteFailed;
|
||||
};
|
||||
},
|
||||
}
|
||||
@@ -324,7 +318,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 +326,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 +355,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 +395,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 +410,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 +431,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 +461,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 +493,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 +527,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 +564,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 +588,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 +631,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 +643,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");
|
||||
|
||||
@@ -461,7 +461,7 @@ fn drainQueue(self: *Runtime) void {
|
||||
self.releaseConnection(conn);
|
||||
continue;
|
||||
};
|
||||
libcurl.curl_multi_add_handle(multi, conn.easy) catch |err| {
|
||||
libcurl.curl_multi_add_handle(multi, conn._easy) catch |err| {
|
||||
lp.log.err(.app, "curl multi add", .{ .err = err });
|
||||
self.releaseConnection(conn);
|
||||
};
|
||||
@@ -565,7 +565,7 @@ pub fn getConnection(self: *Runtime) ?*net_http.Connection {
|
||||
}
|
||||
|
||||
pub fn releaseConnection(self: *Runtime, conn: *net_http.Connection) void {
|
||||
conn.reset() catch |err| {
|
||||
conn.reset(self.config, self.ca_blob) catch |err| {
|
||||
lp.assert(false, "couldn't reset curl easy", .{ .err = err });
|
||||
};
|
||||
|
||||
|
||||
@@ -17,10 +17,6 @@
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
const builtin = @import("builtin");
|
||||
const posix = std.posix;
|
||||
const Allocator = std.mem.Allocator;
|
||||
const ArenaAllocator = std.heap.ArenaAllocator;
|
||||
|
||||
const Config = @import("../Config.zig");
|
||||
const libcurl = @import("../sys/libcurl.zig");
|
||||
@@ -29,18 +25,12 @@ const log = @import("lightpanda").log;
|
||||
const assert = @import("lightpanda").assert;
|
||||
|
||||
pub const ENABLE_DEBUG = false;
|
||||
const IS_DEBUG = builtin.mode == .Debug;
|
||||
|
||||
pub const Blob = libcurl.CurlBlob;
|
||||
pub const WaitFd = libcurl.CurlWaitFd;
|
||||
pub const writefunc_error = libcurl.curl_writefunc_error;
|
||||
|
||||
const Error = libcurl.Error;
|
||||
const ErrorMulti = libcurl.ErrorMulti;
|
||||
const errorFromCode = libcurl.errorFromCode;
|
||||
const errorMFromCode = libcurl.errorMFromCode;
|
||||
const errorCheck = libcurl.errorCheck;
|
||||
const errorMCheck = libcurl.errorMCheck;
|
||||
|
||||
pub fn curl_version() [*c]const u8 {
|
||||
return libcurl.curl_version();
|
||||
@@ -64,14 +54,13 @@ pub const Header = struct {
|
||||
|
||||
pub const Headers = struct {
|
||||
headers: ?*libcurl.CurlSList,
|
||||
cookies: ?[*c]const u8,
|
||||
|
||||
pub fn init(user_agent: [:0]const u8) !Headers {
|
||||
const header_list = libcurl.curl_slist_append(null, user_agent);
|
||||
if (header_list == null) {
|
||||
return error.OutOfMemory;
|
||||
}
|
||||
return .{ .headers = header_list, .cookies = null };
|
||||
return .{ .headers = header_list };
|
||||
}
|
||||
|
||||
pub fn deinit(self: *const Headers) void {
|
||||
@@ -102,20 +91,14 @@ pub const Headers = struct {
|
||||
pub fn iterator(self: *Headers) Iterator {
|
||||
return .{
|
||||
.header = self.headers,
|
||||
.cookies = self.cookies,
|
||||
};
|
||||
}
|
||||
|
||||
const Iterator = struct {
|
||||
header: [*c]libcurl.CurlSList,
|
||||
cookies: ?[*c]const u8,
|
||||
|
||||
pub fn next(self: *Iterator) ?Header {
|
||||
const h = self.header orelse {
|
||||
const cookies = self.cookies orelse return null;
|
||||
self.cookies = null;
|
||||
return .{ .name = "Cookie", .value = std.mem.span(@as([*:0]const u8, cookies)) };
|
||||
};
|
||||
const h = self.header orelse return null;
|
||||
|
||||
self.header = h.*.next;
|
||||
return parseHeader(std.mem.span(@as([*:0]const u8, @ptrCast(h.*.data))));
|
||||
@@ -142,7 +125,7 @@ pub const HeaderIterator = union(enum) {
|
||||
prev: ?*libcurl.CurlHeader = null,
|
||||
|
||||
pub fn next(self: *CurlHeaderIterator) ?Header {
|
||||
const h = libcurl.curl_easy_nextheader(self.conn.easy, .header, -1, self.prev) orelse return null;
|
||||
const h = libcurl.curl_easy_nextheader(self.conn._easy, .header, -1, self.prev) orelse return null;
|
||||
self.prev = h;
|
||||
|
||||
const header = h.*;
|
||||
@@ -174,33 +157,24 @@ const HeaderValue = struct {
|
||||
};
|
||||
|
||||
pub const AuthChallenge = struct {
|
||||
const Source = enum { server, proxy };
|
||||
const Scheme = enum { basic, digest };
|
||||
|
||||
status: u16,
|
||||
source: ?enum { server, proxy },
|
||||
scheme: ?enum { basic, digest },
|
||||
source: ?Source,
|
||||
scheme: ?Scheme,
|
||||
realm: ?[]const u8,
|
||||
|
||||
pub fn parse(status: u16, header: []const u8) !AuthChallenge {
|
||||
pub fn parse(status: u16, source: Source, value: []const u8) !AuthChallenge {
|
||||
var ac: AuthChallenge = .{
|
||||
.status = status,
|
||||
.source = null,
|
||||
.source = source,
|
||||
.realm = null,
|
||||
.scheme = null,
|
||||
};
|
||||
|
||||
const sep = std.mem.indexOfPos(u8, header, 0, ": ") orelse return error.InvalidHeader;
|
||||
const hname = header[0..sep];
|
||||
const hvalue = header[sep + 2 ..];
|
||||
|
||||
if (std.ascii.eqlIgnoreCase("WWW-Authenticate", hname)) {
|
||||
ac.source = .server;
|
||||
} else if (std.ascii.eqlIgnoreCase("Proxy-Authenticate", hname)) {
|
||||
ac.source = .proxy;
|
||||
} else {
|
||||
return error.InvalidAuthChallenge;
|
||||
}
|
||||
|
||||
const pos = std.mem.indexOfPos(u8, std.mem.trim(u8, hvalue, std.ascii.whitespace[0..]), 0, " ") orelse hvalue.len;
|
||||
const _scheme = hvalue[0..pos];
|
||||
const pos = std.mem.indexOfPos(u8, std.mem.trim(u8, value, std.ascii.whitespace[0..]), 0, " ") orelse value.len;
|
||||
const _scheme = value[0..pos];
|
||||
if (std.ascii.eqlIgnoreCase(_scheme, "basic")) {
|
||||
ac.scheme = .basic;
|
||||
} else if (std.ascii.eqlIgnoreCase(_scheme, "digest")) {
|
||||
@@ -236,77 +210,28 @@ pub const ResponseHead = struct {
|
||||
};
|
||||
|
||||
pub const Connection = struct {
|
||||
easy: *libcurl.Curl,
|
||||
_easy: *libcurl.Curl,
|
||||
node: std.DoublyLinkedList.Node = .{},
|
||||
|
||||
pub fn init(
|
||||
ca_blob_: ?libcurl.CurlBlob,
|
||||
ca_blob: ?libcurl.CurlBlob,
|
||||
config: *const Config,
|
||||
) !Connection {
|
||||
const easy = libcurl.curl_easy_init() orelse return error.FailedToInitializeEasy;
|
||||
errdefer libcurl.curl_easy_cleanup(easy);
|
||||
|
||||
// timeouts
|
||||
try libcurl.curl_easy_setopt(easy, .timeout_ms, config.httpTimeout());
|
||||
try libcurl.curl_easy_setopt(easy, .connect_timeout_ms, config.httpConnectTimeout());
|
||||
const self = Connection{ ._easy = easy };
|
||||
errdefer self.deinit();
|
||||
|
||||
// redirect behavior
|
||||
try libcurl.curl_easy_setopt(easy, .max_redirs, config.httpMaxRedirects());
|
||||
try libcurl.curl_easy_setopt(easy, .follow_location, 2);
|
||||
try libcurl.curl_easy_setopt(easy, .redir_protocols_str, "HTTP,HTTPS"); // remove FTP and FTPS from the default
|
||||
|
||||
// proxy
|
||||
const http_proxy = config.httpProxy();
|
||||
if (http_proxy) |proxy| {
|
||||
try libcurl.curl_easy_setopt(easy, .proxy, proxy.ptr);
|
||||
}
|
||||
|
||||
// tls
|
||||
if (ca_blob_) |ca_blob| {
|
||||
try libcurl.curl_easy_setopt(easy, .ca_info_blob, ca_blob);
|
||||
if (http_proxy != null) {
|
||||
try libcurl.curl_easy_setopt(easy, .proxy_ca_info_blob, ca_blob);
|
||||
}
|
||||
} else {
|
||||
assert(config.tlsVerifyHost() == false, "Http.init tls_verify_host", .{});
|
||||
|
||||
try libcurl.curl_easy_setopt(easy, .ssl_verify_host, false);
|
||||
try libcurl.curl_easy_setopt(easy, .ssl_verify_peer, false);
|
||||
|
||||
if (http_proxy != null) {
|
||||
try libcurl.curl_easy_setopt(easy, .proxy_ssl_verify_host, false);
|
||||
try libcurl.curl_easy_setopt(easy, .proxy_ssl_verify_peer, false);
|
||||
}
|
||||
}
|
||||
|
||||
// compression, don't remove this. CloudFront will send gzip content
|
||||
// even if we don't support it, and then it won't be decompressed.
|
||||
// empty string means: use whatever's available
|
||||
try libcurl.curl_easy_setopt(easy, .accept_encoding, "");
|
||||
|
||||
// debug
|
||||
if (comptime ENABLE_DEBUG) {
|
||||
try libcurl.curl_easy_setopt(easy, .verbose, true);
|
||||
|
||||
// Sometimes the default debug output hides some useful data. You can
|
||||
// uncomment the following line (BUT KEEP THE LIVE ABOVE AS-IS), to
|
||||
// get more control over the data (specifically, the `CURLINFO_TEXT`
|
||||
// can include useful data).
|
||||
|
||||
// try libcurl.curl_easy_setopt(easy, .debug_function, debugCallback);
|
||||
}
|
||||
|
||||
return .{
|
||||
.easy = easy,
|
||||
};
|
||||
try self.reset(config, ca_blob);
|
||||
return self;
|
||||
}
|
||||
|
||||
pub fn deinit(self: *const Connection) void {
|
||||
libcurl.curl_easy_cleanup(self.easy);
|
||||
libcurl.curl_easy_cleanup(self._easy);
|
||||
}
|
||||
|
||||
pub fn setURL(self: *const Connection, url: [:0]const u8) !void {
|
||||
try libcurl.curl_easy_setopt(self.easy, .url, url.ptr);
|
||||
try libcurl.curl_easy_setopt(self._easy, .url, url.ptr);
|
||||
}
|
||||
|
||||
// a libcurl request has 2 methods. The first is the method that
|
||||
@@ -329,7 +254,7 @@ pub const Connection = struct {
|
||||
// can infer that based on the presence of the body, but we also reset it
|
||||
// to be safe);
|
||||
pub fn setMethod(self: *const Connection, method: Method) !void {
|
||||
const easy = self.easy;
|
||||
const easy = self._easy;
|
||||
const m: [:0]const u8 = switch (method) {
|
||||
.GET => "GET",
|
||||
.POST => "POST",
|
||||
@@ -344,56 +269,97 @@ pub const Connection = struct {
|
||||
}
|
||||
|
||||
pub fn setBody(self: *const Connection, body: []const u8) !void {
|
||||
const easy = self.easy;
|
||||
const easy = self._easy;
|
||||
try libcurl.curl_easy_setopt(easy, .post, true);
|
||||
try libcurl.curl_easy_setopt(easy, .post_field_size, body.len);
|
||||
try libcurl.curl_easy_setopt(easy, .copy_post_fields, body.ptr);
|
||||
}
|
||||
|
||||
pub fn setGetMode(self: *const Connection) !void {
|
||||
try libcurl.curl_easy_setopt(self.easy, .http_get, true);
|
||||
try libcurl.curl_easy_setopt(self._easy, .http_get, true);
|
||||
}
|
||||
|
||||
pub fn setHeaders(self: *const Connection, headers: *Headers) !void {
|
||||
try libcurl.curl_easy_setopt(self.easy, .http_header, headers.headers);
|
||||
try libcurl.curl_easy_setopt(self._easy, .http_header, headers.headers);
|
||||
}
|
||||
|
||||
pub fn setCookies(self: *const Connection, cookies: [*c]const u8) !void {
|
||||
try libcurl.curl_easy_setopt(self.easy, .cookie, cookies);
|
||||
try libcurl.curl_easy_setopt(self._easy, .cookie, cookies);
|
||||
}
|
||||
|
||||
pub fn setPrivate(self: *const Connection, ptr: *anyopaque) !void {
|
||||
try libcurl.curl_easy_setopt(self.easy, .private, ptr);
|
||||
try libcurl.curl_easy_setopt(self._easy, .private, ptr);
|
||||
}
|
||||
|
||||
pub fn setProxyCredentials(self: *const Connection, creds: [:0]const u8) !void {
|
||||
try libcurl.curl_easy_setopt(self.easy, .proxy_user_pwd, creds.ptr);
|
||||
try libcurl.curl_easy_setopt(self._easy, .proxy_user_pwd, creds.ptr);
|
||||
}
|
||||
|
||||
pub fn setCredentials(self: *const Connection, creds: [:0]const u8) !void {
|
||||
try libcurl.curl_easy_setopt(self.easy, .user_pwd, creds.ptr);
|
||||
try libcurl.curl_easy_setopt(self._easy, .user_pwd, creds.ptr);
|
||||
}
|
||||
|
||||
pub fn setCallbacks(
|
||||
self: *const Connection,
|
||||
comptime header_cb: libcurl.CurlHeaderFunction,
|
||||
self: *Connection,
|
||||
comptime data_cb: libcurl.CurlWriteFunction,
|
||||
) !void {
|
||||
try libcurl.curl_easy_setopt(self.easy, .header_data, self.easy);
|
||||
try libcurl.curl_easy_setopt(self.easy, .header_function, header_cb);
|
||||
try libcurl.curl_easy_setopt(self.easy, .write_data, self.easy);
|
||||
try libcurl.curl_easy_setopt(self.easy, .write_function, data_cb);
|
||||
try libcurl.curl_easy_setopt(self._easy, .write_data, self);
|
||||
try libcurl.curl_easy_setopt(self._easy, .write_function, data_cb);
|
||||
}
|
||||
|
||||
pub fn reset(self: *const Connection) !void {
|
||||
try libcurl.curl_easy_setopt(self.easy, .proxy, null);
|
||||
try libcurl.curl_easy_setopt(self.easy, .http_header, null);
|
||||
pub fn reset(
|
||||
self: *const Connection,
|
||||
config: *const Config,
|
||||
ca_blob: ?libcurl.CurlBlob,
|
||||
) !void {
|
||||
libcurl.curl_easy_reset(self._easy);
|
||||
|
||||
try libcurl.curl_easy_setopt(self.easy, .header_data, null);
|
||||
try libcurl.curl_easy_setopt(self.easy, .header_function, null);
|
||||
// timeouts
|
||||
try libcurl.curl_easy_setopt(self._easy, .timeout_ms, config.httpTimeout());
|
||||
try libcurl.curl_easy_setopt(self._easy, .connect_timeout_ms, config.httpConnectTimeout());
|
||||
|
||||
try libcurl.curl_easy_setopt(self.easy, .write_data, null);
|
||||
try libcurl.curl_easy_setopt(self.easy, .write_function, discardBody);
|
||||
// compression, don't remove this. CloudFront will send gzip content
|
||||
// even if we don't support it, and then it won't be decompressed.
|
||||
// empty string means: use whatever's available
|
||||
try libcurl.curl_easy_setopt(self._easy, .accept_encoding, "");
|
||||
|
||||
// proxy
|
||||
const http_proxy = config.httpProxy();
|
||||
if (http_proxy) |proxy| {
|
||||
try libcurl.curl_easy_setopt(self._easy, .proxy, proxy.ptr);
|
||||
} else {
|
||||
try libcurl.curl_easy_setopt(self._easy, .proxy, null);
|
||||
}
|
||||
|
||||
// tls
|
||||
if (ca_blob) |ca| {
|
||||
try libcurl.curl_easy_setopt(self._easy, .ca_info_blob, ca);
|
||||
if (http_proxy != null) {
|
||||
try libcurl.curl_easy_setopt(self._easy, .proxy_ca_info_blob, ca);
|
||||
}
|
||||
} else {
|
||||
assert(config.tlsVerifyHost() == false, "Http.init tls_verify_host", .{});
|
||||
|
||||
try libcurl.curl_easy_setopt(self._easy, .ssl_verify_host, false);
|
||||
try libcurl.curl_easy_setopt(self._easy, .ssl_verify_peer, false);
|
||||
|
||||
if (http_proxy != null) {
|
||||
try libcurl.curl_easy_setopt(self._easy, .proxy_ssl_verify_host, false);
|
||||
try libcurl.curl_easy_setopt(self._easy, .proxy_ssl_verify_peer, false);
|
||||
}
|
||||
}
|
||||
|
||||
// debug
|
||||
if (comptime ENABLE_DEBUG) {
|
||||
try libcurl.curl_easy_setopt(self._easy, .verbose, true);
|
||||
|
||||
// Sometimes the default debug output hides some useful data. You can
|
||||
// uncomment the following line (BUT KEEP THE LIVE ABOVE AS-IS), to
|
||||
// get more control over the data (specifically, the `CURLINFO_TEXT`
|
||||
// can include useful data).
|
||||
|
||||
// try libcurl.curl_easy_setopt(easy, .debug_function, debugCallback);
|
||||
}
|
||||
}
|
||||
|
||||
fn discardBody(_: [*]const u8, count: usize, len: usize, _: ?*anyopaque) usize {
|
||||
@@ -401,27 +367,31 @@ pub const Connection = struct {
|
||||
}
|
||||
|
||||
pub fn setProxy(self: *const Connection, proxy: ?[:0]const u8) !void {
|
||||
try libcurl.curl_easy_setopt(self.easy, .proxy, if (proxy) |p| p.ptr else null);
|
||||
try libcurl.curl_easy_setopt(self._easy, .proxy, if (proxy) |p| p.ptr else null);
|
||||
}
|
||||
|
||||
pub fn setFollowLocation(self: *const Connection, follow: bool) !void {
|
||||
try libcurl.curl_easy_setopt(self._easy, .follow_location, @as(c_long, if (follow) 2 else 0));
|
||||
}
|
||||
|
||||
pub fn setTlsVerify(self: *const Connection, verify: bool, use_proxy: bool) !void {
|
||||
try libcurl.curl_easy_setopt(self.easy, .ssl_verify_host, verify);
|
||||
try libcurl.curl_easy_setopt(self.easy, .ssl_verify_peer, verify);
|
||||
try libcurl.curl_easy_setopt(self._easy, .ssl_verify_host, verify);
|
||||
try libcurl.curl_easy_setopt(self._easy, .ssl_verify_peer, verify);
|
||||
if (use_proxy) {
|
||||
try libcurl.curl_easy_setopt(self.easy, .proxy_ssl_verify_host, verify);
|
||||
try libcurl.curl_easy_setopt(self.easy, .proxy_ssl_verify_peer, verify);
|
||||
try libcurl.curl_easy_setopt(self._easy, .proxy_ssl_verify_host, verify);
|
||||
try libcurl.curl_easy_setopt(self._easy, .proxy_ssl_verify_peer, verify);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn getEffectiveUrl(self: *const Connection) ![*c]const u8 {
|
||||
var url: [*c]u8 = undefined;
|
||||
try libcurl.curl_easy_getinfo(self.easy, .effective_url, &url);
|
||||
try libcurl.curl_easy_getinfo(self._easy, .effective_url, &url);
|
||||
return url;
|
||||
}
|
||||
|
||||
pub fn getResponseCode(self: *const Connection) !u16 {
|
||||
var status: c_long = undefined;
|
||||
try libcurl.curl_easy_getinfo(self.easy, .response_code, &status);
|
||||
try libcurl.curl_easy_getinfo(self._easy, .response_code, &status);
|
||||
if (status < 0 or status > std.math.maxInt(u16)) {
|
||||
return 0;
|
||||
}
|
||||
@@ -430,13 +400,13 @@ pub const Connection = struct {
|
||||
|
||||
pub fn getRedirectCount(self: *const Connection) !u32 {
|
||||
var count: c_long = undefined;
|
||||
try libcurl.curl_easy_getinfo(self.easy, .redirect_count, &count);
|
||||
try libcurl.curl_easy_getinfo(self._easy, .redirect_count, &count);
|
||||
return @intCast(count);
|
||||
}
|
||||
|
||||
pub fn getResponseHeader(self: *const Connection, name: [:0]const u8, index: usize) ?HeaderValue {
|
||||
var hdr: ?*libcurl.CurlHeader = null;
|
||||
libcurl.curl_easy_header(self.easy, name, index, .header, -1, &hdr) catch |err| {
|
||||
libcurl.curl_easy_header(self._easy, name, index, .header, -1, &hdr) catch |err| {
|
||||
// ErrorHeader includes OutOfMemory — rare but real errors from curl internals.
|
||||
// Logged and returned as null since callers don't expect errors.
|
||||
log.err(.http, "get response header", .{
|
||||
@@ -454,7 +424,7 @@ pub const Connection = struct {
|
||||
|
||||
pub fn getPrivate(self: *const Connection) !*anyopaque {
|
||||
var private: *anyopaque = undefined;
|
||||
try libcurl.curl_easy_getinfo(self.easy, .private, &private);
|
||||
try libcurl.curl_easy_getinfo(self._easy, .private, &private);
|
||||
return private;
|
||||
}
|
||||
|
||||
@@ -471,12 +441,7 @@ pub const Connection = struct {
|
||||
try self.secretHeaders(&header_list, http_headers);
|
||||
try self.setHeaders(&header_list);
|
||||
|
||||
// Add cookies.
|
||||
if (header_list.cookies) |cookies| {
|
||||
try self.setCookies(cookies);
|
||||
}
|
||||
|
||||
try libcurl.curl_easy_perform(self.easy);
|
||||
try libcurl.curl_easy_perform(self._easy);
|
||||
return self.getResponseCode();
|
||||
}
|
||||
};
|
||||
@@ -498,11 +463,11 @@ pub const Handles = struct {
|
||||
}
|
||||
|
||||
pub fn add(self: *Handles, conn: *const Connection) !void {
|
||||
try libcurl.curl_multi_add_handle(self.multi, conn.easy);
|
||||
try libcurl.curl_multi_add_handle(self.multi, conn._easy);
|
||||
}
|
||||
|
||||
pub fn remove(self: *Handles, conn: *const Connection) !void {
|
||||
try libcurl.curl_multi_remove_handle(self.multi, conn.easy);
|
||||
try libcurl.curl_multi_remove_handle(self.multi, conn._easy);
|
||||
}
|
||||
|
||||
pub fn perform(self: *Handles) !c_int {
|
||||
@@ -525,7 +490,7 @@ pub const Handles = struct {
|
||||
const msg = libcurl.curl_multi_info_read(self.multi, &messages_count) orelse return null;
|
||||
return switch (msg.data) {
|
||||
.done => |err| .{
|
||||
.conn = .{ .easy = msg.easy_handle },
|
||||
.conn = .{ ._easy = msg.easy_handle },
|
||||
.err = err,
|
||||
},
|
||||
else => unreachable,
|
||||
|
||||
@@ -324,7 +324,9 @@ pub const WsConnection = struct {
|
||||
pub fn init(socket: posix.socket_t, allocator: Allocator, json_version_response: []const u8, timeout_ms: u32) !WsConnection {
|
||||
const socket_flags = try posix.fcntl(socket, posix.F.GETFL, 0);
|
||||
const nonblocking = @as(u32, @bitCast(posix.O{ .NONBLOCK = true }));
|
||||
assert(socket_flags & nonblocking == nonblocking, "WsConnection.init blocking", .{});
|
||||
if (builtin.is_test == false) {
|
||||
assert(socket_flags & nonblocking == nonblocking, "WsConnection.init blocking", .{});
|
||||
}
|
||||
|
||||
var reader = try Reader(true).init(allocator);
|
||||
errdefer reader.deinit();
|
||||
|
||||
@@ -516,6 +516,10 @@ pub fn curl_easy_cleanup(easy: *Curl) void {
|
||||
c.curl_easy_cleanup(easy);
|
||||
}
|
||||
|
||||
pub fn curl_easy_reset(easy: *Curl) void {
|
||||
c.curl_easy_reset(easy);
|
||||
}
|
||||
|
||||
pub fn curl_easy_perform(easy: *Curl) Error!void {
|
||||
try errorCheck(c.curl_easy_perform(easy));
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ const App = @import("../App.zig");
|
||||
const Config = @import("../Config.zig");
|
||||
const telemetry = @import("telemetry.zig");
|
||||
const Runtime = @import("../network/Runtime.zig");
|
||||
const Connection = @import("../network/http.zig").Connection;
|
||||
|
||||
const URL = "https://telemetry.lightpanda.io";
|
||||
const BUFFER_SIZE = 1024;
|
||||
|
||||
@@ -410,21 +410,46 @@ fn runWebApiTest(test_file: [:0]const u8) !void {
|
||||
page.js.localScope(&ls);
|
||||
defer ls.deinit();
|
||||
|
||||
var try_catch: js.TryCatch = undefined;
|
||||
try_catch.init(&ls.local);
|
||||
defer try_catch.deinit();
|
||||
{
|
||||
var try_catch: js.TryCatch = undefined;
|
||||
try_catch.init(&ls.local);
|
||||
defer try_catch.deinit();
|
||||
|
||||
try page.navigate(url, .{});
|
||||
}
|
||||
|
||||
try page.navigate(url, .{});
|
||||
var runner = try test_session.runner(.{});
|
||||
try runner.wait(.{ .ms = 2000 });
|
||||
|
||||
test_browser.runMicrotasks();
|
||||
var wait_ms: u32 = 2000;
|
||||
var timer = try std.time.Timer.start();
|
||||
while (true) {
|
||||
var try_catch: js.TryCatch = undefined;
|
||||
try_catch.init(&ls.local);
|
||||
defer try_catch.deinit();
|
||||
|
||||
ls.local.eval("testing.assertOk()", "testing.assertOk()") catch |err| {
|
||||
const caught = try_catch.caughtOrError(arena_allocator, err);
|
||||
std.debug.print("{s}: test failure\nError: {f}\n", .{ test_file, caught });
|
||||
return err;
|
||||
};
|
||||
const js_val = ls.local.exec("testing.assertOk()", "testing.assertOk()") catch |err| {
|
||||
const caught = try_catch.caughtOrError(arena_allocator, err);
|
||||
std.debug.print("{s}: test failure\nError: {f}\n", .{ test_file, caught });
|
||||
return err;
|
||||
};
|
||||
if (js_val.isTrue()) {
|
||||
return;
|
||||
}
|
||||
switch (try runner.tick(.{ .ms = 20 })) {
|
||||
.done => return error.TestNeverSignaledCompletion,
|
||||
.ok => |next_ms| {
|
||||
const ms_elapsed = timer.lap() / 1_000_000;
|
||||
if (ms_elapsed >= wait_ms) {
|
||||
return error.TestTimedOut;
|
||||
}
|
||||
wait_ms -= @intCast(ms_elapsed);
|
||||
if (next_ms > 0) {
|
||||
std.Thread.sleep(std.time.ns_per_ms * next_ms);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Used by a few CDP tests - wouldn't be sad to see this go.
|
||||
@@ -445,10 +470,6 @@ pub fn pageTest(comptime test_file: []const u8) !*Page {
|
||||
return page;
|
||||
}
|
||||
|
||||
test {
|
||||
std.testing.refAllDecls(@This());
|
||||
}
|
||||
|
||||
const log = @import("log.zig");
|
||||
const TestHTTPServer = @import("TestHTTPServer.zig");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user