Compare commits

..

22 Commits

Author SHA1 Message Date
Karl Seguin
77b60cebb0 Move finalizers to pure reference counting
Takes https://github.com/lightpanda-io/browser/pull/2024 a step further and
changes all reference counting to be explicit.

Up until this point, finalizers_callback was seen as a fail-safe to make sure
that instances were released no matter what. It exists because v8 might never
call a finalizer, so we need to keep track of finalizables and finalize them
on behalf of v8. BUT, it was used as more than a fallback for v8...it allowed
us to be lazy and acquireRef's in Zig without a matching releaseRef (1), because
why not, the finalizer_callback will handle it.

This commit redefines finalizer_callbacks as strictly being a fallback for v8.
If v8 calls the finalizer, then the finalizer callback is removed (2) - we lose
our fail-safe. This means that every acquireRef must be matched with a
releaseRef. Everything is explicit now. The most obvious impact of this is
that on Page.deinit, we have to releaseRef every MO, IO and blob held by the
page.

This change removes a number of special-cases to deal with various ownership
patterns. For example, Iterators are now properly reference counted and when their
RC reaches 0, they can safely releaseRef on their list. This also elimites
use-after-free potential when 2 RC objects reference each other. This should
eliminate some WPT crashes (e.g. /editing/run/insertimage.html)

(1) - We were only ever lazy about releaseRef during shutdown, so this change
won't result in more aggressive collection.

