From 0ddca754a481cdc47db772dd10b8f0b3847172fd Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Thu, 11 Jun 2026 19:08:19 +0300 Subject: [PATCH 01/40] `URL`: move resolver functions to Rust Implements `url_resolve_with_encoding` and `url_resolve_without_encoding` in Rust; that way, we don't pay the cost of extra `Box`es we allocate during resolving. --- src/html5ever/url.rs | 172 +++++++++++++++++++++++++++++++++++++++++++ src/sys/url.zig | 6 ++ 2 files changed, 178 insertions(+) diff --git a/src/html5ever/url.rs b/src/html5ever/url.rs index 55d14c2ad..804b6037b 100644 --- a/src/html5ever/url.rs +++ b/src/html5ever/url.rs @@ -19,6 +19,8 @@ // host string becomes its punycode form, or an error. use ::url::Url; +use encoding_rs::{EncoderResult, Encoding}; +use std::borrow::Cow; use std::os::raw::c_uchar; use std::slice; @@ -280,6 +282,11 @@ pub struct OwnedString { pub len: usize, } +const EMPTY_OWNED_STRING: OwnedString = OwnedString { + ptr: std::ptr::null_mut(), + len: 0, +}; + #[no_mangle] pub unsafe extern "C" fn free_owned_string(owned: OwnedString) { if owned.ptr.is_null() || owned.len == 0 { @@ -568,3 +575,168 @@ pub unsafe extern "C" fn url_get_href( *out_ptr = href.as_ptr(); *out_len = href.len(); } + +fn encode_query_ncr(encoding: &'static Encoding, s: &str) -> Cow<'static, [u8]> { + // fast path: fully mappable + let (out, _, had_errors) = encoding.encode(s); + if !had_errors { + return Cow::Owned(out.into_owned()); + } + + let mut encoder = encoding.new_encoder(); + let mut result = Vec::with_capacity(s.len() * 2); + let mut input = s; + loop { + let needed = encoder + .max_buffer_length_from_utf8_without_replacement(input.len()) + .unwrap(); + let start = result.len(); + result.resize(start + needed, 0); + let (r, read, written) = + encoder.encode_from_utf8_without_replacement(input, &mut result[start..], true); + result.truncate(start + written); + input = &input[read..]; + match r { + EncoderResult::InputEmpty => break, + EncoderResult::Unmappable(c) => { + result.extend_from_slice(format!("%26%23{}%3B", c as u32).as_bytes()); + } + // Output was sized with max_buffer_length, so it cannot run out. + EncoderResult::OutputFull => unreachable!(), + } + } + Cow::Owned(result) +} + +#[no_mangle] +pub unsafe extern "C" fn url_resolve_with_encoding( + base_ptr: *const c_uchar, + base_len: usize, + input_ptr: *const c_uchar, + input_len: usize, + enc_ptr: *const c_uchar, + enc_len: usize, + err: *mut i32, +) -> OwnedString { + let base_slice = match str_from(base_ptr, base_len) { + Some(s) => s, + None => { + *err = -1; + return EMPTY_OWNED_STRING; + } + }; + // An empty base means the input must be an absolute URL. + let base = if base_slice.is_empty() { + None + } else { + match Url::parse(base_slice) { + Ok(u) => Some(u), + Err(_) => { + *err = -1; + return EMPTY_OWNED_STRING; + } + } + }; + + let slice = match str_from(input_ptr, input_len) { + Some(s) => s, + None => { + *err = -1; + return EMPTY_OWNED_STRING; + } + }; + + let encoding_slice = match str_from(enc_ptr, enc_len) { + Some(s) => s, + None => { + *err = -1; + return EMPTY_OWNED_STRING; + } + }; + // Per the URL spec, queries use the document encoding's *output encoding*. + let encoding = Encoding::for_label(encoding_slice.as_bytes()) + .map(|encoding| encoding.output_encoding()) + .filter(|&encoding| encoding != encoding_rs::UTF_8); + + let result = match encoding { + Some(encoding) => Url::options() + .base_url(base.as_ref()) + .encoding_override(Some(&move |s| encode_query_ncr(encoding, s))) + .parse(slice), + // Fallback to default. + None => match &base { + Some(base) => base.join(slice), + None => Url::parse(slice), + }, + }; + + match result { + Ok(url) => { + *err = 0; + let s = String::from(url); // Moves the serialization, no copy. + let len = s.len(); + let ptr = Box::into_raw(s.into_bytes().into_boxed_slice()) as *mut c_uchar; + OwnedString { ptr, len } + } + Err(_) => { + *err = -1; + EMPTY_OWNED_STRING + } + } +} + +/// Similar to url_parse_with_base; returns a href instead. +#[no_mangle] +pub unsafe extern "C" fn url_resolve_without_encoding( + base_ptr: *const c_uchar, + base_len: usize, + input_ptr: *const c_uchar, + input_len: usize, + err: *mut i32, +) -> OwnedString { + let base_slice = match str_from(base_ptr, base_len) { + Some(s) => s, + None => { + *err = -1; + return EMPTY_OWNED_STRING; + } + }; + // An empty base means the input must be an absolute URL. + let base = if base_slice.is_empty() { + None + } else { + match Url::parse(base_slice) { + Ok(u) => Some(u), + Err(_) => { + *err = -1; + return EMPTY_OWNED_STRING; + } + } + }; + + let input = match str_from(input_ptr, input_len) { + Some(s) => s, + None => { + *err = -1; + return EMPTY_OWNED_STRING; + } + }; + + let result = match &base { + Some(base) => base.join(input), + None => Url::parse(input), + }; + match result { + Ok(url) => { + *err = 0; + let s = String::from(url); // Moves the serialization, no copy. + let len = s.len(); + let ptr = Box::into_raw(s.into_bytes().into_boxed_slice()) as *mut c_uchar; + OwnedString { ptr, len } + } + Err(_) => { + *err = -1; + EMPTY_OWNED_STRING + } + } +} diff --git a/src/sys/url.zig b/src/sys/url.zig index dd0d46928..506225b27 100644 --- a/src/sys/url.zig +++ b/src/sys/url.zig @@ -83,6 +83,12 @@ pub extern "c" fn url_get_fragment(url: *const Url, out_ptr: *[*]const u8, out_l pub extern "c" fn url_set_query(url: *Url, ptr: [*]const u8, len: usize) i32; pub extern "c" fn url_set_query_to_null(url: *Url) void; pub extern "c" fn url_get_query(url: *const Url, out_ptr: *[*]const u8, out_len: *usize) i32; +/// `err` is `0` if there's no error. +/// Returned `OwnedString` doesn't have a sentinel; callers must be aware of that. +pub extern "c" fn url_resolve_with_encoding(base_ptr: [*]const u8, base_len: usize, input_ptr: [*]const u8, input_len: usize, enc_ptr: [*]const u8, enc_len: usize, err: *i32) OwnedString; +/// `err` is `0` if there's no error. +/// Returned `OwnedString` doesn't have a sentinel; callers must be aware of that. +pub extern "c" fn url_resolve_without_encoding(base_ptr: [*]const u8, base_len: usize, input_ptr: [*]const u8, input_len: usize, err: *i32) OwnedString; extern "c" fn url_get_port(url: *const Url) i32; pub inline fn urlGetPort(url: *const Url) ?u16 { From fc004218702596e1ebe829bde799b1d067d980bb Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Thu, 11 Jun 2026 19:09:36 +0300 Subject: [PATCH 02/40] `URL`: retake on `resolve` function --- src/browser/URL.zig | 206 +++++--------------------------------------- 1 file changed, 21 insertions(+), 185 deletions(-) diff --git a/src/browser/URL.zig b/src/browser/URL.zig index f1e7e25c5..2e448ab58 100644 --- a/src/browser/URL.zig +++ b/src/browser/URL.zig @@ -19,206 +19,42 @@ const std = @import("std"); const idna = @import("../sys/idna.zig"); +const U = @import("../sys/url.zig"); + const Allocator = std.mem.Allocator; -pub const ResolveOpts = struct { +pub const ResolveOptions = struct { /// null = don't encode, "UTF-8" = standard percent encoding, - /// other charset = encode query string using that charset with NCR fallback + /// other charset = encode query string using that charset with NCR fallback. encoding: ?[]const u8 = null, always_dupe: bool = false, }; -// 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, source_path: anytype, opts: ResolveOpts) ![:0]const u8 { - const PT = @TypeOf(source_path); +pub fn resolve( + allocator: Allocator, + base: [:0]const u8, + source_path: anytype, + options: ResolveOptions, +) ![:0]const u8 { + const path = source_path; - const needs_dupe = comptime !isNullTerminated(PT); - var path: [:0]const u8 = if (needs_dupe or opts.always_dupe) try allocator.dupeZ(u8, source_path) else source_path; + var err: i32 = 0; + const href = if (options.encoding) |encoding| + U.url_resolve_with_encoding(base.ptr, base.len, path.ptr, path.len, encoding.ptr, encoding.len, &err) + else + U.url_resolve_without_encoding(base.ptr, base.len, path.ptr, path.len, &err); - if (std.mem.indexOfAny(u8, path, "\t\r\n")) |first| { - path = blk: { - var buf: std.ArrayList(u8) = try .initCapacity(allocator, path.len); - buf.appendSliceAssumeCapacity(path[0..first]); - for (path[first + 1 ..]) |c| { - if (c != '\t' and c != '\r' and c != '\n') { - buf.appendAssumeCapacity(c); - } - } - buf.appendAssumeCapacity(0); - break :blk buf.items[0 .. buf.items.len - 1 :0]; - }; + if (err != 0) { + return error.TypeError; } + defer href.deinit(); - 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 (path.len == 0) { - if (opts.always_dupe) { - const dupe = try allocator.dupeZ(u8, base); - return processResolved(allocator, dupe, opts); - } - return processResolved(allocator, base, opts); - } - - if (path[0] == '?') { - const base_path_end = std.mem.indexOfAny(u8, base, "?#") orelse base.len; - const result = try std.mem.joinZ(allocator, "", &.{ base[0..base_path_end], path }); - return processResolved(allocator, result, opts); - } - if (path[0] == '#') { - const base_fragment_start = std.mem.indexOfScalar(u8, base, '#') orelse base.len; - const result = try std.mem.joinZ(allocator, "", &.{ base[0..base_fragment_start], path }); - return processResolved(allocator, result, opts); - } - - if (std.mem.startsWith(u8, path, "//")) { - // network-path reference - const index = std.mem.indexOfScalar(u8, base, ':') orelse { - return processResolved(allocator, path, opts); - }; - const protocol = base[0 .. index + 1]; - const result = try std.mem.joinZ(allocator, "", &.{ protocol, path }); - return processResolved(allocator, result, opts); - } - - const scheme_end = std.mem.indexOf(u8, base, "://"); - const authority_start = if (scheme_end) |end| end + 3 else 0; - const path_start = std.mem.indexOfAnyPos(u8, base, authority_start, "/?#") orelse base.len; - const path_end = std.mem.indexOfAnyPos(u8, base, path_start, "?#") orelse base.len; - - var out: []u8 = undefined; - if (path[0] == '/') { - // Absolute path — keep base authority, replace path. Two trailing - // spaces give us safe lookahead for the dot-segment loop below. - out = try std.mem.join(allocator, "", &.{ base[0..path_start], path, " " }); - } else { - var normalized_base: []const u8 = base[0..path_start]; - if (path_start < path_end) { - if (std.mem.lastIndexOfScalar(u8, base[path_start + 1 .. path_end], '/')) |pos| { - normalized_base = base[0 .. path_start + 1 + pos]; - } - } - - // 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 - out = try std.mem.join(allocator, "", &.{ normalized_base, "/", path, " " }); - } - - const end = out.len - 2; - - const path_marker = path_start + 1; - - // Strip out ./ and ../. This is done in-place, because doing so can - // only ever make `out` smaller. After this, `out` cannot be freed by - // an allocator, which is ok, because we expect allocator to be an arena. - var in_i: usize = 0; - var out_i: usize = 0; - while (in_i < end) { - if (out[in_i] == '.' and (out_i == 0 or out[out_i - 1] == '/')) { - if (out[in_i + 1] == '/') { // always safe, because we added a whitespace - // /./ - in_i += 2; - continue; - } - if (out[in_i + 1] == '.' and (out[in_i + 2] == '/' or in_i + 2 == end)) { - // /../ or trailing /.. — both step up one segment. The - // trailing slash stays implicit (out_i ends up right after - // the previous '/'), matching `new URL("..", base)`. - if (out_i > path_marker) { - // go back before the / - out_i -= 2; - while (out_i > 1 and out[out_i - 1] != '/') { - out_i -= 1; - } - } else { - // if out_i == path_marker, than we've reached the start of - // the path. We can't ../ any more. E.g.: - // http://www.example.com/../hello. - // You might think that's an error, but, at least with - // new URL('../hello', 'http://www.example.com/') - // it just ignores the extra ../ - } - in_i += 3; - continue; - } - if (in_i == end - 1) { - // ignore trailing dot - break; - } - } - - const c = out[in_i]; - out[out_i] = c; - in_i += 1; - out_i += 1; - } - - // we always have an extra space - out[out_i] = 0; - return processResolved(allocator, out[0..out_i :0], opts); -} - -fn processResolved(allocator: Allocator, url: [:0]const u8, opts: ResolveOpts) ![:0]const u8 { - const encoding = opts.encoding orelse return ensureHostAscii(allocator, url); - return ensureEncoded(allocator, url, encoding); + return allocator.dupeZ(u8, href.slice()); } /// IDNA pass: converts a non-ASCII host (`räksmörgås.se`) to its punycode form /// (`xn--rksmrgs-5wao1o.se`), validates any ASCII punycode (`xn--…`) labels, -/// and leaves everything else alone. Returns `error.Idna` for an invalid -/// domain (e.g. malformed punycode), which surfaces as a URL parse failure. +/// and leaves everything else alone. fn ensureHostAscii(allocator: Allocator, url: [:0]const u8) ![:0]const u8 { const hostname = getHostname(url); if (hostname.len == 0 or (!idna.needsAscii(hostname) and !hasAceLabel(hostname))) { From be6f1c915547ae2a7f24dbd20f74cf994e873d30 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Thu, 11 Jun 2026 19:10:33 +0300 Subject: [PATCH 03/40] `Caller`: remove `error.Idna` case --- src/browser/js/Caller.zig | 1 - 1 file changed, 1 deletion(-) diff --git a/src/browser/js/Caller.zig b/src/browser/js/Caller.zig index a6c600e47..48d24a780 100644 --- a/src/browser/js/Caller.zig +++ b/src/browser/js/Caller.zig @@ -372,7 +372,6 @@ fn handleError(comptime T: type, comptime F: type, local: *const Local, err: any error.TryCatchRethrow => return, error.InvalidArgument => isolate.createTypeError("invalid argument"), error.TypeError => isolate.createTypeError(""), - error.Idna => isolate.createTypeError("invalid domain"), error.RangeError => isolate.createRangeError(""), error.OutOfMemory => isolate.createError("out of memory"), error.IllegalConstructor => isolate.createError("Illegal Constructor"), From 3512f1fb1ae05ee18984eccfc7f09b9c2eb71429 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Thu, 11 Jun 2026 19:11:14 +0300 Subject: [PATCH 04/40] update URL-involving tests --- src/browser/URL.zig | 65 ++++++++++++++++++------------ src/browser/markdown.zig | 8 ++-- src/browser/tests/net/request.html | 2 +- 3 files changed, 45 insertions(+), 30 deletions(-) diff --git a/src/browser/URL.zig b/src/browser/URL.zig index 2e448ab58..fc3ca8c7a 100644 --- a/src/browser/URL.zig +++ b/src/browser/URL.zig @@ -860,6 +860,7 @@ test "URL: resolve" { base: [:0]const u8, path: [:0]const u8, expected: [:0]const u8, + expected_error: bool = false, }; const cases = [_]Case{ @@ -1013,30 +1014,37 @@ test "URL: resolve" { .path = "./.././.././hello", .expected = "https://example/hello", }, + // A base must itself be a valid absolute URL; schemeless bases are + // rejected, matching `new URL(path, base)`. .{ .base = "some/page", .path = "hello", - .expected = "some/hello", + .expected = "", + .expected_error = true, }, .{ .base = "some/page/", .path = "hello", - .expected = "some/page/hello", + .expected = "", + .expected_error = true, }, .{ .base = "some/page/other", .path = ".././hello", - .expected = "some/hello", + .expected = "", + .expected_error = true, }, .{ .base = "https://www.example.com/hello/world", .path = "//example/about", .expected = "https://example/about", }, + // "http:" alone is not a valid base (special scheme without a host). .{ .base = "http:", .path = "//example.com/over/9000", - .expected = "http://example.com/over/9000", + .expected = "", + .expected_error = true, }, .{ .base = "https://example.com/", @@ -1081,8 +1089,13 @@ test "URL: resolve" { }; for (cases) |case| { - const result = try resolve(testing.arena_allocator, case.base, case.path, .{}); - try testing.expectString(case.expected, result); + 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); + } } } @@ -1122,15 +1135,13 @@ test "URL: resolve strips tab and newline from input" { test "URL: resolve validates ASCII punycode (xn--) labels" { defer testing.reset(); - // Valid punycode is left untouched (the needsAscii fast path would skip it, - // so this exercises the xn-- gate going through toAscii and back). - const ok = try resolve(testing.arena_allocator, "", "https://xn--rksmrgs-5wao1o.se/x", .{}); + // Valid punycode is left untouched. + const ok = try resolve(testing.arena_allocator, "https://example.com/", "https://xn--rksmrgs-5wao1o.se/x", .{}); try testing.expectString("https://xn--rksmrgs-5wao1o.se/x", ok); // Malformed punycode must be rejected rather than passed through verbatim. - // (URL.init remaps this error.Idna to TypeError for `new URL`.) - try testing.expectError(error.Idna, resolve(testing.arena_allocator, "", "https://xn--0.pt/x", .{})); - try testing.expectError(error.Idna, resolve(testing.arena_allocator, "", "https://xn--a.pt/x", .{})); + try testing.expectError(error.TypeError, resolve(testing.arena_allocator, "https://example.com/", "https://xn--0.pt/x", .{})); + try testing.expectError(error.TypeError, resolve(testing.arena_allocator, "https://example.com/", "https://xn--a.pt/x", .{})); } test "URL: hasAceLabel" { @@ -1333,11 +1344,11 @@ test "URL: resolve with encoding" { .path = "path with multiple spaces", .expected = "https://example.com/path%20with%20%20multiple%20%20%20spaces", }, - // Special characters that need encoding + // Brackets are not in the WHATWG path percent-encode set .{ .base = "https://example.com/", .path = "file[1].html", - .expected = "https://example.com/file%5B1%5D.html", + .expected = "https://example.com/file[1].html", }, .{ .base = "https://example.com/", @@ -1354,20 +1365,24 @@ test "URL: resolve with encoding" { .path = "file\"quote\".html", .expected = "https://example.com/file%22quote%22.html", }, + // Pipe is not in the WHATWG path percent-encode set .{ .base = "https://example.com/", .path = "file|pipe.html", - .expected = "https://example.com/file%7Cpipe.html", + .expected = "https://example.com/file|pipe.html", }, + // Backslash is a path separator in special URLs .{ .base = "https://example.com/", .path = "file\\backslash.html", - .expected = "https://example.com/file%5Cbackslash.html", + .expected = "https://example.com/file/backslash.html", }, + // Note: the current URL spec percent-encodes '^' in paths, but + // rust-url does not (yet); harmless divergence locked in here. .{ .base = "https://example.com/", .path = "file^caret.html", - .expected = "https://example.com/file%5Ecaret.html", + .expected = "https://example.com/file^caret.html", }, .{ .base = "https://example.com/", @@ -1822,31 +1837,31 @@ test "URL: resolve path scheme" { .{ .base = "https://www.example.com/example", .path = "https://about", - .expected = "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", + .expected = "http://about/", }, //different schemes and path as absolute (with one slash) .{ .base = "https://www.example.com/example", .path = "http:/about", - .expected = "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", + .expected = "http://about/", }, //same schemes and path as absolute (with more slashes) .{ .base = "https://site/", .path = "https://path", - .expected = "https://path", + .expected = "https://path/", }, //path scheme is not special and path as absolute (without additional slashes) .{ @@ -1858,19 +1873,19 @@ test "URL: resolve path scheme" { .{ .base = "https://www.example.com/example", .path = "ws://about", - .expected = "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", + .expected = "wss://about/", }, //different schemes and path as absolute (path scheme=ftp) .{ .base = "https://www.example.com/example", .path = "ftp://about", - .expected = "ftp://about", + .expected = "ftp://about/", }, //different schemes and path as absolute (path scheme=file) .{ diff --git a/src/browser/markdown.zig b/src/browser/markdown.zig index e49a9dc1b..50c0e26cc 100644 --- a/src/browser/markdown.zig +++ b/src/browser/markdown.zig @@ -709,7 +709,7 @@ test "browser.markdown: block link" { \\### Title \\ \\Description - \\[https://example.com](https://example.com) + \\[https://example.com/](https://example.com/) \\ ); } @@ -725,7 +725,7 @@ test "browser.markdown: block link with aria-label" { \\### Title \\ \\Description - \\[Docs](https://example.com) + \\[Docs](https://example.com/) \\ ); } @@ -741,7 +741,7 @@ test "browser.markdown: block link with title" { \\### Title \\ \\Description - \\[Docs](https://example.com) + \\[Docs](https://example.com/) \\ ); } @@ -751,7 +751,7 @@ test "browser.markdown: inline link" { \\

Visit Example.

, \\ - \\Visit [Example](https://example.com). + \\Visit [Example](https://example.com/). \\ ); } diff --git a/src/browser/tests/net/request.html b/src/browser/tests/net/request.html index 2bfa1829e..440a1aac4 100644 --- a/src/browser/tests/net/request.html +++ b/src/browser/tests/net/request.html @@ -125,7 +125,7 @@ headers: { "Sender": "me", "Target": "you" } } ); - testing.expectEqual("https://google.com", request2.url); + testing.expectEqual("https://google.com/", request2.url); testing.expectEqual("POST", request2.method); testing.expectEqual("omit", request2.credentials); testing.expectEqual("reload", request2.cache); From 9732c33c3cb4be1b1422d41f8c3756413babee23 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Mon, 15 Jun 2026 15:58:25 +0300 Subject: [PATCH 05/40] `URL`: remove `always_dupe` option from all call sites --- src/browser/Frame.zig | 4 ++-- src/browser/HttpClient.zig | 6 +++--- src/browser/ImportMap.zig | 6 +++--- src/browser/URL.zig | 1 - src/browser/webapi/History.zig | 4 ++-- src/browser/webapi/net/Request.zig | 2 +- src/browser/webapi/net/WebSocket.zig | 2 +- src/browser/webapi/net/XMLHttpRequest.zig | 2 +- src/browser/webapi/storage/CookieStore.zig | 2 +- 9 files changed, 14 insertions(+), 15 deletions(-) diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index a97db6bd7..3f6e30c01 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -817,7 +817,7 @@ fn scheduleNavigationWithArena(originator: *Frame, arena: Allocator, request_url arena, frame_base, request_url, - .{ .always_dupe = true, .encoding = originator.charset }, + .{ .encoding = originator.charset }, ); break :blk .{ u, false }; }; @@ -1769,7 +1769,7 @@ pub fn openPopup(self: *Frame, opts: OpenPopupOpts) !*Frame { frame = frame.parent orelse break :base_blk ""; } }; - break :blk try URL.resolve(self.call_arena, frame_base, opts.url, .{ .always_dupe = true, .encoding = self.charset }); + break :blk try URL.resolve(self.call_arena, frame_base, opts.url, .{ .encoding = self.charset }); }; const popup = try page.frame_arena.create(Frame); diff --git a/src/browser/HttpClient.zig b/src/browser/HttpClient.zig index bc3a32a66..be9ba9f09 100644 --- a/src/browser/HttpClient.zig +++ b/src/browser/HttpClient.zig @@ -1944,9 +1944,9 @@ pub const Transfer = struct { } const base_url = try conn.getEffectiveUrl(); - // base_url and location are owned by curl. The returned value - // will be stored in transfer.req.url, hence the always_dupe. - const resolved = try URL.resolve(arena, std.mem.span(base_url), location, .{ .always_dupe = true }); + // base_url and location are owned by curl; resolve returns a fresh + // arena-owned copy that gets stored in transfer.req.url. + const resolved = try URL.resolve(arena, std.mem.span(base_url), location, .{}); // RFC 7231 §7.1.2: if the Location value has no fragment, the redirect // inherits the fragment from the URI used to generate the request. diff --git a/src/browser/ImportMap.zig b/src/browser/ImportMap.zig index 3066b95d4..a2c0ee7e7 100644 --- a/src/browser/ImportMap.zig +++ b/src/browser/ImportMap.zig @@ -234,7 +234,7 @@ fn parseScopeKey(arena: Allocator, base: [:0]const u8, key: []const u8) ![]const if (key.len == 0) { return base; } - return URL.resolve(arena, base, key, .{ .always_dupe = true, .encoding = "UTF-8" }); + return URL.resolve(arena, base, key, .{ .encoding = "UTF-8" }); } /// Returns the parsed URL if `specifier` looks like a URL. Else returns null; @@ -248,7 +248,7 @@ fn parseIfLikeURL(arena: Allocator, base: [:0]const u8, specifier: []const u8) ? std.mem.startsWith(u8, specifier, "../") or hasScheme(specifier)) { - return URL.resolve(arena, base, specifier, .{ .always_dupe = true, .encoding = "UTF-8" }) catch return null; + return URL.resolve(arena, base, specifier, .{ .encoding = "UTF-8" }) catch return null; } return null; } @@ -331,7 +331,7 @@ fn resolveImportsMatch( const base_addr = entry.resolved orelse return error.SpecifierResolutionFailed; const after = normalized[entry.specifier.len..]; - const url = URL.resolve(arena, base_addr, after, .{ .always_dupe = true, .encoding = "UTF-8" }) catch { + const url = URL.resolve(arena, base_addr, after, .{ .encoding = "UTF-8" }) catch { return error.SpecifierResolutionFailed; }; diff --git a/src/browser/URL.zig b/src/browser/URL.zig index fc3ca8c7a..ca5b39c21 100644 --- a/src/browser/URL.zig +++ b/src/browser/URL.zig @@ -27,7 +27,6 @@ pub const ResolveOptions = struct { /// null = don't encode, "UTF-8" = standard percent encoding, /// other charset = encode query string using that charset with NCR fallback. encoding: ?[]const u8 = null, - always_dupe: bool = false, }; pub fn resolve( diff --git a/src/browser/webapi/History.zig b/src/browser/webapi/History.zig index b09df743e..5f255385c 100644 --- a/src/browser/webapi/History.zig +++ b/src/browser/webapi/History.zig @@ -52,7 +52,7 @@ pub fn setScrollRestoration(self: *History, str: []const u8) void { pub fn pushState(_: *History, state: js.Value, _: ?[]const u8, _url: ?[]const u8, frame: *Frame) !void { const arena = frame._session.arena; const url = if (_url) |u| - try @import("../URL.zig").resolve(arena, frame.url, u, .{ .always_dupe = true }) + try @import("../URL.zig").resolve(arena, frame.url, u, .{}) else try arena.dupeZ(u8, frame.url); @@ -67,7 +67,7 @@ pub fn pushState(_: *History, state: js.Value, _: ?[]const u8, _url: ?[]const u8 pub fn replaceState(_: *History, state: js.Value, _: ?[]const u8, _url: ?[]const u8, frame: *Frame) !void { const arena = frame._session.arena; const url = if (_url) |u| - try @import("../URL.zig").resolve(arena, frame.url, u, .{ .always_dupe = true }) + try @import("../URL.zig").resolve(arena, frame.url, u, .{}) else try arena.dupeZ(u8, frame.url); diff --git a/src/browser/webapi/net/Request.zig b/src/browser/webapi/net/Request.zig index dccb1b15b..dbc1bce3e 100644 --- a/src/browser/webapi/net/Request.zig +++ b/src/browser/webapi/net/Request.zig @@ -81,7 +81,7 @@ const Cache = enum { pub fn init(input: Input, opts_: ?InitOpts, exec: *const Execution) !*Request { const arena = exec.arena; const url = switch (input) { - .url => |u| try URL.resolve(arena, exec.base(), u, .{ .always_dupe = true, .encoding = exec.charset.* }), + .url => |u| try URL.resolve(arena, exec.base(), u, .{ .encoding = exec.charset.* }), .request => |r| try arena.dupeZ(u8, r._url), }; diff --git a/src/browser/webapi/net/WebSocket.zig b/src/browser/webapi/net/WebSocket.zig index 9ce1bf1a9..a98efc591 100644 --- a/src/browser/webapi/net/WebSocket.zig +++ b/src/browser/webapi/net/WebSocket.zig @@ -115,7 +115,7 @@ pub fn init(url: []const u8, protocols: [][]const u8, exec: *const Execution) !* const arena = try exec.getArena(.medium, "WebSocket"); errdefer exec.releaseArena(arena); - const resolved_url = try URL.resolve(arena, exec.base(), url, .{ .always_dupe = true, .encoding = exec.charset.* }); + const resolved_url = try URL.resolve(arena, exec.base(), url, .{ .encoding = exec.charset.* }); const http_client = &exec.session.browser.http_client; const conn = http_client.network.newConnection() orelse { diff --git a/src/browser/webapi/net/XMLHttpRequest.zig b/src/browser/webapi/net/XMLHttpRequest.zig index 7135c627d..98ec6019a 100644 --- a/src/browser/webapi/net/XMLHttpRequest.zig +++ b/src/browser/webapi/net/XMLHttpRequest.zig @@ -200,7 +200,7 @@ pub fn open(self: *XMLHttpRequest, method_: []const u8, url: [:0]const u8) !void const exec = self._exec; self._method = try parseMethod(method_); - self._url = try URL.resolve(self._arena, exec.base(), url, .{ .always_dupe = true, .encoding = exec.charset.* }); + self._url = try URL.resolve(self._arena, exec.base(), url, .{ .encoding = exec.charset.* }); try self.stateChanged(.opened, exec); } diff --git a/src/browser/webapi/storage/CookieStore.zig b/src/browser/webapi/storage/CookieStore.zig index c7c7117ae..9185e0707 100644 --- a/src/browser/webapi/storage/CookieStore.zig +++ b/src/browser/webapi/storage/CookieStore.zig @@ -319,7 +319,7 @@ fn resolveQueryUrl(exec: *const Execution, _override: ?[]const u8) ![:0]const u8 const current = exec.url.*; const override = _override orelse return current; - const resolved = try URL.resolve(exec.call_arena, exec.base(), override, .{ .always_dupe = true }); + const resolved = try URL.resolve(exec.call_arena, exec.base(), override, .{}); if (!exec.isSameOrigin(resolved)) return error.SecurityError; switch (exec.js.global) { From 7bf68a484da739eb65ad5d8ddedeb71bbff0b4dd Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Mon, 15 Jun 2026 15:59:47 +0300 Subject: [PATCH 06/40] `URL`: replace `ensureEncoded` with `resolve` Both essentially do the same; we can stick to `resolve` for further IDNA compat. --- src/browser/URL.zig | 336 +------------------------------------ src/cdp/domains/page.zig | 2 +- src/cdp/domains/target.zig | 2 +- src/lightpanda.zig | 2 +- 4 files changed, 4 insertions(+), 338 deletions(-) diff --git a/src/browser/URL.zig b/src/browser/URL.zig index ca5b39c21..27223a882 100644 --- a/src/browser/URL.zig +++ b/src/browser/URL.zig @@ -17,7 +17,6 @@ // along with this program. If not, see . const std = @import("std"); -const idna = @import("../sys/idna.zig"); const U = @import("../sys/url.zig"); @@ -51,117 +50,7 @@ pub fn resolve( return allocator.dupeZ(u8, href.slice()); } -/// IDNA pass: converts a non-ASCII host (`räksmörgås.se`) to its punycode form -/// (`xn--rksmrgs-5wao1o.se`), validates any ASCII punycode (`xn--…`) labels, -/// and leaves everything else alone. -fn ensureHostAscii(allocator: Allocator, url: [:0]const u8) ![:0]const u8 { - const hostname = getHostname(url); - if (hostname.len == 0 or (!idna.needsAscii(hostname) and !hasAceLabel(hostname))) { - return url; - } - - const ascii = try idna.toAscii(allocator, hostname); - - // hostname is a slice of url, so its start offset is just pointer arithmetic. - const start = @intFromPtr(hostname.ptr) - @intFromPtr(url.ptr); - const end = start + hostname.len; - var buf = try std.ArrayList(u8).initCapacity(allocator, url.len - hostname.len + ascii.len + 1); - buf.appendSliceAssumeCapacity(url[0..start]); - buf.appendSliceAssumeCapacity(ascii); - buf.appendSliceAssumeCapacity(url[end..]); - buf.appendAssumeCapacity(0); - return buf.items[0 .. buf.items.len - 1 :0]; -} - -/// True if any dot-separated label of `host` begins with the IDNA ACE prefix -/// "xn--" (case-insensitive). Such labels are punycode: even though they're -/// pure ASCII, UTS#46 must decode and validate them, so they can't take the -/// `needsAscii` fast path. -fn hasAceLabel(host: []const u8) bool { - var pos: usize = 0; - while (std.mem.indexOfScalarPos(u8, host, pos, '-')) |i| { - pos = i + 1; - if (i < 2 or i + 1 >= host.len or host[i + 1] != '-') { - continue; - } - - if (!std.ascii.eqlIgnoreCase(host[i - 2 .. i], "xn")) { - continue; - } - - const label_start = i - 2; - if (label_start == 0 or host[label_start - 1] == '.') { - return true; - } - } - return false; -} - -pub fn ensureEncoded(allocator: Allocator, url_in: [:0]const u8, encoding: []const u8) ![:0]const u8 { - // Resolve any IDN host first; everything below operates on the ASCII form. - const url = try ensureHostAscii(allocator, url_in); - - const scheme_end = std.mem.indexOf(u8, url, "://"); - const authority_start = if (scheme_end) |end| end + 3 else 0; - const path_start = std.mem.indexOfScalarPos(u8, url, authority_start, '/') orelse return url; - - const query_start = std.mem.indexOfScalarPos(u8, url, path_start, '?'); - const fragment_start = std.mem.indexOfScalarPos(u8, url, query_start orelse path_start, '#'); - - const path_end = query_start orelse fragment_start orelse url.len; - const query_end = if (query_start) |_| (fragment_start orelse url.len) else path_end; - - const path_to_encode = url[path_start..path_end]; - // Path is always UTF-8 percent encoded per URL spec - const encoded_path = try percentEncodeSegment(allocator, path_to_encode, .path); - - // Query string uses document encoding - const encoded_query = if (query_start) |qs| blk: { - const query_to_encode = url[qs + 1 .. query_end]; - break :blk try encodeQueryString(allocator, query_to_encode, encoding); - } else null; - - const encoded_fragment = if (fragment_start) |fs| blk: { - const fragment_to_encode = url[fs + 1 ..]; - break :blk try percentEncodeSegment(allocator, fragment_to_encode, .query); - } else null; - - if (encoded_path.ptr == path_to_encode.ptr and - (encoded_query == null or encoded_query.?.ptr == url[query_start.? + 1 .. query_end].ptr) and - (encoded_fragment == null or encoded_fragment.?.ptr == url[fragment_start.? + 1 ..].ptr)) - { - // nothing has changed - return url; - } - - var buf = try std.ArrayList(u8).initCapacity(allocator, url.len + 20); - try buf.appendSlice(allocator, url[0..path_start]); - try buf.appendSlice(allocator, encoded_path); - if (encoded_query) |eq| { - try buf.append(allocator, '?'); - try buf.appendSlice(allocator, eq); - } - if (encoded_fragment) |ef| { - try buf.append(allocator, '#'); - try buf.appendSlice(allocator, ef); - } - try buf.append(allocator, 0); - return buf.items[0 .. buf.items.len - 1 :0]; -} - -/// Selects which RFC 3986 / WHATWG URL Standard percent-encode set to apply. -/// -/// The `path`, `query`, `query_legacy`, `userinfo`, and `fragment` variants -/// match the corresponding URL spec sets — they assume the input is already -/// structured (e.g. `key=val&key=val` for `query`) and only encode characters -/// disallowed in that location. -/// -/// `component` is stricter: it encodes everything outside the RFC 3986 -/// unreserved set, including sub-delims (`& = + ! * ' ( ) , $ ;`). Use this -/// when embedding an arbitrary string as a single URI component such as a -/// query-parameter value, where reserved characters in the input would -/// otherwise change the URL's structure. -pub const EncodeSet = enum { path, query, query_legacy, userinfo, fragment, component }; +const EncodeSet = enum { path, query, query_legacy, userinfo, fragment }; pub fn percentEncodeSegment(allocator: Allocator, segment: []const u8, comptime encode_set: EncodeSet) ![]const u8 { // Check if encoding is needed @@ -209,52 +98,6 @@ pub fn percentEncodeSegment(allocator: Allocator, segment: []const u8, comptime return buf.items; } -const h5e = @import("parser/html5ever.zig"); - -/// Encode a query string using the specified encoding. -/// For UTF-8, this is standard percent encoding. -/// For legacy encodings, unmappable characters are replaced with NCRs (&#codepoint;). -fn encodeQueryString(allocator: Allocator, query: []const u8, encoding: []const u8) ![]const u8 { - // For UTF-8, use standard percent encoding - if (std.mem.eql(u8, encoding, "UTF-8")) { - return percentEncodeSegment(allocator, query, .query); - } - - // For legacy encodings, first encode to the target charset with NCR fallback - const enc_info = h5e.encoding_for_label(encoding.ptr, encoding.len); - if (!enc_info.isValid()) { - // Unknown encoding, fall back to UTF-8 - return percentEncodeSegment(allocator, query, .query); - } - - // Calculate max buffer size for encoded output - const max_encoded_len = h5e.encoding_max_encode_buffer_length(enc_info.handle.?, query.len); - if (max_encoded_len == 0) { - return percentEncodeSegment(allocator, query, .query); - } - - const encode_buf = try allocator.alloc(u8, max_encoded_len); - defer allocator.free(encode_buf); - - // Encode UTF-8 to legacy encoding with NCR fallback - const result = h5e.encoding_encode_with_ncr( - enc_info.handle.?, - query.ptr, - query.len, - encode_buf.ptr, - encode_buf.len, - ); - - if (!result.isSuccess()) { - // Encoding failed, fall back to UTF-8 - return percentEncodeSegment(allocator, query, .query); - } - - // Now percent-encode the result using query_legacy to preserve NCRs - const encoded_bytes = encode_buf[0..result.bytes_written]; - return percentEncodeSegment(allocator, encoded_bytes, .query_legacy); -} - fn shouldPercentEncode(c: u8, comptime encode_set: EncodeSet) bool { return switch (c) { // Unreserved characters (RFC 3986) @@ -276,10 +119,6 @@ fn shouldPercentEncode(c: u8, comptime encode_set: EncodeSet) bool { }; } -fn isNullTerminated(comptime value: type) bool { - return @typeInfo(value).pointer.sentinel_ptr != null; -} - pub fn isCompleteHTTPUrl(url: []const u8) bool { if (url.len < 3) { // Minimum is "x://" return false; @@ -1143,179 +982,6 @@ test "URL: resolve validates ASCII punycode (xn--) labels" { try testing.expectError(error.TypeError, resolve(testing.arena_allocator, "https://example.com/", "https://xn--a.pt/x", .{})); } -test "URL: hasAceLabel" { - // ACE prefix at a label start (case-insensitive). - try testing.expectEqual(true, hasAceLabel("xn--a")); - try testing.expectEqual(true, hasAceLabel("xn--rksmrgs-5wao1o.se")); - try testing.expectEqual(true, hasAceLabel("a.xn--b.com")); - try testing.expectEqual(true, hasAceLabel("XN--ab.com")); - try testing.expectEqual(true, hasAceLabel("foo.example.xn--p1ai")); - - // Has '-', but no ACE label. - try testing.expectEqual(false, hasAceLabel("example.com")); - try testing.expectEqual(false, hasAceLabel("my-site.com")); - try testing.expectEqual(false, hasAceLabel("axn--b.com")); // xn-- not at a label start - try testing.expectEqual(false, hasAceLabel("x-n--a.com")); // not "xn" before the '-' - try testing.expectEqual(false, hasAceLabel("-.com")); - try testing.expectEqual(false, hasAceLabel("")); -} - -test "URL: ensureEncoded" { - defer testing.reset(); - - const Case = struct { - url: [:0]const u8, - expected: [:0]const u8, - }; - - const cases = [_]Case{ - .{ - .url = "https://example.com/over 9000!", - .expected = "https://example.com/over%209000!", - }, - .{ - .url = "http://example.com/hello world.html", - .expected = "http://example.com/hello%20world.html", - }, - .{ - .url = "https://example.com/file[1].html", - .expected = "https://example.com/file%5B1%5D.html", - }, - .{ - .url = "https://example.com/file{name}.html", - .expected = "https://example.com/file%7Bname%7D.html", - }, - .{ - .url = "https://example.com/page?query=hello world", - .expected = "https://example.com/page?query=hello%20world", - }, - .{ - .url = "https://example.com/page?a=1&b=value with spaces", - .expected = "https://example.com/page?a=1&b=value%20with%20spaces", - }, - .{ - .url = "https://example.com/page#section one", - .expected = "https://example.com/page#section%20one", - }, - .{ - .url = "https://example.com/my path?query=my value#my anchor", - .expected = "https://example.com/my%20path?query=my%20value#my%20anchor", - }, - .{ - .url = "https://example.com/already%20encoded", - .expected = "https://example.com/already%20encoded", - }, - .{ - .url = "https://example.com/file%5B1%5D.html", - .expected = "https://example.com/file%5B1%5D.html", - }, - .{ - .url = "https://example.com/caf%C3%A9", - .expected = "https://example.com/caf%C3%A9", - }, - .{ - .url = "https://example.com/page?query=already%20encoded", - .expected = "https://example.com/page?query=already%20encoded", - }, - .{ - .url = "https://example.com/page?a=1&b=value%20here", - .expected = "https://example.com/page?a=1&b=value%20here", - }, - .{ - .url = "https://example.com/page#section%20one", - .expected = "https://example.com/page#section%20one", - }, - .{ - .url = "https://example.com/part%20encoded and not", - .expected = "https://example.com/part%20encoded%20and%20not", - }, - .{ - .url = "https://example.com/page?a=encoded%20value&b=not encoded", - .expected = "https://example.com/page?a=encoded%20value&b=not%20encoded", - }, - .{ - .url = "https://example.com/my%20path?query=not encoded#encoded%20anchor", - .expected = "https://example.com/my%20path?query=not%20encoded#encoded%20anchor", - }, - .{ - .url = "https://example.com/fully%20encoded?query=also%20encoded#and%20this", - .expected = "https://example.com/fully%20encoded?query=also%20encoded#and%20this", - }, - .{ - .url = "https://example.com/path-with_under~tilde", - .expected = "https://example.com/path-with_under~tilde", - }, - .{ - .url = "https://example.com/sub-delims!$&'()*+,;=", - .expected = "https://example.com/sub-delims!$&'()*+,;=", - }, - .{ - .url = "https://example.com", - .expected = "https://example.com", - }, - .{ - .url = "https://example.com?query=value", - .expected = "https://example.com?query=value", - }, - .{ - .url = "https://example.com/clean/path", - .expected = "https://example.com/clean/path", - }, - .{ - .url = "https://example.com/path?clean=query#clean-fragment", - .expected = "https://example.com/path?clean=query#clean-fragment", - }, - .{ - .url = "https://example.com/100% complete", - .expected = "https://example.com/100%25%20complete", - }, - .{ - .url = "https://example.com/path?value=100% done", - .expected = "https://example.com/path?value=100%25%20done", - }, - .{ - .url = "about:blank", - .expected = "about:blank", - }, - }; - - for (cases) |case| { - const result = try ensureEncoded(testing.arena_allocator, case.url, "UTF-8"); - try testing.expectString(case.expected, result); - } -} - -test "URL: percentEncodeSegment component passes unreserved chars through" { - defer testing.reset(); - const r = try percentEncodeSegment(testing.arena_allocator, "abcXYZ012-._~", .component); - try testing.expectString("abcXYZ012-._~", r); -} - -test "URL: percentEncodeSegment component encodes spaces, sub-delims and reserved chars" { - defer testing.reset(); - const r = try percentEncodeSegment(testing.arena_allocator, "hello world&q=1", .component); - try testing.expectString("hello%20world%26q%3D1", r); - - const r2 = try percentEncodeSegment(testing.arena_allocator, "a+b!c*d", .component); - try testing.expectString("a%2Bb%21c%2Ad", r2); -} - -test "URL: percentEncodeSegment component encodes UTF-8 bytes" { - defer testing.reset(); - const r = try percentEncodeSegment(testing.arena_allocator, "café", .component); - try testing.expectString("caf%C3%A9", r); -} - -test "URL: percentEncodeSegment component re-encodes literal '%' in raw input" { - defer testing.reset(); - const r = try percentEncodeSegment(testing.arena_allocator, "100%", .component); - try testing.expectString("100%25", r); - - // Even when followed by hex digits, treat as opaque user data. - const r2 = try percentEncodeSegment(testing.arena_allocator, "100%2A", .component); - try testing.expectString("100%252A", r2); -} - test "URL: resolve with encoding" { defer testing.reset(); diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig index 808a2fa70..5302862a6 100644 --- a/src/cdp/domains/page.zig +++ b/src/cdp/domains/page.zig @@ -308,7 +308,7 @@ fn navigate(cmd: *CDP.Command) !void { const session = bc.session; const frame = bc.mainFrame() orelse return error.FrameNotLoaded; - const encoded_url = try URL.ensureEncoded(frame.call_arena, params.url, "UTF-8"); + const encoded_url = try URL.resolve(frame.call_arena, "", params.url, .{}); // Fast path: a freshly-created target whose root frame hasn't navigated // yet has nothing to preserve across the HTTP round-trip. Skip the diff --git a/src/cdp/domains/target.zig b/src/cdp/domains/target.zig index d1543e055..15b524c32 100644 --- a/src/cdp/domains/target.zig +++ b/src/cdp/domains/target.zig @@ -222,7 +222,7 @@ fn createTarget(cmd: *CDP.Command) !void { } if (!std.mem.eql(u8, "about:blank", params.url)) { - const encoded_url = try URL.ensureEncoded(frame.call_arena, params.url, "UTF-8"); + const encoded_url = try URL.resolve(frame.call_arena, "", params.url, .{}); try frame.navigate( encoded_url, .{ .reason = .address_bar, .kind = .{ .push = null } }, diff --git a/src/lightpanda.zig b/src/lightpanda.zig index 39e6aff9d..1685fd3bb 100644 --- a/src/lightpanda.zig +++ b/src/lightpanda.zig @@ -104,7 +104,7 @@ pub fn fetch(app: *App, browser: *Browser, url: [:0]const u8, opts: FetchOpts) ! { const frame = page.frame().?; // not guaranteed to be valid after navigate - const encoded_url = try URL.ensureEncoded(frame.call_arena, url, "UTF-8"); + const encoded_url = try URL.resolve(frame.call_arena, "", url, .{}); _ = try frame.navigate(encoded_url, .{ .reason = .address_bar, .kind = .{ .push = null }, From 3c91f3b3e2690f364717d20c38c6f9a4cbfe2bc7 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Thu, 18 Jun 2026 17:47:46 +0300 Subject: [PATCH 07/40] `URL`: fix missing port problem in setter --- src/html5ever/url.rs | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/html5ever/url.rs b/src/html5ever/url.rs index 804b6037b..b587efb98 100644 --- a/src/html5ever/url.rs +++ b/src/html5ever/url.rs @@ -426,21 +426,24 @@ pub unsafe extern "C" fn url_set_host(url: *mut Url, ptr: *const c_uchar, len: u let (host, port_str) = (&input[..i], &input[i + 1..]); + // A trailing colon with no digits ("host:") supplies an empty port, which + // the WHATWG host setter ignores: set the host only, leaving any existing + // port untouched (matches Chrome and Firefox). + if port_str.is_empty() { + return if url.set_host(Some(host)).is_ok() { 0 } else { -1 }; + } + // Validate the port up-front so we never apply the host and then fail. - let new_port: Option = if port_str.is_empty() { - None // "host:" clears the port - } else { - match port_str.parse::() { - Ok(p) => Some(p), - Err(_) => return -1, - } + let new_port: u16 = match port_str.parse::() { + Ok(p) => p, + Err(_) => return -1, }; if url.set_host(Some(host)).is_err() { return -1; } // set_port only errors on cannot-be-a-base, already ruled out by set_host. - let _ = url.set_port(new_port); + let _ = url.set_port(Some(new_port)); 0 } From e223827789b91eeb000367bb664f9819a49eb083 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Thu, 18 Jun 2026 17:48:16 +0300 Subject: [PATCH 08/40] `URL`: reintroduce `parse` static method --- src/browser/webapi/URL.zig | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/browser/webapi/URL.zig b/src/browser/webapi/URL.zig index bde373ebf..6872c8248 100644 --- a/src/browser/webapi/URL.zig +++ b/src/browser/webapi/URL.zig @@ -53,6 +53,11 @@ pub fn init(url: []const u8, maybe_base: ?[]const u8, exec: *const Execution) !* return exec._factory.create(URL{ ._url = u }); } +/// Like the constructor, but returns null instead of throwing when parsing fails. +pub fn parse(url: []const u8, maybe_base: ?[]const u8, exec: *const Execution) ?*URL { + return URL.init(url, maybe_base, exec) catch null; +} + pub fn deinit(self: *URL, _: *Page) void { // Not tracked by arena. U.url_free(self._url); @@ -298,14 +303,6 @@ pub fn toString(self: *const URL, exec: *const Execution) ![]const u8 { return out[0..len]; } -/// Like the constructor, but returns null instead of throwing when parsing fails. -pub fn parse(url: []const u8, maybe_base: ?[]const u8, exec: *const Execution) !?*URL { - return URL.init(url, maybe_base, exec) catch |err| switch (err) { - error.TypeError => null, - else => err, - }; -} - pub fn canParse(url: []const u8, maybe_base: ?[]const u8) bool { if (maybe_base) |base| { return U.url_can_parse_with_base(base.ptr, base.len, url.ptr, url.len); From 20c9a873fdc6bab011de8976b25d766fa9e1acf5 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Thu, 18 Jun 2026 17:48:27 +0300 Subject: [PATCH 09/40] `URL`: update tests --- src/browser/tests/url.html | 90 +++++++++++++++++++++++++++++++------- 1 file changed, 74 insertions(+), 16 deletions(-) diff --git a/src/browser/tests/url.html b/src/browser/tests/url.html index cabc2b13e..903b76fd1 100644 --- a/src/browser/tests/url.html +++ b/src/browser/tests/url.html @@ -333,31 +333,88 @@ @@ -572,12 +629,13 @@ testing.expectEqual('https://[::1]:9000/path', url.href); } - // A trailing colon clears the port. + // A trailing colon supplies an empty port, which the parser ignores: the + // existing port is left untouched (matches Chrome and Firefox). { const url = new URL('https://example.com:8080/path'); url.host = 'newhost.com:'; - testing.expectEqual('newhost.com', url.host); - testing.expectEqual('https://newhost.com/path', url.href); + testing.expectEqual('newhost.com:8080', url.host); + testing.expectEqual('https://newhost.com:8080/path', url.href); } // An invalid host is silently ignored, leaving it unchanged (the setter must From 8d41c6e221835a4f8faa618c6e449d099adce76b Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Thu, 18 Jun 2026 20:12:00 +0300 Subject: [PATCH 10/40] `KeyValueList`: write `=` between key and value at all modes --- src/browser/webapi/KeyValueList.zig | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/browser/webapi/KeyValueList.zig b/src/browser/webapi/KeyValueList.zig index b8e32234f..fe32a9f4a 100644 --- a/src/browser/webapi/KeyValueList.zig +++ b/src/browser/webapi/KeyValueList.zig @@ -195,14 +195,12 @@ pub fn urlEncode(self: *const KeyValueList, comptime mode: URLEncodeMode, alloca fn urlEncodeEntry(entry: Entry, comptime mode: URLEncodeMode, allocator_: ?Allocator, charset: []const u8, writer: *std.Io.Writer) !void { try urlEncodeValue(entry.name.str(), mode, allocator_, charset, writer); + try writer.writeByte('='); - // for a form, for an empty value, we'll do "spice=" - // but for a query, we do "spice" - if ((comptime mode == .query) and entry.value.len == 0) { + if (entry.value.len == 0) { return; } - try writer.writeByte('='); try urlEncodeValue(entry.value.str(), mode, allocator_, charset, writer); } From 84870d552472c83f3f66190ed5b408b467c43133 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Thu, 18 Jun 2026 20:37:47 +0300 Subject: [PATCH 11/40] `URL`: include leading '?' in search/query --- src/browser/webapi/URL.zig | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/browser/webapi/URL.zig b/src/browser/webapi/URL.zig index 6872c8248..dbffa97f6 100644 --- a/src/browser/webapi/URL.zig +++ b/src/browser/webapi/URL.zig @@ -214,7 +214,7 @@ pub fn setSearch(self: *URL, value: []const u8, exec: *const Execution) !void { const search_params = self._search_params orelse return; var out: [*]const u8 = undefined; var len: usize = 0; - const search_value = if (U.url_get_query(self._url, &out, &len) == 0) out[0..len] else ""; + const search_value = if (U.url_get_query(self._url, &out, &len) == 0) (out - 1)[0 .. len + 1] else ""; try search_params.updateFromString(search_value, exec); } @@ -247,10 +247,11 @@ pub fn getSearchParams(self: *URL, exec: *const Execution) !*URLSearchParams { return sp; } - // Get current search string (omitting '?'). + // Get current search string; rust-url always skips '?' so we have to + // go a byte back to include it. var out: [*]const u8 = undefined; var len: usize = 0; - const search_value = if (U.url_get_query(self._url, &out, &len) == 0) out[0..len] else ""; + const search_value = if (U.url_get_query(self._url, &out, &len) == 0) (out - 1)[0 .. len + 1] else ""; const params = try URLSearchParams.init(.{ .query_string = search_value }, exec); self._search_params = params; @@ -278,7 +279,7 @@ pub fn setHref(self: *URL, value: []const u8, exec: *const Execution) !void { const search_params = self._search_params orelse return; var out: [*]const u8 = undefined; var len: usize = 0; - const search_value = if (U.url_get_query(url, &out, &len) == 0) out[0..len] else ""; + const search_value = if (U.url_get_query(url, &out, &len) == 0) (out - 1)[0 .. len + 1] else ""; try search_params.updateFromString(search_value, exec); } From 2cdcecaba4a9e7a1e84f52ad95270c1e39b90e1c Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Thu, 18 Jun 2026 20:53:56 +0300 Subject: [PATCH 12/40] `KeyValueList(urlEncodeUnreserved)`: drop `~` case --- src/browser/webapi/KeyValueList.zig | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/browser/webapi/KeyValueList.zig b/src/browser/webapi/KeyValueList.zig index fe32a9f4a..a79475ac5 100644 --- a/src/browser/webapi/KeyValueList.zig +++ b/src/browser/webapi/KeyValueList.zig @@ -258,7 +258,7 @@ fn urlEncodeValue(value: []const u8, comptime mode: URLEncodeMode, allocator_: ? /// Percent-encode a UTF-8 value - bytes >= 0x80 are percent-encoded directly. fn urlEncodeValueUtf8(value: []const u8, comptime mode: URLEncodeMode, writer: *std.Io.Writer) !void { - if (!urlEncodeShouldEscape(value, mode)) { + if (!urlEncodeShouldEscape(value)) { return writer.writeAll(value); } @@ -268,7 +268,7 @@ fn urlEncodeValueUtf8(value: []const u8, comptime mode: URLEncodeMode, writer: * if (comptime mode == .form) { if (try writeFormLineEnd(value, &i, b, writer)) continue; } - if (urlEncodeUnreserved(b, mode)) { + if (urlEncodeUnreserved(b)) { try writer.writeByte(b); } else if (b == ' ') { try writer.writeByte('+'); @@ -286,7 +286,7 @@ fn urlEncodeValueLegacy(value: []const u8, comptime mode: URLEncodeMode, writer: if (comptime mode == .form) { if (try writeFormLineEnd(value, &i, b, writer)) continue; } - if (urlEncodeUnreserved(b, mode)) { + if (urlEncodeUnreserved(b)) { try writer.writeByte(b); } else if (b == ' ') { try writer.writeByte('+'); @@ -318,19 +318,18 @@ fn writeFormLineEnd(value: []const u8, i: *usize, b: u8, writer: *std.Io.Writer) return false; } -fn urlEncodeShouldEscape(value: []const u8, comptime mode: URLEncodeMode) bool { +fn urlEncodeShouldEscape(value: []const u8) bool { for (value) |b| { - if (!urlEncodeUnreserved(b, mode)) { + if (!urlEncodeUnreserved(b)) { return true; } } return false; } -fn urlEncodeUnreserved(b: u8, comptime mode: URLEncodeMode) bool { +fn urlEncodeUnreserved(b: u8) bool { return switch (b) { 'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_', '*' => true, - '~' => comptime mode == .form, else => false, }; } From a12d781d4abd577fb89b1c705394330713ed4dff Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Thu, 18 Jun 2026 20:54:41 +0300 Subject: [PATCH 13/40] `KeyValueList.zig`: update tests --- src/browser/webapi/KeyValueList.zig | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/browser/webapi/KeyValueList.zig b/src/browser/webapi/KeyValueList.zig index a79475ac5..363e7330e 100644 --- a/src/browser/webapi/KeyValueList.zig +++ b/src/browser/webapi/KeyValueList.zig @@ -388,6 +388,19 @@ test "KeyValueList: urlEncode UTF-8" { try testing.expectString("cafe=caf%C3%A9", buf.written()); } +test "KeyValueList: urlEncode .form percent-encodes ~" { + // '~' is in the application/x-www-form-urlencoded percent-encode set, so it + // must become %7E in both .form and .query modes (not left literal). + const allocator = testing.arena_allocator; + var list = KeyValueList.init(); + try list.append(allocator, "q", "a~b"); + + var buf = std.Io.Writer.Allocating.init(allocator); + try list.urlEncode(.form, null, "UTF-8", &buf.writer); + + try testing.expectString("q=a%7Eb", buf.written()); +} + test "KeyValueList: urlEncode UTF-8 CJK" { // Test 3-byte UTF-8 characters (Chinese/Japanese) const allocator = testing.arena_allocator; From 84fe33c679c8fa6c6b08969d3b877a69aa20196a Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Thu, 18 Jun 2026 20:57:18 +0300 Subject: [PATCH 14/40] `URL(EncodeSet)`: bring back `component` value Lost and found. --- src/browser/URL.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/browser/URL.zig b/src/browser/URL.zig index 27223a882..23491a80c 100644 --- a/src/browser/URL.zig +++ b/src/browser/URL.zig @@ -50,7 +50,7 @@ pub fn resolve( return allocator.dupeZ(u8, href.slice()); } -const EncodeSet = enum { path, query, query_legacy, userinfo, fragment }; +const EncodeSet = enum { path, query, query_legacy, userinfo, fragment, component }; pub fn percentEncodeSegment(allocator: Allocator, segment: []const u8, comptime encode_set: EncodeSet) ![]const u8 { // Check if encoding is needed From 0c4f6f0d87ac9d558a18f86265821ca6c15e7b5a Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Mon, 22 Jun 2026 13:08:35 +0300 Subject: [PATCH 15/40] `getProtocol(Anchor, Area)`: return `:` if resolved href is null --- src/browser/webapi/element/html/Anchor.zig | 15 ++++++++++++--- src/browser/webapi/element/html/Area.zig | 15 ++++++++++++--- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/browser/webapi/element/html/Anchor.zig b/src/browser/webapi/element/html/Anchor.zig index 65acd0091..0a55c457f 100644 --- a/src/browser/webapi/element/html/Anchor.zig +++ b/src/browser/webapi/element/html/Anchor.zig @@ -43,7 +43,11 @@ pub fn getHref(self: *Anchor, frame: *Frame) ![]const u8 { if (href.len == 0) { return ""; } - return self.asNode().resolveURL(href, frame, .{}); + return self.asNode().resolveURL(href, frame, .{}) catch |err| switch (err) { + // Per spec the getter must not throw; it returns the content attribute. + error.TypeError => href, + else => return err, + }; } pub fn setHref(self: *Anchor, value: []const u8, frame: *Frame) !void { @@ -155,7 +159,7 @@ pub fn setPathname(self: *Anchor, value: []const u8, frame: *Frame) !void { } pub fn getProtocol(self: *Anchor, frame: *Frame) ![]const u8 { - const href = try getResolvedHref(self, frame) orelse return ""; + const href = try getResolvedHref(self, frame) orelse return ":"; return URL.getProtocol(href); } @@ -224,7 +228,12 @@ fn getResolvedHref(self: *Anchor, frame: *Frame) !?[:0]const u8 { if (href.len == 0) { return null; } - return try self.asNode().resolveURL(href, frame, .{}); + return self.asNode().resolveURL(href, frame, .{}) catch |err| switch (err) { + // Unparseable against the base: treat as no resolved URL so the + // component getters return "" instead of throwing. + error.TypeError => null, + else => return err, + }; } pub const JsApi = struct { diff --git a/src/browser/webapi/element/html/Area.zig b/src/browser/webapi/element/html/Area.zig index d00e120e2..b58f5fe44 100644 --- a/src/browser/webapi/element/html/Area.zig +++ b/src/browser/webapi/element/html/Area.zig @@ -38,7 +38,11 @@ pub fn getHref(self: *Area, frame: *Frame) ![]const u8 { if (href.len == 0) { return ""; } - return self.asNode().resolveURL(href, frame, .{}); + return self.asNode().resolveURL(href, frame, .{}) catch |err| switch (err) { + // Per spec the getter must not throw; it returns the content attribute. + error.TypeError => href, + else => return err, + }; } pub fn setHref(self: *Area, value: []const u8, frame: *Frame) !void { @@ -164,7 +168,7 @@ pub fn setPathname(self: *Area, value: []const u8, frame: *Frame) !void { } pub fn getProtocol(self: *Area, frame: *Frame) ![]const u8 { - const href = try getResolvedHref(self, frame) orelse return ""; + const href = try getResolvedHref(self, frame) orelse return ":"; return URL.getProtocol(href); } @@ -179,7 +183,12 @@ fn getResolvedHref(self: *Area, frame: *Frame) !?[:0]const u8 { if (href.len == 0) { return null; } - return try self.asNode().resolveURL(href, frame, .{}); + return self.asNode().resolveURL(href, frame, .{}) catch |err| switch (err) { + // Unparseable against the base: treat as no resolved URL so the + // component getters return "" instead of throwing. + error.TypeError => null, + else => return err, + }; } pub const JsApi = struct { From 7d5553238e6725e2e25665259148cb9b7ff6fb63 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Mon, 22 Jun 2026 13:09:23 +0300 Subject: [PATCH 16/40] `URL`: changes on host(name) setter, introduce `clean_hostname_input` --- src/html5ever/url.rs | 45 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/src/html5ever/url.rs b/src/html5ever/url.rs index b587efb98..2d5f60e07 100644 --- a/src/html5ever/url.rs +++ b/src/html5ever/url.rs @@ -335,17 +335,40 @@ pub unsafe extern "C" fn url_get_port(url: *const Url) -> i32 { } } +fn clean_hostname_input(url: &Url, raw: &str) -> String { + let special = matches!( + url.scheme(), + "http" | "https" | "ws" | "wss" | "ftp" | "file" + ); + let mut out = String::with_capacity(raw.len()); + let mut in_brackets = false; + for c in raw.chars() { + match c { + '\t' | '\n' | '\r' => continue, + '[' => in_brackets = true, + ']' => in_brackets = false, + '/' | '?' | '#' => break, + '\\' if special => break, + ':' if !in_brackets => break, + _ => {} + } + out.push(c); + } + out +} + /// WHATWG `hostname` setter: sets the host without touching the port. #[no_mangle] pub unsafe extern "C" fn url_set_hostname(url: *mut Url, ptr: *const c_uchar, len: usize) -> i32 { let url = unsafe { &mut *url }; - let slice = match str_from(ptr, len) { + let raw = match str_from(ptr, len) { Some(s) => s, None => return -1, }; + let host = clean_hostname_input(url, raw); - match url.set_host(Some(slice)) { + match url.set_host(Some(host.as_str())) { Ok(()) => 0, Err(_) => -1, } @@ -406,6 +429,18 @@ pub unsafe extern "C" fn url_set_host(url: *mut Url, ptr: *const c_uchar, len: u None => return -1, }; + // The WHATWG host setter parses in "host state", which stops at the first + // '/', '?', '#' (and '\' for special schemes); the remainder is ignored + // rather than making the whole value invalid. + let special = matches!( + url.scheme(), + "http" | "https" | "ws" | "wss" | "ftp" | "file" + ); + let end = input + .find(|c| c == '/' || c == '?' || c == '#' || (special && c == '\\')) + .unwrap_or(input.len()); + let input = &input[..end]; + // Find the port separator ':', but only outside an IPv6 [...] literal. let colon = if input.starts_with('[') { input @@ -430,7 +465,11 @@ pub unsafe extern "C" fn url_set_host(url: *mut Url, ptr: *const c_uchar, len: u // the WHATWG host setter ignores: set the host only, leaving any existing // port untouched (matches Chrome and Firefox). if port_str.is_empty() { - return if url.set_host(Some(host)).is_ok() { 0 } else { -1 }; + return if url.set_host(Some(host)).is_ok() { + 0 + } else { + -1 + }; } // Validate the port up-front so we never apply the host and then fail. From a499c0cb928bc75b4edf8951acb316a1a8c56379 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Mon, 22 Jun 2026 13:09:32 +0300 Subject: [PATCH 17/40] `URL`: update tests --- src/browser/tests/url.html | 40 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/browser/tests/url.html b/src/browser/tests/url.html index 903b76fd1..5b1b0b12b 100644 --- a/src/browser/tests/url.html +++ b/src/browser/tests/url.html @@ -1321,3 +1321,43 @@ testing.expectEqual('?a=%7E&b=%7E&c=foo', url.search); } + + + + + + + + From 82d78ecd001aec2180e74d1edaf289ce40f89be8 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Thu, 25 Jun 2026 17:48:17 +0300 Subject: [PATCH 18/40] `Node`: add `resolveURLReflect` --- src/browser/webapi/Node.zig | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig index af1e4395c..2ee3aa7c5 100644 --- a/src/browser/webapi/Node.zig +++ b/src/browser/webapi/Node.zig @@ -586,6 +586,15 @@ pub fn resolveURL(self: *const Node, url: anytype, frame: *Frame, opts: ResolveU return URL.resolve(allocator, owner_frame.base(), url, .{ .encoding = owner_frame.charset }); } +// Same as `resolveURL` but can't return `TypeError`, this is needed for multiple +// getters throught codebase. Returns back passed `url` for `TypeError`s. +pub fn resolveURLReflect(self: *const Node, url: []const u8, frame: *Frame, opts: ResolveURLOpts) ![]const u8 { + return self.resolveURL(url, frame, opts) catch |err| switch (err) { + error.TypeError => url, + else => err, + }; +} + pub fn isSameDocumentAs(self: *const Node, other: *const Node, frame: *const Frame) bool { // Get the root document for each node const self_doc = if (self._type == .document) self._type.document else self.ownerDocument(frame); From 6bd4fddf62bae65b0d4c93e153a94619e3bef391 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Thu, 25 Jun 2026 17:57:57 +0300 Subject: [PATCH 19/40] `URL`: add `resolveNavigation` This is needed for schemeless "address bar" style URLs. Would love to have a path that doesn't allocate for this... --- src/browser/URL.zig | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/browser/URL.zig b/src/browser/URL.zig index 23491a80c..5dcddcae5 100644 --- a/src/browser/URL.zig +++ b/src/browser/URL.zig @@ -50,6 +50,18 @@ pub fn resolve( return allocator.dupeZ(u8, href.slice()); } +/// Resolves a user-provided "address bar" URL the way curl does. Bare host like +/// `lightpanda.io` has no scheme, so it can't be parsed as an absolute URL. +pub fn resolveNavigation(allocator: Allocator, url: []const u8, options: ResolveOptions) ![:0]const u8 { + return resolve(allocator, "", url, options) catch |err| switch (err) { + error.TypeError => { + const with_scheme = try std.fmt.allocPrintSentinel(allocator, "http://{s}", .{url}, 0); + return resolve(allocator, "", with_scheme, options); + }, + else => return err, + }; +} + const EncodeSet = enum { path, query, query_legacy, userinfo, fragment, component }; pub fn percentEncodeSegment(allocator: Allocator, segment: []const u8, comptime encode_set: EncodeSet) ![]const u8 { From 67cc2be817409ad9bc4317bbba3a57bdbaaa8d38 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Thu, 25 Jun 2026 17:58:08 +0300 Subject: [PATCH 20/40] `URL.zig`: update tests --- src/browser/URL.zig | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/browser/URL.zig b/src/browser/URL.zig index 5dcddcae5..fc78e5a3e 100644 --- a/src/browser/URL.zig +++ b/src/browser/URL.zig @@ -1667,3 +1667,28 @@ test "URL: resolve path scheme" { } } } + +test "URL: resolveNavigation defaults a schemeless host to http (curl-like)" { + defer testing.reset(); + + const Case = struct { + url: [:0]const u8, + expected: [:0]const u8, + }; + + const cases = [_]Case{ + // Schemeless input (the regression): assume http://, like curl. + .{ .url = "lightpanda.io", .expected = "http://lightpanda.io/" }, + .{ .url = "example.com/path?q=1", .expected = "http://example.com/path?q=1" }, + // An explicit scheme is preserved, not double-prefixed. + .{ .url = "https://example.com/x", .expected = "https://example.com/x" }, + .{ .url = "http://example.com/", .expected = "http://example.com/" }, + // Non-http absolute URLs still parse as-is. + .{ .url = "about:blank", .expected = "about:blank" }, + }; + + for (cases) |case| { + const result = try resolveNavigation(testing.arena_allocator, case.url, .{}); + try testing.expectString(case.expected, result); + } +} From f6fb653a35f2cd317ae9939620502e2e3779bbcf Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Thu, 25 Jun 2026 17:58:43 +0300 Subject: [PATCH 21/40] prefer `resolveNavigation` at page, target and fetch --- src/cdp/domains/page.zig | 2 +- src/cdp/domains/target.zig | 2 +- src/lightpanda.zig | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig index 5302862a6..cca462d1c 100644 --- a/src/cdp/domains/page.zig +++ b/src/cdp/domains/page.zig @@ -308,7 +308,7 @@ fn navigate(cmd: *CDP.Command) !void { const session = bc.session; const frame = bc.mainFrame() orelse return error.FrameNotLoaded; - const encoded_url = try URL.resolve(frame.call_arena, "", params.url, .{}); + const encoded_url = try URL.resolveNavigation(frame.call_arena, params.url, .{}); // Fast path: a freshly-created target whose root frame hasn't navigated // yet has nothing to preserve across the HTTP round-trip. Skip the diff --git a/src/cdp/domains/target.zig b/src/cdp/domains/target.zig index 15b524c32..c11ec2da0 100644 --- a/src/cdp/domains/target.zig +++ b/src/cdp/domains/target.zig @@ -222,7 +222,7 @@ fn createTarget(cmd: *CDP.Command) !void { } if (!std.mem.eql(u8, "about:blank", params.url)) { - const encoded_url = try URL.resolve(frame.call_arena, "", params.url, .{}); + const encoded_url = try URL.resolveNavigation(frame.call_arena, params.url, .{}); try frame.navigate( encoded_url, .{ .reason = .address_bar, .kind = .{ .push = null } }, diff --git a/src/lightpanda.zig b/src/lightpanda.zig index 1685fd3bb..50878bcd3 100644 --- a/src/lightpanda.zig +++ b/src/lightpanda.zig @@ -104,7 +104,7 @@ pub fn fetch(app: *App, browser: *Browser, url: [:0]const u8, opts: FetchOpts) ! { const frame = page.frame().?; // not guaranteed to be valid after navigate - const encoded_url = try URL.resolve(frame.call_arena, "", url, .{}); + const encoded_url = try URL.resolveNavigation(frame.call_arena, url, .{}); _ = try frame.navigate(encoded_url, .{ .reason = .address_bar, .kind = .{ .push = null }, From 30f10b496670379eb33e759f38d1db2641da38bf Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Thu, 25 Jun 2026 18:00:51 +0300 Subject: [PATCH 22/40] prefer error-less URL resolver for various getters --- src/browser/webapi/element/html/Button.zig | 2 +- src/browser/webapi/element/html/Embed.zig | 2 +- src/browser/webapi/element/html/Form.zig | 2 +- src/browser/webapi/element/html/IFrame.zig | 4 ++-- src/browser/webapi/element/html/Image.zig | 2 +- src/browser/webapi/element/html/Input.zig | 4 ++-- src/browser/webapi/element/html/Link.zig | 2 +- src/browser/webapi/element/html/Media.zig | 2 +- src/browser/webapi/element/html/Quote.zig | 2 +- src/browser/webapi/element/html/Script.zig | 2 +- src/browser/webapi/element/html/Source.zig | 2 +- src/browser/webapi/element/html/Video.zig | 2 +- 12 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/browser/webapi/element/html/Button.zig b/src/browser/webapi/element/html/Button.zig index d6fcab0c9..bdf4f15fc 100644 --- a/src/browser/webapi/element/html/Button.zig +++ b/src/browser/webapi/element/html/Button.zig @@ -142,7 +142,7 @@ pub fn getFormAction(self: *Button, frame: *Frame) ![]const u8 { if (action.len == 0) { return owner_url; } - return element.asNode().resolveURL(action, frame, .{}); + return element.asNode().resolveURLReflect(action, frame, .{}); } pub fn setFormAction(self: *Button, value: []const u8, frame: *Frame) !void { diff --git a/src/browser/webapi/element/html/Embed.zig b/src/browser/webapi/element/html/Embed.zig index 6c00c7aaa..250524e0f 100644 --- a/src/browser/webapi/element/html/Embed.zig +++ b/src/browser/webapi/element/html/Embed.zig @@ -41,7 +41,7 @@ pub fn getSrc(self: *const Embed, frame: *Frame) ![]const u8 { if (src.len == 0) { return ""; } - return element.asConstNode().resolveURL(src, frame, .{}); + return element.asConstNode().resolveURLReflect(src, frame, .{}); } pub fn setSrc(self: *Embed, value: []const u8, frame: *Frame) !void { diff --git a/src/browser/webapi/element/html/Form.zig b/src/browser/webapi/element/html/Form.zig index c9999b215..da3d49d38 100644 --- a/src/browser/webapi/element/html/Form.zig +++ b/src/browser/webapi/element/html/Form.zig @@ -120,7 +120,7 @@ pub fn getAction(self: *Form, frame: *Frame) ![]const u8 { if (action.len == 0) { return owner_url; } - return element.asNode().resolveURL(action, frame, .{}); + return element.asNode().resolveURLReflect(action, frame, .{}); } pub fn setAction(self: *Form, value: []const u8, frame: *Frame) !void { diff --git a/src/browser/webapi/element/html/IFrame.zig b/src/browser/webapi/element/html/IFrame.zig index 266fcc1a4..69802598f 100644 --- a/src/browser/webapi/element/html/IFrame.zig +++ b/src/browser/webapi/element/html/IFrame.zig @@ -47,9 +47,9 @@ pub fn getContentDocument(self: *const IFrame) ?*Document { return window._document; } -pub fn getSrc(self: *IFrame, frame: *Frame) ![:0]const u8 { +pub fn getSrc(self: *IFrame, frame: *Frame) ![]const u8 { if (self._src.len == 0) return ""; - return self.asNode().resolveURL(self._src, frame, .{}); + return self.asNode().resolveURLReflect(self._src, frame, .{}); } pub fn setSrc(self: *IFrame, src: []const u8, frame: *Frame) !void { diff --git a/src/browser/webapi/element/html/Image.zig b/src/browser/webapi/element/html/Image.zig index e1b4cf56f..dd75052b0 100644 --- a/src/browser/webapi/element/html/Image.zig +++ b/src/browser/webapi/element/html/Image.zig @@ -39,7 +39,7 @@ pub fn getSrc(self: *const Image, frame: *Frame) ![]const u8 { if (src.len == 0) { return ""; } - return element.asConstNode().resolveURL(src, frame, .{}); + return element.asConstNode().resolveURLReflect(src, frame, .{}); } pub fn setSrc(self: *Image, value: []const u8, frame: *Frame) !void { diff --git a/src/browser/webapi/element/html/Input.zig b/src/browser/webapi/element/html/Input.zig index cca413c20..c665daa96 100644 --- a/src/browser/webapi/element/html/Input.zig +++ b/src/browser/webapi/element/html/Input.zig @@ -659,7 +659,7 @@ pub fn setSize(self: *Input, size: i32, frame: *Frame) !void { pub fn getSrc(self: *const Input, frame: *Frame) ![]const u8 { const src = self.asConstElement().getAttributeSafe(comptime .wrap("src")) orelse return ""; - return self.asConstElement().asConstNode().resolveURL(src, frame, .{}); + return self.asConstElement().asConstNode().resolveURLReflect(src, frame, .{}); } pub fn setSrc(self: *Input, src: []const u8, frame: *Frame) !void { @@ -928,7 +928,7 @@ pub fn getFormAction(self: *Input, frame: *Frame) ![]const u8 { if (action.len == 0) { return owner_url; } - return element.asNode().resolveURL(action, frame, .{}); + return element.asNode().resolveURLReflect(action, frame, .{}); } pub fn setFormAction(self: *Input, value: []const u8, frame: *Frame) !void { diff --git a/src/browser/webapi/element/html/Link.zig b/src/browser/webapi/element/html/Link.zig index 601d713e3..493b6a735 100644 --- a/src/browser/webapi/element/html/Link.zig +++ b/src/browser/webapi/element/html/Link.zig @@ -49,7 +49,7 @@ pub fn getHref(self: *Link, frame: *Frame) ![]const u8 { if (href.len == 0) { return ""; } - return element.asNode().resolveURL(href, frame, .{}); + return element.asNode().resolveURLReflect(href, frame, .{}); } pub fn setHref(self: *Link, value: []const u8, frame: *Frame) !void { diff --git a/src/browser/webapi/element/html/Media.zig b/src/browser/webapi/element/html/Media.zig index 623b776f6..808e23742 100644 --- a/src/browser/webapi/element/html/Media.zig +++ b/src/browser/webapi/element/html/Media.zig @@ -236,7 +236,7 @@ pub fn getSrc(self: *const Media, frame: *Frame) ![]const u8 { if (src.len == 0) { return ""; } - return element.asConstNode().resolveURL(src, frame, .{}); + return element.asConstNode().resolveURLReflect(src, frame, .{}); } pub fn setSrc(self: *Media, value: []const u8, frame: *Frame) !void { diff --git a/src/browser/webapi/element/html/Quote.zig b/src/browser/webapi/element/html/Quote.zig index 47befe495..2213099a6 100644 --- a/src/browser/webapi/element/html/Quote.zig +++ b/src/browser/webapi/element/html/Quote.zig @@ -24,7 +24,7 @@ pub fn asNode(self: *Quote) *Node { pub fn getCite(self: *Quote, frame: *Frame) ![]const u8 { const attr = self.asElement().getAttributeSafe(comptime .wrap("cite")) orelse return ""; if (attr.len == 0) return ""; - return self.asNode().resolveURL(attr, frame, .{}); + return self.asNode().resolveURLReflect(attr, frame, .{}); } pub fn setCite(self: *Quote, value: []const u8, frame: *Frame) !void { diff --git a/src/browser/webapi/element/html/Script.zig b/src/browser/webapi/element/html/Script.zig index 41e440947..064b706bd 100644 --- a/src/browser/webapi/element/html/Script.zig +++ b/src/browser/webapi/element/html/Script.zig @@ -46,7 +46,7 @@ pub fn asNode(self: *Script) *Node { pub fn getSrc(self: *Script, frame: *Frame) ![]const u8 { if (self._src.len == 0) return ""; - return self.asNode().resolveURL(self._src, frame, .{}); + return self.asNode().resolveURLReflect(self._src, frame, .{}); } pub fn setSrc(self: *Script, src: []const u8, frame: *Frame) !void { diff --git a/src/browser/webapi/element/html/Source.zig b/src/browser/webapi/element/html/Source.zig index 24c24042a..189aec5a0 100644 --- a/src/browser/webapi/element/html/Source.zig +++ b/src/browser/webapi/element/html/Source.zig @@ -25,7 +25,7 @@ pub fn getSrc(self: *const Source, frame: *Frame) ![]const u8 { if (src.len == 0) { return ""; } - return element.asConstNode().resolveURL(src, frame, .{}); + return element.asConstNode().resolveURLReflect(src, frame, .{}); } pub fn setSrc(self: *Source, value: []const u8, frame: *Frame) !void { diff --git a/src/browser/webapi/element/html/Video.zig b/src/browser/webapi/element/html/Video.zig index 07b3341a3..4ba703a2e 100644 --- a/src/browser/webapi/element/html/Video.zig +++ b/src/browser/webapi/element/html/Video.zig @@ -57,7 +57,7 @@ pub fn getPoster(self: *const Video, frame: *Frame) ![]const u8 { if (poster.len == 0) { return ""; } - return element.asConstNode().resolveURL(poster, frame, .{}); + return element.asConstNode().resolveURLReflect(poster, frame, .{}); } pub fn setPoster(self: *Video, value: []const u8, frame: *Frame) !void { From 004dbf259e21b13d2b9c233df178eb1ca961d3ab Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Thu, 25 Jun 2026 18:02:01 +0300 Subject: [PATCH 23/40] `Anchor`: update tests --- src/browser/tests/element/html/anchor.html | 34 ++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/browser/tests/element/html/anchor.html b/src/browser/tests/element/html/anchor.html index 74bf486c7..ccabf3ee9 100644 --- a/src/browser/tests/element/html/anchor.html +++ b/src/browser/tests/element/html/anchor.html @@ -253,3 +253,37 @@ testing.expectEqual(testing.BASE_URL + 'element/html/over%209000!', a.href); } + + From e6bd65626e876f65a55c57acc25a0c92f30b67ce Mon Sep 17 00:00:00 2001 From: Boyd Ebsworthy <5266759+bebsworthy@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:59:54 +0200 Subject: [PATCH 24/40] cdp: Fix Page.frameAttached params double-wrapping that broke sub-frame interception MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit frameChildFrameCreated emitted Page.frameAttached with its payload wrapped in an extra `.{ .params = ... }`. CDP.sendEvent already places the payload into the event's `params` field, so this produced a malformed wire event with double-nested params (`params.params.frameId`) — unlike every sibling frame event in the same function, which passes its fields flat. CDP clients (e.g. Playwright via connectOverCDP + page.route) parse frameId/parentFrameId at the top level of params. With the fields one level too deep the client never registers the child frame, so when that frame's Fetch.requestPaused arrives it is bare-continued instead of matched against a route. The result: request interception (page.route / Fetch) silently does not apply to iframe (sub-frame) document navigations — the iframe loads from the real network. The interception layer itself was never at fault; it does emit requestPaused for sub-frames. Drop the extra wrapper so the payload is flat. Add a regression test that dispatches frame_child_frame_created and asserts Page.frameAttached carries frameId/parentFrameId directly under params (fails on the double-nested shape). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cdp/domains/page.zig | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig index 155dc2d4d..eae8128eb 100644 --- a/src/cdp/domains/page.zig +++ b/src/cdp/domains/page.zig @@ -585,10 +585,10 @@ pub fn frameChildFrameCreated(bc: *CDP.BrowserContext, event: *const Notificatio const cdp = bc.cdp; const frame_id = &id.toFrameId(event.frame_id); - try cdp.sendEvent("Page.frameAttached", .{ .params = .{ + try cdp.sendEvent("Page.frameAttached", .{ .frameId = frame_id, .parentFrameId = &id.toFrameId(event.parent_id), - } }, .{ .session_id = session_id }); + }, .{ .session_id = session_id }); if (bc.page_life_cycle_events) { try cdp.sendEvent("Page.lifecycleEvent", LifecycleEvent{ @@ -1055,6 +1055,25 @@ test "cdp.frame: getFrameTree" { } } +test "cdp.frame: frameAttached" { + var ctx = try testing.context(); + defer ctx.deinit(); + + const bc = try ctx.loadBrowserContext(.{ .session_id = "SID-X" }); + + bc.notification.dispatch(.frame_child_frame_created, &.{ + .frame_id = 2, + .parent_id = 1, + .loader_id = 7, + .timestamp = 0, + }); + + try ctx.expectSentEvent("Page.frameAttached", .{ + .frameId = "FID-0000000002", + .parentFrameId = "FID-0000000001", + }, .{ .session_id = "SID-X" }); +} + test "cdp.frame: captureScreenshot" { const LogFilter = @import("../../testing.zig").LogFilter; const filter: LogFilter = .init(&.{.not_implemented}); From b94052dfa7725dbc5d8074927c734bf12bb3262a Mon Sep 17 00:00:00 2001 From: Boyd Ebsworthy <5266759+bebsworthy@users.noreply.github.com> Date: Fri, 26 Jun 2026 17:51:54 +0200 Subject: [PATCH 25/40] webapi: Fix cross-origin MessageEvent.source WindowProxy identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MessageEvent.getSource returned the bare *Window, the only window accessor that did not go through Window.Access.init. Every other accessor (iframe.contentWindow, window.parent, window.top) wraps a cross-origin target as *CrossOriginWindow. JS object identity is keyed by Zig pointer (identity_map), so cross-origin event.source (the *Window) and iframe.contentWindow (&window._cross_origin_wrapper) resolved to different JS objects: `event.source === iframe.contentWindow` was false cross-origin while true same-origin. The WHATWG HTML spec requires these to be the same object regardless of origin (one WindowProxy per browsing context; MessageEvent.source is that WindowProxy). The mismatch breaks postMessage handshakes that authenticate the sender by reference identity, e.g. keycloak-js's 3rd-party-cookie check (`if (iframe.contentWindow !== event.source) return;`), which then times out and fails OIDC init. Route getSource through Window.Access.init(frame.window, source) — exactly like IFrame.getContentWindow — so both paths resolve to the same per-frame proxy. Same-origin behaviour is unchanged. Verified against Chrome 149 (identity holds cross- and same-origin). Adds a regression test, cross_origin_message_source.html. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../frames/cross_origin_message_source.html | 49 +++++++++++++++++++ .../tests/frames/support/post_to_parent.html | 4 ++ src/browser/webapi/event/MessageEvent.zig | 6 ++- 3 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 src/browser/tests/frames/cross_origin_message_source.html create mode 100644 src/browser/tests/frames/support/post_to_parent.html diff --git a/src/browser/tests/frames/cross_origin_message_source.html b/src/browser/tests/frames/cross_origin_message_source.html new file mode 100644 index 000000000..87f553bf6 --- /dev/null +++ b/src/browser/tests/frames/cross_origin_message_source.html @@ -0,0 +1,49 @@ + + + + + + diff --git a/src/browser/tests/frames/support/post_to_parent.html b/src/browser/tests/frames/support/post_to_parent.html new file mode 100644 index 000000000..8de6a0c43 --- /dev/null +++ b/src/browser/tests/frames/support/post_to_parent.html @@ -0,0 +1,4 @@ + + diff --git a/src/browser/webapi/event/MessageEvent.zig b/src/browser/webapi/event/MessageEvent.zig index 2bd071472..47188f3b6 100644 --- a/src/browser/webapi/event/MessageEvent.zig +++ b/src/browser/webapi/event/MessageEvent.zig @@ -21,6 +21,7 @@ const lp = @import("lightpanda"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); +const Frame = @import("../../Frame.zig"); const Event = @import("../Event.zig"); const MessagePort = @import("../MessagePort.zig"); @@ -116,8 +117,9 @@ pub fn getOrigin(self: *const MessageEvent) []const u8 { return self._origin; } -pub fn getSource(self: *const MessageEvent) ?*Window { - return self._source; +pub fn getSource(self: *const MessageEvent, frame: *Frame) ?Window.Access { + const source = self._source orelse return null; + return Window.Access.init(frame.window, source); } pub fn getPorts(self: *const MessageEvent) []const *MessagePort { From b78170c71e636d54329922699f7749f4b56cda73 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Sat, 27 Jun 2026 09:05:26 +0800 Subject: [PATCH 26/40] fix: Reject xhr send() when send already active This builds on top of 995efd57e62730728d4c169ee14e4edd4b3ce4a8. That commit tracked the number of requests being made on a single XHR instance (because a new request can be initiated from a load/error callback of an existing one). However, that was unbound. It wasn't just 1 old + 1 new, it was an unlimited number of new requests, because we didn't prevent sending while sending was already active. This adds a boolean to track our send state, and prevents a send from happening when a send is already active. --- src/browser/tests/net/xhr.html | 27 +++++++++++++++++++++++ src/browser/webapi/net/XMLHttpRequest.zig | 6 ++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/browser/tests/net/xhr.html b/src/browser/tests/net/xhr.html index fff72d387..3f45b3715 100644 --- a/src/browser/tests/net/xhr.html +++ b/src/browser/tests/net/xhr.html @@ -481,3 +481,30 @@ testing.expectEqual(false, registered); } + + diff --git a/src/browser/webapi/net/XMLHttpRequest.zig b/src/browser/webapi/net/XMLHttpRequest.zig index 7135c627d..8d676310e 100644 --- a/src/browser/webapi/net/XMLHttpRequest.zig +++ b/src/browser/webapi/net/XMLHttpRequest.zig @@ -52,6 +52,7 @@ _http_response: ?HttpClient.Response = null, // number of inflight requests, we can have multiple, e.g. xhr calling its own // send from the onload callback _active_requests: u8 = 0, +_send_flag: bool = false, _url: [:0]const u8 = "", _method: http.Method = .GET, @@ -185,6 +186,7 @@ pub fn open(self: *XMLHttpRequest, method_: []const u8, url: [:0]const u8) !void transfer.abort(error.Abort); self._http_response = null; } + self._send_flag = false; // Reset internal state. _override_mime intentionally survives open() // per https://xhr.spec.whatwg.org/#the-overridemimetype()-method. @@ -224,7 +226,7 @@ pub fn send(self: *XMLHttpRequest, body_: ?BodyInit, exec_: *const Execution) !v if (comptime IS_DEBUG) { log.debug(.http, "XMLHttpRequest.send", .{ .url = self._url }); } - if (self._ready_state != .opened) { + if (self._ready_state != .opened or self._send_flag) { return error.InvalidStateError; } @@ -259,6 +261,7 @@ pub fn send(self: *XMLHttpRequest, body_: ?BodyInit, exec_: *const Execution) !v self.acquireRef(); self._active_requests += 1; + self._send_flag = true; exec.makeRequest(.{ .ctx = self, @@ -282,6 +285,7 @@ pub fn send(self: *XMLHttpRequest, body_: ?BodyInit, exec_: *const Execution) !v .body_outlives_request = true, }) catch |err| { self.releaseSelfRef(); + self._send_flag = false; return err; }; } From 0aaa77c7e6ec87eb217cb3208a7d61e70ec41f0b Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Sat, 27 Jun 2026 18:16:31 +0800 Subject: [PATCH 27/40] minor: remove unused imports --- src/browser/Factory.zig | 1 - src/browser/Frame.zig | 3 --- src/browser/Runner.zig | 3 --- src/browser/Session.zig | 1 - src/browser/webapi/Node.zig | 1 - src/browser/webapi/crypto/EC.zig | 1 - src/cdp/Connection.zig | 2 -- src/lightpanda.zig | 2 -- src/network/layer/CacheLayer.zig | 1 - src/network/layer/DeferringLayer.zig | 2 -- src/sys/url.zig | 3 --- 11 files changed, 20 deletions(-) diff --git a/src/browser/Factory.zig b/src/browser/Factory.zig index 6f74483ce..248ededab 100644 --- a/src/browser/Factory.zig +++ b/src/browser/Factory.zig @@ -32,7 +32,6 @@ const MouseEvent = @import("webapi/event/MouseEvent.zig"); const Element = @import("webapi/Element.zig"); const Document = @import("webapi/Document.zig"); const EventTarget = @import("webapi/EventTarget.zig"); -const WorkerGlobalScope = @import("webapi/WorkerGlobalScope.zig"); const XMLHttpRequestEventTarget = @import("webapi/net/XMLHttpRequestEventTarget.zig"); const Blob = @import("webapi/Blob.zig"); const AbstractRange = @import("webapi/AbstractRange.zig"); diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index a97db6bd7..73e3c48a1 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -40,7 +40,6 @@ const FileList = @import("webapi/FileList.zig"); const Node = @import("webapi/Node.zig"); const Event = @import("webapi/Event.zig"); const EventTarget = @import("webapi/EventTarget.zig"); -const Text = @import("webapi/cdata/Text.zig"); const Element = @import("webapi/Element.zig"); const HtmlElement = @import("webapi/element/Html.zig"); const Window = @import("webapi/Window.zig"); @@ -51,8 +50,6 @@ const Performance = @import("webapi/Performance.zig"); const Screen = @import("webapi/Screen.zig"); const VisualViewport = @import("webapi/VisualViewport.zig"); const AbstractRange = @import("webapi/AbstractRange.zig"); -const MutationObserver = @import("webapi/MutationObserver.zig"); -const IntersectionObserver = @import("webapi/IntersectionObserver.zig"); const Worker = @import("webapi/Worker.zig"); const CSSStyleSheet = @import("webapi/css/CSSStyleSheet.zig"); const CustomElementDefinition = @import("webapi/CustomElementDefinition.zig"); diff --git a/src/browser/Runner.zig b/src/browser/Runner.zig index 9c260a469..d07c1b31f 100644 --- a/src/browser/Runner.zig +++ b/src/browser/Runner.zig @@ -18,10 +18,8 @@ const std = @import("std"); const lp = @import("lightpanda"); -const builtin = @import("builtin"); const js = @import("js/js.zig"); -const Frame = @import("Frame.zig"); const Browser = @import("Browser.zig"); const Session = @import("Session.zig"); const HttpClient = @import("HttpClient.zig"); @@ -30,7 +28,6 @@ const Node = @import("webapi/Node.zig"); const Selector = @import("webapi/selector/Selector.zig"); const log = lp.log; -const IS_DEBUG = builtin.mode == .Debug; const Runner = @This(); diff --git a/src/browser/Session.zig b/src/browser/Session.zig index 4cab1caaf..f1af69b3a 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -22,7 +22,6 @@ const builtin = @import("builtin"); const App = @import("../App.zig"); -const js = @import("js/js.zig"); const storage = @import("webapi/storage/storage.zig"); const Navigation = @import("webapi/navigation/Navigation.zig"); const History = @import("webapi/History.zig"); diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig index af1e4395c..37d90ffed 100644 --- a/src/browser/webapi/Node.zig +++ b/src/browser/webapi/Node.zig @@ -35,7 +35,6 @@ pub const DocumentFragment = @import("DocumentFragment.zig"); pub const DocumentType = @import("DocumentType.zig"); pub const ShadowRoot = @import("ShadowRoot.zig"); -const log = lp.log; const String = lp.String; const Allocator = std.mem.Allocator; const LinkedList = std.DoublyLinkedList; diff --git a/src/browser/webapi/crypto/EC.zig b/src/browser/webapi/crypto/EC.zig index 236b2efd9..afa38259b 100644 --- a/src/browser/webapi/crypto/EC.zig +++ b/src/browser/webapi/crypto/EC.zig @@ -20,7 +20,6 @@ //! agreement. Key bytes live as a libcrypto EVP_PKEY in `CryptoKey._vary.pkey`. const std = @import("std"); -const lp = @import("lightpanda"); const crypto = @import("../../../sys/libcrypto.zig"); const js = @import("../../js/js.zig"); diff --git a/src/cdp/Connection.zig b/src/cdp/Connection.zig index 69b984463..7163384ac 100644 --- a/src/cdp/Connection.zig +++ b/src/cdp/Connection.zig @@ -24,13 +24,11 @@ const CDP = @import("CDP.zig"); const App = @import("../App.zig"); const Inbox = @import("../Inbox.zig"); -const Config = @import("../Config.zig"); const WS = @import("../network/WS.zig"); const ArenaPool = @import("../ArenaPool.zig"); const log = lp.log; const posix = std.posix; -const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; pub const Connection = @This(); diff --git a/src/lightpanda.zig b/src/lightpanda.zig index 3179e87be..cd42bcfc1 100644 --- a/src/lightpanda.zig +++ b/src/lightpanda.zig @@ -55,8 +55,6 @@ pub const cookies = @import("cookies.zig"); pub const build_config = @import("build_config"); pub const crash_handler = @import("crash_handler.zig"); -const IS_DEBUG = @import("builtin").mode == .Debug; - pub const FetchOpts = struct { wait_ms: u32 = 5000, wait_until: ?Config.WaitUntil = null, diff --git a/src/network/layer/CacheLayer.zig b/src/network/layer/CacheLayer.zig index b12bd793a..a65a3570e 100644 --- a/src/network/layer/CacheLayer.zig +++ b/src/network/layer/CacheLayer.zig @@ -20,7 +20,6 @@ const std = @import("std"); const lp = @import("lightpanda"); const Layer = @import("../../browser/HttpClient.zig").Layer; -const Request = @import("../../browser/HttpClient.zig").Request; const Transfer = @import("../../browser/HttpClient.zig").Transfer; const Response = @import("../../browser/HttpClient.zig").Response; diff --git a/src/network/layer/DeferringLayer.zig b/src/network/layer/DeferringLayer.zig index beee867a5..0cc4c4717 100644 --- a/src/network/layer/DeferringLayer.zig +++ b/src/network/layer/DeferringLayer.zig @@ -20,10 +20,8 @@ const std = @import("std"); const lp = @import("lightpanda"); const log = lp.log; -const Client = @import("../../browser/HttpClient.zig").Client; const Network = @import("../Network.zig"); const Transfer = @import("../../browser/HttpClient.zig").Transfer; -const Request = @import("../../browser/HttpClient.zig").Request; const Response = @import("../../browser/HttpClient.zig").Response; const Layer = @import("../../browser/HttpClient.zig").Layer; const StableResponse = @import("../../browser/HttpClient.zig").StableResponse; diff --git a/src/sys/url.zig b/src/sys/url.zig index dd0d46928..c5bdeb613 100644 --- a/src/sys/url.zig +++ b/src/sys/url.zig @@ -16,9 +16,6 @@ //! Bindings for Servo's rust-url (https://github.com/servo/rust-url). //! Check @src/html5ever/url.rs for Rust-side of the bindings. -const std = @import("std"); -const mem = std.mem; - pub const Url = anyopaque; pub const OwnedString = extern struct { From b054b26e5687a1a54405e7c3b569d5c3770c750d Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Sat, 27 Jun 2026 19:06:47 +0800 Subject: [PATCH 28/40] dep: update libcurl and nghttp2 Nothing major, but the feature that caught my eye was the addition of a threadpool for DNS resolution, rather than a thread-per-resolution (1). I've enabled it. (1) https://github.com/curl/curl/commit/39036c90216e059bbee64b86b198eae3135e0cda --- build.zig | 110 +++++++++++++++++++++++--------------------------- build.zig.zon | 8 ++-- 2 files changed, 54 insertions(+), 64 deletions(-) diff --git a/build.zig b/build.zig index eb165690c..c0dd98445 100644 --- a/build.zig +++ b/build.zig @@ -511,6 +511,7 @@ fn buildCurl( .USE_OPENSSL = true, .OPENSSL_IS_BORINGSSL = true, + .CURL_BORINGSSL_VERSION = null, .CURL_CA_PATH = null, .CURL_CA_BUNDLE = null, .CURL_CA_FALLBACK = false, @@ -607,6 +608,7 @@ fn buildCurl( // general environment .CURL_KRB5_VERSION = null, + .CURL_PATCHSTAMP = null, .HAVE_ALARM = !is_windows, .HAVE_ARC4RANDOM = is_android, .HAVE_ATOMIC = true, @@ -626,6 +628,7 @@ fn buildCurl( .HAVE_MEMRCHR = !is_darwin and !is_windows, .HAVE_POSIX_STRERROR_R = !is_gnu and !is_windows, .HAVE_PTHREAD_H = !is_windows, + .HAVE_THREADS_POSIX = !is_windows, .HAVE_SETLOCALE = true, .HAVE_SETRLIMIT = !is_windows, .HAVE_SIGACTION = !is_windows, @@ -647,6 +650,7 @@ fn buildCurl( .HAVE_WRITABLE_ARGV = !is_windows, .HAVE__SETMODE = is_windows, .USE_THREADS_POSIX = !is_windows, + .USE_RESOLV_THREADED = !is_windows, // filesystem, network .HAVE_ACCEPT4 = is_linux or is_freebsd or is_netbsd or is_openbsd, @@ -734,66 +738,52 @@ fn buildCurl( }, .files = &.{ // You can include all files from lib, libcurl uses #ifdef-guards to exclude code for disabled functions - "altsvc.c", "amigaos.c", "asyn-ares.c", - "asyn-base.c", "asyn-thrdd.c", "bufq.c", - "bufref.c", "cf-h1-proxy.c", "cf-h2-proxy.c", - "cf-haproxy.c", "cf-https-connect.c", "cf-ip-happy.c", - "cf-socket.c", "cfilters.c", "conncache.c", - "connect.c", "content_encoding.c", "cookie.c", - "cshutdn.c", "curl_addrinfo.c", "curl_endian.c", - "curl_fnmatch.c", "curl_fopen.c", "curl_get_line.c", - "curl_gethostname.c", "curl_gssapi.c", "curl_memrchr.c", - "curl_ntlm_core.c", "curl_range.c", "curl_rtmp.c", - "curl_sasl.c", "curl_sha512_256.c", "curl_share.c", - "curl_sspi.c", "curl_threads.c", "curl_trc.c", - "curlx/base64.c", "curlx/dynbuf.c", "curlx/fopen.c", - "curlx/inet_ntop.c", "curlx/inet_pton.c", "curlx/multibyte.c", - "curlx/nonblock.c", "curlx/strcopy.c", "curlx/strerr.c", - "curlx/strparse.c", "curlx/timediff.c", "curlx/timeval.c", - "curlx/version_win32.c", "curlx/wait.c", "curlx/warnless.c", - "curlx/winapi.c", "cw-out.c", "cw-pause.c", - "dict.c", "dllmain.c", "doh.c", - "dynhds.c", "easy.c", "easygetopt.c", - "easyoptions.c", "escape.c", "fake_addrinfo.c", - "file.c", "fileinfo.c", "formdata.c", - "ftp.c", "ftplistparser.c", "getenv.c", - "getinfo.c", "gopher.c", "hash.c", - "headers.c", "hmac.c", "hostip.c", - "hostip4.c", "hostip6.c", "hsts.c", - "http.c", "http1.c", "http2.c", - "http_aws_sigv4.c", "http_chunks.c", "http_digest.c", - "http_negotiate.c", "http_ntlm.c", "http_proxy.c", - "httpsrr.c", "idn.c", "if2ip.c", - "imap.c", "ldap.c", "llist.c", - "macos.c", "md4.c", "md5.c", - "memdebug.c", "mime.c", "mprintf.c", - "mqtt.c", "multi.c", "multi_ev.c", - "multi_ntfy.c", "netrc.c", "noproxy.c", - "openldap.c", "parsedate.c", "pingpong.c", - "pop3.c", "progress.c", "psl.c", - "rand.c", "ratelimit.c", "request.c", - "rtsp.c", "select.c", "sendf.c", - "setopt.c", "sha256.c", "slist.c", - "smb.c", "smtp.c", "socketpair.c", - "socks.c", "socks_gssapi.c", "socks_sspi.c", - "splay.c", "strcase.c", "strdup.c", - "strequal.c", "strerror.c", "system_win32.c", - "telnet.c", "tftp.c", "transfer.c", - "uint-bset.c", "uint-hash.c", "uint-spbset.c", - "uint-table.c", "url.c", "urlapi.c", - "vauth/cleartext.c", "vauth/cram.c", "vauth/digest.c", - "vauth/digest_sspi.c", "vauth/gsasl.c", "vauth/krb5_gssapi.c", - "vauth/krb5_sspi.c", "vauth/ntlm.c", "vauth/ntlm_sspi.c", - "vauth/oauth2.c", "vauth/spnego_gssapi.c", "vauth/spnego_sspi.c", - "vauth/vauth.c", "version.c", "vquic/curl_ngtcp2.c", - "vquic/curl_osslq.c", "vquic/curl_quiche.c", "vquic/vquic-tls.c", - "vquic/vquic.c", "vssh/libssh.c", "vssh/libssh2.c", - "vssh/vssh.c", "vtls/apple.c", "vtls/cipher_suite.c", - "vtls/gtls.c", "vtls/hostcheck.c", "vtls/keylog.c", - "vtls/mbedtls.c", "vtls/openssl.c", "vtls/rustls.c", - "vtls/schannel.c", "vtls/schannel_verify.c", "vtls/vtls.c", - "vtls/vtls_scache.c", "vtls/vtls_spack.c", "vtls/wolfssl.c", - "vtls/x509asn1.c", "ws.c", + "cf-dns.c", "dnscache.c", "protocol.c", "curlx/strdup.c", + "thrdpool.c", "thrdqueue.c", "altsvc.c", "amigaos.c", + "asyn-ares.c", "asyn-base.c", "asyn-thrdd.c", "bufq.c", + "bufref.c", "cf-h1-proxy.c", "cf-h2-proxy.c", "cf-haproxy.c", + "cf-https-connect.c", "cf-ip-happy.c", "cf-socket.c", "cfilters.c", + "conncache.c", "connect.c", "content_encoding.c", "cookie.c", + "cshutdn.c", "curl_addrinfo.c", "curl_endian.c", "curl_fnmatch.c", + "curl_fopen.c", "curl_get_line.c", "curl_gethostname.c", "curl_gssapi.c", + "curl_memrchr.c", "curl_ntlm_core.c", "curl_range.c", "curl_sasl.c", + "curl_sha512_256.c", "curl_share.c", "curl_sspi.c", "curl_threads.c", + "curl_trc.c", "curlx/base64.c", "curlx/dynbuf.c", "curlx/fopen.c", + "curlx/inet_ntop.c", "curlx/inet_pton.c", "curlx/multibyte.c", "curlx/nonblock.c", + "curlx/strcopy.c", "curlx/strerr.c", "curlx/strparse.c", "curlx/timediff.c", + "curlx/timeval.c", "curlx/version_win32.c", "curlx/wait.c", "curlx/warnless.c", + "curlx/winapi.c", "cw-out.c", "cw-pause.c", "dict.c", + "dllmain.c", "doh.c", "dynhds.c", "easy.c", + "easygetopt.c", "easyoptions.c", "escape.c", "fake_addrinfo.c", + "file.c", "fileinfo.c", "formdata.c", "ftp.c", + "ftplistparser.c", "getenv.c", "getinfo.c", "gopher.c", + "hash.c", "headers.c", "hmac.c", "hostip.c", + "hostip4.c", "hostip6.c", "hsts.c", "http.c", + "http1.c", "http2.c", "http_aws_sigv4.c", "http_chunks.c", + "http_digest.c", "http_negotiate.c", "http_ntlm.c", "http_proxy.c", + "httpsrr.c", "idn.c", "if2ip.c", "imap.c", + "ldap.c", "llist.c", "macos.c", "md4.c", + "md5.c", "memdebug.c", "mime.c", "mprintf.c", + "mqtt.c", "multi.c", "multi_ev.c", "multi_ntfy.c", + "netrc.c", "noproxy.c", "openldap.c", "parsedate.c", + "pingpong.c", "pop3.c", "progress.c", "psl.c", + "rand.c", "ratelimit.c", "request.c", "rtsp.c", + "select.c", "sendf.c", "setopt.c", "sha256.c", + "slist.c", "smb.c", "smtp.c", "socketpair.c", + "socks.c", "socks_gssapi.c", "socks_sspi.c", "splay.c", + "strcase.c", "strequal.c", "strerror.c", "system_win32.c", + "telnet.c", "tftp.c", "transfer.c", "uint-bset.c", + "uint-hash.c", "uint-spbset.c", "uint-table.c", "url.c", + "urlapi.c", "vauth/cleartext.c", "vauth/cram.c", "vauth/digest.c", + "vauth/digest_sspi.c", "vauth/gsasl.c", "vauth/krb5_gssapi.c", "vauth/krb5_sspi.c", + "vauth/ntlm.c", "vauth/ntlm_sspi.c", "vauth/oauth2.c", "vauth/spnego_gssapi.c", + "vauth/spnego_sspi.c", "vauth/vauth.c", "version.c", "vquic/curl_ngtcp2.c", + "vquic/curl_quiche.c", "vquic/vquic-tls.c", "vquic/vquic.c", "vssh/libssh.c", + "vssh/libssh2.c", "vssh/vssh.c", "vtls/apple.c", "vtls/cipher_suite.c", + "vtls/gtls.c", "vtls/hostcheck.c", "vtls/keylog.c", "vtls/mbedtls.c", + "vtls/openssl.c", "vtls/rustls.c", "vtls/schannel.c", "vtls/schannel_verify.c", + "vtls/vtls.c", "vtls/vtls_scache.c", "vtls/vtls_spack.c", "vtls/wolfssl.c", + "vtls/x509asn1.c", "ws.c", }, }); diff --git a/build.zig.zon b/build.zig.zon index b69d1ef42..7c83f22e2 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -19,16 +19,16 @@ .hash = "N-V-__8AAJ2cNgAgfBtAw33Bxfu1IWISDeKKSr3DAqoAysIJ", }, .nghttp2 = .{ - .url = "https://github.com/nghttp2/nghttp2/releases/download/v1.68.0/nghttp2-1.68.0.tar.gz", - .hash = "N-V-__8AAL15vQCI63ZL6Zaz5hJg6JTEgYXGbLnMFSnf7FT3", + .url = "https://github.com/nghttp2/nghttp2/releases/download/v1.69.0/nghttp2-1.69.0.tar.gz", + .hash = "N-V-__8AAMHpvwChC6orFCDB8MeiumT6OlfmKfmrWlak7Int", }, .@"boringssl-zig" = .{ .url = "git+https://github.com/Syndica/boringssl-zig.git#c53df00d06b02b755ad88bbf4d1202ed9687b096", .hash = "boringssl-0.1.0-VtJeWehMAAA4RNnwRnzEvKcS9rjsR1QVRw1uJrwXxmVK", }, .curl = .{ - .url = "https://github.com/curl/curl/releases/download/curl-8_18_0/curl-8.18.0.tar.gz", - .hash = "N-V-__8AALp9QAGn6CCHZ6fK_FfMyGtG824LSHYHHasM3w-y", + .url = "https://github.com/curl/curl/releases/download/curl-8_20_0/curl-8.20.0.tar.gz", + .hash = "N-V-__8AAHM9RQHjzJo8Wn4dbGPqNOC21zZJJA9PcCj7FPF5", }, .sqlite3 = .{ .url = "https://github.com/allyourcodebase/sqlite3/archive/8f840560eae88ab66668c6827c64ffbd0d74ef37.tar.gz", From 0373b9ce3da296a64979b0472ae7cd406055d975 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Sat, 27 Jun 2026 23:56:33 +0300 Subject: [PATCH 29/40] Update src/browser/webapi/Node.zig Co-authored-by: Karl Seguin --- src/browser/webapi/Node.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig index 2ee3aa7c5..853a5bf0b 100644 --- a/src/browser/webapi/Node.zig +++ b/src/browser/webapi/Node.zig @@ -587,7 +587,7 @@ pub fn resolveURL(self: *const Node, url: anytype, frame: *Frame, opts: ResolveU } // Same as `resolveURL` but can't return `TypeError`, this is needed for multiple -// getters throught codebase. Returns back passed `url` for `TypeError`s. +// getters throughout codebase. Returns provided `url` on `TypeError`. pub fn resolveURLReflect(self: *const Node, url: []const u8, frame: *Frame, opts: ResolveURLOpts) ![]const u8 { return self.resolveURL(url, frame, opts) catch |err| switch (err) { error.TypeError => url, From d550a893301ef24f5eb74446d4ad440307a7b050 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Sun, 28 Jun 2026 08:03:02 +0800 Subject: [PATCH 30/40] v8, dep: Update v8 This update of zig-v8-fork is an update of v8 itself. --- build.zig.zon | 2 +- src/browser/js/Caller.zig | 23 ++++++---------- src/browser/js/Env.zig | 3 +-- src/browser/js/Module.zig | 4 +-- src/browser/js/Snapshot.zig | 1 + src/browser/js/bridge.zig | 54 ++++++++++++++++++------------------- 6 files changed, 38 insertions(+), 49 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index b69d1ef42..e9d41446a 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -5,7 +5,7 @@ .minimum_zig_version = "0.15.2", .dependencies = .{ .v8 = .{ - .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.4.8.tar.gz", + .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/3a53a258e508ea6a805654a94339b9fd006e7b2d.tar.gz", .hash = "v8-0.0.0-xddH65qdBAA1hmB-gqEmhEzO0-aXKyEsmo1s34mssemb", }, // .v8 = .{ .path = "../zig-v8-fork" }, diff --git a/src/browser/js/Caller.zig b/src/browser/js/Caller.zig index 48d24a780..8b2d782cc 100644 --- a/src/browser/js/Caller.zig +++ b/src/browser/js/Caller.zig @@ -183,8 +183,7 @@ pub fn getIndex(self: *Caller, comptime T: type, func: anytype, idx: u32, handle const info = PropertyCallbackInfo{ .handle = handle }; return _getIndex(T, local, func, idx, info, opts) catch |err| { handleError(T, @TypeOf(func), local, err, info, opts); - // not intercepted - return 0; + return 1; }; } @@ -210,8 +209,7 @@ pub fn getNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *cons const info = PropertyCallbackInfo{ .handle = handle }; return _getNamedIndex(T, local, func, name, info, opts) catch |err| { handleError(T, @TypeOf(func), local, err, info, opts); - // not intercepted - return 0; + return 1; }; } @@ -237,8 +235,7 @@ pub fn setNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *cons const info = PropertyCallbackInfo{ .handle = handle }; return _setNamedIndex(T, local, func, name, .{ .local = &self.local, .handle = js_value }, info, opts) catch |err| { handleError(T, @TypeOf(func), local, err, info, opts); - // not intercepted - return 0; + return 1; }; } @@ -265,7 +262,7 @@ pub fn deleteNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *c const info = PropertyCallbackInfo{ .handle = handle }; return _deleteNamedIndex(T, local, func, name, info, opts) catch |err| { handleError(T, @TypeOf(func), local, err, info, opts); - return 0; + return 1; }; } @@ -291,8 +288,7 @@ pub fn getEnumerator(self: *Caller, comptime T: type, func: anytype, handle: *co const info = PropertyCallbackInfo{ .handle = handle }; return _getEnumerator(T, local, func, info, opts) catch |err| { handleError(T, @TypeOf(func), local, err, info, opts); - // not intercepted - return 0; + return 1; }; } @@ -318,13 +314,11 @@ fn handleIndexedReturn(comptime T: type, comptime F: type, comptime with_value: // if error.NotHandled is part of the error set. if (isInErrorSet(error.NotHandled, eu.error_set)) { if (err == error.NotHandled) { - // not intercepted - return 0; + return 1; } } handleError(T, F, local, err, info, opts); - // not intercepted - return 0; + return 1; }; }, else => ret, @@ -333,8 +327,7 @@ fn handleIndexedReturn(comptime T: type, comptime F: type, comptime with_value: if (comptime with_value) { info.getReturnValue().set(try local.zigValueToJs(non_error_ret, opts)); } - // intercepted - return 1; + return 0; } fn isInErrorSet(err: anyerror, comptime T: type) bool { diff --git a/src/browser/js/Env.zig b/src/browser/js/Env.zig index 4ba3d3dd3..3a37e8d4a 100644 --- a/src/browser/js/Env.zig +++ b/src/browser/js/Env.zig @@ -581,8 +581,7 @@ fn promiseRejectCallback(message_handle: v8.PromiseRejectMessage) callconv(.c) v return; } - const promise_handle = v8.v8__PromiseRejectMessage__GetPromise(&message_handle).?; - const v8_isolate = v8.v8__Object__GetIsolate(@ptrCast(promise_handle)).?; + const v8_isolate = v8.v8__Isolate__GetCurrent().?; const isolate = js.Isolate{ .handle = v8_isolate }; const ctx, const v8_context = Context.fromIsolate(isolate) orelse return; diff --git a/src/browser/js/Module.zig b/src/browser/js/Module.zig index 1e290d2d6..2a331ccbf 100644 --- a/src/browser/js/Module.zig +++ b/src/browser/js/Module.zig @@ -46,7 +46,6 @@ pub fn getException(self: Module) js.Value { pub fn getModuleRequests(self: Module) Requests { return .{ - .context_handle = self.local.handle, .handle = v8.v8__Module__GetModuleRequests(self.handle).?, }; } @@ -117,14 +116,13 @@ pub const Global = struct { const Requests = struct { handle: *const v8.FixedArray, - context_handle: *const v8.Context, pub fn len(self: Requests) usize { return @intCast(v8.v8__FixedArray__Length(self.handle)); } pub fn get(self: Requests, idx: usize) Request { - return .{ .handle = v8.v8__FixedArray__Get(self.handle, self.context_handle, @intCast(idx)).? }; + return .{ .handle = v8.v8__FixedArray__Get(self.handle, @intCast(idx)).? }; } }; diff --git a/src/browser/js/Snapshot.zig b/src/browser/js/Snapshot.zig index d0d606bbf..a1cfd9d2e 100644 --- a/src/browser/js/Snapshot.zig +++ b/src/browser/js/Snapshot.zig @@ -798,6 +798,7 @@ fn attachClass(comptime JsApi: type, comptime flatten: bool, isolate: *v8.Isolat .deleter = null, .definer = null, .descriptor = null, + .index_of = null, .data = null, .flags = 0, }; diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 0afc1c85e..43db4ff34 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -275,7 +275,7 @@ pub const Indexed = struct { const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?; var caller: Caller = undefined; if (!caller.init(v8_isolate)) { - return 0; + return 1; } defer caller.deinit(); @@ -293,7 +293,7 @@ pub const Indexed = struct { const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?; var caller: Caller = undefined; if (!caller.init(v8_isolate)) { - return 0; + return 1; } defer caller.deinit(); return caller.getEnumerator(T, enumerator, handle.?, .{}); @@ -325,7 +325,7 @@ pub const NamedIndexed = struct { const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?; var caller: Caller = undefined; if (!caller.init(v8_isolate)) { - return 0; + return 1; } defer caller.deinit(); @@ -341,7 +341,7 @@ pub const NamedIndexed = struct { const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?; var caller: Caller = undefined; if (!caller.init(v8_isolate)) { - return 0; + return 1; } defer caller.deinit(); @@ -369,7 +369,7 @@ pub const NamedIndexed = struct { const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?; var caller: Caller = undefined; if (!caller.init(v8_isolate)) { - return 0; + return 1; } defer caller.deinit(); @@ -486,8 +486,8 @@ pub fn unknownWindowPropertyCallback(c_name: ?*const v8.Name, handle: ?*const v8 // During snapshot creation, there's no Context in embedder data yet. // I hate this check, but there doesn't seem to be a way to add this method // to the global, without triggering it during snapshot creation. - const v8_context = v8.v8__Isolate__GetCurrentContext(v8_isolate) orelse return 0; - const ctx: *Context = @ptrCast(@alignCast(v8.v8__Context__GetAlignedPointerFromEmbedderData(v8_context, 1) orelse return 0)); + const v8_context = v8.v8__Isolate__GetCurrentContext(v8_isolate) orelse return 1; + const ctx: *Context = @ptrCast(@alignCast(v8.v8__Context__GetAlignedPointerFromEmbedderData(v8_context, 1) orelse return 1)); var caller: Caller = undefined; caller.initWithContext(ctx, v8_context); @@ -500,7 +500,7 @@ pub fn unknownWindowPropertyCallback(c_name: ?*const v8.Name, handle: ?*const v8 defer hs.deinit(); const property: []const u8 = js.String.toSlice(.{ .local = local, .handle = @ptrCast(c_name.?) }) catch { - return 0; + return 1; }; // Only Page contexts have document.getElementById lookup @@ -508,10 +508,10 @@ pub fn unknownWindowPropertyCallback(c_name: ?*const v8.Name, handle: ?*const v8 .frame => |frame| { const document = frame.document; if (document.getElementById(property, frame)) |el| { - const js_val = local.zigValueToJs(el, .{}) catch return 0; + const js_val = local.zigValueToJs(el, .{}) catch return 1; var pc = Caller.PropertyCallbackInfo{ .handle = handle.? }; pc.getReturnValue().set(js_val); - return 1; + return 0; } }, .worker => {}, // no global lookup in a worker @@ -521,7 +521,7 @@ pub fn unknownWindowPropertyCallback(c_name: ?*const v8.Name, handle: ?*const v8 if (std.mem.startsWith(u8, property, "__")) { // some frameworks will extend built-in types using a __ prefix // these should always be safe to ignore. - return 0; + return 1; } const ignored = std.StaticStringMap(void).initComptime(.{ @@ -553,13 +553,12 @@ pub fn unknownWindowPropertyCallback(c_name: ?*const v8.Name, handle: ?*const v8 }); if (!ignored.has(property)) { var buf: [2048]u8 = undefined; - const key = std.fmt.bufPrint(&buf, "Window:{s}", .{property}) catch return 0; - logUnknownProperty(local, key) catch return 0; + const key = std.fmt.bufPrint(&buf, "Window:{s}", .{property}) catch return 1; + logUnknownProperty(local, key) catch return 1; } } - // not intercepted - return 0; + return 1; } // Only used for debugging @@ -574,7 +573,7 @@ pub fn unknownObjectPropertyCallback(comptime JsApi: type) *const fn (?*const v8 var caller: Caller = undefined; if (!caller.init(v8_isolate)) { - return 0; + return 1; } defer caller.deinit(); @@ -585,45 +584,44 @@ pub fn unknownObjectPropertyCallback(comptime JsApi: type) *const fn (?*const v8 defer hs.deinit(); const property: []const u8 = js.String.toSlice(.{ .local = local, .handle = @ptrCast(c_name.?) }) catch { - return 0; + return 1; }; if (std.mem.startsWith(u8, property, "__")) { // some frameworks will extend built-in types using a __ prefix // these should always be safe to ignore. - return 0; + return 1; } if (std.mem.startsWith(u8, property, "jQuery")) { - return 0; + return 1; } if (JsApi == @import("../webapi/cdata/Text.zig").JsApi or JsApi == @import("../webapi/cdata/Comment.zig").JsApi) { if (std.mem.eql(u8, property, "tagName")) { // knockout does this, a lot. - return 0; + return 1; } } if (JsApi == @import("../webapi/element/Html.zig").JsApi or JsApi == @import("../webapi/Element.zig").JsApi or JsApi == @import("../webapi/element/html/Custom.zig").JsApi) { // react ? - if (std.mem.eql(u8, property, "props")) return 0; - if (std.mem.eql(u8, property, "hydrated")) return 0; - if (std.mem.eql(u8, property, "isHydrated")) return 0; + if (std.mem.eql(u8, property, "props")) return 1; + if (std.mem.eql(u8, property, "hydrated")) return 1; + if (std.mem.eql(u8, property, "isHydrated")) return 1; } if (JsApi == @import("../webapi/Console.zig").JsApi) { - if (std.mem.eql(u8, property, "firebug")) return 0; + if (std.mem.eql(u8, property, "firebug")) return 1; } const ignored = std.StaticStringMap(void).initComptime(.{}); if (!ignored.has(property)) { var buf: [2048]u8 = undefined; - const key = std.fmt.bufPrint(&buf, "{s}:{s}", .{ if (@hasDecl(JsApi.Meta, "name")) JsApi.Meta.name else @typeName(JsApi), property }) catch return 0; - logUnknownProperty(local, key) catch return 0; + const key = std.fmt.bufPrint(&buf, "{s}:{s}", .{ if (@hasDecl(JsApi.Meta, "name")) JsApi.Meta.name else @typeName(JsApi), property }) catch return 1; + logUnknownProperty(local, key) catch return 1; } - // not intercepted - return 0; + return 1; } }.wrap; } From 8350076972a836a38a0860831e44aab10a248f49 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Sun, 28 Jun 2026 14:18:12 +0800 Subject: [PATCH 31/40] update intercepted type from u8 to u32 --- build.zig.zon | 2 +- src/browser/js/Caller.zig | 38 +++++++++---------- src/browser/js/bridge.zig | 78 +++++++++++++++++++-------------------- src/browser/js/js.zig | 5 +++ 4 files changed, 64 insertions(+), 59 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index e9d41446a..90b0a1be6 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -5,7 +5,7 @@ .minimum_zig_version = "0.15.2", .dependencies = .{ .v8 = .{ - .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/3a53a258e508ea6a805654a94339b9fd006e7b2d.tar.gz", + .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/8bb45873631bf2c419a7eeb452f5f65fcb18c609.tar.gz", .hash = "v8-0.0.0-xddH65qdBAA1hmB-gqEmhEzO0-aXKyEsmo1s34mssemb", }, // .v8 = .{ .path = "../zig-v8-fork" }, diff --git a/src/browser/js/Caller.zig b/src/browser/js/Caller.zig index 8b2d782cc..55daf7c32 100644 --- a/src/browser/js/Caller.zig +++ b/src/browser/js/Caller.zig @@ -173,7 +173,7 @@ fn _constructor(self: *Caller, func: anytype, info: FunctionCallbackInfo, compti info.getReturnValue().set(this.handle); } -pub fn getIndex(self: *Caller, comptime T: type, func: anytype, idx: u32, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u8 { +pub fn getIndex(self: *Caller, comptime T: type, func: anytype, idx: u32, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u32 { const local = &self.local; var hs: js.HandleScope = undefined; @@ -183,11 +183,11 @@ pub fn getIndex(self: *Caller, comptime T: type, func: anytype, idx: u32, handle const info = PropertyCallbackInfo{ .handle = handle }; return _getIndex(T, local, func, idx, info, opts) catch |err| { handleError(T, @TypeOf(func), local, err, info, opts); - return 1; + return js.Intercepted.no; }; } -fn _getIndex(comptime T: type, local: *const Local, func: anytype, idx: u32, info: PropertyCallbackInfo, comptime opts: CallOpts) !u8 { +fn _getIndex(comptime T: type, local: *const Local, func: anytype, idx: u32, info: PropertyCallbackInfo, comptime opts: CallOpts) !u32 { const F = @TypeOf(func); var args: ParameterTypes(F) = undefined; @field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis()); @@ -199,7 +199,7 @@ fn _getIndex(comptime T: type, local: *const Local, func: anytype, idx: u32, inf return handleIndexedReturn(T, F, true, local, ret, info, opts); } -pub fn getNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u8 { +pub fn getNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u32 { const local = &self.local; var hs: js.HandleScope = undefined; @@ -209,11 +209,11 @@ pub fn getNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *cons const info = PropertyCallbackInfo{ .handle = handle }; return _getNamedIndex(T, local, func, name, info, opts) catch |err| { handleError(T, @TypeOf(func), local, err, info, opts); - return 1; + return js.Intercepted.no; }; } -fn _getNamedIndex(comptime T: type, local: *const Local, func: anytype, name: *const v8.Name, info: PropertyCallbackInfo, comptime opts: CallOpts) !u8 { +fn _getNamedIndex(comptime T: type, local: *const Local, func: anytype, name: *const v8.Name, info: PropertyCallbackInfo, comptime opts: CallOpts) !u32 { const F = @TypeOf(func); var args: ParameterTypes(F) = undefined; @field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis()); @@ -225,7 +225,7 @@ fn _getNamedIndex(comptime T: type, local: *const Local, func: anytype, name: *c return handleIndexedReturn(T, F, true, local, ret, info, opts); } -pub fn setNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, js_value: *const v8.Value, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u8 { +pub fn setNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, js_value: *const v8.Value, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u32 { const local = &self.local; var hs: js.HandleScope = undefined; @@ -235,11 +235,11 @@ pub fn setNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *cons const info = PropertyCallbackInfo{ .handle = handle }; return _setNamedIndex(T, local, func, name, .{ .local = &self.local, .handle = js_value }, info, opts) catch |err| { handleError(T, @TypeOf(func), local, err, info, opts); - return 1; + return js.Intercepted.no; }; } -fn _setNamedIndex(comptime T: type, local: *const Local, func: anytype, name: *const v8.Name, js_value: js.Value, info: PropertyCallbackInfo, comptime opts: CallOpts) !u8 { +fn _setNamedIndex(comptime T: type, local: *const Local, func: anytype, name: *const v8.Name, js_value: js.Value, info: PropertyCallbackInfo, comptime opts: CallOpts) !u32 { const F = @TypeOf(func); var args: ParameterTypes(F) = undefined; @field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis()); @@ -252,7 +252,7 @@ fn _setNamedIndex(comptime T: type, local: *const Local, func: anytype, name: *c return handleIndexedReturn(T, F, false, local, ret, info, opts); } -pub fn deleteNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u8 { +pub fn deleteNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u32 { const local = &self.local; var hs: js.HandleScope = undefined; @@ -262,11 +262,11 @@ pub fn deleteNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *c const info = PropertyCallbackInfo{ .handle = handle }; return _deleteNamedIndex(T, local, func, name, info, opts) catch |err| { handleError(T, @TypeOf(func), local, err, info, opts); - return 1; + return js.Intercepted.no; }; } -fn _deleteNamedIndex(comptime T: type, local: *const Local, func: anytype, name: *const v8.Name, info: PropertyCallbackInfo, comptime opts: CallOpts) !u8 { +fn _deleteNamedIndex(comptime T: type, local: *const Local, func: anytype, name: *const v8.Name, info: PropertyCallbackInfo, comptime opts: CallOpts) !u32 { const F = @TypeOf(func); var args: ParameterTypes(F) = undefined; @field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis()); @@ -278,7 +278,7 @@ fn _deleteNamedIndex(comptime T: type, local: *const Local, func: anytype, name: return handleIndexedReturn(T, F, false, local, ret, info, opts); } -pub fn getEnumerator(self: *Caller, comptime T: type, func: anytype, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u8 { +pub fn getEnumerator(self: *Caller, comptime T: type, func: anytype, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u32 { const local = &self.local; var hs: js.HandleScope = undefined; @@ -288,11 +288,11 @@ pub fn getEnumerator(self: *Caller, comptime T: type, func: anytype, handle: *co const info = PropertyCallbackInfo{ .handle = handle }; return _getEnumerator(T, local, func, info, opts) catch |err| { handleError(T, @TypeOf(func), local, err, info, opts); - return 1; + return js.Intercepted.no; }; } -fn _getEnumerator(comptime T: type, local: *const Local, func: anytype, info: PropertyCallbackInfo, comptime opts: CallOpts) !u8 { +fn _getEnumerator(comptime T: type, local: *const Local, func: anytype, info: PropertyCallbackInfo, comptime opts: CallOpts) !u32 { const F = @TypeOf(func); var args: ParameterTypes(F) = undefined; @field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis()); @@ -303,7 +303,7 @@ fn _getEnumerator(comptime T: type, local: *const Local, func: anytype, info: Pr return handleIndexedReturn(T, F, true, local, ret, info, opts); } -fn handleIndexedReturn(comptime T: type, comptime F: type, comptime with_value: bool, local: *const Local, ret: anytype, info: PropertyCallbackInfo, comptime opts: CallOpts) !u8 { +fn handleIndexedReturn(comptime T: type, comptime F: type, comptime with_value: bool, local: *const Local, ret: anytype, info: PropertyCallbackInfo, comptime opts: CallOpts) !u32 { // need to unwrap this error immediately for when opts.null_as_undefined == true // and we need to compare it to null; const non_error_ret = switch (@typeInfo(@TypeOf(ret))) { @@ -314,11 +314,11 @@ fn handleIndexedReturn(comptime T: type, comptime F: type, comptime with_value: // if error.NotHandled is part of the error set. if (isInErrorSet(error.NotHandled, eu.error_set)) { if (err == error.NotHandled) { - return 1; + return js.Intercepted.no; } } handleError(T, F, local, err, info, opts); - return 1; + return js.Intercepted.no; }; }, else => ret, @@ -327,7 +327,7 @@ fn handleIndexedReturn(comptime T: type, comptime F: type, comptime with_value: if (comptime with_value) { info.getReturnValue().set(try local.zigValueToJs(non_error_ret, opts)); } - return 0; + return js.Intercepted.yes; } fn isInErrorSet(err: anyerror, comptime T: type) bool { diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 43db4ff34..6131f19b2 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -259,8 +259,8 @@ pub const Accessor = struct { }; pub const Indexed = struct { - getter: *const fn (idx: u32, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8, - enumerator: ?*const fn (handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8, + getter: *const fn (idx: u32, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32, + enumerator: ?*const fn (handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32, const Opts = struct { as_typed_array: bool = false, @@ -271,11 +271,11 @@ pub const Indexed = struct { var indexed = Indexed{ .enumerator = null, .getter = struct { - fn wrap(idx: u32, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8 { + fn wrap(idx: u32, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 { const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?; var caller: Caller = undefined; if (!caller.init(v8_isolate)) { - return 1; + return js.Intercepted.no; } defer caller.deinit(); @@ -289,11 +289,11 @@ pub const Indexed = struct { if (@typeInfo(@TypeOf(enumerator)) != .null) { indexed.enumerator = struct { - fn wrap(handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8 { + fn wrap(handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 { const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?; var caller: Caller = undefined; if (!caller.init(v8_isolate)) { - return 1; + return js.Intercepted.no; } defer caller.deinit(); return caller.getEnumerator(T, enumerator, handle.?, .{}); @@ -306,9 +306,9 @@ pub const Indexed = struct { }; pub const NamedIndexed = struct { - getter: *const fn (c_name: ?*const v8.Name, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8, - setter: ?*const fn (c_name: ?*const v8.Name, c_value: ?*const v8.Value, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8 = null, - deleter: ?*const fn (c_name: ?*const v8.Name, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8 = null, + getter: *const fn (c_name: ?*const v8.Name, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32, + setter: ?*const fn (c_name: ?*const v8.Name, c_value: ?*const v8.Value, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 = null, + deleter: ?*const fn (c_name: ?*const v8.Name, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 = null, const Opts = struct { as_typed_array: bool = false, @@ -321,11 +321,11 @@ pub const NamedIndexed = struct { fn init(comptime T: type, comptime getter: anytype, setter: anytype, deleter: anytype, comptime opts: Opts) NamedIndexed { const getter_fn = struct { - fn wrap(c_name: ?*const v8.Name, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8 { + fn wrap(c_name: ?*const v8.Name, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 { const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?; var caller: Caller = undefined; if (!caller.init(v8_isolate)) { - return 1; + return js.Intercepted.no; } defer caller.deinit(); @@ -337,11 +337,11 @@ pub const NamedIndexed = struct { }.wrap; const setter_fn = if (@typeInfo(@TypeOf(setter)) == .null) null else struct { - fn wrap(c_name: ?*const v8.Name, c_value: ?*const v8.Value, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8 { + fn wrap(c_name: ?*const v8.Name, c_value: ?*const v8.Value, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 { const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?; var caller: Caller = undefined; if (!caller.init(v8_isolate)) { - return 1; + return js.Intercepted.no; } defer caller.deinit(); @@ -365,11 +365,11 @@ pub const NamedIndexed = struct { }.wrap; const deleter_fn = if (@typeInfo(@TypeOf(deleter)) == .null) null else struct { - fn wrap(c_name: ?*const v8.Name, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8 { + fn wrap(c_name: ?*const v8.Name, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 { const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?; var caller: Caller = undefined; if (!caller.init(v8_isolate)) { - return 1; + return js.Intercepted.no; } defer caller.deinit(); @@ -480,14 +480,14 @@ pub const Property = struct { } }; -pub fn unknownWindowPropertyCallback(c_name: ?*const v8.Name, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8 { +pub fn unknownWindowPropertyCallback(c_name: ?*const v8.Name, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 { const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?; // During snapshot creation, there's no Context in embedder data yet. // I hate this check, but there doesn't seem to be a way to add this method // to the global, without triggering it during snapshot creation. - const v8_context = v8.v8__Isolate__GetCurrentContext(v8_isolate) orelse return 1; - const ctx: *Context = @ptrCast(@alignCast(v8.v8__Context__GetAlignedPointerFromEmbedderData(v8_context, 1) orelse return 1)); + const v8_context = v8.v8__Isolate__GetCurrentContext(v8_isolate) orelse return js.Intercepted.no; + const ctx: *Context = @ptrCast(@alignCast(v8.v8__Context__GetAlignedPointerFromEmbedderData(v8_context, 1) orelse return js.Intercepted.no)); var caller: Caller = undefined; caller.initWithContext(ctx, v8_context); @@ -500,7 +500,7 @@ pub fn unknownWindowPropertyCallback(c_name: ?*const v8.Name, handle: ?*const v8 defer hs.deinit(); const property: []const u8 = js.String.toSlice(.{ .local = local, .handle = @ptrCast(c_name.?) }) catch { - return 1; + return js.Intercepted.no; }; // Only Page contexts have document.getElementById lookup @@ -508,10 +508,10 @@ pub fn unknownWindowPropertyCallback(c_name: ?*const v8.Name, handle: ?*const v8 .frame => |frame| { const document = frame.document; if (document.getElementById(property, frame)) |el| { - const js_val = local.zigValueToJs(el, .{}) catch return 1; + const js_val = local.zigValueToJs(el, .{}) catch return js.Intercepted.no; var pc = Caller.PropertyCallbackInfo{ .handle = handle.? }; pc.getReturnValue().set(js_val); - return 0; + return js.Intercepted.yes; } }, .worker => {}, // no global lookup in a worker @@ -521,7 +521,7 @@ pub fn unknownWindowPropertyCallback(c_name: ?*const v8.Name, handle: ?*const v8 if (std.mem.startsWith(u8, property, "__")) { // some frameworks will extend built-in types using a __ prefix // these should always be safe to ignore. - return 1; + return js.Intercepted.no; } const ignored = std.StaticStringMap(void).initComptime(.{ @@ -553,27 +553,27 @@ pub fn unknownWindowPropertyCallback(c_name: ?*const v8.Name, handle: ?*const v8 }); if (!ignored.has(property)) { var buf: [2048]u8 = undefined; - const key = std.fmt.bufPrint(&buf, "Window:{s}", .{property}) catch return 1; - logUnknownProperty(local, key) catch return 1; + const key = std.fmt.bufPrint(&buf, "Window:{s}", .{property}) catch return js.Intercepted.no; + logUnknownProperty(local, key) catch return js.Intercepted.no; } } - return 1; + return js.Intercepted.no; } // Only used for debugging -pub fn unknownObjectPropertyCallback(comptime JsApi: type) *const fn (?*const v8.Name, ?*const v8.PropertyCallbackInfo) callconv(.c) u8 { +pub fn unknownObjectPropertyCallback(comptime JsApi: type) *const fn (?*const v8.Name, ?*const v8.PropertyCallbackInfo) callconv(.c) u32 { if (comptime !IS_DEBUG) { @compileError("unknownObjectPropertyCallback should only be used in debug builds"); } return struct { - fn wrap(c_name: ?*const v8.Name, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8 { + fn wrap(c_name: ?*const v8.Name, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 { const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?; var caller: Caller = undefined; if (!caller.init(v8_isolate)) { - return 1; + return js.Intercepted.no; } defer caller.deinit(); @@ -584,44 +584,44 @@ pub fn unknownObjectPropertyCallback(comptime JsApi: type) *const fn (?*const v8 defer hs.deinit(); const property: []const u8 = js.String.toSlice(.{ .local = local, .handle = @ptrCast(c_name.?) }) catch { - return 1; + return js.Intercepted.no; }; if (std.mem.startsWith(u8, property, "__")) { // some frameworks will extend built-in types using a __ prefix // these should always be safe to ignore. - return 1; + return js.Intercepted.no; } if (std.mem.startsWith(u8, property, "jQuery")) { - return 1; + return js.Intercepted.no; } if (JsApi == @import("../webapi/cdata/Text.zig").JsApi or JsApi == @import("../webapi/cdata/Comment.zig").JsApi) { if (std.mem.eql(u8, property, "tagName")) { // knockout does this, a lot. - return 1; + return js.Intercepted.no; } } if (JsApi == @import("../webapi/element/Html.zig").JsApi or JsApi == @import("../webapi/Element.zig").JsApi or JsApi == @import("../webapi/element/html/Custom.zig").JsApi) { // react ? - if (std.mem.eql(u8, property, "props")) return 1; - if (std.mem.eql(u8, property, "hydrated")) return 1; - if (std.mem.eql(u8, property, "isHydrated")) return 1; + if (std.mem.eql(u8, property, "props")) return js.Intercepted.no; + if (std.mem.eql(u8, property, "hydrated")) return js.Intercepted.no; + if (std.mem.eql(u8, property, "isHydrated")) return js.Intercepted.no; } if (JsApi == @import("../webapi/Console.zig").JsApi) { - if (std.mem.eql(u8, property, "firebug")) return 1; + if (std.mem.eql(u8, property, "firebug")) return js.Intercepted.no; } const ignored = std.StaticStringMap(void).initComptime(.{}); if (!ignored.has(property)) { var buf: [2048]u8 = undefined; - const key = std.fmt.bufPrint(&buf, "{s}:{s}", .{ if (@hasDecl(JsApi.Meta, "name")) JsApi.Meta.name else @typeName(JsApi), property }) catch return 1; - logUnknownProperty(local, key) catch return 1; + const key = std.fmt.bufPrint(&buf, "{s}:{s}", .{ if (@hasDecl(JsApi.Meta, "name")) JsApi.Meta.name else @typeName(JsApi), property }) catch return js.Intercepted.no; + logUnknownProperty(local, key) catch return js.Intercepted.no; } - return 1; + return js.Intercepted.no; } }.wrap; } diff --git a/src/browser/js/js.zig b/src/browser/js/js.zig index 3b1fa5950..3095d20b3 100644 --- a/src/browser/js/js.zig +++ b/src/browser/js/js.zig @@ -21,6 +21,11 @@ const lp = @import("lightpanda"); pub const v8 = @import("v8").c; +pub const Intercepted = struct { + pub const yes: u32 = 0; + pub const no: u32 = 1; +}; + const string = @import("../../string.zig"); pub const Env = @import("Env.zig"); From d9c76d0b0e643cc3cca39090d9bd6dba9e181e26 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Sun, 28 Jun 2026 14:25:13 +0800 Subject: [PATCH 32/40] update zig-v8-fork dep hash --- build.zig.zon | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.zig.zon b/build.zig.zon index 90b0a1be6..1afac904c 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -6,7 +6,7 @@ .dependencies = .{ .v8 = .{ .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/8bb45873631bf2c419a7eeb452f5f65fcb18c609.tar.gz", - .hash = "v8-0.0.0-xddH65qdBAA1hmB-gqEmhEzO0-aXKyEsmo1s34mssemb", + .hash = "v8-0.0.0-xddH63jnAgBlV_WzDYAcATFU7XfA848ysZNHSomz-rXq", }, // .v8 = .{ .path = "../zig-v8-fork" }, .brotli = .{ From 3a3ef67ab007a1a713dd71e72b50e706c290d641 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 29 Jun 2026 07:31:19 +0800 Subject: [PATCH 33/40] webapi: Fix move cookie accessor from HTMLDocument to Document Per spec, this should be accessible on the Document, not the HTMLDocument. I ran into a site that was doing: Object.getOwnPropertyDescriptor(Document.prototype, "cookie") and that was failing --- src/browser/webapi/Document.zig | 26 ++++++++++++++++++++++++++ src/browser/webapi/HTMLDocument.zig | 26 -------------------------- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig index 4b59c8e60..9f02ce08e 100644 --- a/src/browser/webapi/Document.zig +++ b/src/browser/webapi/Document.zig @@ -191,6 +191,31 @@ pub fn setDomain(self: *Document, value: []const u8) !void { try doc_frame.js.setOrigin(key); } +pub fn getCookie(_: *Document, frame: *Frame) ![]const u8 { + var buf: std.ArrayList(u8) = .empty; + try frame._session.cookie_jar.forRequest(frame.url, buf.writer(frame.call_arena), .{ + .is_http = false, + .is_navigation = true, + }); + return buf.items; +} + +pub fn setCookie(_: *Document, cookie_str: []const u8, frame: *Frame) ![]const u8 { + // we use the cookie jar's allocator to parse the cookie because it + // outlives the frame's arena. + const Cookie = @import("storage/Cookie.zig"); + const c = Cookie.parse(frame._session.cookie_jar.allocator, frame.url, cookie_str) catch { + // Invalid cookies should be silently ignored, not throw errors + return ""; + }; + if (c.http_only) { + c.deinit(); + return ""; // HttpOnly cookies cannot be set from JS + } + try frame._session.cookie_jar.add(c, std.time.timestamp(), false); + return cookie_str; +} + // Returns true if the requested domain is valid for the given host fn isRelaxableTo(host: []const u8, requested: []const u8) bool { if (requested.len == 0) { @@ -1254,6 +1279,7 @@ pub const JsApi = struct { pub const fonts = bridge.accessor(Document.getFonts, null, .{}); pub const contentType = bridge.accessor(Document.getContentType, null, .{}); pub const domain = bridge.accessor(Document.getDomain, Document.setDomain, .{ .dom_exception = true }); + pub const cookie = bridge.accessor(Document.getCookie, Document.setCookie, .{}); pub const createElement = bridge.function(Document.createElement, .{ .dom_exception = true }); pub const createElementNS = bridge.function(Document.createElementNS, .{ .dom_exception = true }); pub const createDocumentFragment = bridge.function(Document.createDocumentFragment, .{}); diff --git a/src/browser/webapi/HTMLDocument.zig b/src/browser/webapi/HTMLDocument.zig index ba4d39438..4d2ccb9c2 100644 --- a/src/browser/webapi/HTMLDocument.zig +++ b/src/browser/webapi/HTMLDocument.zig @@ -224,31 +224,6 @@ pub fn getAll(self: *HTMLDocument, frame: *Frame) !*collections.HTMLAllCollectio return frame._factory.create(collections.HTMLAllCollection.init(self.asNode(), frame)); } -pub fn getCookie(_: *HTMLDocument, frame: *Frame) ![]const u8 { - var buf: std.ArrayList(u8) = .empty; - try frame._session.cookie_jar.forRequest(frame.url, buf.writer(frame.call_arena), .{ - .is_http = false, - .is_navigation = true, - }); - return buf.items; -} - -pub fn setCookie(_: *HTMLDocument, cookie_str: []const u8, frame: *Frame) ![]const u8 { - // we use the cookie jar's allocator to parse the cookie because it - // outlives the frame's arena. - const Cookie = @import("storage/Cookie.zig"); - const c = Cookie.parse(frame._session.cookie_jar.allocator, frame.url, cookie_str) catch { - // Invalid cookies should be silently ignored, not throw errors - return ""; - }; - if (c.http_only) { - c.deinit(); - return ""; // HttpOnly cookies cannot be set from JS - } - try frame._session.cookie_jar.add(c, std.time.timestamp(), false); - return cookie_str; -} - pub fn getDocType(self: *HTMLDocument, frame: *Frame) !*DocumentType { if (self._document_type) |dt| { return dt; @@ -302,6 +277,5 @@ pub const JsApi = struct { pub const plugins = bridge.accessor(HTMLDocument.getEmbeds, null, .{}); pub const currentScript = bridge.accessor(HTMLDocument.getCurrentScript, null, .{}); pub const all = bridge.accessor(HTMLDocument.getAll, null, .{}); - pub const cookie = bridge.accessor(HTMLDocument.getCookie, HTMLDocument.setCookie, .{}); pub const doctype = bridge.accessor(HTMLDocument.getDocType, null, .{}); }; From c40e6c5b9419b97b42f4bbbe17ab0725c6e17f49 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 29 Jun 2026 10:48:37 +0800 Subject: [PATCH 34/40] cdp: follow redirects on request interception fulfillment Fixes https://github.com/lightpanda-io/browser/issues/2828 --- src/browser/HttpClient.zig | 223 ++++++++++++++++++++++-- src/network/layer/InterceptionLayer.zig | 44 +++++ 2 files changed, 254 insertions(+), 13 deletions(-) diff --git a/src/browser/HttpClient.zig b/src/browser/HttpClient.zig index 56890ed2e..de9f9ca77 100644 --- a/src/browser/HttpClient.zig +++ b/src/browser/HttpClient.zig @@ -1048,7 +1048,7 @@ fn isTeardownMethod(method: []const u8) bool { std.mem.eql(u8, method, "Page.close"); } -fn isRedirectStatus(status: u16) bool { +pub fn isRedirectStatus(status: u16) bool { return switch (status) { 301, 302, 303, 307, 308 => true, else => false, @@ -1937,12 +1937,6 @@ pub const Transfer = struct { fn handleRedirect(transfer: *Transfer, location: []const u8) !void { const req = &transfer.req; const conn = transfer._conn.?; - const arena = transfer.arena; - - transfer._redirect_count += 1; - if (transfer._redirect_count > transfer.client.network.config.httpMaxRedirects()) { - return error.TooManyRedirects; - } // retrieve cookies from the redirect's response. if (req.cookie_jar) |jar| { @@ -1956,18 +1950,32 @@ pub const Transfer = struct { } } + // base_url and location are owned by curl; applyRedirectTarget resolves a + // fresh arena-owned copy that gets stored in transfer.req.url. + const base_url = try conn.getEffectiveUrl(); + const status = try conn.getResponseCode(); + try transfer.applyRedirectTarget(std.mem.span(base_url), location, status); + } + + // Called above (in handleRedirect) and by a CDP fulfill request which redirects + pub fn applyRedirectTarget(transfer: *Transfer, base: [:0]const u8, location: []const u8, status: u16) !void { + const req = &transfer.req; + const arena = transfer.arena; + + transfer._redirect_count += 1; + if (transfer._redirect_count > transfer.client.network.config.httpMaxRedirects()) { + return error.TooManyRedirects; + } + // resolve the redirect target. const url: [:0]const u8 = blk: { if (location.len == 0) { // Might seem silly, but URL.resovle will return location as-is - // if empty, and location is memory owned by libcurl. + // if empty, and location may be memory owned by libcurl. break :blk ""; } - const base_url = try conn.getEffectiveUrl(); - // base_url and location are owned by curl; resolve returns a fresh - // arena-owned copy that gets stored in transfer.req.url. - const resolved = try URL.resolve(arena, std.mem.span(base_url), location, .{}); + const resolved = try URL.resolve(arena, base, location, .{}); // RFC 7231 §7.1.2: if the Location value has no fragment, the redirect // inherits the fragment from the URI used to generate the request. @@ -1985,7 +1993,6 @@ pub const Transfer = struct { try transfer.updateURL(url); // 301, 302, 303 → change to GET, drop body. // 307, 308 → keep method and body. - const status = try conn.getResponseCode(); if (status == 301 or status == 302 or status == 303) { req.method = .GET; req.body = null; @@ -2478,6 +2485,196 @@ test "HttpClient: fulfillRequest survives a done_callback that tears down the ow try testing.expectEqual(null, owner.transfers.first); } +test "HttpClient: fulfillRequest follows a 3xx redirect" { + // Regression for #2828: a CDP Fetch.fulfillRequest with a 3xx status + a + // Location header must be followed like a real network redirect (re-issued + // down the chain to the resolved target), not delivered as a final response. + var pool = ArenaPool.init(testing.allocator, .{}); + defer pool.deinit(); + + // Only network.config is read (httpMaxRedirects, which ignores its config), + // so a pointer to an otherwise-undefined Network is safe here. + var net: Network = undefined; + + var client: Client = undefined; + client.allocator = testing.allocator; + client.arena_pool = &pool; + client.network = &net; + client.transfers = .empty; + client.queue = .{}; + client.next_tick_queue = .{}; + client.next_tick_count = 0; + client.performing = false; + client.interception_layer = .{}; + defer client.transfers.deinit(testing.allocator); + + // Capturing stub for interception_layer.next: records the re-issued request + // and returns without committing (transfer stays .created; we clean up). + const Captor = struct { + captured: bool = false, + url: []const u8 = "", + method: Method = undefined, + + fn request(ptr: *anyopaque, transfer: *Transfer) anyerror!void { + const self: *@This() = @ptrCast(@alignCast(ptr)); + self.captured = true; + self.url = transfer.req.url; + self.method = transfer.req.method; + } + }; + var captor = Captor{}; + client.interception_layer.next = .{ + .ptr = &captor, + .vtable = &.{ .request = Captor.request }, + }; + + // 302 with a relative Location: rewrite to GET, drop body, resolve target. + { + const arena = try pool.acquire(.small, "test"); + const transfer = try arena.create(Transfer); + transfer.* = .{ + .arena = arena, + .owner = null, + .req = .{ + .frame_id = 0, + .loader_id = 0, + .method = .POST, + .url = "http://example.com/start", + .body = "payload", + .headers = .{ .headers = null }, + .cookie_jar = null, + .cookie_origin = "", + .resource_type = .document, + .notification = undefined, + .ctx = undefined, + }, + .client = &client, + .id = 1, + .start_time = 0, + }; + try client.transfers.putNoClobber(testing.allocator, transfer.id, transfer); + + transfer.park(.intercept_request); + client.interception_layer.intercepted += 1; + + try client.interception_layer.fulfillRequest(transfer, 302, &.{ + .{ .name = "Location", .value = "/end" }, + }, null); + + try testing.expect(captor.captured); + try testing.expectEqual("http://example.com/end", captor.url); + try testing.expectEqual(.GET, captor.method); + try testing.expectEqual(null, transfer.req.body); + // Unparked exactly once; transfer is still alive (re-issued, not deinited). + try testing.expectEqual(0, client.interception_layer.intercepted); + try testing.expectEqual(1, client.transfers.count()); + transfer.deinit(); + } + + // 307 with an absolute Location: keep method and body. + captor = .{}; + { + const arena = try pool.acquire(.small, "test"); + const transfer = try arena.create(Transfer); + transfer.* = .{ + .arena = arena, + .owner = null, + .req = .{ + .frame_id = 0, + .loader_id = 0, + .method = .POST, + .url = "http://example.com/start", + .body = "payload", + .headers = .{ .headers = null }, + .cookie_jar = null, + .cookie_origin = "", + .resource_type = .document, + .notification = undefined, + .ctx = undefined, + }, + .client = &client, + .id = 2, + .start_time = 0, + }; + try client.transfers.putNoClobber(testing.allocator, transfer.id, transfer); + + transfer.park(.intercept_request); + client.interception_layer.intercepted += 1; + + try client.interception_layer.fulfillRequest(transfer, 307, &.{ + .{ .name = "location", .value = "http://example.com/other" }, + }, null); + + try testing.expect(captor.captured); + try testing.expectEqual("http://example.com/other", captor.url); + try testing.expectEqual(.POST, captor.method); + try testing.expectEqual("payload", transfer.req.body.?); + try testing.expectEqual(0, client.interception_layer.intercepted); + transfer.deinit(); + } +} + +test "HttpClient: fulfillRequest delivers a 3xx without a Location as the response" { + // A redirect status with no Location header is not a redirect: the body is + // delivered as the final response (matching the real-network path). + var pool = ArenaPool.init(testing.allocator, .{}); + defer pool.deinit(); + + var client: Client = undefined; + client.allocator = testing.allocator; + client.arena_pool = &pool; + client.transfers = .empty; + client.queue = .{}; + client.next_tick_queue = .{}; + client.next_tick_count = 0; + client.performing = false; + client.interception_layer = .{}; + defer client.transfers.deinit(testing.allocator); + + const Ctx = struct { + done_called: bool = false, + fn doneCallback(ctx: *anyopaque) !void { + const self: *@This() = @ptrCast(@alignCast(ctx)); + self.done_called = true; + } + }; + var ctx = Ctx{}; + + const arena = try pool.acquire(.small, "test"); + const transfer = try arena.create(Transfer); + transfer.* = .{ + .arena = arena, + .owner = null, + .req = .{ + .frame_id = 0, + .loader_id = 0, + .method = .GET, + .url = "http://example.com/", + .headers = .{ .headers = null }, + .cookie_jar = null, + .cookie_origin = "", + .resource_type = .document, + .notification = undefined, + .ctx = &ctx, + .done_callback = Ctx.doneCallback, + }, + .client = &client, + .id = 1, + .start_time = 0, + }; + try client.transfers.putNoClobber(testing.allocator, transfer.id, transfer); + + transfer.park(.intercept_request); + client.interception_layer.intercepted += 1; + + try client.interception_layer.fulfillRequest(transfer, 302, &.{}, "body"); + + // Delivered (done_callback ran) and freed exactly once. + try testing.expect(ctx.done_called); + try testing.expectEqual(0, client.interception_layer.intercepted); + try testing.expectEqual(0, client.transfers.count()); +} + test "HttpClient: abortParked survives an error_callback that tears down the owner" { // Same re-entrancy hazard as fulfillRequest, but on the abort path // (failRequest / continueWithAuth-cancel / session teardown). abortParked diff --git a/src/network/layer/InterceptionLayer.zig b/src/network/layer/InterceptionLayer.zig index 1e060a369..785502606 100644 --- a/src/network/layer/InterceptionLayer.zig +++ b/src/network/layer/InterceptionLayer.zig @@ -16,6 +16,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const std = @import("std"); const builtin = @import("builtin"); const lp = @import("lightpanda"); const log = lp.log; @@ -23,6 +24,7 @@ const log = lp.log; const IS_DEBUG = builtin.mode == .Debug; const http = @import("../http.zig"); +const HttpClient = @import("../../browser/HttpClient.zig"); const Request = @import("../../browser/HttpClient.zig").Request; const Transfer = @import("../../browser/HttpClient.zig").Transfer; const Response = @import("../../browser/HttpClient.zig").Response; @@ -232,6 +234,26 @@ pub fn fulfillRequest( // Leave the parked state (accounting `intercepted` exactly once) and move to // .completing BEFORE running the user callbacks. transfer.unpark(); + + if (HttpClient.isRedirectStatus(status)) { + if (findLocation(headers)) |location| { + fulfilledRedirect(transfer, status, headers, location) catch |err| { + if (transfer.state == .created) { + transfer.abort(err); + } + return err; + }; + self.next.request(transfer) catch |err| { + if (transfer.state == .created) { + transfer.abort(err); + } + return err; + }; + return; + } + } + + // Not a redirect: move to .completing BEFORE running the user callbacks. transfer.state = .completing; defer transfer.deinit(); @@ -281,3 +303,25 @@ fn fulfillInner( done.* = true; try req.done_callback(req.ctx); } + +fn fulfilledRedirect(transfer: *Transfer, status: u16, headers: []const http.Header, location: []const u8) !void { + // retrieve cookies from the fulfilled response's headers. + if (transfer.req.cookie_jar) |jar| { + for (headers) |hdr| { + if (std.ascii.eqlIgnoreCase(hdr.name, "set-cookie")) { + try jar.populateFromResponse(transfer.req.url, hdr.value); + } + } + } + + try transfer.applyRedirectTarget(transfer.req.url, location, status); +} + +fn findLocation(headers: []const http.Header) ?[]const u8 { + for (headers) |hdr| { + if (std.ascii.eqlIgnoreCase(hdr.name, "location")) { + return hdr.value; + } + } + return null; +} From e396cbb90c9a2a85f104cf46aba30d1ec45bf207 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Mon, 29 Jun 2026 09:38:10 +0200 Subject: [PATCH 35/40] ci: login on GH registry during release --- .github/workflows/release.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1c177cd68..583654ce2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -161,6 +161,12 @@ jobs: with: name: lightpanda-x86_64-linux + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: "Generate and publish versions.json" run: | chmod +x lightpanda-x86_64-linux From 30eb23e0c55bc717d75e36531e4e538600fbd120 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Mon, 29 Jun 2026 09:38:10 +0200 Subject: [PATCH 36/40] ci: login on GH registry during release --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 583654ce2..5550e8a81 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -161,7 +161,7 @@ jobs: with: name: lightpanda-x86_64-linux - - uses: docker/login-action@v3 + - uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} From c754c999a4cc40baa0f9f890eddf5768f39722ae Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 30 Jun 2026 13:04:24 +0800 Subject: [PATCH 37/40] minor: silence log in test that intentionally generates an error --- src/cdp/domains/page.zig | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig index c01f5b9a3..2169d3198 100644 --- a/src/cdp/domains/page.zig +++ b/src/cdp/domains/page.zig @@ -1396,6 +1396,9 @@ test "cdp.frame: navigate does not follow Location on a non-redirect 3xx" { } test "cdp.frame: navigate answers with errorText when the navigation fails" { + const filter: testing.LogFilter = .init(&.{ .frame }); + defer filter.deinit(); + // A root navigation that fails before commit (here: connection refused — // nothing listens on port 1) must still answer the Page.navigate command. // Chrome resolves it with an errorText field ("present if and only if From 459daecbead9ff7dcb09531bb8cca697579438ba Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 30 Jun 2026 13:05:51 +0800 Subject: [PATCH 38/40] zig fmt --- src/cdp/domains/page.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig index 2169d3198..355605585 100644 --- a/src/cdp/domains/page.zig +++ b/src/cdp/domains/page.zig @@ -1396,7 +1396,7 @@ test "cdp.frame: navigate does not follow Location on a non-redirect 3xx" { } test "cdp.frame: navigate answers with errorText when the navigation fails" { - const filter: testing.LogFilter = .init(&.{ .frame }); + const filter: testing.LogFilter = .init(&.{.frame}); defer filter.deinit(); // A root navigation that fails before commit (here: connection refused — From 98157e18ab42533e409a83cc91330cd228c26108 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 30 Jun 2026 13:36:02 +0800 Subject: [PATCH 39/40] update zig-v8-fork dep --- .github/actions/install/action.yml | 2 +- .github/actions/v8-snapshot/action.yml | 2 +- Dockerfile | 2 +- build.zig.zon | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/actions/install/action.yml b/.github/actions/install/action.yml index 97c3349c1..f46ab1596 100644 --- a/.github/actions/install/action.yml +++ b/.github/actions/install/action.yml @@ -13,7 +13,7 @@ inputs: zig-v8: description: 'zig v8 version to install' required: false - default: 'v0.4.8' + default: 'v0.4.9' v8: description: 'v8 version to install' required: false diff --git a/.github/actions/v8-snapshot/action.yml b/.github/actions/v8-snapshot/action.yml index 84a61acaa..7e3365f83 100644 --- a/.github/actions/v8-snapshot/action.yml +++ b/.github/actions/v8-snapshot/action.yml @@ -13,7 +13,7 @@ inputs: zig-v8: description: 'zig-v8 release tag the prebuilt lib came from' required: false - default: 'v0.4.8' + default: 'v0.4.9' runs: using: "composite" diff --git a/Dockerfile b/Dockerfile index 8cecd404b..08535cf7c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ FROM debian:stable-slim ARG MINISIG=0.12 ARG ZIG_MINISIG=RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U ARG V8=14.0.365.4 -ARG ZIG_V8=v0.4.8 +ARG ZIG_V8=v0.4.9 ARG TARGETPLATFORM RUN apt-get update -yq && \ diff --git a/build.zig.zon b/build.zig.zon index 1afac904c..5d549b8fd 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -5,8 +5,8 @@ .minimum_zig_version = "0.15.2", .dependencies = .{ .v8 = .{ - .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/8bb45873631bf2c419a7eeb452f5f65fcb18c609.tar.gz", - .hash = "v8-0.0.0-xddH63jnAgBlV_WzDYAcATFU7XfA848ysZNHSomz-rXq", + .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.4.9.tar.gz", + .hash = "v8-0.0.0-xddH63jnAgB8guO5jPzJD4pnX-CI5od-g4v-fIIjmQXr", }, // .v8 = .{ .path = "../zig-v8-fork" }, .brotli = .{ From d2f72dfd4b346e266d2cbd465c34c7fd5689a636 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 30 Jun 2026 13:39:13 +0800 Subject: [PATCH 40/40] update v8 version --- .github/actions/install/action.yml | 2 +- Dockerfile | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/actions/install/action.yml b/.github/actions/install/action.yml index f46ab1596..d752a0b49 100644 --- a/.github/actions/install/action.yml +++ b/.github/actions/install/action.yml @@ -17,7 +17,7 @@ inputs: v8: description: 'v8 version to install' required: false - default: '14.0.365.4' + default: '14.9.207.35' cache-dir: description: 'cache dir to use' required: false diff --git a/Dockerfile b/Dockerfile index 08535cf7c..889c2a78d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,9 @@ + FROM debian:stable-slim ARG MINISIG=0.12 ARG ZIG_MINISIG=RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U -ARG V8=14.0.365.4 +ARG V8=14.9.207.35 ARG ZIG_V8=v0.4.9 ARG TARGETPLATFORM