(2) Since 1 object can be referenced from 0-N IsolatedWorlds, it would be more
accurate to say that the finalizer callback is removed when all referencing
IsolatedWorld finalize it.
2026-04-02 17:04:33 +08:00
Karl Seguin
38fa9602fa Merge pull request #2067 from lightpanda-io/percent-encode-version
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / wba-demo-scripts (push) Has been cancelled
e2e-test / wba-test (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig fmt (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
percent encode version query string for crash report
2026-04-02 07:50:31 +08:00
Pierre Tachoire
9661204c8d percent encode version query string for crash report 2026-04-01 22:15:56 +02:00
Karl Seguin
6800e53b0e Merge pull request #2014 from lightpanda-io/build-check
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / wba-demo-scripts (push) Has been cancelled
e2e-test / wba-test (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig fmt (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
build: add check step to verify compilation
2026-04-01 21:05:04 +08:00
Nikolay Govorov
e79da3a4ad Merge pull request #2064 from lightpanda-io/network_naming
Improve network naming consistency
2026-04-01 13:10:12 +01:00
Karl Seguin
145792c4f5 Merge pull request #2061 from lightpanda-io/ariaAtomic
Add Element.ariaAtomic and Element.ariaLive properties
2026-04-01 20:06:15 +08:00
Karl Seguin
0bb3e3827d Merge pull request #2060 from lightpanda-io/HTMLAnchorElement.rel
Add HTMLAnchorElement.rel property
2026-04-01 20:04:43 +08:00
Karl Seguin
6e6e6e6fad Merge pull request #2057 from lightpanda-io/element-title
Add HTMLElement.title property
2026-04-01 19:36:12 +08:00
Karl Seguin
9d13a7ccdb Merge pull request #2065 from lightpanda-io/browser/resolve-scheme-in-path
Browser/resolve scheme in path
2026-04-01 19:30:40 +08:00
Karl Seguin
7fcaa500d8 Fix typo in variable name
protect against overflow if path stats with ':'

Minor tweaks to https://github.com/lightpanda-io/browser/pull/2046
2026-04-01 19:20:55 +08:00
Karl Seguin
0604056f76 Improve network naming consistency
1.
Runtime.zig -> Network.zig (especially since most places imported it as
`const Network = @import("Runtime.zig")`

2.
const net_http = @import(...) -> const http = @import(...)
2026-04-01 18:46:03 +08:00
Pierre Tachoire
5965d37c79 Add HTMLAnchorElement.rel property
Reflects the `rel` HTML attribute. The `relList` DOMTokenList was
already implemented but the string `rel` accessor was missing.
2026-04-01 11:15:10 +02:00
Pierre Tachoire
e430051fff Add Element.ariaAtomic and Element.ariaLive properties
ARIAMixin attribute reflection on Element, per the ARIA spec.
2026-04-01 11:13:52 +02:00
Pierre Tachoire
ca8361f5c1 Merge pull request #2059 from lightpanda-io/integration-e2e-report
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / wba-demo-scripts (push) Has been cancelled
e2e-test / wba-test (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig fmt (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
ci: send e2e integration results to slack
2026-04-01 10:41:16 +02:00
Pierre Tachoire
bc4afcd82f Merge pull request #2058 from lightpanda-io/wpt-diff
Wpt diff
2026-04-01 10:31:12 +02:00
Pierre Tachoire
1c1bd81daa ci: send e2e integration results to slack 2026-04-01 10:29:56 +02:00
Pierre Tachoire
ea87fc2c50 ci: publish ci regression list into slack 2026-04-01 10:18:06 +02:00
Pierre Tachoire
e412dfed2f Add HTMLElement.title property
Reflects the `title` HTML attribute as a getter/setter on HTMLElement.
2026-04-01 09:15:34 +02:00
dinisimys2018
2d87f5bf47 fix(browser-url): handle specific file scheme and change error InvalidURL to TypeError 2026-03-31 18:42:03 +03:00
dinisimys2018
0a222ff397 fix(browser-url): add more combinations base+path handle 2026-03-31 16:54:06 +03:00
dinisimys2018
9a0cefad26 fix(browser-url): url resolve scheme in path 2026-03-30 18:58:19 +03:00
Adrià Arrufat
3aeba97fc9 build: add check step to verify compilation 2026-03-27 14:25:17 +09:00
30 changed files with 604 additions and 694 deletions

View File

@@ -60,7 +60,20 @@ jobs:
- run: chmod a+x ./lightpanda - run: chmod a+x ./lightpanda
- name: run end to end integration tests - name: run end to end integration tests
continue-on-error: true
run: | run: |
./lightpanda serve --log-level error & echo $! > LPD.pid ./lightpanda serve --log-level error & echo $! > LPD.pid
go run integration/main.go go run integration/main.go |tee result.log
kill `cat LPD.pid` kill `cat LPD.pid`
- name: Send result to slack
uses: slackapi/slack-github-action@v3.0.1
with:
errors: true
method: files.uploadV2
token: ${{ secrets.CI_SLACK_BOT_TOKEN }}
payload: |
channel_id: ${{ vars.E2E_SLACK_CHANNEL_ID }}
initial_comment: "Last e2e integration tests"
file: "./result.log"
filename: "e2e-integration-${{ github.sha }}.txt"

View File

@@ -153,3 +153,34 @@ jobs:
- name: format and send json result - name: format and send json result
run: /perf-fmt wpt ${{ github.sha }} wpt.json run: /perf-fmt wpt ${{ github.sha }} wpt.json
wptdiff:
name: perf-fmt
needs: perf-fmt
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
repository: 'lightpanda-io/demo'
fetch-depth: 0
- run: |
cd ./wptdiff
CGO_ENABLED=0 go build
- run: |
./wptdiff/wptdiff |tee diff.log
- name: Send regression to slack
uses: slackapi/slack-github-action@v3.0.1
with:
errors: true
method: files.uploadV2
token: ${{ secrets.CI_SLACK_BOT_TOKEN }}
payload: |
channel_id: ${{ vars.WPT_SLACK_CHANNEL_ID }}
initial_comment: "Last WPT regressions"
file: "./diff.log"
filename: "wpt-regression-${{ github.sha }}.txt"

View File

@@ -46,8 +46,12 @@ pub fn build(b: *Build) !void {
var stdout = std.fs.File.stdout().writer(&.{}); var stdout = std.fs.File.stdout().writer(&.{});
try stdout.interface.print("Lightpanda {f}\n", .{version}); try stdout.interface.print("Lightpanda {f}\n", .{version});
const version_string = b.fmt("{f}", .{version});
const version_encoded = std.mem.replaceOwned(u8, b.allocator, version_string, "+", "%2B") catch @panic("OOM");
var opts = b.addOptions(); var opts = b.addOptions();
opts.addOption([]const u8, "version", b.fmt("{f}", .{version})); opts.addOption([]const u8, "version", version_string);
opts.addOption([]const u8, "version_encoded", version_encoded);
opts.addOption(?[]const u8, "snapshot_path", snapshot_path); opts.addOption(?[]const u8, "snapshot_path", snapshot_path);
const enable_tsan = b.option(bool, "tsan", "Enable Thread Sanitizer") orelse false; const enable_tsan = b.option(bool, "tsan", "Enable Thread Sanitizer") orelse false;
@@ -85,6 +89,15 @@ pub fn build(b: *Build) !void {
break :blk mod; break :blk mod;
}; };
// Check compilation
const check = b.step("check", "Check if lightpanda compiles");
const check_lib = b.addLibrary(.{
.name = "lightpanda_check",
.root_module = lightpanda_module,
});
check.dependOn(&check_lib.step);
{ {
// browser // browser
const exe = b.addExecutable(.{ const exe = b.addExecutable(.{
@@ -103,6 +116,12 @@ pub fn build(b: *Build) !void {
}); });
b.installArtifact(exe); b.installArtifact(exe);
const exe_check = b.addLibrary(.{
.name = "lightpanda_exe_check",
.root_module = exe.root_module,
});
check.dependOn(&exe_check.step);
const run_cmd = b.addRunArtifact(exe); const run_cmd = b.addRunArtifact(exe);
if (b.args) |args| { if (b.args) |args| {
run_cmd.addArgs(args); run_cmd.addArgs(args);
@@ -132,6 +151,12 @@ pub fn build(b: *Build) !void {
}); });
b.installArtifact(exe); b.installArtifact(exe);
const exe_check = b.addLibrary(.{
.name = "snapshot_creator_check",
.root_module = exe.root_module,
});
check.dependOn(&exe_check.step);
const run_cmd = b.addRunArtifact(exe); const run_cmd = b.addRunArtifact(exe);
if (b.args) |args| { if (b.args) |args| {
run_cmd.addArgs(args); run_cmd.addArgs(args);
@@ -170,6 +195,12 @@ pub fn build(b: *Build) !void {
}); });
b.installArtifact(exe); b.installArtifact(exe);
const exe_check = b.addLibrary(.{
.name = "legacy_test_check",
.root_module = exe.root_module,
});
check.dependOn(&exe_check.step);
const run_cmd = b.addRunArtifact(exe); const run_cmd = b.addRunArtifact(exe);
if (b.args) |args| { if (b.args) |args| {
run_cmd.addArgs(args); run_cmd.addArgs(args);

View File

@@ -26,7 +26,7 @@ const Snapshot = @import("browser/js/Snapshot.zig");
const Platform = @import("browser/js/Platform.zig"); const Platform = @import("browser/js/Platform.zig");
const Telemetry = @import("telemetry/telemetry.zig").Telemetry; const Telemetry = @import("telemetry/telemetry.zig").Telemetry;
const Network = @import("network/Runtime.zig"); const Network = @import("network/Network.zig");
pub const ArenaPool = @import("ArenaPool.zig"); pub const ArenaPool = @import("ArenaPool.zig");
const App = @This(); const App = @This();

View File

@@ -34,7 +34,6 @@ pub const RunMode = enum {
mcp, mcp,
}; };
pub const MAX_LISTENERS = 16;
pub const CDP_MAX_HTTP_REQUEST_SIZE = 4096; pub const CDP_MAX_HTTP_REQUEST_SIZE = 4096;
// max message size // max message size

View File

@@ -30,7 +30,7 @@ const Notification = @import("../Notification.zig");
const CookieJar = @import("webapi/storage/Cookie.zig").Jar; const CookieJar = @import("webapi/storage/Cookie.zig").Jar;
const http = @import("../network/http.zig"); const http = @import("../network/http.zig");
const Runtime = @import("../network/Runtime.zig"); const Network = @import("../network/Network.zig");
const Robots = @import("../network/Robots.zig"); const Robots = @import("../network/Robots.zig");
const IS_DEBUG = builtin.mode == .Debug; const IS_DEBUG = builtin.mode == .Debug;
@@ -86,7 +86,7 @@ queue: std.DoublyLinkedList = .{},
// The main app allocator // The main app allocator
allocator: Allocator, allocator: Allocator,
network: *Runtime, network: *Network,
// Queue of requests that depend on a robots.txt. // Queue of requests that depend on a robots.txt.
// Allows us to fetch the robots.txt just once. // Allows us to fetch the robots.txt just once.
@@ -131,7 +131,7 @@ pub const CDPClient = struct {
blocking_read_end: *const fn (*anyopaque) bool, blocking_read_end: *const fn (*anyopaque) bool,
}; };
pub fn init(allocator: Allocator, network: *Runtime) !*Client { pub fn init(allocator: Allocator, network: *Network) !*Client {
var transfer_pool = std.heap.MemoryPool(Transfer).init(allocator); var transfer_pool = std.heap.MemoryPool(Transfer).init(allocator);
errdefer transfer_pool.deinit(); errdefer transfer_pool.deinit();
@@ -695,7 +695,7 @@ fn perform(self: *Client, timeout_ms: c_int) anyerror!PerformStatus {
break :blk try self.handles.perform(); break :blk try self.handles.perform();
}; };
// Process dirty connections — return them to Runtime pool. // Process dirty connections — return them to Network pool.
while (self.dirty.popFirst()) |node| { while (self.dirty.popFirst()) |node| {
const conn: *http.Connection = @fieldParentPtr("node", node); const conn: *http.Connection = @fieldParentPtr("node", node);
self.handles.remove(conn) catch |err| { self.handles.remove(conn) catch |err| {

View File

@@ -351,6 +351,30 @@ pub fn deinit(self: *Page, abort_http: bool) void {
session.releaseArena(qn.arena); session.releaseArena(qn.arena);
} }
{
// Release all objects we're referencing
{
var it = self._blob_urls.valueIterator();
while (it.next()) |blob| {
blob.*.releaseRef(session);
}
}
{
var it: ?*std.DoublyLinkedList.Node = self._mutation_observers.first;
while (it) |node| : (it = node.next) {
const observer: *MutationObserver = @fieldParentPtr("node", node);
observer.releaseRef(session);
}
}
for (self._intersection_observers.items) |observer| {
observer.releaseRef(session);
}
self.window._document._selection.releaseRef(session);
}
session.browser.env.destroyContext(self.js); session.browser.env.destroyContext(self.js);
self._script_manager.shutdown = true; self._script_manager.shutdown = true;
@@ -1338,20 +1362,24 @@ pub fn schedulePerformanceObserverDelivery(self: *Page) !void {
} }
pub fn registerMutationObserver(self: *Page, observer: *MutationObserver) !void { pub fn registerMutationObserver(self: *Page, observer: *MutationObserver) !void {
observer.acquireRef();
self._mutation_observers.append(&observer.node); self._mutation_observers.append(&observer.node);
} }
pub fn unregisterMutationObserver(self: *Page, observer: *MutationObserver) void { pub fn unregisterMutationObserver(self: *Page, observer: *MutationObserver) void {
observer.releaseRef(self._session);
self._mutation_observers.remove(&observer.node); self._mutation_observers.remove(&observer.node);
} }
pub fn registerIntersectionObserver(self: *Page, observer: *IntersectionObserver) !void { pub fn registerIntersectionObserver(self: *Page, observer: *IntersectionObserver) !void {
observer.acquireRef();
try self._intersection_observers.append(self.arena, observer); try self._intersection_observers.append(self.arena, observer);
} }
pub fn unregisterIntersectionObserver(self: *Page, observer: *IntersectionObserver) void { pub fn unregisterIntersectionObserver(self: *Page, observer: *IntersectionObserver) void {
for (self._intersection_observers.items, 0..) |obs, i| { for (self._intersection_observers.items, 0..) |obs, i| {
if (obs == observer) { if (obs == observer) {
observer.releaseRef(self._session);
_ = self._intersection_observers.swapRemove(i); _ = self._intersection_observers.swapRemove(i);
return; return;
} }

View File

@@ -22,7 +22,7 @@ const builtin = @import("builtin");
const log = @import("../log.zig"); const log = @import("../log.zig");
const HttpClient = @import("HttpClient.zig"); const HttpClient = @import("HttpClient.zig");
const net_http = @import("../network/http.zig"); const http = @import("../network/http.zig");
const String = @import("../string.zig").String; const String = @import("../string.zig").String;
const js = @import("js/js.zig"); const js = @import("js/js.zig");
@@ -136,7 +136,7 @@ fn clearList(list: *std.DoublyLinkedList) void {
} }
} }
fn getHeaders(self: *ScriptManager) !net_http.Headers { fn getHeaders(self: *ScriptManager) !http.Headers {
var headers = try self.client.newHeaders(); var headers = try self.client.newHeaders();
try self.page.headersForRequest(&headers); try self.page.headersForRequest(&headers);
return headers; return headers;

View File

@@ -501,7 +501,11 @@ pub const FinalizerCallback = struct {
session: *Session, session: *Session,
resolved_ptr_id: usize, resolved_ptr_id: usize,
finalizer_ptr_id: usize, finalizer_ptr_id: usize,
_deinit: *const fn (ptr_id: usize, session: *Session) void, release_ref: *const fn (ptr_id: usize, session: *Session) void,
// Track how many identities (JS worlds) reference this FC.
// Only cleanup when all identities have finalized.
identity_count: u8 = 0,
// For every FinalizerCallback we'll have 1+ FinalizerCallback.Identity: one // For every FinalizerCallback we'll have 1+ FinalizerCallback.Identity: one
// for every identity that gets the instance. In most cases, that'l be 1. // for every identity that gets the instance. In most cases, that'l be 1.
@@ -510,8 +514,9 @@ pub const FinalizerCallback = struct {
fc: *Session.FinalizerCallback, fc: *Session.FinalizerCallback,
}; };
// Called during page reset to force cleanup regardless of identity_count.
fn deinit(self: *FinalizerCallback, session: *Session) void { fn deinit(self: *FinalizerCallback, session: *Session) void {
self._deinit(self.finalizer_ptr_id, session); self.release_ref(self.finalizer_ptr_id, session);
session.releaseArena(self.arena); session.releaseArena(self.arena);
} }
}; };

View File

@@ -25,28 +25,72 @@ const ResolveOpts = struct {
}; };
// path is anytype, so that it can be used with both []const u8 and [:0]const u8 // path is anytype, so that it can be used with both []const u8 and [:0]const u8
pub fn resolve(allocator: Allocator, base: [:0]const u8, path: anytype, comptime opts: ResolveOpts) ![:0]const u8 { pub fn resolve(allocator: Allocator, base: [:0]const u8, source_path: anytype, comptime opts: ResolveOpts) ![:0]const u8 {
const PT = @TypeOf(path); const PT = @TypeOf(source_path);
if (base.len == 0 or isCompleteHTTPUrl(path)) {
if (comptime opts.always_dupe or !isNullTerminated(PT)) { var path: [:0]const u8 = if (comptime !isNullTerminated(PT) or opts.always_dupe) try allocator.dupeZ(u8, source_path) else source_path;
const duped = try allocator.dupeZ(u8, path);
return processResolved(allocator, duped, opts); if (base.len == 0) {
return processResolved(allocator, path, opts);
}
// Minimum is "x:" and skip relative path (very common case)
if (path.len >= 2 and path[0] != '/') {
if (std.mem.indexOfScalar(u8, path[0..], ':')) |scheme_path_end| {
scheme_check: {
const scheme_path = path[0..scheme_path_end];
//from "ws" to "https"
if (scheme_path_end >= 2 and scheme_path_end <= 5) {
const has_double_slashes: bool = scheme_path_end + 3 <= path.len and path[scheme_path_end + 1] == '/' and path[scheme_path_end + 2] == '/';
const special_schemes = [_][]const u8{ "https", "http", "ws", "wss", "file", "ftp" };
for (special_schemes) |special_scheme| {
if (std.ascii.eqlIgnoreCase(scheme_path, special_scheme)) {
const base_scheme_end = std.mem.indexOf(u8, base, "://") orelse 0;
if (base_scheme_end > 0 and std.mem.eql(u8, base[0..base_scheme_end], scheme_path) and !has_double_slashes) {
//Skip ":" and exit as relative state
path = path[scheme_path_end + 1 ..];
break :scheme_check;
} else {
var rest_start: usize = scheme_path_end + 1;
//Skip any slashas after "scheme:"
while (rest_start < path.len and (path[rest_start] == '/' or path[rest_start] == '\\')) {
rest_start += 1;
}
// A special scheme (exclude "file") must contain at least any chars after "://"
if (rest_start == path.len and !std.ascii.eqlIgnoreCase(scheme_path, "file")) {
return error.TypeError;
}
//File scheme allow empty host
const separator: []const u8 = if (!has_double_slashes and std.ascii.eqlIgnoreCase(scheme_path, "file")) ":///" else "://";
path = try std.mem.joinZ(allocator, "", &.{ scheme_path, separator, path[rest_start..] });
return processResolved(allocator, path, opts);
}
}
}
}
if (scheme_path.len > 0) {
for (scheme_path[1..]) |c| {
if (!std.ascii.isAlphanumeric(c) and c != '+' and c != '-' and c != '.') {
//Exit as relative state
break :scheme_check;
}
}
}
//path is complete http url
return processResolved(allocator, path, opts);
}
} }
if (comptime opts.encode) {
return processResolved(allocator, path, opts);
}
return path;
} }
if (path.len == 0) { if (path.len == 0) {
if (comptime opts.always_dupe) { if (opts.always_dupe) {
const duped = try allocator.dupeZ(u8, base); const dupe = try allocator.dupeZ(u8, base);
return processResolved(allocator, duped, opts); return processResolved(allocator, dupe, opts);
} }
if (comptime opts.encode) { return processResolved(allocator, base, opts);
return processResolved(allocator, base, opts);
}
return base;
} }
if (path[0] == '?') { if (path[0] == '?') {
@@ -63,14 +107,7 @@ pub fn resolve(allocator: Allocator, base: [:0]const u8, path: anytype, comptime
if (std.mem.startsWith(u8, path, "//")) { if (std.mem.startsWith(u8, path, "//")) {
// network-path reference // network-path reference
const index = std.mem.indexOfScalar(u8, base, ':') orelse { const index = std.mem.indexOfScalar(u8, base, ':') orelse {
if (comptime isNullTerminated(PT)) { return processResolved(allocator, path, opts);
if (comptime opts.encode) {
return processResolved(allocator, path, opts);
}
return path;
}
const duped = try allocator.dupeZ(u8, path);
return processResolved(allocator, duped, opts);
}; };
const protocol = base[0 .. index + 1]; const protocol = base[0 .. index + 1];
const result = try std.mem.joinZ(allocator, "", &.{ protocol, path }); const result = try std.mem.joinZ(allocator, "", &.{ protocol, path });
@@ -96,6 +133,7 @@ pub fn resolve(allocator: Allocator, base: [:0]const u8, path: anytype, comptime
// trailing space so that we always have space to append the null terminator // trailing space so that we always have space to append the null terminator
// and so that we can compare the next two characters without needing to length check // and so that we can compare the next two characters without needing to length check
var out = try std.mem.join(allocator, "", &.{ normalized_base, "/", path, " " }); var out = try std.mem.join(allocator, "", &.{ normalized_base, "/", path, " " });
const end = out.len - 2; const end = out.len - 2;
const path_marker = path_start + 1; const path_marker = path_start + 1;
@@ -1570,3 +1608,182 @@ test "URL: getOrigin" {
} }
} }
} }
test "URL: resolve path scheme" {
const Case = struct {
base: [:0]const u8,
path: [:0]const u8,
expected: [:0]const u8,
expected_error: bool = false,
};
const cases = [_]Case{
//same schemes and path as relative path (one slash)
.{
.base = "https://www.example.com/example",
.path = "https:/about",
.expected = "https://www.example.com/about",
},
//same schemes and path as relative path (without slash)
.{
.base = "https://www.example.com/example",
.path = "https:about",
.expected = "https://www.example.com/about",
},
//same schemes and path as absolute path (two slashes)
.{
.base = "https://www.example.com/example",
.path = "https://about",
.expected = "https://about",
},
//different schemes and path as absolute (without slash)
.{
.base = "https://www.example.com/example",
.path = "http:about",
.expected = "http://about",
},
//different schemes and path as absolute (with one slash)
.{
.base = "https://www.example.com/example",
.path = "http:/about",
.expected = "http://about",
},
//different schemes and path as absolute (with two slashes)
.{
.base = "https://www.example.com/example",
.path = "http://about",
.expected = "http://about",
},
//same schemes and path as absolute (with more slashes)
.{
.base = "https://site/",
.path = "https://path",
.expected = "https://path",
},
//path scheme is not special and path as absolute (without additional slashes)
.{
.base = "http://localhost/",
.path = "data:test",
.expected = "data:test",
},
//different schemes and path as absolute (pathscheme=ws)
.{
.base = "https://www.example.com/example",
.path = "ws://about",
.expected = "ws://about",
},
//different schemes and path as absolute (path scheme=wss)
.{
.base = "https://www.example.com/example",
.path = "wss://about",
.expected = "wss://about",
},
//different schemes and path as absolute (path scheme=ftp)
.{
.base = "https://www.example.com/example",
.path = "ftp://about",
.expected = "ftp://about",
},
//different schemes and path as absolute (path scheme=file)
.{
.base = "https://www.example.com/example",
.path = "file://path/to/file",
.expected = "file://path/to/file",
},
//different schemes and path as absolute (path scheme=file, host is empty)
.{
.base = "https://www.example.com/example",
.path = "file:/path/to/file",
.expected = "file:///path/to/file",
},
//different schemes and path as absolute (path scheme=file, host is empty)
.{
.base = "https://www.example.com/example",
.path = "file:/",
.expected = "file:///",
},
//different schemes without :// and normalize "file" scheme, absolute path
.{
.base = "https://www.example.com/example",
.path = "file:path/to/file",
.expected = "file:///path/to/file",
},
//same schemes without :// in path and rest starts with scheme:/, relative path
.{
.base = "https://www.example.com/example",
.path = "https:/file:/relative/path/",
.expected = "https://www.example.com/file:/relative/path/",
},
//same schemes without :// in path and rest starts with scheme://, relative path
.{
.base = "https://www.example.com/example",
.path = "https:/http://relative/path/",
.expected = "https://www.example.com/http://relative/path/",
},
//same schemes without :// in path , relative state
.{
.base = "http://www.example.com/example",
.path = "http:relative:path",
.expected = "http://www.example.com/relative:path",
},
//repeat different schemes in path
.{
.base = "http://www.example.com/example",
.path = "http:http:/relative/path/",
.expected = "http://www.example.com/http:/relative/path/",
},
//repeat different schemes in path
.{
.base = "http://www.example.com/example",
.path = "http:https://relative:path",
.expected = "http://www.example.com/https://relative:path",
},
//NOT required :// for blob scheme
.{
.base = "http://www.example.com/example",
.path = "blob:other",
.expected = "blob:other",
},
//NOT required :// for NON-special schemes and can contains "+" or "-" or "." in scheme
.{
.base = "http://www.example.com/example",
.path = "custom+foo:other",
.expected = "custom+foo:other",
},
//NOT required :// for NON-special schemes
.{
.base = "http://www.example.com/example",
.path = "blob:",
.expected = "blob:",
},
//NOT required :// for special scheme equal base scheme
.{
.base = "http://www.example.com/example",
.path = "http:",
.expected = "http://www.example.com/example",
},
//required :// for special scheme, so throw error.InvalidURL
.{
.base = "http://www.example.com/example",
.path = "https:",
.expected = "",
.expected_error = true,
},
//incorrect symbols in path scheme
.{
.base = "https://site",
.path = "http?://host/some",
.expected = "https://site/http?://host/some",
},
};
for (cases) |case| {
if (case.expected_error) {
const result = resolve(testing.arena_allocator, case.base, case.path, .{});
try testing.expectError(error.TypeError, result);
} else {
const result = try resolve(testing.arena_allocator, case.base, case.path, .{});
try testing.expectString(case.expected, result);
}
}
}

View File

@@ -22,23 +22,10 @@ const DOMNode = @import("webapi/Node.zig");
const Element = @import("webapi/Element.zig"); const Element = @import("webapi/Element.zig");
const Event = @import("webapi/Event.zig"); const Event = @import("webapi/Event.zig");
const MouseEvent = @import("webapi/event/MouseEvent.zig"); const MouseEvent = @import("webapi/event/MouseEvent.zig");
const KeyboardEvent = @import("webapi/event/KeyboardEvent.zig");
const Page = @import("Page.zig"); const Page = @import("Page.zig");
const Session = @import("Session.zig"); const Session = @import("Session.zig");
const Selector = @import("webapi/selector/Selector.zig"); const Selector = @import("webapi/selector/Selector.zig");
fn dispatchInputAndChangeEvents(el: *Element, page: *Page) !void {
const input_evt: *Event = try .initTrusted(comptime .wrap("input"), .{ .bubbles = true }, page);
page._event_manager.dispatch(el.asEventTarget(), input_evt) catch |err| {
lp.log.err(.app, "dispatch input event failed", .{ .err = err });
};
const change_evt: *Event = try .initTrusted(comptime .wrap("change"), .{ .bubbles = true }, page);
page._event_manager.dispatch(el.asEventTarget(), change_evt) catch |err| {
lp.log.err(.app, "dispatch change event failed", .{ .err = err });
};
}
pub fn click(node: *DOMNode, page: *Page) !void { pub fn click(node: *DOMNode, page: *Page) !void {
const el = node.is(Element) orelse return error.InvalidNodeType; const el = node.is(Element) orelse return error.InvalidNodeType;
@@ -56,107 +43,9 @@ pub fn click(node: *DOMNode, page: *Page) !void {
}; };
} }
pub fn hover(node: *DOMNode, page: *Page) !void {
const el = node.is(Element) orelse return error.InvalidNodeType;
const mouseover_event: *MouseEvent = try .initTrusted(comptime .wrap("mouseover"), .{
.bubbles = true,
.cancelable = true,
.composed = true,
}, page);
page._event_manager.dispatch(el.asEventTarget(), mouseover_event.asEvent()) catch |err| {
lp.log.err(.app, "hover mouseover failed", .{ .err = err });
return error.ActionFailed;
};
const mouseenter_event: *MouseEvent = try .initTrusted(comptime .wrap("mouseenter"), .{
.composed = true,
}, page);
page._event_manager.dispatch(el.asEventTarget(), mouseenter_event.asEvent()) catch |err| {
lp.log.err(.app, "hover mouseenter failed", .{ .err = err });
return error.ActionFailed;
};
}
pub fn press(node: ?*DOMNode, key: []const u8, page: *Page) !void {
const target = if (node) |n|
(n.is(Element) orelse return error.InvalidNodeType).asEventTarget()
else
page.document.asNode().asEventTarget();
const keydown_event: *KeyboardEvent = try .initTrusted(comptime .wrap("keydown"), .{
.bubbles = true,
.cancelable = true,
.composed = true,
.key = key,
}, page);
page._event_manager.dispatch(target, keydown_event.asEvent()) catch |err| {
lp.log.err(.app, "press keydown failed", .{ .err = err });
return error.ActionFailed;
};
const keyup_event: *KeyboardEvent = try .initTrusted(comptime .wrap("keyup"), .{
.bubbles = true,
.cancelable = true,
.composed = true,
.key = key,
}, page);
page._event_manager.dispatch(target, keyup_event.asEvent()) catch |err| {
lp.log.err(.app, "press keyup failed", .{ .err = err });
return error.ActionFailed;
};
}
pub fn selectOption(node: *DOMNode, value: []const u8, page: *Page) !void {
const el = node.is(Element) orelse return error.InvalidNodeType;
const select = el.is(Element.Html.Select) orelse return error.InvalidNodeType;
select.setValue(value, page) catch |err| {
lp.log.err(.app, "select setValue failed", .{ .err = err });
return error.ActionFailed;
};
try dispatchInputAndChangeEvents(el, page);
}
pub fn setChecked(node: *DOMNode, checked: bool, page: *Page) !void {
const el = node.is(Element) orelse return error.InvalidNodeType;
const input = el.is(Element.Html.Input) orelse return error.InvalidNodeType;
if (input._input_type != .checkbox and input._input_type != .radio) {
return error.InvalidNodeType;
}
input.setChecked(checked, page) catch |err| {
lp.log.err(.app, "setChecked failed", .{ .err = err });
return error.ActionFailed;
};
// Match browser event order: click fires first, then input and change.
const click_event: *MouseEvent = try .initTrusted(comptime .wrap("click"), .{
.bubbles = true,
.cancelable = true,
.composed = true,
}, page);
page._event_manager.dispatch(el.asEventTarget(), click_event.asEvent()) catch |err| {
lp.log.err(.app, "dispatch click event failed", .{ .err = err });
};
try dispatchInputAndChangeEvents(el, page);
}
pub fn fill(node: *DOMNode, text: []const u8, page: *Page) !void { pub fn fill(node: *DOMNode, text: []const u8, page: *Page) !void {
const el = node.is(Element) orelse return error.InvalidNodeType; const el = node.is(Element) orelse return error.InvalidNodeType;
el.focus(page) catch |err| {
lp.log.err(.app, "fill focus failed", .{ .err = err });
};
if (el.is(Element.Html.Input)) |input| { if (el.is(Element.Html.Input)) |input| {
input.setValue(text, page) catch |err| { input.setValue(text, page) catch |err| {
lp.log.err(.app, "fill input failed", .{ .err = err }); lp.log.err(.app, "fill input failed", .{ .err = err });
@@ -176,7 +65,15 @@ pub fn fill(node: *DOMNode, text: []const u8, page: *Page) !void {
return error.InvalidNodeType; return error.InvalidNodeType;
} }
try dispatchInputAndChangeEvents(el, page); const input_evt: *Event = try .initTrusted(comptime .wrap("input"), .{ .bubbles = true }, page);
page._event_manager.dispatch(el.asEventTarget(), input_evt) catch |err| {
lp.log.err(.app, "dispatch input event failed", .{ .err = err });
};
const change_evt: *Event = try .initTrusted(comptime .wrap("change"), .{ .bubbles = true }, page);
page._event_manager.dispatch(el.asEventTarget(), change_evt) catch |err| {
lp.log.err(.app, "dispatch change event failed", .{ .err = err });
};
} }
pub fn scroll(node: ?*DOMNode, x: ?i32, y: ?i32, page: *Page) !void { pub fn scroll(node: ?*DOMNode, x: ?i32, y: ?i32, page: *Page) !void {

View File

@@ -266,7 +266,6 @@ pub fn mapZigInstanceToJs(self: *const Local, js_obj_handle: ?*const v8.Object,
v8.v8__Global__New(isolate.handle, js_obj.handle, gop.value_ptr); v8.v8__Global__New(isolate.handle, js_obj.handle, gop.value_ptr);
if (resolved.finalizer) |finalizer| { if (resolved.finalizer) |finalizer| {
const finalizer_ptr_id = finalizer.ptr_id; const finalizer_ptr_id = finalizer.ptr_id;
finalizer.acquireRef(finalizer_ptr_id);
const session = ctx.session; const session = ctx.session;
const finalizer_gop = try session.finalizer_callbacks.getOrPut(session.page_arena, finalizer_ptr_id); const finalizer_gop = try session.finalizer_callbacks.getOrPut(session.page_arena, finalizer_ptr_id);
@@ -275,7 +274,8 @@ pub fn mapZigInstanceToJs(self: *const Local, js_obj_handle: ?*const v8.Object,
// see this Zig instance. We need to create the FinalizerCallback // see this Zig instance. We need to create the FinalizerCallback
// so that we can cleanup on page reset if v8 doesn't finalize. // so that we can cleanup on page reset if v8 doesn't finalize.
errdefer _ = session.finalizer_callbacks.remove(finalizer_ptr_id); errdefer _ = session.finalizer_callbacks.remove(finalizer_ptr_id);
finalizer_gop.value_ptr.* = try self.createFinalizerCallback(resolved_ptr_id, finalizer_ptr_id, finalizer.deinit); finalizer.acquire_ref(finalizer_ptr_id);
finalizer_gop.value_ptr.* = try self.createFinalizerCallback(resolved_ptr_id, finalizer_ptr_id, finalizer.release_ref_from_zig);
} }
const fc = finalizer_gop.value_ptr.*; const fc = finalizer_gop.value_ptr.*;
const identity_finalizer = try fc.arena.create(Session.FinalizerCallback.Identity); const identity_finalizer = try fc.arena.create(Session.FinalizerCallback.Identity);
@@ -283,8 +283,9 @@ pub fn mapZigInstanceToJs(self: *const Local, js_obj_handle: ?*const v8.Object,
.fc = fc, .fc = fc,
.identity = ctx.identity, .identity = ctx.identity,
}; };
fc.identity_count += 1;
v8.v8__Global__SetWeakFinalizer(gop.value_ptr, identity_finalizer, finalizer.release, v8.kParameter); v8.v8__Global__SetWeakFinalizer(gop.value_ptr, identity_finalizer, finalizer.release_ref, v8.kParameter);
} }
return js_obj; return js_obj;
}, },
@@ -1128,9 +1129,9 @@ const Resolved = struct {
// Resolved.ptr is the most specific value in a chain (e.g. IFrame, not EventTarget, Node, ...) // Resolved.ptr is the most specific value in a chain (e.g. IFrame, not EventTarget, Node, ...)
// Finalizer.ptr_id is the most specific value in a chain that defines an acquireRef // Finalizer.ptr_id is the most specific value in a chain that defines an acquireRef
ptr_id: usize, ptr_id: usize,
deinit: *const fn (ptr_id: usize, session: *Session) void, acquire_ref: *const fn (ptr_id: usize) void,
acquireRef: *const fn (ptr_id: usize) void, release_ref: *const fn (handle: ?*const v8.WeakCallbackInfo) callconv(.c) void,
release: *const fn (handle: ?*const v8.WeakCallbackInfo) callconv(.c) void, release_ref_from_zig: *const fn (ptr_id: usize, session: *Session) void,
}; };
}; };
pub fn resolveValue(value: anytype) Resolved { pub fn resolveValue(value: anytype) Resolved {
@@ -1170,32 +1171,49 @@ fn resolveT(comptime T: type, value: *T) Resolved {
const finalizer_ptr = getFinalizerPtr(value); const finalizer_ptr = getFinalizerPtr(value);
const Wrap = struct { const Wrap = struct {
fn deinit(ptr_id: usize, session: *Session) void {
FT.deinit(@ptrFromInt(ptr_id), session);
}
fn acquireRef(ptr_id: usize) void { fn acquireRef(ptr_id: usize) void {
FT.acquireRef(@ptrFromInt(ptr_id)); FT.acquireRef(@ptrFromInt(ptr_id));
} }
fn release(handle: ?*const v8.WeakCallbackInfo) callconv(.c) void { fn releaseRef(handle: ?*const v8.WeakCallbackInfo) callconv(.c) void {
const ptr = v8.v8__WeakCallbackInfo__GetParameter(handle.?).?; const ptr = v8.v8__WeakCallbackInfo__GetParameter(handle.?).?;
const identity_finalizer: *Session.FinalizerCallback.Identity = @ptrCast(@alignCast(ptr)); const identity_finalizer: *Session.FinalizerCallback.Identity = @ptrCast(@alignCast(ptr));
const fc = identity_finalizer.fc; const fc = identity_finalizer.fc;
const session = fc.session;
const finalizer_ptr_id = fc.finalizer_ptr_id;
// Remove from this identity's map
if (identity_finalizer.identity.identity_map.fetchRemove(fc.resolved_ptr_id)) |kv| { if (identity_finalizer.identity.identity_map.fetchRemove(fc.resolved_ptr_id)) |kv| {
var global = kv.value; var global = kv.value;
v8.v8__Global__Reset(&global); v8.v8__Global__Reset(&global);
} }
FT.releaseRef(@ptrFromInt(fc.finalizer_ptr_id), fc.session); const identity_count = fc.identity_count;
if (identity_count == 1) {
// All IsolatedWorlds that reference this object have
// released it. Release the instance ref, remove the
// FinalizerCallback and free it.
FT.releaseRef(@ptrFromInt(finalizer_ptr_id), session);
const removed = session.finalizer_callbacks.remove(finalizer_ptr_id);
if (comptime IS_DEBUG) {
std.debug.assert(removed);
}
session.releaseArena(fc.arena);
} else {
fc.identity_count = identity_count - 1;
}
}
fn releaseRefFromZig(ptr_id: usize, session: *Session) void {
FT.releaseRef(@ptrFromInt(ptr_id), session);
} }
}; };
break :blk .{ break :blk .{
.ptr_id = @intFromPtr(finalizer_ptr), .ptr_id = @intFromPtr(finalizer_ptr),
.deinit = Wrap.deinit, .acquire_ref = Wrap.acquireRef,
.acquireRef = Wrap.acquireRef, .release_ref = Wrap.releaseRef,
.release = Wrap.release, .release_ref_from_zig = Wrap.releaseRefFromZig,
}; };
}, },
}; };
@@ -1454,7 +1472,7 @@ fn createFinalizerCallback(
// The most specific value where finalizers are defined // The most specific value where finalizers are defined
// What actually gets acquired / released / deinit // What actually gets acquired / released / deinit
finalizer_ptr_id: usize, finalizer_ptr_id: usize,
deinit: *const fn (ptr_id: usize, session: *Session) void, release_ref: *const fn (ptr_id: usize, session: *Session) void,
) !*Session.FinalizerCallback { ) !*Session.FinalizerCallback {
const session = self.ctx.session; const session = self.ctx.session;
@@ -1465,7 +1483,7 @@ fn createFinalizerCallback(
fc.* = .{ fc.* = .{
.arena = arena, .arena = arena,
.session = session, .session = session,
._deinit = deinit, .release_ref = release_ref,
.resolved_ptr_id = resolved_ptr_id, .resolved_ptr_id = resolved_ptr_id,
.finalizer_ptr_id = finalizer_ptr_id, .finalizer_ptr_id = finalizer_ptr_id,
}; };

View File

@@ -10,20 +10,5 @@
<div id="scrollbox" style="width: 100px; height: 100px; overflow: scroll;" onscroll="window.scrolled = true;"> <div id="scrollbox" style="width: 100px; height: 100px; overflow: scroll;" onscroll="window.scrolled = true;">
<div style="height: 500px;">Long content</div> <div style="height: 500px;">Long content</div>
</div> </div>
<div id="hoverTarget" onmouseover="window.hovered = true;">Hover Me</div>
<input id="keyTarget" onkeydown="window.keyPressed = event.key;" onkeyup="window.keyReleased = event.key;">
<select id="sel2" onchange="window.sel2Changed = this.value">
<option value="a">Alpha</option>
<option value="b">Beta</option>
<option value="c">Gamma</option>
</select>
<input id="chk" type="checkbox">
<input id="rad" type="radio" name="group1">
<script>
document.getElementById('chk').addEventListener('click', function() { window.chkClicked = true; });
document.getElementById('chk').addEventListener('change', function() { window.chkChanged = true; });
document.getElementById('rad').addEventListener('click', function() { window.radClicked = true; });
document.getElementById('rad').addEventListener('change', function() { window.radChanged = true; });
</script>
</body> </body>
</html> </html>

View File

@@ -4,7 +4,7 @@
<div id=empty></div> <div id=empty></div>
<div id=one><p id=p10></p></div> <div id=one><p id=p10></p></div>
<script id=childNodes> <!--<script id=childNodes>
const div = $('#d1'); const div = $('#d1');
const children = div.childNodes; const children = div.childNodes;
testing.expectEqual(true, children instanceof NodeList); testing.expectEqual(true, children instanceof NodeList);
@@ -65,24 +65,24 @@
testing.expectEqual([], Array.from(empty.values())); testing.expectEqual([], Array.from(empty.values()));
testing.expectEqual([], Array.from(empty.entries())); testing.expectEqual([], Array.from(empty.entries()));
testing.expectEqual([], Array.from(empty)); testing.expectEqual([], Array.from(empty));
</script> </script> -->
<script id=one> <script id=one>
const one = $('#one').childNodes; const one = $('#one').childNodes;
const p10 = $('#p10'); // const p10 = $('#p10');
testing.expectEqual(1, one.length); // testing.expectEqual(1, one.length);
testing.expectEqual(p10, one[0]); // testing.expectEqual(p10, one[0]);
testing.expectEqual([0], Array.from(one.keys())); // testing.expectEqual([0], Array.from(one.keys()));
testing.expectEqual([p10], Array.from(one.values())); // testing.expectEqual([p10], Array.from(one.values()));
testing.expectEqual([[0, p10]], Array.from(one.entries())); // testing.expectEqual([[0, p10]], Array.from(one.entries()));
testing.expectEqual([p10], Array.from(one)); // testing.expectEqual([p10], Array.from(one));
let foreach = []; let foreach = [];
one.forEach((p) => foreach.push(p)); one.forEach((p) => foreach.push(p));
testing.expectEqual([p10], foreach); testing.expectEqual([p10], foreach);
</script> </script>
<script id=contains> <!-- <script id=contains>
testing.expectEqual(true, document.contains(document)); testing.expectEqual(true, document.contains(document));
testing.expectEqual(true, $('#d1').contains($('#d1'))); testing.expectEqual(true, $('#d1').contains($('#d1')));
testing.expectEqual(true, document.contains($('#d1'))); testing.expectEqual(true, document.contains($('#d1')));
@@ -94,3 +94,4 @@
testing.expectEqual(false, $('#d1').contains($('#empty'))); testing.expectEqual(false, $('#d1').contains($('#empty')));
testing.expectEqual(false, $('#d1').contains($('#p10'))); testing.expectEqual(false, $('#d1').contains($('#p10')));
</script> </script>
-->

View File

@@ -523,6 +523,31 @@ pub fn setDir(self: *Element, value: []const u8, page: *Page) !void {
return self.setAttributeSafe(comptime .wrap("dir"), .wrap(value), page); return self.setAttributeSafe(comptime .wrap("dir"), .wrap(value), page);
} }
// ARIAMixin - ARIA attribute reflection
pub fn getAriaAtomic(self: *const Element) ?[]const u8 {
return self.getAttributeSafe(comptime .wrap("aria-atomic"));
}
pub fn setAriaAtomic(self: *Element, value: ?[]const u8, page: *Page) !void {
if (value) |v| {
try self.setAttributeSafe(comptime .wrap("aria-atomic"), .wrap(v), page);
} else {
try self.removeAttribute(comptime .wrap("aria-atomic"), page);
}
}
pub fn getAriaLive(self: *const Element) ?[]const u8 {
return self.getAttributeSafe(comptime .wrap("aria-live"));
}
pub fn setAriaLive(self: *Element, value: ?[]const u8, page: *Page) !void {
if (value) |v| {
try self.setAttributeSafe(comptime .wrap("aria-live"), .wrap(v), page);
} else {
try self.removeAttribute(comptime .wrap("aria-live"), page);
}
}
pub fn getClassName(self: *const Element) []const u8 { pub fn getClassName(self: *const Element) []const u8 {
return self.getAttributeSafe(comptime .wrap("class")) orelse ""; return self.getAttributeSafe(comptime .wrap("class")) orelse "";
} }
@@ -1686,6 +1711,8 @@ pub const JsApi = struct {
pub const localName = bridge.accessor(Element.getLocalName, null, .{}); pub const localName = bridge.accessor(Element.getLocalName, null, .{});
pub const id = bridge.accessor(Element.getId, Element.setId, .{}); pub const id = bridge.accessor(Element.getId, Element.setId, .{});
pub const slot = bridge.accessor(Element.getSlot, Element.setSlot, .{}); pub const slot = bridge.accessor(Element.getSlot, Element.setSlot, .{});
pub const ariaAtomic = bridge.accessor(Element.getAriaAtomic, Element.setAriaAtomic, .{});
pub const ariaLive = bridge.accessor(Element.getAriaLive, Element.setAriaLive, .{});
pub const dir = bridge.accessor(Element.getDir, Element.setDir, .{}); pub const dir = bridge.accessor(Element.getDir, Element.setDir, .{});
pub const className = bridge.accessor(Element.getClassName, Element.setClassName, .{}); pub const className = bridge.accessor(Element.getClassName, Element.setClassName, .{});
pub const classList = bridge.accessor(Element.getClassList, Element.setClassList, .{}); pub const classList = bridge.accessor(Element.getClassList, Element.setClassList, .{});

View File

@@ -114,7 +114,9 @@ pub fn init(callback: js.Function.Temp, options: ?ObserverInit, page: *Page) !*I
pub fn deinit(self: *IntersectionObserver, session: *Session) void { pub fn deinit(self: *IntersectionObserver, session: *Session) void {
self._callback.release(); self._callback.release();
for (self._pending_entries.items) |entry| { for (self._pending_entries.items) |entry| {
entry.deinitIfUnused(session); // These were never handed to v8, they do not have a corresponding
// FinalizerCallback. We 100% own them.
entry.deinit(session);
} }
session.releaseArena(self._arena); session.releaseArena(self._arena);
} }
@@ -135,14 +137,11 @@ pub fn observe(self: *IntersectionObserver, target: *Element, page: *Page) !void
} }
} }
// Register with page if this is our first observation try self._observing.append(self._arena, target);
if (self._observing.items.len == 0) { if (self._observing.items.len == 1) {
self._rc._refs += 1;
try page.registerIntersectionObserver(self); try page.registerIntersectionObserver(self);
} }
try self._observing.append(self._arena, target);
// Don't initialize previous state yet - let checkIntersection do it // Don't initialize previous state yet - let checkIntersection do it
// This ensures we get an entry on first observation // This ensures we get an entry on first observation
@@ -166,7 +165,7 @@ pub fn unobserve(self: *IntersectionObserver, target: *Element, page: *Page) voi
while (j < self._pending_entries.items.len) { while (j < self._pending_entries.items.len) {
if (self._pending_entries.items[j]._target == target) { if (self._pending_entries.items[j]._target == target) {
const entry = self._pending_entries.swapRemove(j); const entry = self._pending_entries.swapRemove(j);
entry.deinitIfUnused(page._session); entry.deinit(page._session);
} else { } else {
j += 1; j += 1;
} }
@@ -176,25 +175,21 @@ pub fn unobserve(self: *IntersectionObserver, target: *Element, page: *Page) voi
} }
if (original_length > 0 and self._observing.items.len == 0) { if (original_length > 0 and self._observing.items.len == 0) {
self._rc._refs -= 1; page.unregisterIntersectionObserver(self);
} }
} }
pub fn disconnect(self: *IntersectionObserver, page: *Page) void { pub fn disconnect(self: *IntersectionObserver, page: *Page) void {
for (self._pending_entries.items) |entry| { for (self._pending_entries.items) |entry| {
entry.deinitIfUnused(page._session); entry.deinit(page._session);
} }
self._pending_entries.clearRetainingCapacity(); self._pending_entries.clearRetainingCapacity();
self._previous_states.clearRetainingCapacity(); self._previous_states.clearRetainingCapacity();
const observing_count = self._observing.items.len; if (self._observing.items.len > 0) {
self._observing.clearRetainingCapacity(); page.unregisterIntersectionObserver(self);
page.unregisterIntersectionObserver(self);
if (observing_count > 0) {
_ = self.releaseRef(page._session);
} }
self._observing.clearRetainingCapacity();
} }
pub fn takeRecords(self: *IntersectionObserver, page: *Page) ![]*IntersectionObserverEntry { pub fn takeRecords(self: *IntersectionObserver, page: *Page) ![]*IntersectionObserverEntry {
@@ -340,13 +335,6 @@ pub const IntersectionObserverEntry = struct {
session.releaseArena(self._arena); session.releaseArena(self._arena);
} }
fn deinitIfUnused(self: *IntersectionObserverEntry, session: *Session) void {
if (self._rc._refs == 0) {
// hasn't been handed to JS yet.
self.deinit(session);
}
}
pub fn releaseRef(self: *IntersectionObserverEntry, session: *Session) void { pub fn releaseRef(self: *IntersectionObserverEntry, session: *Session) void {
self._rc.release(self, session); self._rc.release(self, session);
} }

View File

@@ -87,8 +87,12 @@ pub fn init(callback: js.Function.Temp, page: *Page) !*MutationObserver {
return self; return self;
} }
/// Force cleanup on Session shutdown.
pub fn deinit(self: *MutationObserver, session: *Session) void { pub fn deinit(self: *MutationObserver, session: *Session) void {
for (self._pending_records.items) |record| {
// These were never handed to v8, they do not have a corresponding
// FinalizerCallback. We 100% own them.
record.deinit(session);
}
self._callback.release(); self._callback.release();
session.releaseArena(self._arena); session.releaseArena(self._arena);
} }
@@ -163,16 +167,14 @@ pub fn observe(self: *MutationObserver, target: *Node, options: ObserveOptions,
} }
} }
// Register with page if this is our first observation
if (self._observing.items.len == 0) {
self._rc._refs += 1;
try page.registerMutationObserver(self);
}
try self._observing.append(arena, .{ try self._observing.append(arena, .{
.target = target, .target = target,
.options = store_options, .options = store_options,
}); });
if (self._observing.items.len == 1) {
try page.registerMutationObserver(self);
}
} }
pub fn disconnect(self: *MutationObserver, page: *Page) void { pub fn disconnect(self: *MutationObserver, page: *Page) void {
@@ -180,13 +182,11 @@ pub fn disconnect(self: *MutationObserver, page: *Page) void {
_ = record.releaseRef(page._session); _ = record.releaseRef(page._session);
} }
self._pending_records.clearRetainingCapacity(); self._pending_records.clearRetainingCapacity();
const observing_count = self._observing.items.len;
self._observing.clearRetainingCapacity();
if (observing_count > 0) { if (self._observing.items.len > 0) {
_ = self.releaseRef(page._session); page.unregisterMutationObserver(self);
} }
page.unregisterMutationObserver(self); self._observing.clearRetainingCapacity();
} }
pub fn takeRecords(self: *MutationObserver, page: *Page) ![]*MutationRecord { pub fn takeRecords(self: *MutationObserver, page: *Page) ![]*MutationRecord {

View File

@@ -42,8 +42,8 @@ _rc: lp.RC(u32) = .{},
pub fn deinit(self: *NodeList, session: *Session) void { pub fn deinit(self: *NodeList, session: *Session) void {
switch (self._data) { switch (self._data) {
.selector_list => |list| list.deinit(session),
.child_nodes => |cn| cn.deinit(session), .child_nodes => |cn| cn.deinit(session),
.selector_list => |list| list.deinit(session),
else => {}, else => {},
} }
} }
@@ -92,7 +92,12 @@ pub fn entries(self: *NodeList, page: *Page) !*EntryIterator {
pub fn forEach(self: *NodeList, cb: js.Function, page: *Page) !void { pub fn forEach(self: *NodeList, cb: js.Function, page: *Page) !void {
var i: i32 = 0; var i: i32 = 0;
var it = try self.values(page); var it = try self.values(page);
// the iterator takes a reference against our list
defer self.releaseRef(page._session);
while (true) : (i += 1) { while (true) : (i += 1) {
const next = try it.next(page); const next = try it.next(page);
if (next.done) { if (next.done) {

View File

@@ -26,7 +26,8 @@ pub fn Entry(comptime Inner: type, comptime field: ?[]const u8) type {
const R = reflect(Inner, field); const R = reflect(Inner, field);
return struct { return struct {
inner: Inner, _inner: Inner,
_rc: lp.RC(u8) = .{},
const Self = @This(); const Self = @This();
@@ -38,29 +39,31 @@ pub fn Entry(comptime Inner: type, comptime field: ?[]const u8) type {
}; };
pub fn init(inner: Inner, page: *Page) !*Self { pub fn init(inner: Inner, page: *Page) !*Self {
return page._factory.create(Self{ .inner = inner }); const self = try page._factory.create(Self{ ._inner = inner });
if (@hasDecl(Inner, "acquireRef")) {
self._inner.acquireRef();
}
return self;
} }
pub fn deinit(self: *Self, session: *Session) void { pub fn deinit(self: *Self, session: *Session) void {
_ = self; if (@hasDecl(Inner, "releaseRef")) {
_ = session; self._inner.releaseRef(session);
}
session.factory.destroy(self);
} }
pub fn releaseRef(self: *Self, session: *Session) void { pub fn releaseRef(self: *Self, session: *Session) void {
// Release the reference to the inner type that we acquired self._rc.release(self, session);
if (@hasDecl(Inner, "releaseRef")) {
self.inner.releaseRef(session);
}
} }
pub fn acquireRef(self: *Self) void { pub fn acquireRef(self: *Self) void {
if (@hasDecl(Inner, "acquireRef")) { self._rc.acquire();
self.inner.acquireRef();
}
} }
pub fn next(self: *Self, page: *Page) if (R.has_error_return) anyerror!Result else Result { pub fn next(self: *Self, page: *Page) if (R.has_error_return) anyerror!Result else Result {
const entry = (if (comptime R.has_error_return) try self.inner.next(page) else self.inner.next(page)) orelse { const entry = (if (comptime R.has_error_return) try self._inner.next(page) else self._inner.next(page)) orelse {
return .{ .done = true, .value = null }; return .{ .done = true, .value = null };
}; };

View File

@@ -391,6 +391,14 @@ pub fn setLang(self: *HtmlElement, value: []const u8, page: *Page) !void {
try self.asElement().setAttributeSafe(comptime .wrap("lang"), .wrap(value), page); try self.asElement().setAttributeSafe(comptime .wrap("lang"), .wrap(value), page);
} }
pub fn getTitle(self: *HtmlElement) []const u8 {
return self.asElement().getAttributeSafe(comptime .wrap("title")) orelse "";
}
pub fn setTitle(self: *HtmlElement, value: []const u8, page: *Page) !void {
try self.asElement().setAttributeSafe(comptime .wrap("title"), .wrap(value), page);
}
pub fn getAttributeFunction( pub fn getAttributeFunction(
self: *HtmlElement, self: *HtmlElement,
listener_type: GlobalEventHandler, listener_type: GlobalEventHandler,
@@ -1231,6 +1239,7 @@ pub const JsApi = struct {
pub const hidden = bridge.accessor(HtmlElement.getHidden, HtmlElement.setHidden, .{}); pub const hidden = bridge.accessor(HtmlElement.getHidden, HtmlElement.setHidden, .{});
pub const lang = bridge.accessor(HtmlElement.getLang, HtmlElement.setLang, .{}); pub const lang = bridge.accessor(HtmlElement.getLang, HtmlElement.setLang, .{});
pub const tabIndex = bridge.accessor(HtmlElement.getTabIndex, HtmlElement.setTabIndex, .{}); pub const tabIndex = bridge.accessor(HtmlElement.getTabIndex, HtmlElement.setTabIndex, .{});
pub const title = bridge.accessor(HtmlElement.getTitle, HtmlElement.setTitle, .{});
pub const onabort = bridge.accessor(HtmlElement.getOnAbort, HtmlElement.setOnAbort, .{}); pub const onabort = bridge.accessor(HtmlElement.getOnAbort, HtmlElement.setOnAbort, .{});
pub const onanimationcancel = bridge.accessor(HtmlElement.getOnAnimationCancel, HtmlElement.setOnAnimationCancel, .{}); pub const onanimationcancel = bridge.accessor(HtmlElement.getOnAnimationCancel, HtmlElement.setOnAnimationCancel, .{});

View File

@@ -174,6 +174,14 @@ pub fn setType(self: *Anchor, value: []const u8, page: *Page) !void {
try self.asElement().setAttributeSafe(comptime .wrap("type"), .wrap(value), page); try self.asElement().setAttributeSafe(comptime .wrap("type"), .wrap(value), page);
} }
pub fn getRel(self: *Anchor) []const u8 {
return self.asConstElement().getAttributeSafe(comptime .wrap("rel")) orelse "";
}
pub fn setRel(self: *Anchor, value: []const u8, page: *Page) !void {
try self.asElement().setAttributeSafe(comptime .wrap("rel"), .wrap(value), page);
}
pub fn getName(self: *const Anchor) []const u8 { pub fn getName(self: *const Anchor) []const u8 {
return self.asConstElement().getAttributeSafe(comptime .wrap("name")) orelse ""; return self.asConstElement().getAttributeSafe(comptime .wrap("name")) orelse "";
} }
@@ -218,6 +226,7 @@ pub const JsApi = struct {
pub const pathname = bridge.accessor(Anchor.getPathname, Anchor.setPathname, .{}); pub const pathname = bridge.accessor(Anchor.getPathname, Anchor.setPathname, .{});
pub const search = bridge.accessor(Anchor.getSearch, Anchor.setSearch, .{}); pub const search = bridge.accessor(Anchor.getSearch, Anchor.setSearch, .{});
pub const hash = bridge.accessor(Anchor.getHash, Anchor.setHash, .{}); pub const hash = bridge.accessor(Anchor.getHash, Anchor.setHash, .{});
pub const rel = bridge.accessor(Anchor.getRel, Anchor.setRel, .{});
pub const @"type" = bridge.accessor(Anchor.getType, Anchor.setType, .{}); pub const @"type" = bridge.accessor(Anchor.getType, Anchor.setType, .{});
pub const text = bridge.accessor(Anchor.getText, Anchor.setText, .{}); pub const text = bridge.accessor(Anchor.getText, Anchor.setText, .{});
pub const relList = bridge.accessor(_getRelList, null, .{ .null_as_undefined = true }); pub const relList = bridge.accessor(_getRelList, null, .{ .null_as_undefined = true });

View File

@@ -86,8 +86,8 @@ pub fn forEach(self: *Headers, cb_: js.Function, js_this_: ?js.Object) !void {
} }
// TODO: do we really need 2 different header structs?? // TODO: do we really need 2 different header structs??
const net_http = @import("../../../network/http.zig"); const http = @import("../../../network/http.zig");
pub fn populateHttpHeader(self: *Headers, allocator: Allocator, http_headers: *net_http.Headers) !void { pub fn populateHttpHeader(self: *Headers, allocator: Allocator, http_headers: *http.Headers) !void {
for (self._list._entries.items) |entry| { for (self._list._entries.items) |entry| {
const merged = try std.mem.concatWithSentinel(allocator, u8, &.{ entry.name.str(), ": ", entry.value.str() }, 0); const merged = try std.mem.concatWithSentinel(allocator, u8, &.{ entry.name.str(), ": ", entry.value.str() }, 0);
try http_headers.add(merged); try http_headers.add(merged);

View File

@@ -19,7 +19,7 @@
const std = @import("std"); const std = @import("std");
const js = @import("../../js/js.zig"); const js = @import("../../js/js.zig");
const net_http = @import("../../../network/http.zig"); const http = @import("../../../network/http.zig");
const URL = @import("../URL.zig"); const URL = @import("../URL.zig");
const Page = @import("../../Page.zig"); const Page = @import("../../Page.zig");
@@ -31,7 +31,7 @@ const Allocator = std.mem.Allocator;
const Request = @This(); const Request = @This();
_url: [:0]const u8, _url: [:0]const u8,
_method: net_http.Method, _method: http.Method,
_headers: ?*Headers, _headers: ?*Headers,
_body: ?[]const u8, _body: ?[]const u8,
_arena: Allocator, _arena: Allocator,
@@ -119,14 +119,14 @@ pub fn init(input: Input, opts_: ?InitOpts, page: *Page) !*Request {
}); });
} }
fn parseMethod(method: []const u8, page: *Page) !net_http.Method { fn parseMethod(method: []const u8, page: *Page) !http.Method {
if (method.len > "propfind".len) { if (method.len > "propfind".len) {
return error.InvalidMethod; return error.InvalidMethod;
} }
const lower = std.ascii.lowerString(&page.buf, method); const lower = std.ascii.lowerString(&page.buf, method);
const method_lookup = std.StaticStringMap(net_http.Method).initComptime(.{ const method_lookup = std.StaticStringMap(http.Method).initComptime(.{
.{ "get", .GET }, .{ "get", .GET },
.{ "post", .POST }, .{ "post", .POST },
.{ "delete", .DELETE }, .{ "delete", .DELETE },

View File

@@ -22,7 +22,7 @@ const js = @import("../../js/js.zig");
const log = @import("../../../log.zig"); const log = @import("../../../log.zig");
const HttpClient = @import("../../HttpClient.zig"); const HttpClient = @import("../../HttpClient.zig");
const net_http = @import("../../../network/http.zig"); const http = @import("../../../network/http.zig");
const URL = @import("../../URL.zig"); const URL = @import("../../URL.zig");
const Mime = @import("../../Mime.zig"); const Mime = @import("../../Mime.zig");
@@ -47,7 +47,7 @@ _transfer: ?*HttpClient.Transfer = null,
_active_request: bool = false, _active_request: bool = false,
_url: [:0]const u8 = "", _url: [:0]const u8 = "",
_method: net_http.Method = .GET, _method: http.Method = .GET,
_request_headers: *Headers, _request_headers: *Headers,
_request_body: ?[]const u8 = null, _request_body: ?[]const u8 = null,
@@ -90,13 +90,13 @@ const ResponseType = enum {
pub fn init(page: *Page) !*XMLHttpRequest { pub fn init(page: *Page) !*XMLHttpRequest {
const arena = try page.getArena(.{ .debug = "XMLHttpRequest" }); const arena = try page.getArena(.{ .debug = "XMLHttpRequest" });
errdefer page.releaseArena(arena); errdefer page.releaseArena(arena);
const xhr = try page._factory.xhrEventTarget(arena, XMLHttpRequest{ const self = try page._factory.xhrEventTarget(arena, XMLHttpRequest{
._page = page, ._page = page,
._arena = arena, ._arena = arena,
._proto = undefined, ._proto = undefined,
._request_headers = try Headers.init(null, page), ._request_headers = try Headers.init(null, page),
}); });
return xhr; return self;
} }
pub fn deinit(self: *XMLHttpRequest, session: *Session) void { pub fn deinit(self: *XMLHttpRequest, session: *Session) void {
@@ -243,7 +243,10 @@ pub fn send(self: *XMLHttpRequest, body_: ?[]const u8) !void {
try page.headersForRequest(&headers); try page.headersForRequest(&headers);
} }
try http_client.request(.{ self.acquireRef();
self._active_request = true;
http_client.request(.{
.ctx = self, .ctx = self,
.url = self._url, .url = self._url,
.method = self._method, .method = self._method,
@@ -260,9 +263,10 @@ pub fn send(self: *XMLHttpRequest, body_: ?[]const u8) !void {
.done_callback = httpDoneCallback, .done_callback = httpDoneCallback,
.error_callback = httpErrorCallback, .error_callback = httpErrorCallback,
.shutdown_callback = httpShutdownCallback, .shutdown_callback = httpShutdownCallback,
}); }) catch |err| {
self.acquireRef(); self.releaseSelfRef();
self._active_request = true; return err;
};
} }
fn handleBlobUrl(self: *XMLHttpRequest, page: *Page) !void { fn handleBlobUrl(self: *XMLHttpRequest, page: *Page) !void {
@@ -406,7 +410,7 @@ fn httpStartCallback(transfer: *HttpClient.Transfer) !void {
self._transfer = transfer; self._transfer = transfer;
} }
fn httpHeaderCallback(transfer: *HttpClient.Transfer, header: net_http.Header) !void { fn httpHeaderCallback(transfer: *HttpClient.Transfer, header: http.Header) !void {
const self: *XMLHttpRequest = @ptrCast(@alignCast(transfer.ctx)); const self: *XMLHttpRequest = @ptrCast(@alignCast(transfer.ctx));
const joined = try std.fmt.allocPrint(self._arena, "{s}: {s}", .{ header.name, header.value }); const joined = try std.fmt.allocPrint(self._arena, "{s}: {s}", .{ header.name, header.value });
try self._response_headers.append(self._arena, joined); try self._response_headers.append(self._arena, joined);
@@ -518,6 +522,7 @@ fn httpErrorCallback(ctx: *anyopaque, err: anyerror) void {
fn httpShutdownCallback(ctx: *anyopaque) void { fn httpShutdownCallback(ctx: *anyopaque) void {
const self: *XMLHttpRequest = @ptrCast(@alignCast(ctx)); const self: *XMLHttpRequest = @ptrCast(@alignCast(ctx));
self._transfer = null; self._transfer = null;
self.releaseSelfRef();
} }
pub fn abort(self: *XMLHttpRequest) void { pub fn abort(self: *XMLHttpRequest) void {
@@ -574,7 +579,7 @@ fn stateChanged(self: *XMLHttpRequest, state: ReadyState, page: *Page) !void {
} }
} }
fn parseMethod(method: []const u8) !net_http.Method { fn parseMethod(method: []const u8) !http.Method {
if (std.ascii.eqlIgnoreCase(method, "get")) { if (std.ascii.eqlIgnoreCase(method, "get")) {
return .GET; return .GET;
} }

View File

@@ -23,7 +23,7 @@ const CDP = @import("../CDP.zig");
const log = @import("../../log.zig"); const log = @import("../../log.zig");
const HttpClient = @import("../../browser/HttpClient.zig"); const HttpClient = @import("../../browser/HttpClient.zig");
const net_http = @import("../../network/http.zig"); const http = @import("../../network/http.zig");
const Notification = @import("../../Notification.zig"); const Notification = @import("../../Notification.zig");
const network = @import("network.zig"); const network = @import("network.zig");
@@ -224,7 +224,7 @@ fn continueRequest(cmd: *CDP.Command) !void {
url: ?[]const u8 = null, url: ?[]const u8 = null,
method: ?[]const u8 = null, method: ?[]const u8 = null,
postData: ?[]const u8 = null, postData: ?[]const u8 = null,
headers: ?[]const net_http.Header = null, headers: ?[]const http.Header = null,
interceptResponse: bool = false, interceptResponse: bool = false,
})) orelse return error.InvalidParams; })) orelse return error.InvalidParams;
@@ -249,7 +249,7 @@ fn continueRequest(cmd: *CDP.Command) !void {
try transfer.updateURL(try arena.dupeZ(u8, url)); try transfer.updateURL(try arena.dupeZ(u8, url));
} }
if (params.method) |method| { if (params.method) |method| {
transfer.req.method = std.meta.stringToEnum(net_http.Method, method) orelse return error.InvalidParams; transfer.req.method = std.meta.stringToEnum(http.Method, method) orelse return error.InvalidParams;
} }
if (params.headers) |headers| { if (params.headers) |headers| {
@@ -326,7 +326,7 @@ fn fulfillRequest(cmd: *CDP.Command) !void {
const params = (try cmd.params(struct { const params = (try cmd.params(struct {
requestId: []const u8, // "INT-{d}" requestId: []const u8, // "INT-{d}"
responseCode: u16, responseCode: u16,
responseHeaders: ?[]const net_http.Header = null, responseHeaders: ?[]const http.Header = null,
binaryResponseHeaders: ?[]const u8 = null, binaryResponseHeaders: ?[]const u8 = null,
body: ?[]const u8 = null, body: ?[]const u8 = null,
responsePhrase: ?[]const u8 = null, responsePhrase: ?[]const u8 = null,

View File

@@ -86,7 +86,7 @@ fn report(reason: []const u8, begin_addr: usize, args: anytype) !void {
var url_buffer: [4096]u8 = undefined; var url_buffer: [4096]u8 = undefined;
const url = blk: { const url = blk: {
var writer: std.Io.Writer = .fixed(&url_buffer); var writer: std.Io.Writer = .fixed(&url_buffer);
try writer.print("https://crash.lightpanda.io/c?v={s}&r=", .{lp.build_config.version}); try writer.print("https://crash.lightpanda.io/c?v={s}&r=", .{lp.build_config.version_encoded});
for (reason) |b| { for (reason) |b| {
switch (b) { switch (b) {
'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_' => try writer.writeByte(b), 'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_' => try writer.writeByte(b),

View File

@@ -18,7 +18,7 @@
const std = @import("std"); const std = @import("std");
pub const App = @import("App.zig"); pub const App = @import("App.zig");
pub const Network = @import("network/Runtime.zig"); pub const Network = @import("network/Network.zig");
pub const Server = @import("Server.zig"); pub const Server = @import("Server.zig");
pub const Config = @import("Config.zig"); pub const Config = @import("Config.zig");
pub const URL = @import("browser/URL.zig"); pub const URL = @import("browser/URL.zig");
@@ -259,9 +259,6 @@ pub fn RC(comptime T: type) type {
return; return;
} }
value.deinit(session); value.deinit(session);
if (session.finalizer_callbacks.fetchRemove(@intFromPtr(value))) |kv| {
session.releaseArena(kv.value.arena);
}
} }
pub fn format(self: @This(), writer: *std.Io.Writer) !void { pub fn format(self: @This(), writer: *std.Io.Writer) !void {

View File

@@ -179,74 +179,6 @@ pub const tool_list = [_]protocol.Tool{
\\} \\}
), ),
}, },
.{
.name = "hover",
.description = "Hover over an element, triggering mouseover and mouseenter events. Useful for menus, tooltips, and hover states.",
.inputSchema = protocol.minify(
\\{
\\ "type": "object",
\\ "properties": {
\\ "backendNodeId": { "type": "integer", "description": "The backend node ID of the element to hover over." }
\\ },
\\ "required": ["backendNodeId"]
\\}
),
},
.{
.name = "press",
.description = "Press a keyboard key, dispatching keydown and keyup events. Use key names like 'Enter', 'Tab', 'Escape', 'ArrowDown', 'Backspace', or single characters like 'a', '1'.",
.inputSchema = protocol.minify(
\\{
\\ "type": "object",
\\ "properties": {
\\ "key": { "type": "string", "description": "The key to press (e.g. 'Enter', 'Tab', 'a')." },
\\ "backendNodeId": { "type": "integer", "description": "Optional backend node ID of the element to target. Defaults to the document." }
\\ },
\\ "required": ["key"]
\\}
),
},
.{
.name = "selectOption",
.description = "Select an option in a <select> dropdown element by its value. Dispatches input and change events.",
.inputSchema = protocol.minify(
\\{
\\ "type": "object",
\\ "properties": {
\\ "backendNodeId": { "type": "integer", "description": "The backend node ID of the <select> element." },
\\ "value": { "type": "string", "description": "The value of the option to select." }
\\ },
\\ "required": ["backendNodeId", "value"]
\\}
),
},
.{
.name = "setChecked",
.description = "Check or uncheck a checkbox or radio button. Dispatches input, change, and click events.",
.inputSchema = protocol.minify(
\\{
\\ "type": "object",
\\ "properties": {
\\ "backendNodeId": { "type": "integer", "description": "The backend node ID of the checkbox or radio input element." },
\\ "checked": { "type": "boolean", "description": "Whether to check (true) or uncheck (false) the element." }
\\ },
\\ "required": ["backendNodeId", "checked"]
\\}
),
},
.{
.name = "findElement",
.description = "Find interactive elements by role and/or accessible name. Returns matching elements with their backend node IDs. Useful for locating specific elements without parsing the full semantic tree.",
.inputSchema = protocol.minify(
\\{
\\ "type": "object",
\\ "properties": {
\\ "role": { "type": "string", "description": "Optional ARIA role to match (e.g. 'button', 'link', 'textbox', 'checkbox')." },
\\ "name": { "type": "string", "description": "Optional accessible name substring to match (case-insensitive)." }
\\ }
\\}
),
},
}; };
pub fn handleList(server: *Server, arena: std.mem.Allocator, req: protocol.Request) !void { pub fn handleList(server: *Server, arena: std.mem.Allocator, req: protocol.Request) !void {
@@ -347,11 +279,6 @@ const ToolAction = enum {
fill, fill,
scroll, scroll,
waitForSelector, waitForSelector,
hover,
press,
selectOption,
setChecked,
findElement,
}; };
const tool_map = std.StaticStringMap(ToolAction).initComptime(.{ const tool_map = std.StaticStringMap(ToolAction).initComptime(.{
@@ -369,11 +296,6 @@ const tool_map = std.StaticStringMap(ToolAction).initComptime(.{
.{ "fill", .fill }, .{ "fill", .fill },
.{ "scroll", .scroll }, .{ "scroll", .scroll },
.{ "waitForSelector", .waitForSelector }, .{ "waitForSelector", .waitForSelector },
.{ "hover", .hover },
.{ "press", .press },
.{ "selectOption", .selectOption },
.{ "setChecked", .setChecked },
.{ "findElement", .findElement },
}); });
pub fn handleCall(server: *Server, arena: std.mem.Allocator, req: protocol.Request) !void { pub fn handleCall(server: *Server, arena: std.mem.Allocator, req: protocol.Request) !void {
@@ -408,11 +330,6 @@ pub fn handleCall(server: *Server, arena: std.mem.Allocator, req: protocol.Reque
.fill => try handleFill(server, arena, req.id.?, call_params.arguments), .fill => try handleFill(server, arena, req.id.?, call_params.arguments),
.scroll => try handleScroll(server, arena, req.id.?, call_params.arguments), .scroll => try handleScroll(server, arena, req.id.?, call_params.arguments),
.waitForSelector => try handleWaitForSelector(server, arena, req.id.?, call_params.arguments), .waitForSelector => try handleWaitForSelector(server, arena, req.id.?, call_params.arguments),
.hover => try handleHover(server, arena, req.id.?, call_params.arguments),
.press => try handlePress(server, arena, req.id.?, call_params.arguments),
.selectOption => try handleSelectOption(server, arena, req.id.?, call_params.arguments),
.setChecked => try handleSetChecked(server, arena, req.id.?, call_params.arguments),
.findElement => try handleFindElement(server, arena, req.id.?, call_params.arguments),
} }
} }
@@ -477,9 +394,17 @@ fn handleNodeDetails(server: *Server, arena: std.mem.Allocator, id: std.json.Val
backendNodeId: CDPNode.Id, backendNodeId: CDPNode.Id,
}; };
const args = try parseArgs(Params, arena, arguments, server, id, "nodeDetails"); const args = try parseArgs(Params, arena, arguments, server, id, "nodeDetails");
const resolved = try resolveNodeAndPage(server, id, args.backendNodeId);
const details = lp.SemanticTree.getNodeDetails(arena, resolved.node, &server.node_registry, resolved.page) catch { _ = server.session.currentPage() orelse {
return server.sendError(id, .PageNotLoaded, "Page not loaded");
};
const node = server.node_registry.lookup_by_id.get(args.backendNodeId) orelse {
return server.sendError(id, .InvalidParams, "Node not found");
};
const page = server.session.currentPage().?;
const details = lp.SemanticTree.getNodeDetails(arena, node.dom, &server.node_registry, page) catch {
return server.sendError(id, .InternalError, "Failed to get node details"); return server.sendError(id, .InternalError, "Failed to get node details");
}; };
@@ -579,19 +504,26 @@ fn handleClick(server: *Server, arena: std.mem.Allocator, id: std.json.Value, ar
backendNodeId: CDPNode.Id, backendNodeId: CDPNode.Id,
}; };
const args = try parseArgs(ClickParams, arena, arguments, server, id, "click"); const args = try parseArgs(ClickParams, arena, arguments, server, id, "click");
const resolved = try resolveNodeAndPage(server, id, args.backendNodeId);
lp.actions.click(resolved.node, resolved.page) catch |err| { const page = server.session.currentPage() orelse {
return server.sendError(id, .PageNotLoaded, "Page not loaded");
};
const node = server.node_registry.lookup_by_id.get(args.backendNodeId) orelse {
return server.sendError(id, .InvalidParams, "Node not found");
};
lp.actions.click(node.dom, page) catch |err| {
if (err == error.InvalidNodeType) { if (err == error.InvalidNodeType) {
return server.sendError(id, .InvalidParams, "Node is not an HTML element"); return server.sendError(id, .InvalidParams, "Node is not an HTML element");
} }
return server.sendError(id, .InternalError, "Failed to click element"); return server.sendError(id, .InternalError, "Failed to click element");
}; };
const page_title = resolved.page.getTitle() catch null; const page_title = page.getTitle() catch null;
const result_text = try std.fmt.allocPrint(arena, "Clicked element (backendNodeId: {d}). Page url: {s}, title: {s}", .{ const result_text = try std.fmt.allocPrint(arena, "Clicked element (backendNodeId: {d}). Page url: {s}, title: {s}", .{
args.backendNodeId, args.backendNodeId,
resolved.page.url, page.url,
page_title orelse "(none)", page_title orelse "(none)",
}); });
const content = [_]protocol.TextContent([]const u8){.{ .text = result_text }}; const content = [_]protocol.TextContent([]const u8){.{ .text = result_text }};
@@ -604,20 +536,27 @@ fn handleFill(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arg
text: []const u8, text: []const u8,
}; };
const args = try parseArgs(FillParams, arena, arguments, server, id, "fill"); const args = try parseArgs(FillParams, arena, arguments, server, id, "fill");
const resolved = try resolveNodeAndPage(server, id, args.backendNodeId);
lp.actions.fill(resolved.node, args.text, resolved.page) catch |err| { const page = server.session.currentPage() orelse {
return server.sendError(id, .PageNotLoaded, "Page not loaded");
};
const node = server.node_registry.lookup_by_id.get(args.backendNodeId) orelse {
return server.sendError(id, .InvalidParams, "Node not found");
};
lp.actions.fill(node.dom, args.text, page) catch |err| {
if (err == error.InvalidNodeType) { if (err == error.InvalidNodeType) {
return server.sendError(id, .InvalidParams, "Node is not an input, textarea or select"); return server.sendError(id, .InvalidParams, "Node is not an input, textarea or select");
} }
return server.sendError(id, .InternalError, "Failed to fill element"); return server.sendError(id, .InternalError, "Failed to fill element");
}; };
const page_title = resolved.page.getTitle() catch null; const page_title = page.getTitle() catch null;
const result_text = try std.fmt.allocPrint(arena, "Filled element (backendNodeId: {d}) with \"{s}\". Page url: {s}, title: {s}", .{ const result_text = try std.fmt.allocPrint(arena, "Filled element (backendNodeId: {d}) with \"{s}\". Page url: {s}, title: {s}", .{
args.backendNodeId, args.backendNodeId,
args.text, args.text,
resolved.page.url, page.url,
page_title orelse "(none)", page_title orelse "(none)",
}); });
const content = [_]protocol.TextContent([]const u8){.{ .text = result_text }}; const content = [_]protocol.TextContent([]const u8){.{ .text = result_text }};
@@ -691,189 +630,6 @@ fn handleWaitForSelector(server: *Server, arena: std.mem.Allocator, id: std.json
return server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); return server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content });
} }
fn handleHover(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const Params = struct {
backendNodeId: CDPNode.Id,
};
const args = try parseArgs(Params, arena, arguments, server, id, "hover");
const resolved = try resolveNodeAndPage(server, id, args.backendNodeId);
lp.actions.hover(resolved.node, resolved.page) catch |err| {
if (err == error.InvalidNodeType) {
return server.sendError(id, .InvalidParams, "Node is not an HTML element");
}
return server.sendError(id, .InternalError, "Failed to hover element");
};
const page_title = resolved.page.getTitle() catch null;
const result_text = try std.fmt.allocPrint(arena, "Hovered element (backendNodeId: {d}). Page url: {s}, title: {s}", .{
args.backendNodeId,
resolved.page.url,
page_title orelse "(none)",
});
const content = [_]protocol.TextContent([]const u8){.{ .text = result_text }};
try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content });
}
fn handlePress(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const Params = struct {
key: []const u8,
backendNodeId: ?CDPNode.Id = null,
};
const args = try parseArgs(Params, arena, arguments, server, id, "press");
const page = server.session.currentPage() orelse {
return server.sendError(id, .PageNotLoaded, "Page not loaded");
};
var target_node: ?*DOMNode = null;
if (args.backendNodeId) |node_id| {
const node = server.node_registry.lookup_by_id.get(node_id) orelse {
return server.sendError(id, .InvalidParams, "Node not found");
};
target_node = node.dom;
}
lp.actions.press(target_node, args.key, page) catch |err| {
if (err == error.InvalidNodeType) {
return server.sendError(id, .InvalidParams, "Node is not an HTML element");
}
return server.sendError(id, .InternalError, "Failed to press key");
};
const page_title = page.getTitle() catch null;
const result_text = try std.fmt.allocPrint(arena, "Pressed key '{s}'. Page url: {s}, title: {s}", .{
args.key,
page.url,
page_title orelse "(none)",
});
const content = [_]protocol.TextContent([]const u8){.{ .text = result_text }};
try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content });
}
fn handleSelectOption(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const Params = struct {
backendNodeId: CDPNode.Id,
value: []const u8,
};
const args = try parseArgs(Params, arena, arguments, server, id, "selectOption");
const resolved = try resolveNodeAndPage(server, id, args.backendNodeId);
lp.actions.selectOption(resolved.node, args.value, resolved.page) catch |err| {
if (err == error.InvalidNodeType) {
return server.sendError(id, .InvalidParams, "Node is not a <select> element");
}
return server.sendError(id, .InternalError, "Failed to select option");
};
const page_title = resolved.page.getTitle() catch null;
const result_text = try std.fmt.allocPrint(arena, "Selected option '{s}' (backendNodeId: {d}). Page url: {s}, title: {s}", .{
args.value,
args.backendNodeId,
resolved.page.url,
page_title orelse "(none)",
});
const content = [_]protocol.TextContent([]const u8){.{ .text = result_text }};
try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content });
}
fn handleSetChecked(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const Params = struct {
backendNodeId: CDPNode.Id,
checked: bool,
};
const args = try parseArgs(Params, arena, arguments, server, id, "setChecked");
const resolved = try resolveNodeAndPage(server, id, args.backendNodeId);
lp.actions.setChecked(resolved.node, args.checked, resolved.page) catch |err| {
if (err == error.InvalidNodeType) {
return server.sendError(id, .InvalidParams, "Node is not a checkbox or radio input");
}
return server.sendError(id, .InternalError, "Failed to set checked state");
};
const state_str = if (args.checked) "checked" else "unchecked";
const page_title = resolved.page.getTitle() catch null;
const result_text = try std.fmt.allocPrint(arena, "Set element (backendNodeId: {d}) to {s}. Page url: {s}, title: {s}", .{
args.backendNodeId,
state_str,
resolved.page.url,
page_title orelse "(none)",
});
const content = [_]protocol.TextContent([]const u8){.{ .text = result_text }};
try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content });
}
fn handleFindElement(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const Params = struct {
role: ?[]const u8 = null,
name: ?[]const u8 = null,
};
const args = try parseArgsOrDefault(Params, arena, arguments, server, id);
if (args.role == null and args.name == null) {
return server.sendError(id, .InvalidParams, "At least one of 'role' or 'name' must be provided");
}
const page = server.session.currentPage() orelse {
return server.sendError(id, .PageNotLoaded, "Page not loaded");
};
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");
};
var matches: std.ArrayList(lp.interactive.InteractiveElement) = .empty;
for (elements) |el| {
if (args.role) |role| {
const el_role = el.role orelse continue;
if (!std.ascii.eqlIgnoreCase(el_role, role)) continue;
}
if (args.name) |name| {
const el_name = el.name orelse continue;
if (!containsIgnoreCase(el_name, name)) continue;
}
try matches.append(arena, el);
}
const matched = try matches.toOwnedSlice(arena);
lp.interactive.registerNodes(matched, &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(matched, .{}, &aw.writer);
const content = [_]protocol.TextContent([]const u8){.{ .text = aw.written() }};
try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content });
}
fn containsIgnoreCase(haystack: []const u8, needle: []const u8) bool {
if (needle.len > haystack.len) return false;
if (needle.len == 0) return true;
const end = haystack.len - needle.len + 1;
for (0..end) |i| {
if (std.ascii.eqlIgnoreCase(haystack[i..][0..needle.len], needle)) return true;
}
return false;
}
const NodeAndPage = struct { node: *DOMNode, page: *lp.Page };
fn resolveNodeAndPage(server: *Server, id: std.json.Value, node_id: CDPNode.Id) !NodeAndPage {
const page = server.session.currentPage() orelse {
try server.sendError(id, .PageNotLoaded, "Page not loaded");
return error.PageNotLoaded;
};
const node = server.node_registry.lookup_by_id.get(node_id) orelse {
try server.sendError(id, .InvalidParams, "Node not found");
return error.InvalidParams;
};
return .{ .node = node.dom, .page = page };
}
fn ensurePage(server: *Server, id: std.json.Value, url: ?[:0]const u8) !*lp.Page { fn ensurePage(server: *Server, id: std.json.Value, url: ?[:0]const u8) !*lp.Page {
if (url) |u| { if (url) |u| {
try performGoto(server, u, id); try performGoto(server, u, id);
@@ -971,7 +727,7 @@ test "MCP - evaluate error reporting" {
} }, out.written()); } }, out.written());
} }
test "MCP - Actions: click, fill, scroll, hover, press, selectOption, setChecked" { test "MCP - Actions: click, fill, scroll" {
defer testing.reset(); defer testing.reset();
const aa = testing.arena_allocator; const aa = testing.arena_allocator;
@@ -1032,67 +788,7 @@ test "MCP - Actions: click, fill, scroll, hover, press, selectOption, setChecked
out.clearRetainingCapacity(); out.clearRetainingCapacity();
} }
{ // Evaluate assertions
// Test Hover
const el = page.document.getElementById("hoverTarget", page).?.asNode();
const el_id = (try server.node_registry.register(el)).id;
var id_buf: [12]u8 = undefined;
const id_str = std.fmt.bufPrint(&id_buf, "{d}", .{el_id}) catch unreachable;
const msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"tools/call\",\"params\":{\"name\":\"hover\",\"arguments\":{\"backendNodeId\":", id_str, "}}}" });
try router.handleMessage(server, aa, msg);
try testing.expect(std.mem.indexOf(u8, out.written(), "Hovered element") != null);
out.clearRetainingCapacity();
}
{
// Test Press
const el = page.document.getElementById("keyTarget", page).?.asNode();
const el_id = (try server.node_registry.register(el)).id;
var id_buf: [12]u8 = undefined;
const id_str = std.fmt.bufPrint(&id_buf, "{d}", .{el_id}) catch unreachable;
const msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":6,\"method\":\"tools/call\",\"params\":{\"name\":\"press\",\"arguments\":{\"key\":\"Enter\",\"backendNodeId\":", id_str, "}}}" });
try router.handleMessage(server, aa, msg);
try testing.expect(std.mem.indexOf(u8, out.written(), "Pressed key") != null);
out.clearRetainingCapacity();
}
{
// Test SelectOption
const el = page.document.getElementById("sel2", page).?.asNode();
const el_id = (try server.node_registry.register(el)).id;
var id_buf: [12]u8 = undefined;
const id_str = std.fmt.bufPrint(&id_buf, "{d}", .{el_id}) catch unreachable;
const msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"tools/call\",\"params\":{\"name\":\"selectOption\",\"arguments\":{\"backendNodeId\":", id_str, ",\"value\":\"b\"}}}" });
try router.handleMessage(server, aa, msg);
try testing.expect(std.mem.indexOf(u8, out.written(), "Selected option") != null);
out.clearRetainingCapacity();
}
{
// Test SetChecked (checkbox)
const el = page.document.getElementById("chk", page).?.asNode();
const el_id = (try server.node_registry.register(el)).id;
var id_buf: [12]u8 = undefined;
const id_str = std.fmt.bufPrint(&id_buf, "{d}", .{el_id}) catch unreachable;
const msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":8,\"method\":\"tools/call\",\"params\":{\"name\":\"setChecked\",\"arguments\":{\"backendNodeId\":", id_str, ",\"checked\":true}}}" });
try router.handleMessage(server, aa, msg);
try testing.expect(std.mem.indexOf(u8, out.written(), "checked") != null);
out.clearRetainingCapacity();
}
{
// Test SetChecked (radio)
const el = page.document.getElementById("rad", page).?.asNode();
const el_id = (try server.node_registry.register(el)).id;
var id_buf: [12]u8 = undefined;
const id_str = std.fmt.bufPrint(&id_buf, "{d}", .{el_id}) catch unreachable;
const msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":9,\"method\":\"tools/call\",\"params\":{\"name\":\"setChecked\",\"arguments\":{\"backendNodeId\":", id_str, ",\"checked\":true}}}" });
try router.handleMessage(server, aa, msg);
try testing.expect(std.mem.indexOf(u8, out.written(), "checked") != null);
out.clearRetainingCapacity();
}
// Evaluate JS assertions for all actions
var ls: js.Local.Scope = undefined; var ls: js.Local.Scope = undefined;
page.js.localScope(&ls); page.js.localScope(&ls);
defer ls.deinit(); defer ls.deinit();
@@ -1104,66 +800,12 @@ test "MCP - Actions: click, fill, scroll, hover, press, selectOption, setChecked
const result = try ls.local.exec( const result = try ls.local.exec(
\\ window.clicked === true && window.inputVal === 'hello' && \\ window.clicked === true && window.inputVal === 'hello' &&
\\ window.changed === true && window.selChanged === 'opt2' && \\ window.changed === true && window.selChanged === 'opt2' &&
\\ window.scrolled === true && \\ window.scrolled === true
\\ window.hovered === true &&
\\ window.keyPressed === 'Enter' && window.keyReleased === 'Enter' &&
\\ window.sel2Changed === 'b' &&
\\ window.chkClicked === true && window.chkChanged === true &&
\\ window.radClicked === true && window.radChanged === true
, null); , null);
try testing.expect(result.isTrue()); try testing.expect(result.isTrue());
} }
test "MCP - findElement" {
defer testing.reset();
const aa = testing.arena_allocator;
var out: std.io.Writer.Allocating = .init(aa);
const server = try testLoadPage("http://localhost:9582/src/browser/tests/mcp_actions.html", &out.writer);
defer server.deinit();
{
// Find by role
const msg =
\\{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"findElement","arguments":{"role":"button"}}}
;
try router.handleMessage(server, aa, msg);
try testing.expect(std.mem.indexOf(u8, out.written(), "Click Me") != null);
out.clearRetainingCapacity();
}
{
// Find by name (case-insensitive substring)
const msg =
\\{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"findElement","arguments":{"name":"click"}}}
;
try router.handleMessage(server, aa, msg);
try testing.expect(std.mem.indexOf(u8, out.written(), "Click Me") != null);
out.clearRetainingCapacity();
}
{
// Find with no matches
const msg =
\\{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"findElement","arguments":{"role":"slider"}}}
;
try router.handleMessage(server, aa, msg);
try testing.expect(std.mem.indexOf(u8, out.written(), "[]") != null);
out.clearRetainingCapacity();
}
{
// Error: no params provided
const msg =
\\{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"findElement","arguments":{}}}
;
try router.handleMessage(server, aa, msg);
try testing.expect(std.mem.indexOf(u8, out.written(), "error") != null);
out.clearRetainingCapacity();
}
}
test "MCP - waitForSelector: existing element" { test "MCP - waitForSelector: existing element" {
defer testing.reset(); defer testing.reset();
var out: std.io.Writer.Allocating = .init(testing.arena_allocator); var out: std.io.Writer.Allocating = .init(testing.arena_allocator);

View File

@@ -26,11 +26,11 @@ const lp = @import("lightpanda");
const Config = @import("../Config.zig"); const Config = @import("../Config.zig");
const libcurl = @import("../sys/libcurl.zig"); const libcurl = @import("../sys/libcurl.zig");
const net_http = @import("http.zig"); const http = @import("http.zig");
const RobotStore = @import("Robots.zig").RobotStore; const RobotStore = @import("Robots.zig").RobotStore;
const WebBotAuth = @import("WebBotAuth.zig"); const WebBotAuth = @import("WebBotAuth.zig");
const Runtime = @This(); const Network = @This();
const Listener = struct { const Listener = struct {
socket: posix.socket_t, socket: posix.socket_t,
@@ -46,11 +46,11 @@ const MAX_TICK_CALLBACKS = 16;
allocator: Allocator, allocator: Allocator,
config: *const Config, config: *const Config,
ca_blob: ?net_http.Blob, ca_blob: ?http.Blob,
robot_store: RobotStore, robot_store: RobotStore,
web_bot_auth: ?WebBotAuth, web_bot_auth: ?WebBotAuth,
connections: []net_http.Connection, connections: []http.Connection,
available: std.DoublyLinkedList = .{}, available: std.DoublyLinkedList = .{},
conn_mutex: std.Thread.Mutex = .{}, conn_mutex: std.Thread.Mutex = .{},
@@ -63,8 +63,8 @@ wakeup_pipe: [2]posix.fd_t = .{ -1, -1 },
shutdown: std.atomic.Value(bool) = .init(false), shutdown: std.atomic.Value(bool) = .init(false),
// Multi is a heavy structure that can consume up to 2MB of RAM. // Multi is a heavy structure that can consume up to 2MB of RAM.
// Currently, Runtime is used sparingly, and we only create it on demand. // Currently, Network is used sparingly, and we only create it on demand.
// When Runtime becomes truly shared, it should become a regular field. // When Network becomes truly shared, it should become a regular field.
multi: ?*libcurl.CurlM = null, multi: ?*libcurl.CurlM = null,
submission_mutex: std.Thread.Mutex = .{}, submission_mutex: std.Thread.Mutex = .{},
submission_queue: std.DoublyLinkedList = .{}, submission_queue: std.DoublyLinkedList = .{},
@@ -200,7 +200,7 @@ fn globalDeinit() void {
libcurl.curl_global_cleanup(); libcurl.curl_global_cleanup();
} }
pub fn init(allocator: Allocator, config: *const Config) !Runtime { pub fn init(allocator: Allocator, config: *const Config) !Network {
globalInit(allocator); globalInit(allocator);
errdefer globalDeinit(); errdefer globalDeinit();
@@ -213,18 +213,18 @@ pub fn init(allocator: Allocator, config: *const Config) !Runtime {
@memset(pollfds, .{ .fd = -1, .events = 0, .revents = 0 }); @memset(pollfds, .{ .fd = -1, .events = 0, .revents = 0 });
pollfds[0] = .{ .fd = pipe[0], .events = posix.POLL.IN, .revents = 0 }; pollfds[0] = .{ .fd = pipe[0], .events = posix.POLL.IN, .revents = 0 };
var ca_blob: ?net_http.Blob = null; var ca_blob: ?http.Blob = null;
if (config.tlsVerifyHost()) { if (config.tlsVerifyHost()) {
ca_blob = try loadCerts(allocator); ca_blob = try loadCerts(allocator);
} }
const count: usize = config.httpMaxConcurrent(); const count: usize = config.httpMaxConcurrent();
const connections = try allocator.alloc(net_http.Connection, count); const connections = try allocator.alloc(http.Connection, count);
errdefer allocator.free(connections); errdefer allocator.free(connections);
var available: std.DoublyLinkedList = .{}; var available: std.DoublyLinkedList = .{};
for (0..count) |i| { for (0..count) |i| {
connections[i] = try net_http.Connection.init(ca_blob, config); connections[i] = try http.Connection.init(ca_blob, config);
available.append(&connections[i].node); available.append(&connections[i].node);
} }
@@ -249,7 +249,7 @@ pub fn init(allocator: Allocator, config: *const Config) !Runtime {
}; };
} }
pub fn deinit(self: *Runtime) void { pub fn deinit(self: *Network) void {
if (self.multi) |multi| { if (self.multi) |multi| {
libcurl.curl_multi_cleanup(multi) catch {}; libcurl.curl_multi_cleanup(multi) catch {};
} }
@@ -282,7 +282,7 @@ pub fn deinit(self: *Runtime) void {
} }
pub fn bind( pub fn bind(
self: *Runtime, self: *Network,
address: net.Address, address: net.Address,
ctx: *anyopaque, ctx: *anyopaque,
on_accept: *const fn (ctx: *anyopaque, socket: posix.socket_t) void, on_accept: *const fn (ctx: *anyopaque, socket: posix.socket_t) void,
@@ -313,7 +313,7 @@ pub fn bind(
}; };
} }
pub fn onTick(self: *Runtime, ctx: *anyopaque, callback: *const fn (*anyopaque) void) void { pub fn onTick(self: *Network, ctx: *anyopaque, callback: *const fn (*anyopaque) void) void {
self.callbacks_mutex.lock(); self.callbacks_mutex.lock();
defer self.callbacks_mutex.unlock(); defer self.callbacks_mutex.unlock();
@@ -328,7 +328,7 @@ pub fn onTick(self: *Runtime, ctx: *anyopaque, callback: *const fn (*anyopaque)
self.wakeupPoll(); self.wakeupPoll();
} }
pub fn fireTicks(self: *Runtime) void { pub fn fireTicks(self: *Network) void {
self.callbacks_mutex.lock(); self.callbacks_mutex.lock();
defer self.callbacks_mutex.unlock(); defer self.callbacks_mutex.unlock();
@@ -337,7 +337,7 @@ pub fn fireTicks(self: *Runtime) void {
} }
} }
pub fn run(self: *Runtime) void { pub fn run(self: *Network) void {
var drain_buf: [64]u8 = undefined; var drain_buf: [64]u8 = undefined;
var running_handles: c_int = 0; var running_handles: c_int = 0;
@@ -428,18 +428,18 @@ pub fn run(self: *Runtime) void {
} }
} }
pub fn submitRequest(self: *Runtime, conn: *net_http.Connection) void { pub fn submitRequest(self: *Network, conn: *http.Connection) void {
self.submission_mutex.lock(); self.submission_mutex.lock();
self.submission_queue.append(&conn.node); self.submission_queue.append(&conn.node);
self.submission_mutex.unlock(); self.submission_mutex.unlock();
self.wakeupPoll(); self.wakeupPoll();
} }
fn wakeupPoll(self: *Runtime) void { fn wakeupPoll(self: *Network) void {
_ = posix.write(self.wakeup_pipe[1], &.{1}) catch {}; _ = posix.write(self.wakeup_pipe[1], &.{1}) catch {};
} }
fn drainQueue(self: *Runtime) void { fn drainQueue(self: *Network) void {
self.submission_mutex.lock(); self.submission_mutex.lock();
defer self.submission_mutex.unlock(); defer self.submission_mutex.unlock();
@@ -455,7 +455,7 @@ fn drainQueue(self: *Runtime) void {
}; };
while (self.submission_queue.popFirst()) |node| { while (self.submission_queue.popFirst()) |node| {
const conn: *net_http.Connection = @fieldParentPtr("node", node); const conn: *http.Connection = @fieldParentPtr("node", node);
conn.setPrivate(conn) catch |err| { conn.setPrivate(conn) catch |err| {
lp.log.err(.app, "curl set private", .{ .err = err }); lp.log.err(.app, "curl set private", .{ .err = err });
self.releaseConnection(conn); self.releaseConnection(conn);
@@ -468,12 +468,12 @@ fn drainQueue(self: *Runtime) void {
} }
} }
pub fn stop(self: *Runtime) void { pub fn stop(self: *Network) void {
self.shutdown.store(true, .release); self.shutdown.store(true, .release);
self.wakeupPoll(); self.wakeupPoll();
} }
fn acceptConnections(self: *Runtime) void { fn acceptConnections(self: *Network) void {
if (self.shutdown.load(.acquire)) { if (self.shutdown.load(.acquire)) {
return; return;
} }
@@ -503,7 +503,7 @@ fn acceptConnections(self: *Runtime) void {
} }
} }
fn preparePollFds(self: *Runtime, multi: *libcurl.CurlM) void { fn preparePollFds(self: *Network, multi: *libcurl.CurlM) void {
const curl_fds = self.pollfds[PSEUDO_POLLFDS..]; const curl_fds = self.pollfds[PSEUDO_POLLFDS..];
@memset(curl_fds, .{ .fd = -1, .events = 0, .revents = 0 }); @memset(curl_fds, .{ .fd = -1, .events = 0, .revents = 0 });
@@ -514,14 +514,14 @@ fn preparePollFds(self: *Runtime, multi: *libcurl.CurlM) void {
}; };
} }
fn getCurlTimeout(self: *Runtime) i32 { fn getCurlTimeout(self: *Network) i32 {
const multi = self.multi orelse return -1; const multi = self.multi orelse return -1;
var timeout_ms: c_long = -1; var timeout_ms: c_long = -1;
libcurl.curl_multi_timeout(multi, &timeout_ms) catch return -1; libcurl.curl_multi_timeout(multi, &timeout_ms) catch return -1;
return @intCast(@min(timeout_ms, std.math.maxInt(i32))); return @intCast(@min(timeout_ms, std.math.maxInt(i32)));
} }
fn processCompletions(self: *Runtime, multi: *libcurl.CurlM) void { fn processCompletions(self: *Network, multi: *libcurl.CurlM) void {
var msgs_in_queue: c_int = 0; var msgs_in_queue: c_int = 0;
while (libcurl.curl_multi_info_read(multi, &msgs_in_queue)) |msg| { while (libcurl.curl_multi_info_read(multi, &msgs_in_queue)) |msg| {
switch (msg.data) { switch (msg.data) {
@@ -537,7 +537,7 @@ fn processCompletions(self: *Runtime, multi: *libcurl.CurlM) void {
var ptr: *anyopaque = undefined; var ptr: *anyopaque = undefined;
libcurl.curl_easy_getinfo(easy, .private, &ptr) catch libcurl.curl_easy_getinfo(easy, .private, &ptr) catch
lp.assert(false, "curl getinfo private", .{}); lp.assert(false, "curl getinfo private", .{});
const conn: *net_http.Connection = @ptrCast(@alignCast(ptr)); const conn: *http.Connection = @ptrCast(@alignCast(ptr));
libcurl.curl_multi_remove_handle(multi, easy) catch {}; libcurl.curl_multi_remove_handle(multi, easy) catch {};
self.releaseConnection(conn); self.releaseConnection(conn);
@@ -556,7 +556,7 @@ comptime {
} }
} }
pub fn getConnection(self: *Runtime) ?*net_http.Connection { pub fn getConnection(self: *Network) ?*http.Connection {
self.conn_mutex.lock(); self.conn_mutex.lock();
defer self.conn_mutex.unlock(); defer self.conn_mutex.unlock();
@@ -564,7 +564,7 @@ pub fn getConnection(self: *Runtime) ?*net_http.Connection {
return @fieldParentPtr("node", node); return @fieldParentPtr("node", node);
} }
pub fn releaseConnection(self: *Runtime, conn: *net_http.Connection) void { pub fn releaseConnection(self: *Network, conn: *http.Connection) void {
conn.reset(self.config, self.ca_blob) catch |err| { conn.reset(self.config, self.ca_blob) catch |err| {
lp.assert(false, "couldn't reset curl easy", .{ .err = err }); lp.assert(false, "couldn't reset curl easy", .{ .err = err });
}; };
@@ -575,8 +575,8 @@ pub fn releaseConnection(self: *Runtime, conn: *net_http.Connection) void {
self.available.append(&conn.node); self.available.append(&conn.node);
} }
pub fn newConnection(self: *Runtime) !net_http.Connection { pub fn newConnection(self: *Network) !http.Connection {
return net_http.Connection.init(self.ca_blob, self.config); return http.Connection.init(self.ca_blob, self.config);
} }
// Wraps lines @ 64 columns. A PEM is basically a base64 encoded DER (which is // Wraps lines @ 64 columns. A PEM is basically a base64 encoded DER (which is

View File

@@ -8,7 +8,7 @@ const log = @import("../log.zig");
const App = @import("../App.zig"); const App = @import("../App.zig");
const Config = @import("../Config.zig"); const Config = @import("../Config.zig");
const telemetry = @import("telemetry.zig"); const telemetry = @import("telemetry.zig");
const Runtime = @import("../network/Runtime.zig"); const Network = @import("../network/Network.zig");
const URL = "https://telemetry.lightpanda.io"; const URL = "https://telemetry.lightpanda.io";
const BUFFER_SIZE = 1024; const BUFFER_SIZE = 1024;
@@ -17,7 +17,7 @@ const MAX_BODY_SIZE = 500 * 1024; // 500KB server limit
const LightPanda = @This(); const LightPanda = @This();
allocator: Allocator, allocator: Allocator,
runtime: *Runtime, network: *Network,
writer: std.Io.Writer.Allocating, writer: std.Io.Writer.Allocating,
/// Protects concurrent producers in send(). /// Protects concurrent producers in send().
@@ -36,11 +36,11 @@ pub fn init(self: *LightPanda, app: *App, iid: ?[36]u8, run_mode: Config.RunMode
.iid = iid, .iid = iid,
.run_mode = run_mode, .run_mode = run_mode,
.allocator = app.allocator, .allocator = app.allocator,
.runtime = &app.network, .network = &app.network,
.writer = std.Io.Writer.Allocating.init(app.allocator), .writer = std.Io.Writer.Allocating.init(app.allocator),
}; };
self.runtime.onTick(@ptrCast(self), flushCallback); self.network.onTick(@ptrCast(self), flushCallback);
} }
pub fn deinit(self: *LightPanda) void { pub fn deinit(self: *LightPanda) void {
@@ -70,17 +70,17 @@ fn flushCallback(ctx: *anyopaque) void {
} }
fn postEvent(self: *LightPanda) !void { fn postEvent(self: *LightPanda) !void {
const conn = self.runtime.getConnection() orelse { const conn = self.network.getConnection() orelse {
return; return;
}; };
errdefer self.runtime.releaseConnection(conn); errdefer self.network.releaseConnection(conn);
const h = self.head.load(.monotonic); const h = self.head.load(.monotonic);
const t = self.tail.load(.acquire); const t = self.tail.load(.acquire);
const dropped = self.dropped.swap(0, .monotonic); const dropped = self.dropped.swap(0, .monotonic);
if (h == t and dropped == 0) { if (h == t and dropped == 0) {
self.runtime.releaseConnection(conn); self.network.releaseConnection(conn);
return; return;
} }
errdefer _ = self.dropped.fetchAdd(dropped, .monotonic); errdefer _ = self.dropped.fetchAdd(dropped, .monotonic);
@@ -104,7 +104,7 @@ fn postEvent(self: *LightPanda) !void {
try conn.setBody(self.writer.written()); try conn.setBody(self.writer.written());
self.head.store(h + sent, .release); self.head.store(h + sent, .release);
self.runtime.submitRequest(conn); self.network.submitRequest(conn);
} }
fn writeEvent(self: *LightPanda, event: telemetry.Event) !bool { fn writeEvent(self: *LightPanda, event: telemetry.Event) !bool {