12 Commits

Author SHA1 Message Date
Pierre Tachoire
e42cbe3336 ci: add a web bot auth signature test 2026-03-10 10:25:06 +01:00
Pierre Tachoire
1f2dd7e6e5 ci: add e2e tests w/ web bot auth 2026-03-10 10:24:58 +01:00
Muki Kiboigo
02f3b8899b add WebBotAuth unit tests 2026-03-05 21:38:16 -08:00
Muki Kiboigo
b18c0311d0 fix cli argument for WebBotAuth domain 2026-03-05 19:29:33 -08:00
Muki Kiboigo
9754c2830c simplify parsePemPrivateKey 2026-03-05 19:29:32 -08:00
Muki Kiboigo
e4b32a1a91 make pem private key buffers smaller with comments 2026-03-05 19:29:32 -08:00
Muki Kiboigo
6161c0d701 use transfer arena to sign webbotauth request 2026-03-05 19:29:32 -08:00
Muki Kiboigo
5107395917 auth challenge only on use_proxy 2026-03-05 19:29:32 -08:00
Muki Kiboigo
91254eb365 properly deinit web bot auth in app 2026-03-05 19:29:32 -08:00
Muki Kiboigo
79c6b1ed0a add support for WebBotAuth in Client 2026-03-05 19:29:32 -08:00
Muki Kiboigo
48b00634c6 add WebBotAuth and support for ed25119 to crypto 2026-03-05 19:29:32 -08:00
Muki Kiboigo
201e445ca8 add web bot auth args 2026-03-05 19:29:31 -08:00
174 changed files with 2401 additions and 8481 deletions

View File

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

View File

@@ -186,16 +186,9 @@ jobs:
LIGHTPANDA_DISABLE_TELEMETRY: true
runs-on: ubuntu-latest
timeout-minutes: 5
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
with:
repository: 'lightpanda-io/demo'
fetch-depth: 0
- run: echo "${{ secrets.WBA_PRIVATE_KEY_PEM }}" > private_key.pem
- name: download artifact
uses: actions/download-artifact@v4
with:
@@ -203,18 +196,18 @@ jobs:
- run: chmod a+x ./lightpanda
- name: run wba test
run: |
node webbotauth/validator.js &
VALIDATOR_PID=$!
sleep 2
- run: echo "${{ secrets.WBA_PRIVATE_KEY_PEM }}" > private_key.pem
./lightpanda fetch http://127.0.0.1:8989/ \
- run: |
./lightpanda fetch https://crawltest.com/cdn-cgi/web-bot-auth \
--log_level error \
--web_bot_auth_key_file private_key.pem \
--web_bot_auth_keyid ${{ vars.WBA_KEY_ID }} \
--web_bot_auth_domain ${{ vars.WBA_DOMAIN }}
--web_bot_auth_domain ${{ vars.WBA_DOMAIN }} \
--dump markdown \
| tee output.log
wait $VALIDATOR_PID
- run: cat output.log | grep -q "unknown public key or unknown verified bot ID for keyid"
cdp-and-hyperfine-bench:
name: cdp-and-hyperfine-bench

View File

@@ -5,7 +5,6 @@ env:
AWS_SECRET_ACCESS_KEY: ${{ secrets.LPD_PERF_AWS_SECRET_ACCESS_KEY }}
AWS_BUCKET: ${{ vars.LPD_PERF_AWS_BUCKET }}
AWS_REGION: ${{ vars.LPD_PERF_AWS_REGION }}
AWS_CF_DISTRIBUTION: ${{ vars.AWS_CF_DISTRIBUTION }}
LIGHTPANDA_DISABLE_TELEMETRY: true
on:
@@ -74,7 +73,7 @@ jobs:
# use a self host runner.
runs-on: lpd-bench-hetzner
timeout-minutes: 180
timeout-minutes: 120
steps:
- uses: actions/checkout@v6
@@ -108,7 +107,7 @@ jobs:
run: |
./wpt serve 2> /dev/null & echo $! > WPT.pid
sleep 10s
./wptrunner -lpd-path ./lightpanda -json -concurrency 10 -pool 3 > wpt.json
./wptrunner -lpd-path ./lightpanda -json -concurrency 3 > wpt.json
kill `cat WPT.pid`
- name: write commit

View File

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

View File

@@ -52,19 +52,8 @@ pub fn build(b: *Build) !void {
mod.addImport("lightpanda", mod); // allow circular "lightpanda" import
mod.addImport("build_config", opts.createModule());
// Format check
const fmt_step = b.step("fmt", "Check code formatting");
const fmt = b.addFmt(.{
.paths = &.{ "src", "build.zig", "build.zig.zon" },
.check = true,
});
fmt_step.dependOn(&fmt.step);
// Set default behavior
b.default_step.dependOn(fmt_step);
try linkV8(b, mod, enable_asan, enable_tsan, prebuilt_v8_path);
try linkCurl(b, mod, enable_tsan);
try linkCurl(b, mod);
try linkHtml5Ever(b, mod);
break :blk mod;
@@ -200,19 +189,19 @@ fn linkHtml5Ever(b: *Build, mod: *Build.Module) !void {
mod.addObjectFile(obj);
}
fn linkCurl(b: *Build, mod: *Build.Module, is_tsan: bool) !void {
fn linkCurl(b: *Build, mod: *Build.Module) !void {
const target = mod.resolved_target.?;
const curl = buildCurl(b, target, mod.optimize.?, is_tsan);
const curl = buildCurl(b, target, mod.optimize.?);
mod.linkLibrary(curl);
const zlib = buildZlib(b, target, mod.optimize.?, is_tsan);
const zlib = buildZlib(b, target, mod.optimize.?);
curl.root_module.linkLibrary(zlib);
const brotli = buildBrotli(b, target, mod.optimize.?, is_tsan);
const brotli = buildBrotli(b, target, mod.optimize.?);
for (brotli) |lib| curl.root_module.linkLibrary(lib);
const nghttp2 = buildNghttp2(b, target, mod.optimize.?, is_tsan);
const nghttp2 = buildNghttp2(b, target, mod.optimize.?);
curl.root_module.linkLibrary(nghttp2);
const boringssl = buildBoringSsl(b, target, mod.optimize.?);
@@ -229,14 +218,13 @@ fn linkCurl(b: *Build, mod: *Build.Module, is_tsan: bool) !void {
}
}
fn buildZlib(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, is_tsan: bool) *Build.Step.Compile {
fn buildZlib(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.OptimizeMode) *Build.Step.Compile {
const dep = b.dependency("zlib", .{});
const mod = b.createModule(.{
.target = target,
.optimize = optimize,
.link_libc = true,
.sanitize_thread = is_tsan,
});
const lib = b.addLibrary(.{ .name = "z", .root_module = mod });
@@ -261,14 +249,13 @@ fn buildZlib(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.Opti
return lib;
}
fn buildBrotli(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, is_tsan: bool) [3]*Build.Step.Compile {
fn buildBrotli(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.OptimizeMode) [3]*Build.Step.Compile {
const dep = b.dependency("brotli", .{});
const mod = b.createModule(.{
.target = target,
.optimize = optimize,
.link_libc = true,
.sanitize_thread = is_tsan,
});
mod.addIncludePath(dep.path("c/include"));
@@ -324,14 +311,13 @@ fn buildBoringSsl(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin
return .{ ssl, crypto };
}
fn buildNghttp2(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, is_tsan: bool) *Build.Step.Compile {
fn buildNghttp2(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.OptimizeMode) *Build.Step.Compile {
const dep = b.dependency("nghttp2", .{});
const mod = b.createModule(.{
.target = target,
.optimize = optimize,
.link_libc = true,
.sanitize_thread = is_tsan,
});
mod.addIncludePath(dep.path("lib/includes"));
@@ -376,7 +362,6 @@ fn buildCurl(
b: *Build,
target: Build.ResolvedTarget,
optimize: std.builtin.OptimizeMode,
is_tsan: bool,
) *Build.Step.Compile {
const dep = b.dependency("curl", .{});
@@ -384,7 +369,6 @@ fn buildCurl(
.target = target,
.optimize = optimize,
.link_libc = true,
.sanitize_thread = is_tsan,
});
mod.addIncludePath(dep.path("lib"));
mod.addIncludePath(dep.path("include"));

View File

@@ -5,10 +5,11 @@
.minimum_zig_version = "0.15.2",
.dependencies = .{
.v8 = .{
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.3.3.tar.gz",
.hash = "v8-0.0.0-xddH6yx3BAAGD9jSoq_ttt_bk9MectTU44s_HZxxE5LD",
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.3.1.tar.gz",
.hash = "v8-0.0.0-xddH64J7BAC81mkf6G9RbEJxS-W3TIRl5iFnShwbqCqy",
},
// .v8 = .{ .path = "../zig-v8-fork" },
//.v8 = .{ .path = "../zig-v8-fork" },
.brotli = .{
// v1.2.0
.url = "https://github.com/google/brotli/archive/028fb5a23661f123017c060daa546b55cf4bde29.tar.gz",

View File

@@ -25,38 +25,44 @@ const Config = @import("Config.zig");
const Snapshot = @import("browser/js/Snapshot.zig");
const Platform = @import("browser/js/Platform.zig");
const Telemetry = @import("telemetry/telemetry.zig").Telemetry;
const RobotStore = @import("browser/Robots.zig").RobotStore;
const WebBotAuth = @import("browser/WebBotAuth.zig");
const Network = @import("network/Runtime.zig");
pub const Http = @import("http/Http.zig");
pub const ArenaPool = @import("ArenaPool.zig");
const App = @This();
network: Network,
http: Http,
config: *const Config,
platform: Platform,
snapshot: Snapshot,
telemetry: Telemetry,
allocator: Allocator,
arena_pool: ArenaPool,
robots: RobotStore,
web_bot_auth: ?WebBotAuth,
app_dir_path: ?[]const u8,
shutdown: bool = false,
pub fn init(allocator: Allocator, config: *const Config) !*App {
const app = try allocator.create(App);
errdefer allocator.destroy(app);
app.* = .{
.config = config,
.allocator = allocator,
.network = undefined,
.platform = undefined,
.snapshot = undefined,
.app_dir_path = undefined,
.telemetry = undefined,
.arena_pool = undefined,
};
app.config = config;
app.allocator = allocator;
app.network = try Network.init(allocator, config);
errdefer app.network.deinit();
app.robots = RobotStore.init(allocator);
if (config.webBotAuth()) |wba_cfg| {
app.web_bot_auth = try WebBotAuth.fromConfig(allocator, &wba_cfg);
} else {
app.web_bot_auth = null;
}
errdefer if (app.web_bot_auth) |wba| wba.deinit(allocator);
app.http = try Http.init(allocator, &app.robots, &app.web_bot_auth, config);
errdefer app.http.deinit();
app.platform = try Platform.init();
errdefer app.platform.deinit();
@@ -75,18 +81,22 @@ pub fn init(allocator: Allocator, config: *const Config) !*App {
return app;
}
pub fn shutdown(self: *const App) bool {
return self.network.shutdown.load(.acquire);
}
pub fn deinit(self: *App) void {
if (@atomicRmw(bool, &self.shutdown, .Xchg, true, .monotonic)) {
return;
}
const allocator = self.allocator;
if (self.app_dir_path) |app_dir_path| {
allocator.free(app_dir_path);
self.app_dir_path = null;
}
self.telemetry.deinit();
self.network.deinit();
self.robots.deinit();
if (self.web_bot_auth) |wba| {
wba.deinit(allocator);
}
self.http.deinit();
self.snapshot.deinit();
self.platform.deinit();
self.arena_pool.deinit();

View File

@@ -23,7 +23,7 @@ const Allocator = std.mem.Allocator;
const log = @import("log.zig");
const dump = @import("browser/dump.zig");
const WebBotAuthConfig = @import("network/WebBotAuth.zig").Config;
const WebBotAuthConfig = @import("browser/WebBotAuth.zig").Config;
pub const RunMode = enum {
help,
@@ -33,7 +33,6 @@ pub const RunMode = enum {
mcp,
};
pub const MAX_LISTENERS = 16;
pub const CDP_MAX_HTTP_REQUEST_SIZE = 4096;
// max message size
@@ -156,13 +155,6 @@ pub fn userAgentSuffix(self: *const Config) ?[]const u8 {
};
}
pub fn cdpTimeout(self: *const Config) usize {
return switch (self.mode) {
.serve => |opts| if (opts.timeout > 604_800) 604_800_000 else @as(usize, opts.timeout) * 1000,
else => unreachable,
};
}
pub fn webBotAuth(self: *const Config) ?WebBotAuthConfig {
return switch (self.mode) {
inline .serve, .fetch, .mcp => |opts| WebBotAuthConfig{
@@ -213,8 +205,6 @@ pub const DumpFormat = enum {
html,
markdown,
wpt,
semantic_tree,
semantic_tree_text,
};
pub const Fetch = struct {
@@ -373,7 +363,7 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void {
\\
\\Options:
\\--dump Dumps document to stdout.
\\ Argument must be 'html', 'markdown', 'semantic_tree', or 'semantic_tree_text'.
\\ Argument must be 'html' or 'markdown'.
\\ Defaults to no dump.
\\
\\--strip_mode Comma separated list of tag groups to remove from dump

View File

@@ -21,10 +21,717 @@ const builtin = @import("builtin");
const posix = std.posix;
const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const libcurl = @import("sys/libcurl.zig");
const log = @import("lightpanda").log;
const log = @import("log.zig");
const Config = @import("Config.zig");
const assert = @import("lightpanda").assert;
const CDP_MAX_MESSAGE_SIZE = @import("../Config.zig").CDP_MAX_MESSAGE_SIZE;
pub const ENABLE_DEBUG = false;
const IS_DEBUG = builtin.mode == .Debug;
pub const Blob = libcurl.CurlBlob;
pub const WaitFd = libcurl.CurlWaitFd;
pub const writefunc_error = libcurl.curl_writefunc_error;
const Error = libcurl.Error;
const ErrorMulti = libcurl.ErrorMulti;
const errorFromCode = libcurl.errorFromCode;
const errorMFromCode = libcurl.errorMFromCode;
const errorCheck = libcurl.errorCheck;
const errorMCheck = libcurl.errorMCheck;
pub fn curl_version() [*c]const u8 {
return libcurl.curl_version();
}
pub const Method = enum(u8) {
GET = 0,
PUT = 1,
POST = 2,
DELETE = 3,
HEAD = 4,
OPTIONS = 5,
PATCH = 6,
PROPFIND = 7,
};
pub const Header = struct {
name: []const u8,
value: []const u8,
};
pub const Headers = struct {
headers: ?*libcurl.CurlSList,
cookies: ?[*c]const u8,
pub fn init(user_agent: [:0]const u8) !Headers {
const header_list = libcurl.curl_slist_append(null, user_agent);
if (header_list == null) {
return error.OutOfMemory;
}
return .{ .headers = header_list, .cookies = null };
}
pub fn deinit(self: *const Headers) void {
if (self.headers) |hdr| {
libcurl.curl_slist_free_all(hdr);
}
}
pub fn add(self: *Headers, header: [*c]const u8) !void {
// Copies the value
const updated_headers = libcurl.curl_slist_append(self.headers, header);
if (updated_headers == null) {
return error.OutOfMemory;
}
self.headers = updated_headers;
}
fn parseHeader(header_str: []const u8) ?Header {
const colon_pos = std.mem.indexOfScalar(u8, header_str, ':') orelse return null;
const name = std.mem.trim(u8, header_str[0..colon_pos], " \t");
const value = std.mem.trim(u8, header_str[colon_pos + 1 ..], " \t");
return .{ .name = name, .value = value };
}
pub fn iterator(self: *Headers) Iterator {
return .{
.header = self.headers,
.cookies = self.cookies,
};
}
const Iterator = struct {
header: [*c]libcurl.CurlSList,
cookies: ?[*c]const u8,
pub fn next(self: *Iterator) ?Header {
const h = self.header orelse {
const cookies = self.cookies orelse return null;
self.cookies = null;
return .{ .name = "Cookie", .value = std.mem.span(@as([*:0]const u8, cookies)) };
};
self.header = h.*.next;
return parseHeader(std.mem.span(@as([*:0]const u8, @ptrCast(h.*.data))));
}
};
};
// In normal cases, the header iterator comes from the curl linked list.
// But it's also possible to inject a response, via `transfer.fulfill`. In that
// case, the resposne headers are a list, []const Http.Header.
// This union, is an iterator that exposes the same API for either case.
pub const HeaderIterator = union(enum) {
curl: CurlHeaderIterator,
list: ListHeaderIterator,
pub fn next(self: *HeaderIterator) ?Header {
switch (self.*) {
inline else => |*it| return it.next(),
}
}
const CurlHeaderIterator = struct {
conn: *const Connection,
prev: ?*libcurl.CurlHeader = null,
pub fn next(self: *CurlHeaderIterator) ?Header {
const h = libcurl.curl_easy_nextheader(self.conn.easy, .header, -1, self.prev) orelse return null;
self.prev = h;
const header = h.*;
return .{
.name = std.mem.span(header.name),
.value = std.mem.span(header.value),
};
}
};
const ListHeaderIterator = struct {
index: usize = 0,
list: []const Header,
pub fn next(self: *ListHeaderIterator) ?Header {
const idx = self.index;
if (idx == self.list.len) {
return null;
}
self.index = idx + 1;
return self.list[idx];
}
};
};
const HeaderValue = struct {
value: []const u8,
amount: usize,
};
pub const AuthChallenge = struct {
status: u16,
source: ?enum { server, proxy },
scheme: ?enum { basic, digest },
realm: ?[]const u8,
pub fn parse(status: u16, header: []const u8) !AuthChallenge {
var ac: AuthChallenge = .{
.status = status,
.source = null,
.realm = null,
.scheme = null,
};
const sep = std.mem.indexOfPos(u8, header, 0, ": ") orelse return error.InvalidHeader;
const hname = header[0..sep];
const hvalue = header[sep + 2 ..];
if (std.ascii.eqlIgnoreCase("WWW-Authenticate", hname)) {
ac.source = .server;
} else if (std.ascii.eqlIgnoreCase("Proxy-Authenticate", hname)) {
ac.source = .proxy;
} else {
return error.InvalidAuthChallenge;
}
const pos = std.mem.indexOfPos(u8, std.mem.trim(u8, hvalue, std.ascii.whitespace[0..]), 0, " ") orelse hvalue.len;
const _scheme = hvalue[0..pos];
if (std.ascii.eqlIgnoreCase(_scheme, "basic")) {
ac.scheme = .basic;
} else if (std.ascii.eqlIgnoreCase(_scheme, "digest")) {
ac.scheme = .digest;
} else {
return error.UnknownAuthChallengeScheme;
}
return ac;
}
};
pub const ResponseHead = struct {
pub const MAX_CONTENT_TYPE_LEN = 64;
status: u16,
url: [*c]const u8,
redirect_count: u32,
_content_type_len: usize = 0,
_content_type: [MAX_CONTENT_TYPE_LEN]u8 = undefined,
// this is normally an empty list, but if the response is being injected
// than it'll be populated. It isn't meant to be used directly, but should
// be used through the transfer.responseHeaderIterator() which abstracts
// whether the headers are from a live curl easy handle, or injected.
_injected_headers: []const Header = &.{},
pub fn contentType(self: *ResponseHead) ?[]u8 {
if (self._content_type_len == 0) {
return null;
}
return self._content_type[0..self._content_type_len];
}
};
pub fn globalInit() Error!void {
try libcurl.curl_global_init(.{ .ssl = true });
}
pub fn globalDeinit() void {
libcurl.curl_global_cleanup();
}
pub const Connection = struct {
easy: *libcurl.Curl,
node: Handles.HandleList.Node = .{},
pub fn init(
ca_blob_: ?libcurl.CurlBlob,
config: *const Config,
) !Connection {
const easy = libcurl.curl_easy_init() orelse return error.FailedToInitializeEasy;
errdefer libcurl.curl_easy_cleanup(easy);
// timeouts
try libcurl.curl_easy_setopt(easy, .timeout_ms, config.httpTimeout());
try libcurl.curl_easy_setopt(easy, .connect_timeout_ms, config.httpConnectTimeout());
// redirect behavior
try libcurl.curl_easy_setopt(easy, .max_redirs, config.httpMaxRedirects());
try libcurl.curl_easy_setopt(easy, .follow_location, 2);
try libcurl.curl_easy_setopt(easy, .redir_protocols_str, "HTTP,HTTPS"); // remove FTP and FTPS from the default
// proxy
const http_proxy = config.httpProxy();
if (http_proxy) |proxy| {
try libcurl.curl_easy_setopt(easy, .proxy, proxy.ptr);
}
// tls
if (ca_blob_) |ca_blob| {
try libcurl.curl_easy_setopt(easy, .ca_info_blob, ca_blob);
if (http_proxy != null) {
try libcurl.curl_easy_setopt(easy, .proxy_ca_info_blob, ca_blob);
}
} else {
assert(config.tlsVerifyHost() == false, "Http.init tls_verify_host", .{});
try libcurl.curl_easy_setopt(easy, .ssl_verify_host, false);
try libcurl.curl_easy_setopt(easy, .ssl_verify_peer, false);
if (http_proxy != null) {
try libcurl.curl_easy_setopt(easy, .proxy_ssl_verify_host, false);
try libcurl.curl_easy_setopt(easy, .proxy_ssl_verify_peer, false);
}
}
// compression, don't remove this. CloudFront will send gzip content
// even if we don't support it, and then it won't be decompressed.
// empty string means: use whatever's available
try libcurl.curl_easy_setopt(easy, .accept_encoding, "");
// debug
if (comptime ENABLE_DEBUG) {
try libcurl.curl_easy_setopt(easy, .verbose, true);
// Sometimes the default debug output hides some useful data. You can
// uncomment the following line (BUT KEEP THE LIVE ABOVE AS-IS), to
// get more control over the data (specifically, the `CURLINFO_TEXT`
// can include useful data).
// try libcurl.curl_easy_setopt(easy, .debug_function, debugCallback);
}
return .{
.easy = easy,
};
}
pub fn deinit(self: *const Connection) void {
libcurl.curl_easy_cleanup(self.easy);
}
pub fn setURL(self: *const Connection, url: [:0]const u8) !void {
try libcurl.curl_easy_setopt(self.easy, .url, url.ptr);
}
// a libcurl request has 2 methods. The first is the method that
// controls how libcurl behaves. This specifically influences how redirects
// are handled. For example, if you do a POST and get a 301, libcurl will
// change that to a GET. But if you do a POST and get a 308, libcurl will
// keep the POST (and re-send the body).
// The second method is the actual string that's included in the request
// headers.
// These two methods can be different - you can tell curl to behave as though
// you made a GET, but include "POST" in the request header.
//
// Here, we're only concerned about the 2nd method. If we want, we'll set
// the first one based on whether or not we have a body.
//
// It's important that, for each use of this connection, we set the 2nd
// method. Else, if we make a HEAD request and re-use the connection, but
// DON'T reset this, it'll keep making HEAD requests.
// (I don't know if it's as important to reset the 1st method, or if libcurl
// can infer that based on the presence of the body, but we also reset it
// to be safe);
pub fn setMethod(self: *const Connection, method: Method) !void {
const easy = self.easy;
const m: [:0]const u8 = switch (method) {
.GET => "GET",
.POST => "POST",
.PUT => "PUT",
.DELETE => "DELETE",
.HEAD => "HEAD",
.OPTIONS => "OPTIONS",
.PATCH => "PATCH",
.PROPFIND => "PROPFIND",
};
try libcurl.curl_easy_setopt(easy, .custom_request, m.ptr);
}
pub fn setBody(self: *const Connection, body: []const u8) !void {
const easy = self.easy;
try libcurl.curl_easy_setopt(easy, .post, true);
try libcurl.curl_easy_setopt(easy, .post_field_size, body.len);
try libcurl.curl_easy_setopt(easy, .copy_post_fields, body.ptr);
}
pub fn setGetMode(self: *const Connection) !void {
try libcurl.curl_easy_setopt(self.easy, .http_get, true);
}
pub fn setHeaders(self: *const Connection, headers: *Headers) !void {
try libcurl.curl_easy_setopt(self.easy, .http_header, headers.headers);
}
pub fn setCookies(self: *const Connection, cookies: [*c]const u8) !void {
try libcurl.curl_easy_setopt(self.easy, .cookie, cookies);
}
pub fn setPrivate(self: *const Connection, ptr: *anyopaque) !void {
try libcurl.curl_easy_setopt(self.easy, .private, ptr);
}
pub fn setProxyCredentials(self: *const Connection, creds: [:0]const u8) !void {
try libcurl.curl_easy_setopt(self.easy, .proxy_user_pwd, creds.ptr);
}
pub fn setCallbacks(
self: *const Connection,
comptime header_cb: libcurl.CurlHeaderFunction,
comptime data_cb: libcurl.CurlWriteFunction,
) !void {
try libcurl.curl_easy_setopt(self.easy, .header_data, self.easy);
try libcurl.curl_easy_setopt(self.easy, .header_function, header_cb);
try libcurl.curl_easy_setopt(self.easy, .write_data, self.easy);
try libcurl.curl_easy_setopt(self.easy, .write_function, data_cb);
}
pub fn setProxy(self: *const Connection, proxy: ?[*:0]const u8) !void {
try libcurl.curl_easy_setopt(self.easy, .proxy, proxy);
}
pub fn setTlsVerify(self: *const Connection, verify: bool, use_proxy: bool) !void {
try libcurl.curl_easy_setopt(self.easy, .ssl_verify_host, verify);
try libcurl.curl_easy_setopt(self.easy, .ssl_verify_peer, verify);
if (use_proxy) {
try libcurl.curl_easy_setopt(self.easy, .proxy_ssl_verify_host, verify);
try libcurl.curl_easy_setopt(self.easy, .proxy_ssl_verify_peer, verify);
}
}
pub fn getEffectiveUrl(self: *const Connection) ![*c]const u8 {
var url: [*c]u8 = undefined;
try libcurl.curl_easy_getinfo(self.easy, .effective_url, &url);
return url;
}
pub fn getResponseCode(self: *const Connection) !u16 {
var status: c_long = undefined;
try libcurl.curl_easy_getinfo(self.easy, .response_code, &status);
if (status < 0 or status > std.math.maxInt(u16)) {
return 0;
}
return @intCast(status);
}
pub fn getRedirectCount(self: *const Connection) !u32 {
var count: c_long = undefined;
try libcurl.curl_easy_getinfo(self.easy, .redirect_count, &count);
return @intCast(count);
}
pub fn getResponseHeader(self: *const Connection, name: [:0]const u8, index: usize) ?HeaderValue {
var hdr: ?*libcurl.CurlHeader = null;
libcurl.curl_easy_header(self.easy, name, index, .header, -1, &hdr) catch |err| {
// ErrorHeader includes OutOfMemory rare but real errors from curl internals.
// Logged and returned as null since callers don't expect errors.
log.err(.http, "get response header", .{
.name = name,
.err = err,
});
return null;
};
const h = hdr orelse return null;
return .{
.amount = h.amount,
.value = std.mem.span(h.value),
};
}
pub fn getPrivate(self: *const Connection) !*anyopaque {
var private: *anyopaque = undefined;
try libcurl.curl_easy_getinfo(self.easy, .private, &private);
return private;
}
// These are headers that may not be send to the users for inteception.
pub fn secretHeaders(_: *const Connection, headers: *Headers, http_headers: *const Config.HttpHeaders) !void {
if (http_headers.proxy_bearer_header) |hdr| {
try headers.add(hdr);
}
}
pub fn request(self: *const Connection, http_headers: *const Config.HttpHeaders) !u16 {
var header_list = try Headers.init(http_headers.user_agent_header);
defer header_list.deinit();
try self.secretHeaders(&header_list, http_headers);
try self.setHeaders(&header_list);
// Add cookies.
if (header_list.cookies) |cookies| {
try self.setCookies(cookies);
}
try libcurl.curl_easy_perform(self.easy);
return self.getResponseCode();
}
};
pub const Handles = struct {
connections: []Connection,
dirty: HandleList,
in_use: HandleList,
available: HandleList,
multi: *libcurl.CurlM,
performing: bool = false,
pub const HandleList = std.DoublyLinkedList;
pub fn init(
allocator: Allocator,
ca_blob: ?libcurl.CurlBlob,
config: *const Config,
) !Handles {
const count: usize = config.httpMaxConcurrent();
if (count == 0) return error.InvalidMaxConcurrent;
const multi = libcurl.curl_multi_init() orelse return error.FailedToInitializeMulti;
errdefer libcurl.curl_multi_cleanup(multi) catch {};
try libcurl.curl_multi_setopt(multi, .max_host_connections, config.httpMaxHostOpen());
const connections = try allocator.alloc(Connection, count);
errdefer allocator.free(connections);
var available: HandleList = .{};
for (0..count) |i| {
connections[i] = try Connection.init(ca_blob, config);
available.append(&connections[i].node);
}
return .{
.dirty = .{},
.in_use = .{},
.connections = connections,
.available = available,
.multi = multi,
};
}
pub fn deinit(self: *Handles, allocator: Allocator) void {
for (self.connections) |*conn| {
conn.deinit();
}
allocator.free(self.connections);
libcurl.curl_multi_cleanup(self.multi) catch {};
}
pub fn hasAvailable(self: *const Handles) bool {
return self.available.first != null;
}
pub fn get(self: *Handles) ?*Connection {
if (self.available.popFirst()) |node| {
self.in_use.append(node);
return @as(*Connection, @fieldParentPtr("node", node));
}
return null;
}
pub fn add(self: *Handles, conn: *const Connection) !void {
try libcurl.curl_multi_add_handle(self.multi, conn.easy);
}
pub fn remove(self: *Handles, conn: *Connection) void {
if (libcurl.curl_multi_remove_handle(self.multi, conn.easy)) {
self.isAvailable(conn);
} else |err| {
// can happen if we're in a perform() call, so we'll queue this
// for cleanup later.
const node = &conn.node;
self.in_use.remove(node);
self.dirty.append(node);
log.warn(.http, "multi remove handle", .{ .err = err });
}
}
pub fn isAvailable(self: *Handles, conn: *Connection) void {
const node = &conn.node;
self.in_use.remove(node);
self.available.append(node);
}
pub fn perform(self: *Handles) !c_int {
self.performing = true;
defer self.performing = false;
const multi = self.multi;
var running: c_int = undefined;
try libcurl.curl_multi_perform(self.multi, &running);
{
const list = &self.dirty;
while (list.first) |node| {
list.remove(node);
const conn: *Connection = @fieldParentPtr("node", node);
if (libcurl.curl_multi_remove_handle(multi, conn.easy)) {
self.available.append(node);
} else |err| {
log.fatal(.http, "multi remove handle", .{ .err = err, .src = "perform" });
@panic("multi_remove_handle");
}
}
}
return running;
}
pub fn poll(self: *Handles, extra_fds: []libcurl.CurlWaitFd, timeout_ms: c_int) !void {
try libcurl.curl_multi_poll(self.multi, extra_fds, timeout_ms, null);
}
pub const MultiMessage = struct {
conn: Connection,
err: ?Error,
};
pub fn readMessage(self: *Handles) ?MultiMessage {
var messages_count: c_int = 0;
const msg = libcurl.curl_multi_info_read(self.multi, &messages_count) orelse return null;
return switch (msg.data) {
.done => |err| .{
.conn = .{ .easy = msg.easy_handle },
.err = err,
},
else => unreachable,
};
}
};
// TODO: on BSD / Linux, we could just read the PEM file directly.
// This whole rescan + decode is really just needed for MacOS. On Linux
// bundle.rescan does find the .pem file(s) which could be in a few different
// places, so it's still useful, just not efficient.
pub fn loadCerts(allocator: Allocator) !libcurl.CurlBlob {
var bundle: std.crypto.Certificate.Bundle = .{};
try bundle.rescan(allocator);
defer bundle.deinit(allocator);
const bytes = bundle.bytes.items;
if (bytes.len == 0) {
log.warn(.app, "No system certificates", .{});
return .{
.len = 0,
.flags = 0,
.data = bytes.ptr,
};
}
const encoder = std.base64.standard.Encoder;
var arr: std.ArrayList(u8) = .empty;
const encoded_size = encoder.calcSize(bytes.len);
const buffer_size = encoded_size +
(bundle.map.count() * 75) + // start / end per certificate + extra, just in case
(encoded_size / 64) // newline per 64 characters
;
try arr.ensureTotalCapacity(allocator, buffer_size);
errdefer arr.deinit(allocator);
var writer = arr.writer(allocator);
var it = bundle.map.valueIterator();
while (it.next()) |index| {
const cert = try std.crypto.Certificate.der.Element.parse(bytes, index.*);
try writer.writeAll("-----BEGIN CERTIFICATE-----\n");
var line_writer = LineWriter{ .inner = writer };
try encoder.encodeWriter(&line_writer, bytes[index.*..cert.slice.end]);
try writer.writeAll("\n-----END CERTIFICATE-----\n");
}
// Final encoding should not be larger than our initial size estimate
assert(buffer_size > arr.items.len, "Http loadCerts", .{ .estimate = buffer_size, .len = arr.items.len });
// Allocate exactly the size needed and copy the data
const result = try allocator.dupe(u8, arr.items);
// Free the original oversized allocation
arr.deinit(allocator);
return .{
.len = result.len,
.data = result.ptr,
.flags = 0,
};
}
// Wraps lines @ 64 columns. A PEM is basically a base64 encoded DER (which is
// what Zig has), with lines wrapped at 64 characters and with a basic header
// and footer
const LineWriter = struct {
col: usize = 0,
inner: std.ArrayList(u8).Writer,
pub fn writeAll(self: *LineWriter, data: []const u8) !void {
var writer = self.inner;
var col = self.col;
const len = 64 - col;
var remain = data;
if (remain.len > len) {
col = 0;
try writer.writeAll(data[0..len]);
try writer.writeByte('\n');
remain = data[len..];
}
while (remain.len > 64) {
try writer.writeAll(remain[0..64]);
try writer.writeByte('\n');
remain = data[len..];
}
try writer.writeAll(remain);
self.col = col + remain.len;
}
};
fn debugCallback(_: *libcurl.Curl, msg_type: libcurl.CurlInfoType, raw: [*c]u8, len: usize, _: *anyopaque) c_int {
const data = raw[0..len];
switch (msg_type) {
.text => std.debug.print("libcurl [text]: {s}\n", .{data}),
.header_out => std.debug.print("libcurl [req-h]: {s}\n", .{data}),
.header_in => std.debug.print("libcurl [res-h]: {s}\n", .{data}),
// .data_in => std.debug.print("libcurl [res-b]: {s}\n", .{data}),
else => std.debug.print("libcurl ?? {d}\n", .{msg_type}),
}
return 0;
}
// Zig is in a weird backend transition right now. Need to determine if
// SIMD is even available.
const backend_supports_vectors = switch (builtin.zig_backend) {
.stage2_llvm, .stage2_c => true,
else => false,
};
// Websocket messages from client->server are masked using a 4 byte XOR mask
fn mask(m: []const u8, payload: []u8) void {
var data = payload;
if (!comptime backend_supports_vectors) return simpleMask(m, data);
const vector_size = std.simd.suggestVectorLength(u8) orelse @sizeOf(usize);
if (data.len >= vector_size) {
const mask_vector = std.simd.repeat(vector_size, @as(@Vector(4, u8), m[0..4].*));
while (data.len >= vector_size) {
const slice = data[0..vector_size];
const masked_data_slice: @Vector(vector_size, u8) = slice.*;
slice.* = masked_data_slice ^ mask_vector;
data = data[vector_size..];
}
}
simpleMask(m, data);
}
// Used when SIMD isn't available, or for any remaining part of the message
// which is too small to effectively use SIMD.
fn simpleMask(m: []const u8, payload: []u8) void {
for (payload, 0..) |b, i| {
payload[i] = b ^ m[i & 3];
}
}
const Fragments = struct {
type: Message.Type,
@@ -52,6 +759,76 @@ const OpCode = enum(u8) {
pong = 128 | 10,
};
fn fillWebsocketHeader(buf: std.ArrayList(u8)) []const u8 {
// can't use buf[0..10] here, because the header length
// is variable. If it's just 2 bytes, for example, we need the
// framed message to be:
// h1, h2, data
// If we use buf[0..10], we'd get:
// h1, h2, 0, 0, 0, 0, 0, 0, 0, 0, data
var header_buf: [10]u8 = undefined;
// -10 because we reserved 10 bytes for the header above
const header = websocketHeader(&header_buf, .text, buf.items.len - 10);
const start = 10 - header.len;
const message = buf.items;
@memcpy(message[start..10], header);
return message[start..];
}
// makes the assumption that our caller reserved the first
// 10 bytes for the header
fn websocketHeader(buf: []u8, op_code: OpCode, payload_len: usize) []const u8 {
assert(buf.len == 10, "Websocket.Header", .{ .len = buf.len });
const len = payload_len;
buf[0] = 128 | @intFromEnum(op_code); // fin | opcode
if (len <= 125) {
buf[1] = @intCast(len);
return buf[0..2];
}
if (len < 65536) {
buf[1] = 126;
buf[2] = @intCast((len >> 8) & 0xFF);
buf[3] = @intCast(len & 0xFF);
return buf[0..4];
}
buf[1] = 127;
buf[2] = 0;
buf[3] = 0;
buf[4] = 0;
buf[5] = 0;
buf[6] = @intCast((len >> 24) & 0xFF);
buf[7] = @intCast((len >> 16) & 0xFF);
buf[8] = @intCast((len >> 8) & 0xFF);
buf[9] = @intCast(len & 0xFF);
return buf[0..10];
}
fn growBuffer(allocator: Allocator, buf: []u8, required_capacity: usize) ![]u8 {
// from std.ArrayList
var new_capacity = buf.len;
while (true) {
new_capacity +|= new_capacity / 2 + 8;
if (new_capacity >= required_capacity) break;
}
log.debug(.app, "CDP buffer growth", .{ .from = buf.len, .to = new_capacity });
if (allocator.resize(buf, new_capacity)) {
return buf.ptr[0..new_capacity];
}
const new_buffer = try allocator.alloc(u8, new_capacity);
@memcpy(new_buffer[0..buf.len], buf);
allocator.free(buf);
return new_buffer;
}
// WebSocket message reader. Given websocket message, acts as an iterator that
// can return zero or more Messages. When next returns null, any incomplete
// message will remain in reader.data
@@ -151,7 +928,7 @@ pub fn Reader(comptime EXPECT_MASK: bool) type {
if (message_len > 125) {
return error.ControlTooLarge;
}
} else if (message_len > CDP_MAX_MESSAGE_SIZE) {
} else if (message_len > Config.CDP_MAX_MESSAGE_SIZE) {
return error.TooLarge;
} else if (message_len > self.buf.len) {
const len = self.buf.len;
@@ -179,7 +956,7 @@ pub fn Reader(comptime EXPECT_MASK: bool) type {
if (is_continuation) {
const fragments = &(self.fragments orelse return error.InvalidContinuation);
if (fragments.message.items.len + message_len > CDP_MAX_MESSAGE_SIZE) {
if (fragments.message.items.len + message_len > Config.CDP_MAX_MESSAGE_SIZE) {
return error.TooLarge;
}
@@ -305,6 +1082,14 @@ pub fn Reader(comptime EXPECT_MASK: bool) type {
};
}
// In-place string lowercase
fn toLower(str: []u8) []u8 {
for (str, 0..) |ch, i| {
str[i] = std.ascii.toLower(ch);
}
return str;
}
pub const WsConnection = struct {
// CLOSE, 2 length, code
const CLOSE_NORMAL = [_]u8{ 136, 2, 3, 232 }; // code: 1000
@@ -596,118 +1381,6 @@ pub const WsConnection = struct {
}
};
fn fillWebsocketHeader(buf: std.ArrayList(u8)) []const u8 {
// can't use buf[0..10] here, because the header length
// is variable. If it's just 2 bytes, for example, we need the
// framed message to be:
// h1, h2, data
// If we use buf[0..10], we'd get:
// h1, h2, 0, 0, 0, 0, 0, 0, 0, 0, data
var header_buf: [10]u8 = undefined;
// -10 because we reserved 10 bytes for the header above
const header = websocketHeader(&header_buf, .text, buf.items.len - 10);
const start = 10 - header.len;
const message = buf.items;
@memcpy(message[start..10], header);
return message[start..];
}
// makes the assumption that our caller reserved the first
// 10 bytes for the header
fn websocketHeader(buf: []u8, op_code: OpCode, payload_len: usize) []const u8 {
assert(buf.len == 10, "Websocket.Header", .{ .len = buf.len });
const len = payload_len;
buf[0] = 128 | @intFromEnum(op_code); // fin | opcode
if (len <= 125) {
buf[1] = @intCast(len);
return buf[0..2];
}
if (len < 65536) {
buf[1] = 126;
buf[2] = @intCast((len >> 8) & 0xFF);
buf[3] = @intCast(len & 0xFF);
return buf[0..4];
}
buf[1] = 127;
buf[2] = 0;
buf[3] = 0;
buf[4] = 0;
buf[5] = 0;
buf[6] = @intCast((len >> 24) & 0xFF);
buf[7] = @intCast((len >> 16) & 0xFF);
buf[8] = @intCast((len >> 8) & 0xFF);
buf[9] = @intCast(len & 0xFF);
return buf[0..10];
}
fn growBuffer(allocator: Allocator, buf: []u8, required_capacity: usize) ![]u8 {
// from std.ArrayList
var new_capacity = buf.len;
while (true) {
new_capacity +|= new_capacity / 2 + 8;
if (new_capacity >= required_capacity) break;
}
log.debug(.app, "CDP buffer growth", .{ .from = buf.len, .to = new_capacity });
if (allocator.resize(buf, new_capacity)) {
return buf.ptr[0..new_capacity];
}
const new_buffer = try allocator.alloc(u8, new_capacity);
@memcpy(new_buffer[0..buf.len], buf);
allocator.free(buf);
return new_buffer;
}
// In-place string lowercase
fn toLower(str: []u8) []u8 {
for (str, 0..) |ch, i| {
str[i] = std.ascii.toLower(ch);
}
return str;
}
// Used when SIMD isn't available, or for any remaining part of the message
// which is too small to effectively use SIMD.
fn simpleMask(m: []const u8, payload: []u8) void {
for (payload, 0..) |b, i| {
payload[i] = b ^ m[i & 3];
}
}
// Zig is in a weird backend transition right now. Need to determine if
// SIMD is even available.
const backend_supports_vectors = switch (builtin.zig_backend) {
.stage2_llvm, .stage2_c => true,
else => false,
};
// Websocket messages from client->server are masked using a 4 byte XOR mask
fn mask(m: []const u8, payload: []u8) void {
var data = payload;
if (!comptime backend_supports_vectors) return simpleMask(m, data);
const vector_size = std.simd.suggestVectorLength(u8) orelse @sizeOf(usize);
if (data.len >= vector_size) {
const mask_vector = std.simd.repeat(vector_size, @as(@Vector(4, u8), m[0..4].*));
while (data.len >= vector_size) {
const slice = data[0..vector_size];
const masked_data_slice: @Vector(vector_size, u8) = slice.*;
slice.* = masked_data_slice ^ mask_vector;
data = data[vector_size..];
}
}
simpleMask(m, data);
}
const testing = std.testing;
test "mask" {

View File

@@ -21,7 +21,7 @@ const lp = @import("lightpanda");
const log = @import("log.zig");
const Page = @import("browser/Page.zig");
const Transfer = @import("browser/HttpClient.zig").Transfer;
const Transfer = @import("http/Client.zig").Transfer;
const Allocator = std.mem.Allocator;

View File

@@ -1,450 +0,0 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. See <https://www.gnu.org/licenses/>.
const std = @import("std");
const lp = @import("lightpanda");
const log = @import("log.zig");
const isAllWhitespace = @import("string.zig").isAllWhitespace;
const Page = lp.Page;
const interactive = @import("browser/interactive.zig");
const CData = @import("browser/webapi/CData.zig");
const Element = @import("browser/webapi/Element.zig");
const Node = @import("browser/webapi/Node.zig");
const AXNode = @import("cdp/AXNode.zig");
const CDPNode = @import("cdp/Node.zig");
const Self = @This();
dom_node: *Node,
registry: *CDPNode.Registry,
page: *Page,
arena: std.mem.Allocator,
prune: bool = false,
pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) error{WriteFailed}!void {
var visitor = JsonVisitor{ .jw = jw, .tree = self };
var xpath_buffer: std.ArrayList(u8) = .{};
const listener_targets = interactive.buildListenerTargetMap(self.page, self.arena) catch |err| {
log.err(.app, "listener map failed", .{ .err = err });
return error.WriteFailed;
};
self.walk(self.dom_node, &xpath_buffer, null, &visitor, 1, listener_targets) catch |err| {
log.err(.app, "semantic tree json dump failed", .{ .err = err });
return error.WriteFailed;
};
}
pub fn textStringify(self: @This(), writer: *std.Io.Writer) error{WriteFailed}!void {
var visitor = TextVisitor{ .writer = writer, .tree = self, .depth = 0 };
var xpath_buffer: std.ArrayList(u8) = .empty;
const listener_targets = interactive.buildListenerTargetMap(self.page, self.arena) catch |err| {
log.err(.app, "listener map failed", .{ .err = err });
return error.WriteFailed;
};
self.walk(self.dom_node, &xpath_buffer, null, &visitor, 1, listener_targets) catch |err| {
log.err(.app, "semantic tree text dump failed", .{ .err = err });
return error.WriteFailed;
};
}
const OptionData = struct {
value: []const u8,
text: []const u8,
selected: bool,
};
const NodeData = struct {
id: u32,
axn: AXNode,
role: []const u8,
name: ?[]const u8,
value: ?[]const u8,
options: ?[]OptionData = null,
xpath: []const u8,
is_interactive: bool,
node_name: []const u8,
};
fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_name: ?[]const u8, visitor: anytype, index: usize, listener_targets: interactive.ListenerTargetMap) !void {
// 1. Skip non-content nodes
if (node.is(Element)) |el| {
const tag = el.getTag();
if (tag.isMetadata() or tag == .svg) return;
// We handle options/optgroups natively inside their parents, skip them in the general walk
if (tag == .datalist or tag == .option or tag == .optgroup) return;
// Check visibility using the engine's checkVisibility which handles CSS display: none
if (!el.checkVisibility(self.page)) {
return;
}
if (el.is(Element.Html)) |html_el| {
if (html_el.getHidden()) return;
}
} else if (node.is(CData.Text)) |text_node| {
const text = text_node.getWholeText();
if (isAllWhitespace(text)) {
return;
}
} else if (node._type != .document and node._type != .document_fragment) {
return;
}
const cdp_node = try self.registry.register(node);
const axn = AXNode.fromNode(node);
const role = try axn.getRole();
var is_interactive = false;
var value: ?[]const u8 = null;
var options: ?[]OptionData = null;
var node_name: []const u8 = "text";
if (node.is(Element)) |el| {
node_name = el.getTagNameLower();
if (el.is(Element.Html.Input)) |input| {
value = input.getValue();
if (el.getAttributeSafe(comptime lp.String.wrap("list"))) |list_id| {
options = try extractDataListOptions(list_id, self.page, self.arena);
}
} else if (el.is(Element.Html.TextArea)) |textarea| {
value = textarea.getValue();
} else if (el.is(Element.Html.Select)) |select| {
value = select.getValue(self.page);
options = try extractSelectOptions(el.asNode(), self.page, self.arena);
}
if (el.is(Element.Html)) |html_el| {
if (interactive.classifyInteractivity(el, html_el, listener_targets) != null) {
is_interactive = true;
}
}
} else if (node._type == .document or node._type == .document_fragment) {
node_name = "root";
}
const initial_xpath_len = xpath_buffer.items.len;
try appendXPathSegment(node, xpath_buffer.writer(self.arena), index);
const xpath = xpath_buffer.items;
var name = try axn.getName(self.page, self.arena);
const has_explicit_label = if (node.is(Element)) |el|
el.getAttributeSafe(.wrap("aria-label")) != null or el.getAttributeSafe(.wrap("title")) != null
else
false;
const structural = isStructuralRole(role);
// Filter out computed concatenated names for generic containers without explicit labels.
// This prevents token bloat and ensures their StaticText children aren't incorrectly pruned.
// We ignore interactivity because a generic wrapper with an event listener still shouldn't hoist all text.
if (name != null and structural and !has_explicit_label) {
name = null;
}
var data = NodeData{
.id = cdp_node.id,
.axn = axn,
.role = role,
.name = name,
.value = value,
.options = options,
.xpath = xpath,
.is_interactive = is_interactive,
.node_name = node_name,
};
var should_visit = true;
if (self.prune) {
if (structural and !is_interactive and !has_explicit_label) {
should_visit = false;
}
if (std.mem.eql(u8, role, "StaticText") and node._parent != null) {
if (parent_name != null and name != null and std.mem.indexOf(u8, parent_name.?, name.?) != null) {
should_visit = false;
}
}
}
var did_visit = false;
var should_walk_children = true;
if (should_visit) {
should_walk_children = try visitor.visit(node, &data);
did_visit = true; // Always true if should_visit was true, because visit() executed and opened structures
} else {
// If we skip the node, we must NOT tell the visitor to close it later
did_visit = false;
}
if (should_walk_children) {
// If we are printing this node normally OR skipping it and unrolling its children,
// we walk the children iterator.
var it = node.childrenIterator();
var tag_counts = std.StringArrayHashMap(usize).init(self.arena);
while (it.next()) |child| {
var tag: []const u8 = "text()";
if (child.is(Element)) |el| {
tag = el.getTagNameLower();
}
const gop = try tag_counts.getOrPut(tag);
if (!gop.found_existing) {
gop.value_ptr.* = 0;
}
gop.value_ptr.* += 1;
try self.walk(child, xpath_buffer, name, visitor, gop.value_ptr.*, listener_targets);
}
}
if (did_visit) {
try visitor.leave();
}
xpath_buffer.shrinkRetainingCapacity(initial_xpath_len);
}
fn extractSelectOptions(node: *Node, page: *Page, arena: std.mem.Allocator) ![]OptionData {
var options = std.ArrayListUnmanaged(OptionData){};
var it = node.childrenIterator();
while (it.next()) |child| {
if (child.is(Element)) |el| {
if (el.getTag() == .option) {
if (el.is(Element.Html.Option)) |opt| {
const text = opt.getText(page);
const value = opt.getValue(page);
const selected = opt.getSelected();
try options.append(arena, .{ .text = text, .value = value, .selected = selected });
}
} else if (el.getTag() == .optgroup) {
var group_it = child.childrenIterator();
while (group_it.next()) |group_child| {
if (group_child.is(Element.Html.Option)) |opt| {
const text = opt.getText(page);
const value = opt.getValue(page);
const selected = opt.getSelected();
try options.append(arena, .{ .text = text, .value = value, .selected = selected });
}
}
}
}
}
return options.toOwnedSlice(arena);
}
fn extractDataListOptions(list_id: []const u8, page: *Page, arena: std.mem.Allocator) !?[]OptionData {
if (page.document.getElementById(list_id, page)) |referenced_el| {
if (referenced_el.getTag() == .datalist) {
return try extractSelectOptions(referenced_el.asNode(), page, arena);
}
}
return null;
}
fn appendXPathSegment(node: *Node, writer: anytype, index: usize) !void {
if (node.is(Element)) |el| {
const tag = el.getTagNameLower();
try std.fmt.format(writer, "/{s}[{d}]", .{ tag, index });
} else if (node.is(CData.Text)) |_| {
try std.fmt.format(writer, "/text()[{d}]", .{index});
}
}
const JsonVisitor = struct {
jw: *std.json.Stringify,
tree: Self,
pub fn visit(self: *JsonVisitor, node: *Node, data: *NodeData) !bool {
try self.jw.beginObject();
try self.jw.objectField("nodeId");
try self.jw.write(try std.fmt.allocPrint(self.tree.arena, "{d}", .{data.id}));
try self.jw.objectField("backendDOMNodeId");
try self.jw.write(data.id);
try self.jw.objectField("nodeName");
try self.jw.write(data.node_name);
try self.jw.objectField("xpath");
try self.jw.write(data.xpath);
if (node.is(Element)) |el| {
try self.jw.objectField("nodeType");
try self.jw.write(1);
try self.jw.objectField("isInteractive");
try self.jw.write(data.is_interactive);
try self.jw.objectField("role");
try self.jw.write(data.role);
if (data.name) |name| {
if (name.len > 0) {
try self.jw.objectField("name");
try self.jw.write(name);
}
}
if (data.value) |value| {
try self.jw.objectField("value");
try self.jw.write(value);
}
if (el._attributes) |attrs| {
try self.jw.objectField("attributes");
try self.jw.beginObject();
var iter = attrs.iterator();
while (iter.next()) |attr| {
try self.jw.objectField(attr._name.str());
try self.jw.write(attr._value.str());
}
try self.jw.endObject();
}
if (data.options) |options| {
try self.jw.objectField("options");
try self.jw.beginArray();
for (options) |opt| {
try self.jw.beginObject();
try self.jw.objectField("value");
try self.jw.write(opt.value);
try self.jw.objectField("text");
try self.jw.write(opt.text);
try self.jw.objectField("selected");
try self.jw.write(opt.selected);
try self.jw.endObject();
}
try self.jw.endArray();
}
} else if (node.is(CData.Text)) |text_node| {
try self.jw.objectField("nodeType");
try self.jw.write(3);
try self.jw.objectField("nodeValue");
try self.jw.write(text_node.getWholeText());
} else {
try self.jw.objectField("nodeType");
try self.jw.write(9);
}
try self.jw.objectField("children");
try self.jw.beginArray();
if (data.options != null) {
// Signal to not walk children, as we handled them natively
return false;
}
return true;
}
pub fn leave(self: *JsonVisitor) !void {
try self.jw.endArray();
try self.jw.endObject();
}
};
fn isStructuralRole(role: []const u8) bool {
const structural_roles = std.StaticStringMap(void).initComptime(.{
.{ "none", {} },
.{ "generic", {} },
.{ "InlineTextBox", {} },
.{ "banner", {} },
.{ "navigation", {} },
.{ "main", {} },
.{ "list", {} },
.{ "listitem", {} },
.{ "table", {} },
.{ "rowgroup", {} },
.{ "row", {} },
.{ "cell", {} },
.{ "region", {} },
});
return structural_roles.has(role);
}
const TextVisitor = struct {
writer: *std.Io.Writer,
tree: Self,
depth: usize,
pub fn visit(self: *TextVisitor, node: *Node, data: *NodeData) !bool {
// Format: " [12] link: Hacker News (value)"
for (0..(self.depth * 2)) |_| {
try self.writer.writeByte(' ');
}
try self.writer.print("[{d}] {s}: ", .{ data.id, data.role });
if (data.name) |n| {
if (n.len > 0) {
try self.writer.writeAll(n);
}
} else if (node.is(CData.Text)) |text_node| {
const trimmed = std.mem.trim(u8, text_node.getWholeText(), " \t\r\n");
if (trimmed.len > 0) {
try self.writer.writeAll(trimmed);
}
}
if (data.value) |v| {
if (v.len > 0) {
try self.writer.print(" (value: {s})", .{v});
}
}
if (data.options) |options| {
try self.writer.writeAll(" options: [");
for (options, 0..) |opt, i| {
if (i > 0) try self.writer.writeAll(", ");
try self.writer.print("'{s}'", .{opt.value});
if (opt.selected) {
try self.writer.writeAll(" (selected)");
}
}
try self.writer.writeAll("]\n");
self.depth += 1;
return false; // Native handling complete, do not walk children
}
try self.writer.writeByte('\n');
self.depth += 1;
// If this is a leaf-like semantic node and we already have a name,
// skip children to avoid redundant StaticText or noise.
const is_leaf_semantic = std.mem.eql(u8, data.role, "link") or
std.mem.eql(u8, data.role, "button") or
std.mem.eql(u8, data.role, "heading") or
std.mem.eql(u8, data.role, "code");
if (is_leaf_semantic and data.name != null and data.name.?.len > 0) {
return false;
}
return true;
}
pub fn leave(self: *TextVisitor) !void {
if (self.depth > 0) {
self.depth -= 1;
}
}
};

View File

@@ -18,6 +18,8 @@
const std = @import("std");
const lp = @import("lightpanda");
const builtin = @import("builtin");
const net = std.net;
const posix = std.posix;
@@ -28,13 +30,16 @@ const log = @import("log.zig");
const App = @import("App.zig");
const Config = @import("Config.zig");
const CDP = @import("cdp/cdp.zig").CDP;
const Net = @import("network/websocket.zig");
const HttpClient = @import("browser/HttpClient.zig");
const Net = @import("Net.zig");
const Http = @import("http/Http.zig");
const HttpClient = @import("http/Client.zig");
const Server = @This();
app: *App,
shutdown: std.atomic.Value(bool) = .init(false),
allocator: Allocator,
listener: ?posix.socket_t,
json_version_response: []const u8,
// Thread management
@@ -43,52 +48,103 @@ clients: std.ArrayList(*Client) = .{},
client_mutex: std.Thread.Mutex = .{},
clients_pool: std.heap.MemoryPool(Client),
pub fn init(app: *App, address: net.Address) !*Server {
pub fn init(app: *App, address: net.Address) !Server {
const allocator = app.allocator;
const json_version_response = try buildJSONVersionResponse(allocator, address);
errdefer allocator.free(json_version_response);
const self = try allocator.create(Server);
errdefer allocator.destroy(self);
self.* = .{
return .{
.app = app,
.listener = null,
.allocator = allocator,
.json_version_response = json_version_response,
.clients_pool = std.heap.MemoryPool(Client).init(allocator),
.clients_pool = std.heap.MemoryPool(Client).init(app.allocator),
};
try self.app.network.bind(address, self, onAccept);
log.info(.app, "server running", .{ .address = address });
return self;
}
pub fn deinit(self: *Server) void {
// Stop all active clients
/// Interrupts the server so that main can complete normally and call all defer handlers.
pub fn stop(self: *Server) void {
if (self.shutdown.swap(true, .release)) {
return;
}
// Shutdown all active clients
{
self.client_mutex.lock();
defer self.client_mutex.unlock();
for (self.clients.items) |client| {
client.stop();
}
}
// Linux and BSD/macOS handle canceling a socket blocked on accept differently.
// For Linux, we use std.shutdown, which will cause accept to return error.SocketNotListening (EINVAL).
// For BSD, shutdown will return an error. Instead we call posix.close, which will result with error.ConnectionAborted (BADF).
if (self.listener) |listener| switch (builtin.target.os.tag) {
.linux => posix.shutdown(listener, .recv) catch |err| {
log.warn(.app, "listener shutdown", .{ .err = err });
},
.macos, .freebsd, .netbsd, .openbsd => {
self.listener = null;
posix.close(listener);
},
else => unreachable,
};
}
pub fn deinit(self: *Server) void {
if (!self.shutdown.load(.acquire)) {
self.stop();
}
self.joinThreads();
if (self.listener) |listener| {
posix.close(listener);
self.listener = null;
}
self.clients.deinit(self.allocator);
self.clients_pool.deinit();
self.allocator.free(self.json_version_response);
self.allocator.destroy(self);
}
fn onAccept(ctx: *anyopaque, socket: posix.socket_t) void {
const self: *Server = @ptrCast(@alignCast(ctx));
const timeout_ms: u32 = @intCast(self.app.config.cdpTimeout());
self.spawnWorker(socket, timeout_ms) catch |err| {
log.err(.app, "CDP spawn", .{ .err = err });
posix.close(socket);
};
pub fn run(self: *Server, address: net.Address, timeout_ms: u32) !void {
const flags = posix.SOCK.STREAM | posix.SOCK.CLOEXEC | posix.SOCK.NONBLOCK;
const listener = try posix.socket(address.any.family, flags, posix.IPPROTO.TCP);
self.listener = listener;
try posix.setsockopt(listener, posix.SOL.SOCKET, posix.SO.REUSEADDR, &std.mem.toBytes(@as(c_int, 1)));
if (@hasDecl(posix.TCP, "NODELAY")) {
try posix.setsockopt(listener, posix.IPPROTO.TCP, posix.TCP.NODELAY, &std.mem.toBytes(@as(c_int, 1)));
}
try posix.bind(listener, &address.any, address.getOsSockLen());
try posix.listen(listener, self.app.config.maxPendingConnections());
log.info(.app, "server running", .{ .address = address });
while (!self.shutdown.load(.acquire)) {
const socket = posix.accept(listener, null, null, posix.SOCK.NONBLOCK) catch |err| {
switch (err) {
error.SocketNotListening, error.ConnectionAborted => {
log.info(.app, "server stopped", .{});
break;
},
error.WouldBlock => {
std.Thread.sleep(10 * std.time.ns_per_ms);
continue;
},
else => {
log.err(.app, "CDP accept", .{ .err = err });
std.Thread.sleep(std.time.ns_per_s);
continue;
},
}
};
self.spawnWorker(socket, timeout_ms) catch |err| {
log.err(.app, "CDP spawn", .{ .err = err });
posix.close(socket);
};
}
}
fn handleConnection(self: *Server, socket: posix.socket_t, timeout_ms: u32) void {
@@ -117,10 +173,10 @@ fn handleConnection(self: *Server, socket: posix.socket_t, timeout_ms: u32) void
self.registerClient(client);
defer self.unregisterClient(client);
// Check shutdown after registering to avoid missing the stop signal.
// If deinit() already iterated over clients, this client won't receive stop()
// Check shutdown after registering to avoid missing stop() signal.
// If stop() already iterated over clients, this client won't receive stop()
// and would block joinThreads() indefinitely.
if (self.app.shutdown()) {
if (self.shutdown.load(.acquire)) {
return;
}
@@ -157,7 +213,7 @@ fn unregisterClient(self: *Server, client: *Client) void {
}
fn spawnWorker(self: *Server, socket: posix.socket_t, timeout_ms: u32) !void {
if (self.app.shutdown()) {
if (self.shutdown.load(.acquire)) {
return error.ShuttingDown;
}
@@ -227,7 +283,7 @@ pub const Client = struct {
log.info(.app, "client connected", .{ .ip = client_address });
}
const http = try HttpClient.init(allocator, &app.network);
const http = try app.http.createClient(allocator);
errdefer http.deinit();
return .{

View File

@@ -24,7 +24,7 @@ const ArenaAllocator = std.heap.ArenaAllocator;
const js = @import("js/js.zig");
const log = @import("../log.zig");
const App = @import("../App.zig");
const HttpClient = @import("HttpClient.zig");
const HttpClient = @import("../http/Client.zig");
const ArenaPool = App.ArenaPool;

View File

@@ -205,7 +205,7 @@ pub fn dispatch(self: *EventManager, target: *EventTarget, event: *Event) Dispat
pub fn dispatchOpts(self: *EventManager, target: *EventTarget, event: *Event, comptime opts: DispatchOpts) DispatchError!void {
event.acquireRef();
defer event.deinit(false, self.page._session);
defer event.deinit(false, self.page);
if (comptime IS_DEBUG) {
log.debug(.event, "eventManager.dispatch", .{ .type = event._type_string.str(), .bubbles = event._bubbles });
@@ -234,7 +234,7 @@ pub fn dispatchDirect(self: *EventManager, target: *EventTarget, event: *Event,
const page = self.page;
event.acquireRef();
defer event.deinit(false, page._session);
defer event.deinit(false, page);
if (comptime IS_DEBUG) {
log.debug(.event, "dispatchDirect", .{ .type = event._type_string, .context = opts.context });
@@ -365,29 +365,6 @@ fn getFunction(handler: anytype, local: *const js.Local) ?js.Function {
};
}
/// Check if there are any listeners for a direct dispatch (non-DOM target).
/// Use this to avoid creating an event when there are no listeners.
pub fn hasDirectListeners(self: *EventManager, target: *EventTarget, typ: []const u8, handler: anytype) bool {
if (hasHandler(handler)) {
return true;
}
return self.lookup.get(.{
.event_target = @intFromPtr(target),
.type_string = .wrap(typ),
}) != null;
}
fn hasHandler(handler: anytype) bool {
const ti = @typeInfo(@TypeOf(handler));
if (ti == .null) {
return false;
}
if (ti == .optional) {
return handler != null;
}
return true;
}
fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts: DispatchOpts) !void {
const ShadowRoot = @import("webapi/ShadowRoot.zig");

View File

@@ -48,11 +48,13 @@ const Factory = @This();
_arena: Allocator,
_slab: SlabAllocator,
pub fn init(arena: Allocator) Factory {
return .{
pub fn init(arena: Allocator) !*Factory {
const self = try arena.create(Factory);
self.* = .{
._arena = arena,
._slab = SlabAllocator.init(arena, 128),
};
return self;
}
// this is a root object
@@ -247,15 +249,16 @@ fn eventInit(arena: Allocator, typ: String, value: anytype) !Event {
};
}
pub fn blob(_: *const Factory, arena: Allocator, child: anytype) !*@TypeOf(child) {
pub fn blob(self: *Factory, child: anytype) !*@TypeOf(child) {
const allocator = self._slab.allocator();
// Special case: Blob has slice and mime fields, so we need manual setup
const chain = try PrototypeChain(
&.{ Blob, @TypeOf(child) },
).allocate(arena);
).allocate(allocator);
const blob_ptr = chain.get(0);
blob_ptr.* = .{
._arena = arena,
._type = unionInit(Blob.Type, chain.get(1)),
._slice = "",
._mime = "",
@@ -265,23 +268,19 @@ pub fn blob(_: *const Factory, arena: Allocator, child: anytype) !*@TypeOf(child
return chain.get(1);
}
pub fn abstractRange(_: *const Factory, arena: Allocator, child: anytype, page: *Page) !*@TypeOf(child) {
const chain = try PrototypeChain(&.{ AbstractRange, @TypeOf(child) }).allocate(arena);
pub fn abstractRange(self: *Factory, child: anytype, page: *Page) !*@TypeOf(child) {
const allocator = self._slab.allocator();
const chain = try PrototypeChain(&.{ AbstractRange, @TypeOf(child) }).allocate(allocator);
const doc = page.document.asNode();
const abstract_range = chain.get(0);
abstract_range.* = AbstractRange{
._rc = 0,
._arena = arena,
._page_id = page.id,
chain.set(0, AbstractRange{
._type = unionInit(AbstractRange.Type, chain.get(1)),
._end_offset = 0,
._start_offset = 0,
._end_container = doc,
._start_container = doc,
};
});
chain.setLeaf(1, child);
page._live_ranges.append(&abstract_range._range_link);
return chain.get(1);
}

File diff suppressed because it is too large Load Diff

View File

@@ -21,8 +21,7 @@ const lp = @import("lightpanda");
const builtin = @import("builtin");
const log = @import("../log.zig");
const HttpClient = @import("HttpClient.zig");
const net_http = @import("../network/http.zig");
const Http = @import("../http/Http.zig");
const String = @import("../string.zig").String;
const js = @import("js/js.zig");
@@ -61,7 +60,7 @@ ready_scripts: std.DoublyLinkedList,
shutdown: bool = false,
client: *HttpClient,
client: *Http.Client,
allocator: Allocator,
buffer_pool: BufferPool,
@@ -89,7 +88,7 @@ importmap: std.StringHashMapUnmanaged([:0]const u8),
// event).
page_notified_of_completion: bool,
pub fn init(allocator: Allocator, http_client: *HttpClient, page: *Page) ScriptManager {
pub fn init(allocator: Allocator, http_client: *Http.Client, page: *Page) ScriptManager {
return .{
.page = page,
.async_scripts = .{},
@@ -142,7 +141,7 @@ fn clearList(list: *std.DoublyLinkedList) void {
}
}
pub fn getHeaders(self: *ScriptManager, url: [:0]const u8) !net_http.Headers {
pub fn getHeaders(self: *ScriptManager, url: [:0]const u8) !Http.Headers {
var headers = try self.client.newHeaders();
try self.page.headersForRequest(self.page.arena, url, &headers);
return headers;
@@ -159,6 +158,7 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
// <script> has already been processed.
return;
}
script_element._executed = true;
const element = script_element.asElement();
if (element.getAttributeSafe(comptime .wrap("nomodule")) != null) {
@@ -203,22 +203,10 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
source = .{ .remote = .{} };
}
} else {
var buf = std.Io.Writer.Allocating.init(page.arena);
try element.asNode().getChildTextContent(&buf.writer);
try buf.writer.writeByte(0);
const data = buf.written();
const inline_source: [:0]const u8 = data[0 .. data.len - 1 :0];
if (inline_source.len == 0) {
// we haven't set script_element._executed = true yet, which is good.
// If content is appended to the script, we will execute it then.
return;
}
const inline_source = try element.asNode().getTextContentAlloc(page.arena);
source = .{ .@"inline" = inline_source };
}
// Only set _executed (already-started) when we actually have content to execute
script_element._executed = true;
const script = try self.script_pool.create();
errdefer self.script_pool.destroy(script);
@@ -687,11 +675,11 @@ pub const Script = struct {
self.manager.script_pool.destroy(self);
}
fn startCallback(transfer: *HttpClient.Transfer) !void {
fn startCallback(transfer: *Http.Transfer) !void {
log.debug(.http, "script fetch start", .{ .req = transfer });
}
fn headerCallback(transfer: *HttpClient.Transfer) !bool {
fn headerCallback(transfer: *Http.Transfer) !bool {
const self: *Script = @ptrCast(@alignCast(transfer.ctx));
const header = &transfer.response_header.?;
self.status = header.status;
@@ -758,14 +746,14 @@ pub const Script = struct {
return true;
}
fn dataCallback(transfer: *HttpClient.Transfer, data: []const u8) !void {
fn dataCallback(transfer: *Http.Transfer, data: []const u8) !void {
const self: *Script = @ptrCast(@alignCast(transfer.ctx));
self._dataCallback(transfer, data) catch |err| {
log.err(.http, "SM.dataCallback", .{ .err = err, .transfer = transfer, .len = data.len });
return err;
};
}
fn _dataCallback(self: *Script, _: *HttpClient.Transfer, data: []const u8) !void {
fn _dataCallback(self: *Script, _: *Http.Transfer, data: []const u8) !void {
try self.source.remote.appendSlice(self.manager.allocator, data);
}

View File

@@ -21,7 +21,6 @@ const lp = @import("lightpanda");
const builtin = @import("builtin");
const log = @import("../log.zig");
const App = @import("../App.zig");
const js = @import("js/js.zig");
const storage = @import("webapi/storage/storage.zig");
@@ -30,88 +29,47 @@ const History = @import("webapi/History.zig");
const Page = @import("Page.zig");
const Browser = @import("Browser.zig");
const Factory = @import("Factory.zig");
const Notification = @import("../Notification.zig");
const QueuedNavigation = Page.QueuedNavigation;
const Allocator = std.mem.Allocator;
const ArenaPool = App.ArenaPool;
const IS_DEBUG = builtin.mode == .Debug;
// Session is like a browser's tab.
// It owns the js env and the loader for all the pages of the session.
// You can create successively multiple pages for a session, but you must
// deinit a page before running another one. It manages two distinct lifetimes.
//
// The first is the lifetime of the Session itself, where pages are created and
// removed, but share the same cookie jar and navigation history (etc...)
//
// The second is as a container the data needed by the full page hierarchy, i.e. \
// the root page and all of its frames (and all of their frames.)
// deinit a page before running another one.
const Session = @This();
// These are the fields that remain intact for the duration of the Session
browser: *Browser,
notification: *Notification,
// Used to create our Inspector and in the BrowserContext.
arena: Allocator,
cookie_jar: storage.Cookie.Jar,
storage_shed: storage.Shed,
history: History,
navigation: Navigation,
storage_shed: storage.Shed,
notification: *Notification,
cookie_jar: storage.Cookie.Jar,
// These are the fields that get reset whenever the Session's page (the root) is reset.
factory: Factory,
page_arena: Allocator,
// Origin map for same-origin context sharing. Scoped to the root page lifetime.
origins: std.StringHashMapUnmanaged(*js.Origin) = .empty,
// Shared resources for all pages in this session.
// These live for the duration of the page tree (root + frames).
arena_pool: *ArenaPool,
// In Debug, we use this to see if anything fails to release an arena back to
// the pool.
_arena_pool_leak_track: if (IS_DEBUG) std.AutoHashMapUnmanaged(usize, struct {
owner: []const u8,
count: usize,
}) else void = if (IS_DEBUG) .empty else {},
page: ?Page,
queued_navigation: std.ArrayList(*Page),
// Temporary buffer for about:blank navigations during processing.
// We process async navigations first (safe from re-entrance), then sync
// about:blank navigations (which may add to queued_navigation).
queued_queued_navigation: std.ArrayList(*Page),
page_id_gen: u32,
frame_id_gen: u32,
pub fn init(self: *Session, browser: *Browser, notification: *Notification) !void {
const allocator = browser.app.allocator;
const arena_pool = browser.arena_pool;
const arena = try arena_pool.acquire();
errdefer arena_pool.release(arena);
const page_arena = try arena_pool.acquire();
errdefer arena_pool.release(page_arena);
const arena = try browser.arena_pool.acquire();
errdefer browser.arena_pool.release(arena);
self.* = .{
.page = null,
.arena = arena,
.arena_pool = arena_pool,
.page_arena = page_arena,
.factory = Factory.init(page_arena),
.history = .{},
.page_id_gen = 0,
.frame_id_gen = 0,
// The prototype (EventTarget) for Navigation is created when a Page is created.
.navigation = .{ ._proto = undefined },
.storage_shed = .{},
.browser = browser,
.queued_navigation = .{},
.queued_queued_navigation = .{},
.notification = notification,
.cookie_jar = storage.Cookie.Jar.init(allocator),
};
@@ -121,11 +79,11 @@ pub fn deinit(self: *Session) void {
if (self.page != null) {
self.removePage();
}
self.cookie_jar.deinit();
const browser = self.browser;
self.storage_shed.deinit(self.browser.app.allocator);
self.arena_pool.release(self.page_arena);
self.arena_pool.release(self.arena);
self.cookie_jar.deinit();
self.storage_shed.deinit(browser.app.allocator);
browser.arena_pool.release(self.arena);
}
// NOTE: the caller is not the owner of the returned value,
@@ -155,137 +113,33 @@ pub fn removePage(self: *Session) void {
self.notification.dispatch(.page_remove, .{});
lp.assert(self.page != null, "Session.removePage - page is null", .{});
self.page.?.deinit(false);
self.page.?.deinit();
self.page = null;
self.navigation.onRemovePage();
self.resetPageResources();
if (comptime IS_DEBUG) {
log.debug(.browser, "remove page", .{});
}
}
pub const GetArenaOpts = struct {
debug: []const u8,
};
pub fn getArena(self: *Session, opts: GetArenaOpts) !Allocator {
const allocator = try self.arena_pool.acquire();
if (comptime IS_DEBUG) {
// Use session's arena (not page_arena) since page_arena gets reset between pages
const gop = try self._arena_pool_leak_track.getOrPut(self.arena, @intFromPtr(allocator.ptr));
if (gop.found_existing and gop.value_ptr.count != 0) {
log.err(.bug, "ArenaPool Double Use", .{ .owner = gop.value_ptr.*.owner });
@panic("ArenaPool Double Use");
}
gop.value_ptr.* = .{ .owner = opts.debug, .count = 1 };
}
return allocator;
}
pub fn releaseArena(self: *Session, allocator: Allocator) void {
if (comptime IS_DEBUG) {
const found = self._arena_pool_leak_track.getPtr(@intFromPtr(allocator.ptr)).?;
if (found.count != 1) {
log.err(.bug, "ArenaPool Double Free", .{ .owner = found.owner, .count = found.count });
if (comptime builtin.is_test) {
@panic("ArenaPool Double Free");
}
return;
}
found.count = 0;
}
return self.arena_pool.release(allocator);
}
pub fn getOrCreateOrigin(self: *Session, key_: ?[]const u8) !*js.Origin {
const key = key_ orelse {
var opaque_origin: [36]u8 = undefined;
@import("../id.zig").uuidv4(&opaque_origin);
// Origin.init will dupe opaque_origin. It's fine that this doesn't
// get added to self.origins. In fact, it further isolates it. When the
// context is freed, it'll call session.releaseOrigin which will free it.
return js.Origin.init(self.browser.app, self.browser.env.isolate, &opaque_origin);
};
const gop = try self.origins.getOrPut(self.arena, key);
if (gop.found_existing) {
const origin = gop.value_ptr.*;
origin.rc += 1;
return origin;
}
errdefer _ = self.origins.remove(key);
const origin = try js.Origin.init(self.browser.app, self.browser.env.isolate, key);
gop.key_ptr.* = origin.key;
gop.value_ptr.* = origin;
return origin;
}
pub fn releaseOrigin(self: *Session, origin: *js.Origin) void {
const rc = origin.rc;
if (rc == 1) {
_ = self.origins.remove(origin.key);
origin.deinit(self.browser.app);
} else {
origin.rc = rc - 1;
}
}
/// Reset page_arena and factory for a clean slate.
/// Called when root page is removed.
fn resetPageResources(self: *Session) void {
// Check for arena leaks before releasing
if (comptime IS_DEBUG) {
var it = self._arena_pool_leak_track.valueIterator();
while (it.next()) |value_ptr| {
if (value_ptr.count > 0) {
log.err(.bug, "ArenaPool Leak", .{ .owner = value_ptr.owner });
}
}
self._arena_pool_leak_track.clearRetainingCapacity();
}
// All origins should have been released when contexts were destroyed
if (comptime IS_DEBUG) {
std.debug.assert(self.origins.count() == 0);
}
// Defensive cleanup in case origins leaked
{
const app = self.browser.app;
var it = self.origins.valueIterator();
while (it.next()) |value| {
value.*.deinit(app);
}
self.origins.clearRetainingCapacity();
}
// Release old page_arena and acquire fresh one
self.frame_id_gen = 0;
self.arena_pool.reset(self.page_arena, 64 * 1024);
self.factory = Factory.init(self.page_arena);
}
pub fn replacePage(self: *Session) !*Page {
if (comptime IS_DEBUG) {
log.debug(.browser, "replace page", .{});
}
lp.assert(self.page != null, "Session.replacePage null page", .{});
lp.assert(self.page.?.parent == null, "Session.replacePage with parent", .{});
var current = self.page.?;
const frame_id = current._frame_id;
current.deinit(true);
const parent = current.parent;
current.deinit();
self.resetPageResources();
self.browser.env.memoryPressureNotification(.moderate);
self.page = @as(Page, undefined);
const page = &self.page.?;
try Page.init(page, frame_id, self, null);
try Page.init(page, frame_id, self, parent);
return page;
}
@@ -299,24 +153,9 @@ pub const WaitResult = enum {
cdp_socket,
};
pub fn findPageByFrameId(self: *Session, frame_id: u32) ?*Page {
pub fn findPage(self: *Session, frame_id: u32) ?*Page {
const page = self.currentPage() orelse return null;
return findPageBy(page, "_frame_id", frame_id);
}
pub fn findPageById(self: *Session, id: u32) ?*Page {
const page = self.currentPage() orelse return null;
return findPageBy(page, "id", id);
}
fn findPageBy(page: *Page, comptime field: []const u8, id: u32) ?*Page {
if (@field(page, field) == id) return page;
for (page.frames.items) |f| {
if (findPageBy(f, field, id)) |found| {
return found;
}
}
return null;
return if (page._frame_id == frame_id) page else null;
}
pub fn wait(self: *Session, wait_ms: u32) WaitResult {
@@ -335,11 +174,10 @@ pub fn wait(self: *Session, wait_ms: u32) WaitResult {
switch (wait_result) {
.done => {
if (self.queued_navigation.items.len == 0) {
if (page._queued_navigation == null) {
return .done;
}
self.processQueuedNavigation() catch return .done;
page = &self.page.?; // might have changed
page = self.processScheduledNavigation(page) catch return .done;
},
else => |result| return result,
}
@@ -391,7 +229,7 @@ fn _wait(self: *Session, page: *Page, wait_ms: u32) !WaitResult {
}
},
.html, .complete => {
if (self.queued_navigation.items.len != 0) {
if (page._queued_navigation != null) {
return .done;
}
@@ -507,145 +345,42 @@ fn _wait(self: *Session, page: *Page, wait_ms: u32) !WaitResult {
}
}
pub fn scheduleNavigation(self: *Session, page: *Page) !void {
const list = &self.queued_navigation;
fn processScheduledNavigation(self: *Session, current_page: *Page) !*Page {
const browser = self.browser;
// Check if page is already queued
for (list.items) |existing| {
if (existing == page) {
// Already queued
return;
}
}
return list.append(self.arena, page);
}
fn processQueuedNavigation(self: *Session) !void {
const navigations = &self.queued_navigation;
if (self.page.?._queued_navigation != null) {
// This is both an optimization and a simplification of sorts. If the
// root page is navigating, then we don't need to process any other
// navigation. Also, the navigation for the root page and for a frame
// is different enough that have two distinct code blocks is, imo,
// better. Yes, there will be duplication.
navigations.clearRetainingCapacity();
return self.processRootQueuedNavigation();
}
const about_blank_queue = &self.queued_queued_navigation;
defer about_blank_queue.clearRetainingCapacity();
// First pass: process async navigations (non-about:blank)
// These cannot cause re-entrant navigation scheduling
for (navigations.items) |page| {
const qn = page._queued_navigation.?;
if (qn.is_about_blank) {
// Defer about:blank to second pass
try about_blank_queue.append(self.arena, page);
continue;
}
try self.processFrameNavigation(page, qn);
}
// Clear the queue after first pass
navigations.clearRetainingCapacity();
// Second pass: process synchronous navigations (about:blank)
// These may trigger new navigations which go into queued_navigation
for (about_blank_queue.items) |page| {
const qn = page._queued_navigation.?;
try self.processFrameNavigation(page, qn);
}
// Safety: Remove any about:blank navigations that were queued during the
// second pass to prevent infinite loops
var i: usize = 0;
while (i < navigations.items.len) {
const page = navigations.items[i];
if (page._queued_navigation) |qn| {
if (qn.is_about_blank) {
log.warn(.page, "recursive about blank", .{});
_ = navigations.swapRemove(i);
continue;
}
}
i += 1;
}
}
fn processFrameNavigation(self: *Session, page: *Page, qn: *QueuedNavigation) !void {
lp.assert(page.parent != null, "root queued navigation", .{});
const iframe = page.iframe.?;
const parent = page.parent.?;
page._queued_navigation = null;
defer self.releaseArena(qn.arena);
errdefer iframe._window = null;
if (page._parent_notified) {
// we already notified the parent that we had loaded
parent._pending_loads += 1;
}
const frame_id = page._frame_id;
page.deinit(true);
page.* = undefined;
try Page.init(page, frame_id, self, parent);
errdefer page.deinit(true);
page.iframe = iframe;
iframe._window = page.window;
page.navigate(qn.url, qn.opts) catch |err| {
log.err(.browser, "queued frame navigation error", .{ .err = err });
return err;
};
}
fn processRootQueuedNavigation(self: *Session) !void {
const current_page = &self.page.?;
const frame_id = current_page._frame_id;
// create a copy before the page is cleared
const qn = current_page._queued_navigation.?;
// take ownership of the page's queued navigation
current_page._queued_navigation = null;
defer browser.arena_pool.release(qn.arena);
defer self.arena_pool.release(qn.arena);
const frame_id, const parent = blk: {
const page = &self.page.?;
const frame_id = page._frame_id;
const parent = page.parent;
// HACK
// Mark as released in tracking BEFORE removePage clears the map.
// We can't call releaseArena() because that would also return the arena
// to the pool, making the memory invalid before we use qn.url/qn.opts.
if (comptime IS_DEBUG) {
if (self._arena_pool_leak_track.getPtr(@intFromPtr(qn.arena.ptr))) |found| {
found.count = 0;
}
}
browser.http_client.abort();
self.removePage();
self.removePage();
break :blk .{ frame_id, parent };
};
self.page = @as(Page, undefined);
const new_page = &self.page.?;
try Page.init(new_page, frame_id, self, null);
const page = &self.page.?;
try Page.init(page, frame_id, self, parent);
// Creates a new NavigationEventTarget for this page.
try self.navigation.onNewPage(new_page);
try self.navigation.onNewPage(page);
// start JS env
// Inform CDP the main page has been created such that additional context for other Worlds can be created as well
self.notification.dispatch(.page_created, new_page);
self.notification.dispatch(.page_created, page);
new_page.navigate(qn.url, qn.opts) catch |err| {
log.err(.browser, "queued navigation error", .{ .err = err });
page.navigate(qn.url, qn.opts) catch |err| {
log.err(.browser, "queued navigation error", .{ .err = err, .url = qn.url });
return err;
};
return page;
}
pub fn nextFrameId(self: *Session) u32 {
@@ -653,9 +388,3 @@ pub fn nextFrameId(self: *Session) u32 {
self.frame_id_gen = id;
return id;
}
pub fn nextPageId(self: *Session) u32 {
const id = self.page_id_gen +% 1;
self.page_id_gen = id;
return id;
}

View File

@@ -167,17 +167,17 @@ pub fn ensureEncoded(allocator: Allocator, url: [:0]const u8) ![:0]const u8 {
const query_end = if (query_start) |_| (fragment_start orelse url.len) else path_end;
const path_to_encode = url[path_start..path_end];
const encoded_path = try percentEncodeSegment(allocator, path_to_encode, .path);
const encoded_path = try percentEncodeSegment(allocator, path_to_encode, true);
const encoded_query = if (query_start) |qs| blk: {
const query_to_encode = url[qs + 1 .. query_end];
const encoded = try percentEncodeSegment(allocator, query_to_encode, .query);
const encoded = try percentEncodeSegment(allocator, query_to_encode, false);
break :blk encoded;
} else null;
const encoded_fragment = if (fragment_start) |fs| blk: {
const fragment_to_encode = url[fs + 1 ..];
const encoded = try percentEncodeSegment(allocator, fragment_to_encode, .query);
const encoded = try percentEncodeSegment(allocator, fragment_to_encode, false);
break :blk encoded;
} else null;
@@ -204,13 +204,11 @@ pub fn ensureEncoded(allocator: Allocator, url: [:0]const u8) ![:0]const u8 {
return buf.items[0 .. buf.items.len - 1 :0];
}
const EncodeSet = enum { path, query, userinfo };
fn percentEncodeSegment(allocator: Allocator, segment: []const u8, comptime encode_set: EncodeSet) ![]const u8 {
fn percentEncodeSegment(allocator: Allocator, segment: []const u8, comptime is_path: bool) ![]const u8 {
// Check if encoding is needed
var needs_encoding = false;
for (segment) |c| {
if (shouldPercentEncode(c, encode_set)) {
if (shouldPercentEncode(c, is_path)) {
needs_encoding = true;
break;
}
@@ -237,7 +235,7 @@ fn percentEncodeSegment(allocator: Allocator, segment: []const u8, comptime enco
}
}
if (shouldPercentEncode(c, encode_set)) {
if (shouldPercentEncode(c, is_path)) {
try buf.writer(allocator).print("%{X:0>2}", .{c});
} else {
try buf.append(allocator, c);
@@ -247,17 +245,16 @@ fn percentEncodeSegment(allocator: Allocator, segment: []const u8, comptime enco
return buf.items;
}
fn shouldPercentEncode(c: u8, comptime encode_set: EncodeSet) bool {
fn shouldPercentEncode(c: u8, comptime is_path: bool) bool {
return switch (c) {
// Unreserved characters (RFC 3986)
'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_', '~' => false,
// sub-delims allowed in path/query but some must be encoded in userinfo
'!', '$', '&', '\'', '(', ')', '*', '+', ',' => false,
';', '=' => encode_set == .userinfo,
// Separators: userinfo must encode these
'/', ':', '@' => encode_set == .userinfo,
// '?' is allowed in queries but not in paths or userinfo
'?' => encode_set != .query,
// sub-delims allowed in both path and query
'!', '$', '&', '\'', '(', ')', '*', '+', ',', ';', '=' => false,
// Separators allowed in both path and query
'/', ':', '@' => false,
// Query-specific: '?' is allowed in queries but not in paths
'?' => comptime is_path,
// Everything else needs encoding (including space)
else => true,
};
@@ -277,11 +274,6 @@ pub fn isCompleteHTTPUrl(url: []const u8) bool {
return false;
}
// blob: and data: URLs are complete but don't follow scheme:// pattern
if (std.mem.startsWith(u8, url, "blob:") or std.mem.startsWith(u8, url, "data:")) {
return true;
}
// Check if there's a scheme (protocol) ending with ://
const colon_pos = std.mem.indexOfScalar(u8, url, ':') orelse return false;
@@ -522,7 +514,7 @@ pub fn setHost(current: [:0]const u8, value: []const u8, allocator: Allocator) !
const search = getSearch(current);
const hash = getHash(current);
// Check if the new value includes a port
// Check if the host includes a port
const colon_pos = std.mem.lastIndexOfScalar(u8, value, ':');
const clean_host = if (colon_pos) |pos| blk: {
const port_str = value[pos + 1 ..];
@@ -534,14 +526,7 @@ pub fn setHost(current: [:0]const u8, value: []const u8, allocator: Allocator) !
break :blk value[0..pos];
}
break :blk value;
} else blk: {
// No port in new value - preserve existing port
const current_port = getPort(current);
if (current_port.len > 0) {
break :blk try std.fmt.allocPrint(allocator, "{s}:{s}", .{ value, current_port });
}
break :blk value;
};
} else value;
return buildUrl(allocator, protocol, clean_host, pathname, search, hash);
}
@@ -559,9 +544,6 @@ pub fn setHostname(current: [:0]const u8, value: []const u8, allocator: Allocato
pub fn setPort(current: [:0]const u8, value: ?[]const u8, allocator: Allocator) ![:0]const u8 {
const hostname = getHostname(current);
const protocol = getProtocol(current);
const pathname = getPathname(current);
const search = getSearch(current);
const hash = getHash(current);
// Handle null or default ports
const new_host = if (value) |port_str| blk: {
@@ -578,7 +560,7 @@ pub fn setPort(current: [:0]const u8, value: ?[]const u8, allocator: Allocator)
break :blk try std.fmt.allocPrint(allocator, "{s}:{s}", .{ hostname, port_str });
} else hostname;
return buildUrl(allocator, protocol, new_host, pathname, search, hash);
return setHost(current, new_host, allocator);
}
pub fn setPathname(current: [:0]const u8, value: []const u8, allocator: Allocator) ![:0]const u8 {
@@ -626,64 +608,6 @@ pub fn setHash(current: [:0]const u8, value: []const u8, allocator: Allocator) !
return buildUrl(allocator, protocol, host, pathname, search, hash);
}
pub fn setUsername(current: [:0]const u8, value: []const u8, allocator: Allocator) ![:0]const u8 {
const protocol = getProtocol(current);
const host = getHost(current);
const pathname = getPathname(current);
const search = getSearch(current);
const hash = getHash(current);
const password = getPassword(current);
const encoded_username = try percentEncodeSegment(allocator, value, .userinfo);
return buildUrlWithUserInfo(allocator, protocol, encoded_username, password, host, pathname, search, hash);
}
pub fn setPassword(current: [:0]const u8, value: []const u8, allocator: Allocator) ![:0]const u8 {
const protocol = getProtocol(current);
const host = getHost(current);
const pathname = getPathname(current);
const search = getSearch(current);
const hash = getHash(current);
const username = getUsername(current);
const encoded_password = try percentEncodeSegment(allocator, value, .userinfo);
return buildUrlWithUserInfo(allocator, protocol, username, encoded_password, host, pathname, search, hash);
}
fn buildUrlWithUserInfo(
allocator: Allocator,
protocol: []const u8,
username: []const u8,
password: []const u8,
host: []const u8,
pathname: []const u8,
search: []const u8,
hash: []const u8,
) ![:0]const u8 {
if (username.len == 0 and password.len == 0) {
return buildUrl(allocator, protocol, host, pathname, search, hash);
} else if (password.len == 0) {
return std.fmt.allocPrintSentinel(allocator, "{s}//{s}@{s}{s}{s}{s}", .{
protocol,
username,
host,
pathname,
search,
hash,
}, 0);
} else {
return std.fmt.allocPrintSentinel(allocator, "{s}//{s}:{s}@{s}{s}{s}{s}", .{
protocol,
username,
password,
host,
pathname,
search,
hash,
}, 0);
}
}
pub fn concatQueryString(arena: Allocator, url: []const u8, query_string: []const u8) ![:0]const u8 {
if (query_string.len == 0) {
return arena.dupeZ(u8, url);
@@ -1405,12 +1329,3 @@ test "URL: unescape" {
try testing.expectEqual("hello%2", result);
}
}
test "URL: getHost" {
try testing.expectEqualSlices(u8, "example.com:8080", getHost("https://example.com:8080/path"));
try testing.expectEqualSlices(u8, "example.com", getHost("https://example.com/path"));
try testing.expectEqualSlices(u8, "example.com:443", getHost("https://example.com:443/"));
try testing.expectEqualSlices(u8, "example.com", getHost("https://user:pass@example.com/page"));
try testing.expectEqualSlices(u8, "example.com:8080", getHost("https://user:pass@example.com:8080/page"));
try testing.expectEqualSlices(u8, "", getHost("not-a-url"));
}

View File

@@ -19,7 +19,7 @@
const std = @import("std");
const crypto = @import("../crypto.zig");
const Http = @import("../network/http.zig");
const Http = @import("../http/Http.zig");
const WebBotAuth = @This();

View File

@@ -1,546 +0,0 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const Page = @import("Page.zig");
const URL = @import("URL.zig");
const TreeWalker = @import("webapi/TreeWalker.zig");
const Element = @import("webapi/Element.zig");
const Node = @import("webapi/Node.zig");
const EventTarget = @import("webapi/EventTarget.zig");
const Allocator = std.mem.Allocator;
pub const InteractivityType = enum {
native,
aria,
contenteditable,
listener,
focusable,
};
pub const InteractiveElement = struct {
node: *Node,
tag_name: []const u8,
role: ?[]const u8,
name: ?[]const u8,
interactivity_type: InteractivityType,
listener_types: []const []const u8,
disabled: bool,
tab_index: i32,
id: ?[]const u8,
class: ?[]const u8,
href: ?[]const u8,
input_type: ?[]const u8,
value: ?[]const u8,
element_name: ?[]const u8,
placeholder: ?[]const u8,
pub fn jsonStringify(self: *const InteractiveElement, jw: anytype) !void {
try jw.beginObject();
try jw.objectField("tagName");
try jw.write(self.tag_name);
try jw.objectField("role");
try jw.write(self.role);
try jw.objectField("name");
try jw.write(self.name);
try jw.objectField("type");
try jw.write(@tagName(self.interactivity_type));
if (self.listener_types.len > 0) {
try jw.objectField("listeners");
try jw.beginArray();
for (self.listener_types) |lt| {
try jw.write(lt);
}
try jw.endArray();
}
if (self.disabled) {
try jw.objectField("disabled");
try jw.write(true);
}
try jw.objectField("tabIndex");
try jw.write(self.tab_index);
if (self.id) |v| {
try jw.objectField("id");
try jw.write(v);
}
if (self.class) |v| {
try jw.objectField("class");
try jw.write(v);
}
if (self.href) |v| {
try jw.objectField("href");
try jw.write(v);
}
if (self.input_type) |v| {
try jw.objectField("inputType");
try jw.write(v);
}
if (self.value) |v| {
try jw.objectField("value");
try jw.write(v);
}
if (self.element_name) |v| {
try jw.objectField("elementName");
try jw.write(v);
}
if (self.placeholder) |v| {
try jw.objectField("placeholder");
try jw.write(v);
}
try jw.endObject();
}
};
/// Collect all interactive elements under `root`.
pub fn collectInteractiveElements(
root: *Node,
arena: Allocator,
page: *Page,
) ![]InteractiveElement {
// Pre-build a map of event_target pointer → event type names,
// so classify and getListenerTypes are both O(1) per element.
const listener_targets = try buildListenerTargetMap(page, arena);
var results: std.ArrayList(InteractiveElement) = .empty;
var tw = TreeWalker.Full.init(root, .{});
while (tw.next()) |node| {
const el = node.is(Element) orelse continue;
const html_el = el.is(Element.Html) orelse continue;
// Skip non-visual elements that are never user-interactive.
switch (el.getTag()) {
.script, .style, .link, .meta, .head, .noscript, .template => continue,
else => {},
}
const itype = classifyInteractivity(el, html_el, listener_targets) orelse continue;
const listener_types = getListenerTypes(
el.asEventTarget(),
listener_targets,
);
try results.append(arena, .{
.node = node,
.tag_name = el.getTagNameLower(),
.role = getRole(el),
.name = try getAccessibleName(el, arena),
.interactivity_type = itype,
.listener_types = listener_types,
.disabled = isDisabled(el),
.tab_index = html_el.getTabIndex(),
.id = el.getAttributeSafe(comptime .wrap("id")),
.class = el.getAttributeSafe(comptime .wrap("class")),
.href = if (el.getAttributeSafe(comptime .wrap("href"))) |href|
URL.resolve(arena, page.base(), href, .{ .encode = true }) catch href
else
null,
.input_type = getInputType(el),
.value = getInputValue(el),
.element_name = el.getAttributeSafe(comptime .wrap("name")),
.placeholder = el.getAttributeSafe(comptime .wrap("placeholder")),
});
}
return results.items;
}
pub const ListenerTargetMap = std.AutoHashMapUnmanaged(usize, std.ArrayList([]const u8));
/// Pre-build a map from event_target pointer → list of event type names.
/// This lets both classifyInteractivity (O(1) "has any?") and
/// getListenerTypes (O(1) "which ones?") avoid re-iterating per element.
pub fn buildListenerTargetMap(page: *Page, arena: Allocator) !ListenerTargetMap {
var map = ListenerTargetMap{};
// addEventListener registrations
var it = page._event_manager.lookup.iterator();
while (it.next()) |entry| {
const list = entry.value_ptr.*;
if (list.first != null) {
const gop = try map.getOrPut(arena, entry.key_ptr.event_target);
if (!gop.found_existing) gop.value_ptr.* = .empty;
try gop.value_ptr.append(arena, entry.key_ptr.type_string.str());
}
}
// Inline handlers (onclick, onmousedown, etc.)
var attr_it = page._event_target_attr_listeners.iterator();
while (attr_it.next()) |entry| {
const gop = try map.getOrPut(arena, @intFromPtr(entry.key_ptr.target));
if (!gop.found_existing) gop.value_ptr.* = .empty;
// Strip "on" prefix to get the event type name.
try gop.value_ptr.append(arena, @tagName(entry.key_ptr.handler)[2..]);
}
return map;
}
pub fn classifyInteractivity(
el: *Element,
html_el: *Element.Html,
listener_targets: ListenerTargetMap,
) ?InteractivityType {
// 1. Native interactive by tag
switch (el.getTag()) {
.button, .summary, .details, .select, .textarea => return .native,
.anchor, .area => {
if (el.getAttributeSafe(comptime .wrap("href")) != null) return .native;
},
.input => {
if (el.is(Element.Html.Input)) |input| {
if (input._input_type != .hidden) return .native;
}
},
else => {},
}
// 2. ARIA interactive role
if (el.getAttributeSafe(comptime .wrap("role"))) |role| {
if (isInteractiveRole(role)) return .aria;
}
// 3. contenteditable (15 bytes, exceeds SSO limit for comptime)
if (el.getAttributeSafe(.wrap("contenteditable"))) |ce| {
if (ce.len == 0 or std.ascii.eqlIgnoreCase(ce, "true")) return .contenteditable;
}
// 4. Event listeners (addEventListener or inline handlers)
const et_ptr = @intFromPtr(html_el.asEventTarget());
if (listener_targets.get(et_ptr) != null) return .listener;
// 5. Explicitly focusable via tabindex.
// Only count elements with an EXPLICIT tabindex attribute,
// since getTabIndex() returns 0 for all interactive tags by default
// (including anchors without href and hidden inputs).
if (el.getAttributeSafe(comptime .wrap("tabindex"))) |_| {
if (html_el.getTabIndex() >= 0) return .focusable;
}
return null;
}
fn isInteractiveRole(role: []const u8) bool {
const interactive_roles = [_][]const u8{
"button", "link", "tab", "menuitem",
"menuitemcheckbox", "menuitemradio", "switch", "checkbox",
"radio", "slider", "spinbutton", "searchbox",
"combobox", "option", "treeitem",
};
for (interactive_roles) |r| {
if (std.ascii.eqlIgnoreCase(role, r)) return true;
}
return false;
}
fn getRole(el: *Element) ?[]const u8 {
// Explicit role attribute takes precedence
if (el.getAttributeSafe(comptime .wrap("role"))) |role| return role;
// Implicit role from tag
return switch (el.getTag()) {
.button, .summary => "button",
.anchor, .area => if (el.getAttributeSafe(comptime .wrap("href")) != null) "link" else null,
.input => blk: {
if (el.is(Element.Html.Input)) |input| {
break :blk switch (input._input_type) {
.text, .tel, .url, .email => "textbox",
.checkbox => "checkbox",
.radio => "radio",
.button, .submit, .reset, .image => "button",
.range => "slider",
.number => "spinbutton",
.search => "searchbox",
else => null,
};
}
break :blk null;
},
.select => "combobox",
.textarea => "textbox",
.details => "group",
else => null,
};
}
fn getAccessibleName(el: *Element, arena: Allocator) !?[]const u8 {
// aria-label
if (el.getAttributeSafe(comptime .wrap("aria-label"))) |v| {
if (v.len > 0) return v;
}
// alt (for img, input[type=image])
if (el.getAttributeSafe(comptime .wrap("alt"))) |v| {
if (v.len > 0) return v;
}
// title
if (el.getAttributeSafe(comptime .wrap("title"))) |v| {
if (v.len > 0) return v;
}
// placeholder
if (el.getAttributeSafe(comptime .wrap("placeholder"))) |v| {
if (v.len > 0) return v;
}
// value (for buttons)
if (el.getTag() == .input) {
if (el.getAttributeSafe(comptime .wrap("value"))) |v| {
if (v.len > 0) return v;
}
}
// Text content (first non-empty text node, trimmed)
return try getTextContent(el.asNode(), arena);
}
fn getTextContent(node: *Node, arena: Allocator) !?[]const u8 {
var tw: TreeWalker.FullExcludeSelf = .init(node, .{});
var arr: std.ArrayList(u8) = .empty;
var single_chunk: ?[]const u8 = null;
while (tw.next()) |child| {
// Skip text inside script/style elements.
if (child.is(Element)) |el| {
switch (el.getTag()) {
.script, .style => {
tw.skipChildren();
continue;
},
else => {},
}
}
if (child.is(Node.CData)) |cdata| {
if (cdata.is(Node.CData.Text)) |text| {
const content = std.mem.trim(u8, text.getWholeText(), &std.ascii.whitespace);
if (content.len > 0) {
if (single_chunk == null and arr.items.len == 0) {
single_chunk = content;
} else {
if (single_chunk) |sc| {
try arr.appendSlice(arena, sc);
try arr.append(arena, ' ');
single_chunk = null;
}
try arr.appendSlice(arena, content);
try arr.append(arena, ' ');
}
}
}
}
}
if (single_chunk) |sc| return sc;
if (arr.items.len == 0) return null;
// strip out trailing space
return arr.items[0 .. arr.items.len - 1];
}
fn isDisabled(el: *Element) bool {
if (el.getAttributeSafe(comptime .wrap("disabled")) != null) return true;
return isDisabledByFieldset(el);
}
/// Check if an element is disabled by an ancestor <fieldset disabled>.
/// Per spec, elements inside the first <legend> child of a disabled fieldset
/// are NOT disabled by that fieldset.
fn isDisabledByFieldset(el: *Element) bool {
const element_node = el.asNode();
var current: ?*Node = element_node._parent;
while (current) |node| {
current = node._parent;
const ancestor = node.is(Element) orelse continue;
if (ancestor.getTag() == .fieldset and ancestor.getAttributeSafe(comptime .wrap("disabled")) != null) {
// Check if element is inside the first <legend> child of this fieldset
var child = ancestor.firstElementChild();
while (child) |c| {
if (c.getTag() == .legend) {
if (c.asNode().contains(element_node)) return false;
break;
}
child = c.nextElementSibling();
}
return true;
}
}
return false;
}
fn getInputType(el: *Element) ?[]const u8 {
if (el.is(Element.Html.Input)) |input| {
return input._input_type.toString();
}
return null;
}
fn getInputValue(el: *Element) ?[]const u8 {
if (el.is(Element.Html.Input)) |input| {
return input.getValue();
}
return null;
}
/// Get all event listener types registered on this target.
fn getListenerTypes(target: *EventTarget, listener_targets: ListenerTargetMap) []const []const u8 {
if (listener_targets.get(@intFromPtr(target))) |types| return types.items;
return &.{};
}
const testing = @import("../testing.zig");
fn testInteractive(html: []const u8) ![]InteractiveElement {
const page = try testing.test_session.createPage();
defer testing.test_session.removePage();
const doc = page.window._document;
const div = try doc.createElement("div", null, page);
try page.parseHtmlAsChildren(div.asNode(), html);
return collectInteractiveElements(div.asNode(), page.call_arena, page);
}
test "browser.interactive: button" {
const elements = try testInteractive("<button>Click me</button>");
try testing.expectEqual(1, elements.len);
try testing.expectEqual("button", elements[0].tag_name);
try testing.expectEqual("button", elements[0].role.?);
try testing.expectEqual("Click me", elements[0].name.?);
try testing.expectEqual(InteractivityType.native, elements[0].interactivity_type);
}
test "browser.interactive: anchor with href" {
const elements = try testInteractive("<a href=\"/page\">Link</a>");
try testing.expectEqual(1, elements.len);
try testing.expectEqual("a", elements[0].tag_name);
try testing.expectEqual("link", elements[0].role.?);
try testing.expectEqual("Link", elements[0].name.?);
}
test "browser.interactive: anchor without href" {
const elements = try testInteractive("<a>Not a link</a>");
try testing.expectEqual(0, elements.len);
}
test "browser.interactive: input types" {
const elements = try testInteractive(
\\<input type="text" placeholder="Search">
\\<input type="hidden" name="csrf">
);
try testing.expectEqual(1, elements.len);
try testing.expectEqual("input", elements[0].tag_name);
try testing.expectEqual("text", elements[0].input_type.?);
try testing.expectEqual("Search", elements[0].placeholder.?);
}
test "browser.interactive: select and textarea" {
const elements = try testInteractive(
\\<select name="color"><option>Red</option></select>
\\<textarea name="msg"></textarea>
);
try testing.expectEqual(2, elements.len);
try testing.expectEqual("select", elements[0].tag_name);
try testing.expectEqual("textarea", elements[1].tag_name);
}
test "browser.interactive: aria role" {
const elements = try testInteractive("<div role=\"button\">Custom</div>");
try testing.expectEqual(1, elements.len);
try testing.expectEqual("div", elements[0].tag_name);
try testing.expectEqual("button", elements[0].role.?);
try testing.expectEqual(InteractivityType.aria, elements[0].interactivity_type);
}
test "browser.interactive: contenteditable" {
const elements = try testInteractive("<div contenteditable=\"true\">Edit me</div>");
try testing.expectEqual(1, elements.len);
try testing.expectEqual(InteractivityType.contenteditable, elements[0].interactivity_type);
}
test "browser.interactive: tabindex" {
const elements = try testInteractive("<div tabindex=\"0\">Focusable</div>");
try testing.expectEqual(1, elements.len);
try testing.expectEqual(InteractivityType.focusable, elements[0].interactivity_type);
try testing.expectEqual(@as(i32, 0), elements[0].tab_index);
}
test "browser.interactive: disabled" {
const elements = try testInteractive("<button disabled>Off</button>");
try testing.expectEqual(1, elements.len);
try testing.expect(elements[0].disabled);
}
test "browser.interactive: disabled by fieldset" {
const elements = try testInteractive(
\\<fieldset disabled>
\\ <button>Disabled</button>
\\ <legend><button>In legend</button></legend>
\\</fieldset>
);
try testing.expectEqual(2, elements.len);
// Button outside legend is disabled by fieldset
try testing.expect(elements[0].disabled);
// Button inside first legend is NOT disabled
try testing.expect(!elements[1].disabled);
}
test "browser.interactive: non-interactive div" {
const elements = try testInteractive("<div>Just text</div>");
try testing.expectEqual(0, elements.len);
}
test "browser.interactive: details and summary" {
const elements = try testInteractive("<details><summary>More</summary><p>Content</p></details>");
try testing.expectEqual(2, elements.len);
try testing.expectEqual("details", elements[0].tag_name);
try testing.expectEqual("summary", elements[1].tag_name);
}
test "browser.interactive: mixed elements" {
const elements = try testInteractive(
\\<div>
\\ <a href="/home">Home</a>
\\ <p>Some text</p>
\\ <button id="btn1">Submit</button>
\\ <input type="email" placeholder="Email">
\\ <div>Not interactive</div>
\\ <div role="tab">Tab</div>
\\</div>
);
try testing.expectEqual(4, elements.len);
}

View File

@@ -734,7 +734,7 @@ fn getArgs(comptime F: type, comptime offset: usize, local: *const Local, info:
if (last_parameter_type_info == .pointer and last_parameter_type_info.pointer.size == .slice) {
const slice_type = last_parameter_type_info.pointer.child;
const corresponding_js_value = info.getArg(@intCast(last_js_parameter), local);
if (slice_type == js.Value or (corresponding_js_value.isArray() == false and corresponding_js_value.isTypedArray() == false and slice_type != u8)) {
if (corresponding_js_value.isArray() == false and corresponding_js_value.isTypedArray() == false and slice_type != u8) {
is_variadic = true;
if (js_parameter_count == 0) {
@field(args, tupleFieldName(params_to_map.len + offset - 1)) = &.{};

View File

@@ -23,11 +23,9 @@ const log = @import("../../log.zig");
const js = @import("js.zig");
const Env = @import("Env.zig");
const bridge = @import("bridge.zig");
const Origin = @import("Origin.zig");
const Scheduler = @import("Scheduler.zig");
const Page = @import("../Page.zig");
const Session = @import("../Session.zig");
const ScriptManager = @import("../ScriptManager.zig");
const v8 = js.v8;
@@ -43,7 +41,6 @@ const Context = @This();
id: usize,
env: *Env,
page: *Page,
session: *Session,
isolate: js.Isolate,
// Per-context microtask queue for isolation between contexts
@@ -77,11 +74,39 @@ call_depth: usize = 0,
// context.localScope
local: ?*const js.Local = null,
origin: *Origin,
// Serves two purposes. Like `global_objects`, this is used to free
// every Global(Object) we've created during the lifetime of the context.
// More importantly, it serves as an identity map - for a given Zig
// instance, we map it to the same Global(Object).
// The key is the @intFromPtr of the Zig value
identity_map: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty,
// Unlike other v8 types, like functions or objects, modules are not shared
// across origins.
// Any type that is stored in the identity_map which has a finalizer declared
// will have its finalizer stored here. This is only used when shutting down
// if v8 hasn't called the finalizer directly itself.
finalizer_callbacks: std.AutoHashMapUnmanaged(usize, *FinalizerCallback) = .empty,
finalizer_callback_pool: std.heap.MemoryPool(FinalizerCallback),
// Some web APIs have to manage opaque values. Ideally, they use an
// js.Object, but the js.Object has no lifetime guarantee beyond the
// current call. They can call .persist() on their js.Object to get
// a `Global(Object)`. We need to track these to free them.
// This used to be a map and acted like identity_map; the key was
// the @intFromPtr(js_obj.handle). But v8 can re-use address. Without
// a reliable way to know if an object has already been persisted,
// we now simply persist every time persist() is called.
global_values: std.ArrayList(v8.Global) = .empty,
global_objects: std.ArrayList(v8.Global) = .empty,
global_modules: std.ArrayList(v8.Global) = .empty,
global_promises: std.ArrayList(v8.Global) = .empty,
global_functions: std.ArrayList(v8.Global) = .empty,
global_promise_resolvers: std.ArrayList(v8.Global) = .empty,
// Temp variants stored in HashMaps for O(1) early cleanup.
// Key is global.data_ptr.
global_values_temp: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty,
global_promises_temp: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty,
global_functions_temp: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty,
// Our module cache: normalized module specifier => module.
module_cache: std.StringHashMapUnmanaged(ModuleEntry) = .empty,
@@ -128,7 +153,7 @@ pub fn fromIsolate(isolate: js.Isolate) *Context {
}
pub fn deinit(self: *Context) void {
if (comptime IS_DEBUG and @import("builtin").is_test == false) {
if (comptime IS_DEBUG) {
var it = self.unknown_properties.iterator();
while (it.next()) |kv| {
log.debug(.unknown_prop, "unknown property", .{
@@ -149,11 +174,64 @@ pub fn deinit(self: *Context) void {
// this can release objects
self.scheduler.deinit();
{
var it = self.identity_map.valueIterator();
while (it.next()) |global| {
v8.v8__Global__Reset(global);
}
}
{
var it = self.finalizer_callbacks.valueIterator();
while (it.next()) |finalizer| {
finalizer.*.deinit();
}
self.finalizer_callback_pool.deinit();
}
for (self.global_values.items) |*global| {
v8.v8__Global__Reset(global);
}
for (self.global_objects.items) |*global| {
v8.v8__Global__Reset(global);
}
for (self.global_modules.items) |*global| {
v8.v8__Global__Reset(global);
}
self.session.releaseOrigin(self.origin);
for (self.global_functions.items) |*global| {
v8.v8__Global__Reset(global);
}
for (self.global_promises.items) |*global| {
v8.v8__Global__Reset(global);
}
for (self.global_promise_resolvers.items) |*global| {
v8.v8__Global__Reset(global);
}
{
var it = self.global_values_temp.valueIterator();
while (it.next()) |global| {
v8.v8__Global__Reset(global);
}
}
{
var it = self.global_promises_temp.valueIterator();
while (it.next()) |global| {
v8.v8__Global__Reset(global);
}
}
{
var it = self.global_functions_temp.valueIterator();
while (it.next()) |global| {
v8.v8__Global__Reset(global);
}
}
v8.v8__Global__Reset(&self.handle);
env.isolate.notifyContextDisposed();
@@ -163,41 +241,8 @@ pub fn deinit(self: *Context) void {
v8.v8__MicrotaskQueue__DELETE(self.microtask_queue);
}
pub fn setOrigin(self: *Context, key: ?[]const u8) !void {
const env = self.env;
const isolate = env.isolate;
const origin = try self.session.getOrCreateOrigin(key);
errdefer self.session.releaseOrigin(origin);
try self.origin.transferTo(origin);
lp.assert(self.origin.rc == 1, "Ref opaque origin", .{ .rc = self.origin.rc });
self.origin.deinit(env.app);
self.origin = origin;
{
var ls: js.Local.Scope = undefined;
self.localScope(&ls);
defer ls.deinit();
// Set the V8::Context SecurityToken, which is a big part of what allows
// one context to access another.
const token_local = v8.v8__Global__Get(&origin.security_token, isolate.handle);
v8.v8__Context__SetSecurityToken(ls.local.handle, token_local);
}
}
pub fn trackGlobal(self: *Context, global: v8.Global) !void {
return self.origin.trackGlobal(global);
}
pub fn trackTemp(self: *Context, global: v8.Global) !void {
return self.origin.trackTemp(global);
}
pub fn weakRef(self: *Context, obj: anytype) void {
const fc = self.origin.finalizer_callbacks.get(@intFromPtr(obj)) orelse {
const fc = self.finalizer_callbacks.get(@intFromPtr(obj)) orelse {
if (comptime IS_DEBUG) {
// should not be possible
std.debug.assert(false);
@@ -208,7 +253,7 @@ pub fn weakRef(self: *Context, obj: anytype) void {
}
pub fn safeWeakRef(self: *Context, obj: anytype) void {
const fc = self.origin.finalizer_callbacks.get(@intFromPtr(obj)) orelse {
const fc = self.finalizer_callbacks.get(@intFromPtr(obj)) orelse {
if (comptime IS_DEBUG) {
// should not be possible
std.debug.assert(false);
@@ -220,7 +265,7 @@ pub fn safeWeakRef(self: *Context, obj: anytype) void {
}
pub fn strongRef(self: *Context, obj: anytype) void {
const fc = self.origin.finalizer_callbacks.get(@intFromPtr(obj)) orelse {
const fc = self.finalizer_callbacks.get(@intFromPtr(obj)) orelse {
if (comptime IS_DEBUG) {
// should not be possible
std.debug.assert(false);
@@ -230,6 +275,45 @@ pub fn strongRef(self: *Context, obj: anytype) void {
v8.v8__Global__ClearWeak(&fc.global);
}
pub fn release(self: *Context, item: anytype) void {
if (@TypeOf(item) == *anyopaque) {
// Existing *anyopaque path for identity_map. Called internally from
// finalizers
var global = self.identity_map.fetchRemove(@intFromPtr(item)) orelse {
if (comptime IS_DEBUG) {
// should not be possible
std.debug.assert(false);
}
return;
};
v8.v8__Global__Reset(&global.value);
// The item has been fianalized, remove it for the finalizer callback so that
// we don't try to call it again on shutdown.
const fc = self.finalizer_callbacks.fetchRemove(@intFromPtr(item)) orelse {
if (comptime IS_DEBUG) {
// should not be possible
std.debug.assert(false);
}
return;
};
self.finalizer_callback_pool.destroy(fc.value);
return;
}
var map = switch (@TypeOf(item)) {
js.Value.Temp => &self.global_values_temp,
js.Promise.Temp => &self.global_promises_temp,
js.Function.Temp => &self.global_functions_temp,
else => |T| @compileError("Context.release cannot be called with a " ++ @typeName(T)),
};
if (map.fetchRemove(item.handle.data_ptr)) |kv| {
var global = kv.value;
v8.v8__Global__Reset(&global);
}
}
// Any operation on the context have to be made from a local.
pub fn localScope(self: *Context, ls: *js.Local.Scope) void {
const isolate = self.isolate;
@@ -252,18 +336,28 @@ pub fn toLocal(self: *Context, global: anytype) js.Local.ToLocalReturnType(@Type
return l.toLocal(global);
}
pub fn stringToPersistedFunction(
self: *Context,
function_body: []const u8,
comptime parameter_names: []const []const u8,
extensions: []const v8.Object,
) !js.Function.Global {
// This isn't expected to be called often. It's for converting attributes into
// function calls, e.g. <body onload="doSomething"> will turn that "doSomething"
// string into a js.Function which looks like: function(e) { doSomething(e) }
// There might be more efficient ways to do this, but doing it this way means
// our code only has to worry about js.Funtion, not some union of a js.Function
// or a string.
pub fn stringToPersistedFunction(self: *Context, str: []const u8) !js.Function.Global {
var ls: js.Local.Scope = undefined;
self.localScope(&ls);
defer ls.deinit();
const js_function = try ls.local.compileFunction(function_body, parameter_names, extensions);
return js_function.persist();
var extra: []const u8 = "";
const normalized = std.mem.trim(u8, str, &std.ascii.whitespace);
if (normalized.len > 0 and normalized[normalized.len - 1] != ')') {
extra = "(e)";
}
const full = try std.fmt.allocPrintSentinel(self.call_arena, "(function(e) {{ {s}{s} }})", .{ normalized, extra }, 0);
const js_val = try ls.local.compileAndRun(full, null);
if (!js_val.isFunction()) {
return error.StringFunctionError;
}
return try (js.Function{ .local = &ls.local, .handle = @ptrCast(js_val.handle) }).persist();
}
pub fn module(self: *Context, comptime want_result: bool, local: *const js.Local, src: []const u8, url: []const u8, cacheable: bool) !(if (want_result) ModuleEntry else void) {
@@ -441,14 +535,6 @@ fn postCompileModule(self: *Context, mod: js.Module, url: [:0]const u8, local: *
nested_gop.key_ptr.* = owned_specifier;
nested_gop.value_ptr.* = .{};
try script_manager.preloadImport(owned_specifier, url);
} else if (nested_gop.value_ptr.module == null) {
// Entry exists but module failed to compile previously.
// The imported_modules entry may have been consumed, so
// re-preload to ensure waitForImport can find it.
// Key was stored via dupeZ so it has a sentinel in memory.
const key = nested_gop.key_ptr.*;
const key_z: [:0]const u8 = key.ptr[0..key.len :0];
try script_manager.preloadImport(key_z, url);
}
}
}
@@ -597,15 +683,7 @@ fn _resolveModuleCallback(self: *Context, referrer: js.Module, specifier: [:0]co
return local.toLocal(m).handle;
}
var source = self.script_manager.?.waitForImport(normalized_specifier) catch |err| switch (err) {
error.UnknownModule => blk: {
// Module is in cache but was consumed from imported_modules
// (e.g., by a previous failed resolution). Re-preload and retry.
try self.script_manager.?.preloadImport(normalized_specifier, referrer_path);
break :blk try self.script_manager.?.waitForImport(normalized_specifier);
},
else => return err,
};
var source = try self.script_manager.?.waitForImport(normalized_specifier);
defer source.deinit();
var try_catch: js.TryCatch = undefined;
@@ -945,6 +1023,34 @@ pub fn queueMicrotaskFunc(self: *Context, cb: js.Function) void {
v8.v8__MicrotaskQueue__EnqueueMicrotaskFunc(self.microtask_queue, self.isolate.handle, cb.handle);
}
pub fn createFinalizerCallback(self: *Context, global: v8.Global, ptr: *anyopaque, finalizerFn: *const fn (ptr: *anyopaque, page: *Page) void) !*FinalizerCallback {
const fc = try self.finalizer_callback_pool.create();
fc.* = .{
.ctx = self,
.ptr = ptr,
.global = global,
.finalizerFn = finalizerFn,
};
return fc;
}
// == Misc ==
// A type that has a finalizer can have its finalizer called one of two ways.
// The first is from V8 via the WeakCallback we give to weakRef. But that isn't
// guaranteed to fire, so we track this in ctx._finalizers and call them on
// context shutdown.
pub const FinalizerCallback = struct {
ctx: *Context,
ptr: *anyopaque,
global: v8.Global,
finalizerFn: *const fn (ptr: *anyopaque, page: *Page) void,
pub fn deinit(self: *FinalizerCallback) void {
self.finalizerFn(self.ptr, self.ctx.page);
self.ctx.finalizer_callback_pool.destroy(self);
}
};
// == Profiler ==
pub fn startCpuProfiler(self: *Context) void {
if (comptime !IS_DEBUG) {

View File

@@ -26,7 +26,6 @@ const App = @import("../../App.zig");
const log = @import("../../log.zig");
const bridge = @import("bridge.zig");
const Origin = @import("Origin.zig");
const Context = @import("Context.zig");
const Isolate = @import("Isolate.zig");
const Platform = @import("Platform.zig");
@@ -58,8 +57,6 @@ const Env = @This();
app: *App,
allocator: Allocator,
platform: *const Platform,
// the global isolate
@@ -73,11 +70,6 @@ isolate_params: *v8.CreateParams,
context_id: usize,
// Maps origin -> shared Origin contains, for v8 values shared across
// same-origin Contexts. There's a mismatch here between our JS model and our
// Browser model. Origins only live as long as the root page of a session exists.
// It would be wrong/dangerous to re-use an Origin across root page navigations.
// Global handles that need to be freed on deinit
eternal_function_templates: []v8.Eternal,
@@ -214,7 +206,6 @@ pub fn init(app: *App, opts: InitOpts) !Env {
return .{
.app = app,
.context_id = 0,
.allocator = allocator,
.contexts = undefined,
.context_count = 0,
.isolate = isolate,
@@ -237,9 +228,7 @@ pub fn deinit(self: *Env) void {
ctx.deinit();
}
const app = self.app;
const allocator = app.allocator;
const allocator = self.app.allocator;
if (self.inspector) |i| {
i.deinit(allocator);
}
@@ -283,7 +272,6 @@ pub fn createContext(self: *Env, page: *Page) !*Context {
// get the global object for the context, this maps to our Window
const global_obj = v8.v8__Context__Global(v8_context).?;
{
// Store our TAO inside the internal field of the global object. This
// maps the v8::Object -> Zig instance. Almost all objects have this, and
@@ -299,7 +287,6 @@ pub fn createContext(self: *Env, page: *Page) !*Context {
};
v8.v8__Object__SetAlignedPointerInInternalField(global_obj, 0, tao);
}
// our window wrapped in a v8::Global
var global_global: v8.Global = undefined;
v8.v8__Global__New(isolate.handle, global_obj, &global_global);
@@ -307,15 +294,10 @@ pub fn createContext(self: *Env, page: *Page) !*Context {
const context_id = self.context_id;
self.context_id = context_id + 1;
const origin = try page._session.getOrCreateOrigin(null);
errdefer page._session.releaseOrigin(origin);
const context = try context_arena.create(Context);
context.* = .{
.env = self,
.page = page,
.session = page._session,
.origin = origin,
.id = context_id,
.isolate = isolate,
.arena = context_arena,
@@ -325,8 +307,9 @@ pub fn createContext(self: *Env, page: *Page) !*Context {
.microtask_queue = microtask_queue,
.script_manager = &page._script_manager,
.scheduler = .init(context_arena),
.finalizer_callback_pool = std.heap.MemoryPool(Context.FinalizerCallback).init(self.app.allocator),
};
try context.origin.identity_map.putNoClobber(origin.arena, @intFromPtr(page.window), global_global);
try context.identity_map.putNoClobber(context_arena, @intFromPtr(page.window), global_global);
// Store a pointer to our context inside the v8 context so that, given
// a v8 context, we can get our context out

View File

@@ -160,8 +160,8 @@ fn _tryCallWithThis(self: *const Function, comptime T: type, this: anytype, args
try_catch.rethrow();
return error.TryCatchRethrow;
}
caught.* = try_catch.caughtOrError(local.call_arena, error.JsException);
return error.JsException;
caught.* = try_catch.caughtOrError(local.call_arena, error.JSExecCallback);
return error.JSExecCallback;
};
if (@typeInfo(T) == .void) {
@@ -209,11 +209,11 @@ fn _persist(self: *const Function, comptime is_global: bool) !(if (is_global) Gl
var global: v8.Global = undefined;
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
if (comptime is_global) {
try ctx.trackGlobal(global);
return .{ .handle = global, .origin = {} };
try ctx.global_functions.append(ctx.arena, global);
} else {
try ctx.global_functions_temp.put(ctx.arena, global.data_ptr, global);
}
try ctx.trackTemp(global);
return .{ .handle = global, .origin = ctx.origin };
return .{ .handle = global };
}
pub fn tempWithThis(self: *const Function, value: anytype) !Temp {
@@ -226,18 +226,15 @@ pub fn persistWithThis(self: *const Function, value: anytype) !Global {
return with_this.persist();
}
pub const Temp = G(.temp);
pub const Global = G(.global);
pub const Temp = G(0);
pub const Global = G(1);
const GlobalType = enum(u8) {
temp,
global,
};
fn G(comptime global_type: GlobalType) type {
fn G(comptime discriminator: u8) type {
return struct {
handle: v8.Global,
origin: if (global_type == .temp) *js.Origin else void,
// makes the types different (G(0) != G(1)), without taking up space
comptime _: u8 = discriminator,
const Self = @This();
@@ -255,9 +252,5 @@ fn G(comptime global_type: GlobalType) type {
pub fn isEqual(self: *const Self, other: Function) bool {
return v8.v8__Global__IsEqual(&self.handle, other.handle);
}
pub fn release(self: *const Self) void {
self.origin.releaseTemp(self.handle);
}
};
}

View File

@@ -130,12 +130,6 @@ pub fn contextCreated(
pub fn contextDestroyed(self: *Inspector, context: *const v8.Context) void {
v8.v8_inspector__Inspector__ContextDestroyed(self.handle, context);
if (self.default_context) |*dc| {
if (v8.v8__Global__IsEqual(dc, context)) {
self.default_context = null;
}
}
}
pub fn resetContextGroup(self: *const Inspector) void {

View File

@@ -18,7 +18,6 @@
const std = @import("std");
const Page = @import("../Page.zig");
const Session = @import("../Session.zig");
const log = @import("../../log.zig");
const string = @import("../../string.zig");
@@ -116,49 +115,6 @@ pub fn exec(self: *const Local, src: []const u8, name: ?[]const u8) !js.Value {
return self.compileAndRun(src, name);
}
/// Compiles a function body as function.
///
/// https://v8.github.io/api/head/classv8_1_1ScriptCompiler.html#a3a15bb5a7dfc3f998e6ac789e6b4646a
pub fn compileFunction(
self: *const Local,
function_body: []const u8,
/// We tend to know how many params we'll pass; can remove the comptime if necessary.
comptime parameter_names: []const []const u8,
extensions: []const v8.Object,
) !js.Function {
// TODO: Make configurable.
const script_name = self.isolate.initStringHandle("anonymous");
const script_source = self.isolate.initStringHandle(function_body);
var parameter_list: [parameter_names.len]*const v8.String = undefined;
inline for (0..parameter_names.len) |i| {
parameter_list[i] = self.isolate.initStringHandle(parameter_names[i]);
}
// Create `ScriptOrigin`.
var origin: v8.ScriptOrigin = undefined;
v8.v8__ScriptOrigin__CONSTRUCT(&origin, script_name);
// Create `ScriptCompilerSource`.
var script_compiler_source: v8.ScriptCompilerSource = undefined;
v8.v8__ScriptCompiler__Source__CONSTRUCT2(script_source, &origin, null, &script_compiler_source);
defer v8.v8__ScriptCompiler__Source__DESTRUCT(&script_compiler_source);
// Compile the function.
const result = v8.v8__ScriptCompiler__CompileFunction(
self.handle,
&script_compiler_source,
parameter_list.len,
&parameter_list,
extensions.len,
@ptrCast(&extensions),
v8.kNoCompileOptions,
v8.kNoCacheNoReason,
) orelse return error.CompilationError;
return .{ .local = self, .handle = result };
}
pub fn compileAndRun(self: *const Local, src: []const u8, name: ?[]const u8) !js.Value {
const script_name = self.isolate.initStringHandle(name orelse "anonymous");
const script_source = self.isolate.initStringHandle(src);
@@ -181,7 +137,7 @@ pub fn compileAndRun(self: *const Local, src: []const u8, name: ?[]const u8) !js
) orelse return error.CompilationError;
// Run the script
const result = v8.v8__Script__Run(v8_script, self.handle) orelse return error.JsException;
const result = v8.v8__Script__Run(v8_script, self.handle) orelse return error.ExecutionError;
return .{ .local = self, .handle = result };
}
@@ -202,20 +158,20 @@ pub fn compileAndRun(self: *const Local, src: []const u8, name: ?[]const u8) !js
// we can just grab it from the identity_map)
pub fn mapZigInstanceToJs(self: *const Local, js_obj_handle: ?*const v8.Object, value: anytype) !js.Object {
const ctx = self.ctx;
const origin_arena = ctx.origin.arena;
const arena = ctx.arena;
const T = @TypeOf(value);
switch (@typeInfo(T)) {
.@"struct" => {
// Struct, has to be placed on the heap
const heap = try origin_arena.create(T);
const heap = try arena.create(T);
heap.* = value;
return self.mapZigInstanceToJs(js_obj_handle, heap);
},
.pointer => |ptr| {
const resolved = resolveValue(value);
const gop = try ctx.origin.addIdentity(@intFromPtr(resolved.ptr));
const gop = try ctx.identity_map.getOrPut(arena, @intFromPtr(resolved.ptr));
if (gop.found_existing) {
// we've seen this instance before, return the same object
return (js.Object.Global{ .handle = gop.value_ptr.* }).local(self);
@@ -244,7 +200,7 @@ pub fn mapZigInstanceToJs(self: *const Local, js_obj_handle: ?*const v8.Object,
// The TAO contains the pointer to our Zig instance as
// well as any meta data we'll need to use it later.
// See the TaggedOpaque struct for more details.
const tao = try origin_arena.create(TaggedOpaque);
const tao = try arena.create(TaggedOpaque);
tao.* = .{
.value = resolved.ptr,
.prototype_chain = resolved.prototype_chain.ptr,
@@ -269,17 +225,16 @@ pub fn mapZigInstanceToJs(self: *const Local, js_obj_handle: ?*const v8.Object,
// can't figure out how to make that work, since it depends on
// the [runtime] `value`.
// We need the resolved finalizer, which we have in resolved.
//
// The above if statement would be more clear as:
// if (resolved.finalizer_from_v8) |finalizer| {
// But that's a runtime check.
// Instead, we check if the base has finalizer. The assumption
// here is that if a resolve type has a finalizer, then the base
// should have a finalizer too.
const fc = try ctx.origin.createFinalizerCallback(ctx.session, gop.value_ptr.*, resolved.ptr, resolved.finalizer_from_zig.?);
const fc = try ctx.createFinalizerCallback(gop.value_ptr.*, resolved.ptr, resolved.finalizer_from_zig.?);
{
errdefer fc.deinit();
try ctx.origin.finalizer_callbacks.put(ctx.origin.arena, @intFromPtr(resolved.ptr), fc);
try ctx.finalizer_callbacks.put(ctx.arena, @intFromPtr(resolved.ptr), fc);
}
conditionallyReference(value);
@@ -1128,7 +1083,7 @@ const Resolved = struct {
class_id: u16,
prototype_chain: []const @import("TaggedOpaque.zig").PrototypeChainEntry,
finalizer_from_v8: ?*const fn (handle: ?*const v8.WeakCallbackInfo) callconv(.c) void = null,
finalizer_from_zig: ?*const fn (ptr: *anyopaque, session: *Session) void = null,
finalizer_from_zig: ?*const fn (ptr: *anyopaque, page: *Page) void = null,
};
pub fn resolveValue(value: anytype) Resolved {
const T = bridge.Struct(@TypeOf(value));

View File

@@ -97,7 +97,7 @@ pub fn persist(self: Object) !Global {
var global: v8.Global = undefined;
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
try ctx.trackGlobal(global);
try ctx.global_objects.append(ctx.arena, global);
return .{ .handle = global };
}

View File

@@ -1,253 +0,0 @@
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// Origin represents the shared Zig<->JS bridge state for all contexts within
// the same origin. Multiple contexts (frames) from the same origin share a
// single Origin, ensuring that JS objects maintain their identity across frames.
const std = @import("std");
const js = @import("js.zig");
const App = @import("../../App.zig");
const Session = @import("../Session.zig");
const v8 = js.v8;
const Allocator = std.mem.Allocator;
const IS_DEBUG = @import("builtin").mode == .Debug;
const Origin = @This();
rc: usize = 1,
arena: Allocator,
// The key, e.g. lightpanda.io:443
key: []const u8,
// Security token - all contexts in this realm must use the same v8::Value instance
// as their security token for V8 to allow cross-context access
security_token: v8.Global,
// Serves two purposes. Like `global_objects`, this is used to free
// every Global(Object) we've created during the lifetime of the realm.
// More importantly, it serves as an identity map - for a given Zig
// instance, we map it to the same Global(Object).
// The key is the @intFromPtr of the Zig value
identity_map: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty,
// Some web APIs have to manage opaque values. Ideally, they use an
// js.Object, but the js.Object has no lifetime guarantee beyond the
// current call. They can call .persist() on their js.Object to get
// a `Global(Object)`. We need to track these to free them.
// This used to be a map and acted like identity_map; the key was
// the @intFromPtr(js_obj.handle). But v8 can re-use address. Without
// a reliable way to know if an object has already been persisted,
// we now simply persist every time persist() is called.
globals: std.ArrayList(v8.Global) = .empty,
// Temp variants stored in HashMaps for O(1) early cleanup.
// Key is global.data_ptr.
temps: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty,
// Any type that is stored in the identity_map which has a finalizer declared
// will have its finalizer stored here. This is only used when shutting down
// if v8 hasn't called the finalizer directly itself.
finalizer_callbacks: std.AutoHashMapUnmanaged(usize, *FinalizerCallback) = .empty,
pub fn init(app: *App, isolate: js.Isolate, key: []const u8) !*Origin {
const arena = try app.arena_pool.acquire();
errdefer app.arena_pool.release(arena);
var hs: js.HandleScope = undefined;
hs.init(isolate);
defer hs.deinit();
const owned_key = try arena.dupe(u8, key);
const token_local = isolate.initStringHandle(owned_key);
var token_global: v8.Global = undefined;
v8.v8__Global__New(isolate.handle, token_local, &token_global);
const self = try arena.create(Origin);
self.* = .{
.rc = 1,
.arena = arena,
.key = owned_key,
.globals = .empty,
.temps = .empty,
.security_token = token_global,
};
return self;
}
pub fn deinit(self: *Origin, app: *App) void {
// Call finalizers before releasing anything
{
var it = self.finalizer_callbacks.valueIterator();
while (it.next()) |finalizer| {
finalizer.*.deinit();
}
}
v8.v8__Global__Reset(&self.security_token);
{
var it = self.identity_map.valueIterator();
while (it.next()) |global| {
v8.v8__Global__Reset(global);
}
}
for (self.globals.items) |*global| {
v8.v8__Global__Reset(global);
}
{
var it = self.temps.valueIterator();
while (it.next()) |global| {
v8.v8__Global__Reset(global);
}
}
app.arena_pool.release(self.arena);
}
pub fn trackGlobal(self: *Origin, global: v8.Global) !void {
return self.globals.append(self.arena, global);
}
pub const IdentityResult = struct {
value_ptr: *v8.Global,
found_existing: bool,
};
pub fn addIdentity(self: *Origin, ptr: usize) !IdentityResult {
const gop = try self.identity_map.getOrPut(self.arena, ptr);
return .{
.value_ptr = gop.value_ptr,
.found_existing = gop.found_existing,
};
}
pub fn trackTemp(self: *Origin, global: v8.Global) !void {
return self.temps.put(self.arena, global.data_ptr, global);
}
pub fn releaseTemp(self: *Origin, global: v8.Global) void {
if (self.temps.fetchRemove(global.data_ptr)) |kv| {
var g = kv.value;
v8.v8__Global__Reset(&g);
}
}
/// Release an item from the identity_map (called after finalizer runs from V8)
pub fn release(self: *Origin, item: *anyopaque) void {
var global = self.identity_map.fetchRemove(@intFromPtr(item)) orelse {
if (comptime IS_DEBUG) {
std.debug.assert(false);
}
return;
};
v8.v8__Global__Reset(&global.value);
// The item has been finalized, remove it from the finalizer callback so that
// we don't try to call it again on shutdown.
const kv = self.finalizer_callbacks.fetchRemove(@intFromPtr(item)) orelse {
if (comptime IS_DEBUG) {
std.debug.assert(false);
}
return;
};
const fc = kv.value;
fc.session.releaseArena(fc.arena);
}
pub fn createFinalizerCallback(
self: *Origin,
session: *Session,
global: v8.Global,
ptr: *anyopaque,
zig_finalizer: *const fn (ptr: *anyopaque, session: *Session) void,
) !*FinalizerCallback {
const arena = try session.getArena(.{ .debug = "FinalizerCallback" });
errdefer session.releaseArena(arena);
const fc = try arena.create(FinalizerCallback);
fc.* = .{
.arena = arena,
.origin = self,
.session = session,
.ptr = ptr,
.global = global,
.zig_finalizer = zig_finalizer,
};
return fc;
}
pub fn transferTo(self: *Origin, dest: *Origin) !void {
const arena = dest.arena;
try dest.globals.ensureUnusedCapacity(arena, self.globals.items.len);
for (self.globals.items) |obj| {
dest.globals.appendAssumeCapacity(obj);
}
self.globals.clearRetainingCapacity();
{
try dest.temps.ensureUnusedCapacity(arena, self.temps.count());
var it = self.temps.iterator();
while (it.next()) |kv| {
try dest.temps.put(arena, kv.key_ptr.*, kv.value_ptr.*);
}
self.temps.clearRetainingCapacity();
}
{
try dest.finalizer_callbacks.ensureUnusedCapacity(arena, self.finalizer_callbacks.count());
var it = self.finalizer_callbacks.iterator();
while (it.next()) |kv| {
kv.value_ptr.*.origin = dest;
try dest.finalizer_callbacks.put(arena, kv.key_ptr.*, kv.value_ptr.*);
}
self.finalizer_callbacks.clearRetainingCapacity();
}
{
try dest.identity_map.ensureUnusedCapacity(arena, self.identity_map.count());
var it = self.identity_map.iterator();
while (it.next()) |kv| {
try dest.identity_map.put(arena, kv.key_ptr.*, kv.value_ptr.*);
}
self.identity_map.clearRetainingCapacity();
}
}
// A type that has a finalizer can have its finalizer called one of two ways.
// The first is from V8 via the WeakCallback we give to weakRef. But that isn't
// guaranteed to fire, so we track this in finalizer_callbacks and call them on
// origin shutdown.
pub const FinalizerCallback = struct {
arena: Allocator,
origin: *Origin,
session: *Session,
ptr: *anyopaque,
global: v8.Global,
zig_finalizer: *const fn (ptr: *anyopaque, session: *Session) void,
pub fn deinit(self: *FinalizerCallback) void {
self.zig_finalizer(self.ptr, self.session);
self.session.releaseArena(self.arena);
}
};

View File

@@ -62,25 +62,22 @@ fn _persist(self: *const Promise, comptime is_global: bool) !(if (is_global) Glo
var global: v8.Global = undefined;
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
if (comptime is_global) {
try ctx.trackGlobal(global);
return .{ .handle = global, .origin = {} };
try ctx.global_promises.append(ctx.arena, global);
} else {
try ctx.global_promises_temp.put(ctx.arena, global.data_ptr, global);
}
try ctx.trackTemp(global);
return .{ .handle = global, .origin = ctx.origin };
return .{ .handle = global };
}
pub const Temp = G(.temp);
pub const Global = G(.global);
pub const Temp = G(0);
pub const Global = G(1);
const GlobalType = enum(u8) {
temp,
global,
};
fn G(comptime global_type: GlobalType) type {
fn G(comptime discriminator: u8) type {
return struct {
handle: v8.Global,
origin: if (global_type == .temp) *js.Origin else void,
// makes the types different (G(0) != G(1)), without taking up space
comptime _: u8 = discriminator,
const Self = @This();
@@ -94,9 +91,5 @@ fn G(comptime global_type: GlobalType) type {
.handle = @ptrCast(v8.v8__Global__Get(&self.handle, l.isolate.handle)),
};
}
pub fn release(self: *const Self) void {
self.origin.releaseTemp(self.handle);
}
};
}

View File

@@ -79,7 +79,7 @@ pub fn persist(self: PromiseResolver) !Global {
var ctx = self.local.ctx;
var global: v8.Global = undefined;
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
try ctx.trackGlobal(global);
try ctx.global_promise_resolvers.append(ctx.arena, global);
return .{ .handle = global };
}

View File

@@ -56,7 +56,7 @@ fn _toSlice(self: String, comptime null_terminate: bool, allocator: Allocator) !
pub fn toSSO(self: String, comptime global: bool) !(if (global) SSO.Global else SSO) {
if (comptime global) {
return .{ .str = try self.toSSOWithAlloc(self.local.ctx.origin.arena) };
return .{ .str = try self.toSSOWithAlloc(self.local.ctx.arena) };
}
return self.toSSOWithAlloc(self.local.call_arena);
}

View File

@@ -245,46 +245,6 @@ pub fn toJson(self: Value, allocator: Allocator) ![]u8 {
return js.String.toSliceWithAlloc(.{ .local = local, .handle = str_handle }, allocator);
}
// Currently does not support host objects (Blob, File, etc.) or transferables
// which require delegate callbacks to be implemented.
pub fn structuredClone(self: Value) !Value {
const local = self.local;
const v8_context = local.handle;
const v8_isolate = local.isolate.handle;
const size, const data = blk: {
const serializer = v8.v8__ValueSerializer__New(v8_isolate, null) orelse return error.JsException;
defer v8.v8__ValueSerializer__DELETE(serializer);
var write_result: v8.MaybeBool = undefined;
v8.v8__ValueSerializer__WriteHeader(serializer);
v8.v8__ValueSerializer__WriteValue(serializer, v8_context, self.handle, &write_result);
if (!write_result.has_value or !write_result.value) {
return error.JsException;
}
var size: usize = undefined;
const data = v8.v8__ValueSerializer__Release(serializer, &size) orelse return error.JsException;
break :blk .{ size, data };
};
defer v8.v8__ValueSerializer__FreeBuffer(data);
const cloned_handle = blk: {
const deserializer = v8.v8__ValueDeserializer__New(v8_isolate, data, size, null) orelse return error.JsException;
defer v8.v8__ValueDeserializer__DELETE(deserializer);
var read_header_result: v8.MaybeBool = undefined;
v8.v8__ValueDeserializer__ReadHeader(deserializer, v8_context, &read_header_result);
if (!read_header_result.has_value or !read_header_result.value) {
return error.JsException;
}
break :blk v8.v8__ValueDeserializer__ReadValue(deserializer, v8_context) orelse return error.JsException;
};
return .{ .local = local, .handle = cloned_handle };
}
pub fn persist(self: Value) !Global {
return self._persist(true);
}
@@ -299,11 +259,11 @@ fn _persist(self: *const Value, comptime is_global: bool) !(if (is_global) Globa
var global: v8.Global = undefined;
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
if (comptime is_global) {
try ctx.trackGlobal(global);
return .{ .handle = global, .origin = {} };
try ctx.global_values.append(ctx.arena, global);
} else {
try ctx.global_values_temp.put(ctx.arena, global.data_ptr, global);
}
try ctx.trackTemp(global);
return .{ .handle = global, .origin = ctx.origin };
return .{ .handle = global };
}
pub fn toZig(self: Value, comptime T: type) !T {
@@ -350,18 +310,15 @@ pub fn format(self: Value, writer: *std.Io.Writer) !void {
return js_str.format(writer);
}
pub const Temp = G(.temp);
pub const Global = G(.global);
pub const Temp = G(0);
pub const Global = G(1);
const GlobalType = enum(u8) {
temp,
global,
};
fn G(comptime global_type: GlobalType) type {
fn G(comptime discriminator: u8) type {
return struct {
handle: v8.Global,
origin: if (global_type == .temp) *js.Origin else void,
// makes the types different (G(0) != G(1)), without taking up space
comptime _: u8 = discriminator,
const Self = @This();
@@ -379,9 +336,5 @@ fn G(comptime global_type: GlobalType) type {
pub fn isEqual(self: *const Self, other: Value) bool {
return v8.v8__Global__IsEqual(&self.handle, other.handle);
}
pub fn release(self: *const Self) void {
self.origin.releaseTemp(self.handle);
}
};
}

View File

@@ -21,13 +21,11 @@ const js = @import("js.zig");
const lp = @import("lightpanda");
const log = @import("../../log.zig");
const Page = @import("../Page.zig");
const Session = @import("../Session.zig");
const v8 = js.v8;
const Caller = @import("Caller.zig");
const Context = @import("Context.zig");
const Origin = @import("Origin.zig");
const IS_DEBUG = @import("builtin").mode == .Debug;
@@ -106,24 +104,24 @@ pub fn Builder(comptime T: type) type {
return entries;
}
pub fn finalizer(comptime func: *const fn (self: *T, shutdown: bool, session: *Session) void) Finalizer {
pub fn finalizer(comptime func: *const fn (self: *T, shutdown: bool, page: *Page) void) Finalizer {
return .{
.from_zig = struct {
fn wrap(ptr: *anyopaque, session: *Session) void {
func(@ptrCast(@alignCast(ptr)), true, session);
fn wrap(ptr: *anyopaque, page: *Page) void {
func(@ptrCast(@alignCast(ptr)), true, page);
}
}.wrap,
.from_v8 = struct {
fn wrap(handle: ?*const v8.WeakCallbackInfo) callconv(.c) void {
const ptr = v8.v8__WeakCallbackInfo__GetParameter(handle.?).?;
const fc: *Origin.FinalizerCallback = @ptrCast(@alignCast(ptr));
const fc: *Context.FinalizerCallback = @ptrCast(@alignCast(ptr));
const origin = fc.origin;
const ctx = fc.ctx;
const value_ptr = fc.ptr;
if (origin.finalizer_callbacks.contains(@intFromPtr(value_ptr))) {
func(@ptrCast(@alignCast(value_ptr)), false, fc.session);
origin.release(value_ptr);
if (ctx.finalizer_callbacks.contains(@intFromPtr(value_ptr))) {
func(@ptrCast(@alignCast(value_ptr)), false, ctx.page);
ctx.release(value_ptr);
} else {
// A bit weird, but v8 _requires_ that we release it
// If we don't. We'll 100% crash.
@@ -415,12 +413,12 @@ pub const Property = struct {
};
const Finalizer = struct {
// The finalizer wrapper when called from Zig. This is only called on
// Origin.deinit
from_zig: *const fn (ctx: *anyopaque, session: *Session) void,
// The finalizer wrapper when called fro Zig. This is only called on
// Context.deinit
from_zig: *const fn (ctx: *anyopaque, page: *Page) void,
// The finalizer wrapper when called from V8. This may never be called
// (hence why we fallback to calling in Origin.deinit). If it is called,
// (hence why we fallback to calling in Context.denit). If it is called,
// it is only ever called after we SetWeak on the Global.
from_v8: *const fn (?*const v8.WeakCallbackInfo) callconv(.c) void,
};
@@ -732,7 +730,6 @@ pub const JsApis = flattenTypes(&.{
@import("../webapi/css/CSSStyleRule.zig"),
@import("../webapi/css/CSSStyleSheet.zig"),
@import("../webapi/css/CSSStyleProperties.zig"),
@import("../webapi/css/FontFace.zig"),
@import("../webapi/css/FontFaceSet.zig"),
@import("../webapi/css/MediaQueryList.zig"),
@import("../webapi/css/StyleSheetList.zig"),
@@ -885,7 +882,6 @@ pub const JsApis = flattenTypes(&.{
@import("../webapi/IdleDeadline.zig"),
@import("../webapi/Blob.zig"),
@import("../webapi/File.zig"),
@import("../webapi/FileList.zig"),
@import("../webapi/FileReader.zig"),
@import("../webapi/Screen.zig"),
@import("../webapi/VisualViewport.zig"),

View File

@@ -24,7 +24,6 @@ const string = @import("../../string.zig");
pub const Env = @import("Env.zig");
pub const bridge = @import("bridge.zig");
pub const Caller = @import("Caller.zig");
pub const Origin = @import("Origin.zig");
pub const Context = @import("Context.zig");
pub const Local = @import("Local.zig");
pub const Inspector = @import("Inspector.zig");
@@ -162,7 +161,7 @@ pub fn ArrayBufferRef(comptime kind: ArrayType) type {
var ctx = self.local.ctx;
var global: v8.Global = undefined;
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
try ctx.trackGlobal(global);
try ctx.global_values.append(ctx.arena, global);
return .{ .handle = global };
}

View File

@@ -24,7 +24,6 @@ const TreeWalker = @import("webapi/TreeWalker.zig");
const CData = @import("webapi/CData.zig");
const Element = @import("webapi/Element.zig");
const Node = @import("webapi/Node.zig");
const isAllWhitespace = @import("../string.zig").isAllWhitespace;
pub const Opts = struct {
// Options for future customization (e.g., dialect)
@@ -47,6 +46,13 @@ const State = struct {
last_char_was_newline: bool = true,
};
fn isBlock(tag: Element.Tag) bool {
return switch (tag) {
.p, .div, .section, .article, .main, .header, .footer, .nav, .aside, .h1, .h2, .h3, .h4, .h5, .h6, .ul, .ol, .blockquote, .pre, .table, .hr => true,
else => false,
};
}
fn shouldAddSpacing(tag: Element.Tag) bool {
return switch (tag) {
.p, .h1, .h2, .h3, .h4, .h5, .h6, .blockquote, .pre, .table => true,
@@ -93,18 +99,26 @@ fn isSignificantText(node: *Node) bool {
}
fn isVisibleElement(el: *Element) bool {
const tag = el.getTag();
return !tag.isMetadata() and tag != .svg;
return switch (el.getTag()) {
.script, .style, .noscript, .template, .head, .meta, .link, .title, .svg => false,
else => true,
};
}
fn getAnchorLabel(el: *Element) ?[]const u8 {
return el.getAttributeSafe(comptime .wrap("aria-label")) orelse el.getAttributeSafe(comptime .wrap("title"));
}
fn isAllWhitespace(text: []const u8) bool {
return for (text) |c| {
if (!std.ascii.isWhitespace(c)) break false;
} else true;
}
fn hasBlockDescendant(root: *Node) bool {
var tw = TreeWalker.FullExcludeSelf.Elements.init(root, .{});
while (tw.next()) |el| {
if (el.getTag().isBlock()) return true;
if (isBlock(el.getTag())) return true;
}
return false;
}
@@ -178,7 +192,7 @@ fn renderElement(el: *Element, state: *State, writer: *std.Io.Writer, page: *Pag
// --- Opening Tag Logic ---
// Ensure block elements start on a new line (double newline for paragraphs etc)
if (tag.isBlock() and !state.in_table) {
if (isBlock(tag) and !state.in_table) {
try ensureNewline(state, writer);
if (shouldAddSpacing(tag)) {
try writer.writeByte('\n');
@@ -417,7 +431,7 @@ fn renderElement(el: *Element, state: *State, writer: *std.Io.Writer, page: *Pag
}
// Post-block newlines
if (tag.isBlock() and !state.in_table) {
if (isBlock(tag) and !state.in_table) {
try ensureNewline(state, writer);
}
}

View File

@@ -23,9 +23,6 @@ const h5e = @import("html5ever.zig");
const Page = @import("../Page.zig");
const Node = @import("../webapi/Node.zig");
const Element = @import("../webapi/Element.zig");
pub const AttributeIterator = h5e.AttributeIterator;
const Allocator = std.mem.Allocator;
const IS_DEBUG = @import("builtin").mode == .Debug;

View File

@@ -1,489 +0,0 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const Page = @import("Page.zig");
const URL = @import("URL.zig");
const TreeWalker = @import("webapi/TreeWalker.zig");
const Element = @import("webapi/Element.zig");
const Node = @import("webapi/Node.zig");
const Allocator = std.mem.Allocator;
/// Key-value pair for structured data properties.
pub const Property = struct {
key: []const u8,
value: []const u8,
};
pub const AlternateLink = struct {
href: []const u8,
hreflang: ?[]const u8,
type: ?[]const u8,
title: ?[]const u8,
};
pub const StructuredData = struct {
json_ld: []const []const u8,
open_graph: []const Property,
twitter_card: []const Property,
meta: []const Property,
links: []const Property,
alternate: []const AlternateLink,
pub fn jsonStringify(self: *const StructuredData, jw: anytype) !void {
try jw.beginObject();
try jw.objectField("jsonLd");
try jw.write(self.json_ld);
try jw.objectField("openGraph");
try writeProperties(jw, self.open_graph);
try jw.objectField("twitterCard");
try writeProperties(jw, self.twitter_card);
try jw.objectField("meta");
try writeProperties(jw, self.meta);
try jw.objectField("links");
try writeProperties(jw, self.links);
if (self.alternate.len > 0) {
try jw.objectField("alternate");
try jw.beginArray();
for (self.alternate) |alt| {
try jw.beginObject();
try jw.objectField("href");
try jw.write(alt.href);
if (alt.hreflang) |v| {
try jw.objectField("hreflang");
try jw.write(v);
}
if (alt.type) |v| {
try jw.objectField("type");
try jw.write(v);
}
if (alt.title) |v| {
try jw.objectField("title");
try jw.write(v);
}
try jw.endObject();
}
try jw.endArray();
}
try jw.endObject();
}
};
/// Serializes properties as a JSON object. When a key appears multiple times
/// (e.g. multiple og:image tags), values are grouped into an array.
/// Alternatives considered: always-array values (verbose), or an array of
/// {key, value} pairs (preserves order but less ergonomic for consumers).
fn writeProperties(jw: anytype, properties: []const Property) !void {
try jw.beginObject();
for (properties, 0..) |prop, i| {
// Skip keys already written by an earlier occurrence.
var already_written = false;
for (properties[0..i]) |prev| {
if (std.mem.eql(u8, prev.key, prop.key)) {
already_written = true;
break;
}
}
if (already_written) continue;
// Count total occurrences to decide string vs array.
var count: usize = 0;
for (properties) |p| {
if (std.mem.eql(u8, p.key, prop.key)) count += 1;
}
try jw.objectField(prop.key);
if (count == 1) {
try jw.write(prop.value);
} else {
try jw.beginArray();
for (properties) |p| {
if (std.mem.eql(u8, p.key, prop.key)) {
try jw.write(p.value);
}
}
try jw.endArray();
}
}
try jw.endObject();
}
/// Extract all structured data from the page.
pub fn collectStructuredData(
root: *Node,
arena: Allocator,
page: *Page,
) !StructuredData {
var json_ld: std.ArrayList([]const u8) = .empty;
var open_graph: std.ArrayList(Property) = .empty;
var twitter_card: std.ArrayList(Property) = .empty;
var meta: std.ArrayList(Property) = .empty;
var links: std.ArrayList(Property) = .empty;
var alternate: std.ArrayList(AlternateLink) = .empty;
// Extract language from the root <html> element.
if (root.is(Element)) |root_el| {
if (root_el.getAttributeSafe(comptime .wrap("lang"))) |lang| {
try meta.append(arena, .{ .key = "language", .value = lang });
}
} else {
// Root is document — check documentElement.
var children = root.childrenIterator();
while (children.next()) |child| {
const el = child.is(Element) orelse continue;
if (el.getTag() == .html) {
if (el.getAttributeSafe(comptime .wrap("lang"))) |lang| {
try meta.append(arena, .{ .key = "language", .value = lang });
}
break;
}
}
}
var tw = TreeWalker.Full.init(root, .{});
while (tw.next()) |node| {
const el = node.is(Element) orelse continue;
switch (el.getTag()) {
.script => {
try collectJsonLd(el, arena, &json_ld);
tw.skipChildren();
},
.meta => collectMeta(el, &open_graph, &twitter_card, &meta, arena) catch {},
.title => try collectTitle(node, arena, &meta),
.link => try collectLink(el, arena, page, &links, &alternate),
// Skip body subtree for non-JSON-LD — all other metadata is in <head>.
// JSON-LD can appear in <body> so we don't skip the whole body.
else => {},
}
}
return .{
.json_ld = json_ld.items,
.open_graph = open_graph.items,
.twitter_card = twitter_card.items,
.meta = meta.items,
.links = links.items,
.alternate = alternate.items,
};
}
fn collectJsonLd(
el: *Element,
arena: Allocator,
json_ld: *std.ArrayList([]const u8),
) !void {
const type_attr = el.getAttributeSafe(comptime .wrap("type")) orelse return;
if (!std.ascii.eqlIgnoreCase(type_attr, "application/ld+json")) return;
var buf: std.Io.Writer.Allocating = .init(arena);
try el.asNode().getTextContent(&buf.writer);
const text = buf.written();
if (text.len > 0) {
try json_ld.append(arena, std.mem.trim(u8, text, &std.ascii.whitespace));
}
}
fn collectMeta(
el: *Element,
open_graph: *std.ArrayList(Property),
twitter_card: *std.ArrayList(Property),
meta: *std.ArrayList(Property),
arena: Allocator,
) !void {
// charset: <meta charset="..."> (no content attribute needed).
if (el.getAttributeSafe(comptime .wrap("charset"))) |charset| {
try meta.append(arena, .{ .key = "charset", .value = charset });
}
const content = el.getAttributeSafe(comptime .wrap("content")) orelse return;
// Open Graph: <meta property="og:...">
if (el.getAttributeSafe(comptime .wrap("property"))) |property| {
if (std.mem.startsWith(u8, property, "og:")) {
try open_graph.append(arena, .{ .key = property[3..], .value = content });
return;
}
// Article, profile, etc. are OG sub-namespaces.
if (std.mem.startsWith(u8, property, "article:") or
std.mem.startsWith(u8, property, "profile:") or
std.mem.startsWith(u8, property, "book:") or
std.mem.startsWith(u8, property, "music:") or
std.mem.startsWith(u8, property, "video:"))
{
try open_graph.append(arena, .{ .key = property, .value = content });
return;
}
}
// Twitter Cards: <meta name="twitter:...">
if (el.getAttributeSafe(comptime .wrap("name"))) |name| {
if (std.mem.startsWith(u8, name, "twitter:")) {
try twitter_card.append(arena, .{ .key = name[8..], .value = content });
return;
}
// Standard meta tags by name.
const known_names = [_][]const u8{
"description", "author", "keywords", "robots",
"viewport", "generator", "theme-color",
};
for (known_names) |known| {
if (std.ascii.eqlIgnoreCase(name, known)) {
try meta.append(arena, .{ .key = known, .value = content });
return;
}
}
}
// http-equiv (e.g. Content-Type, refresh)
if (el.getAttributeSafe(comptime .wrap("http-equiv"))) |http_equiv| {
try meta.append(arena, .{ .key = http_equiv, .value = content });
}
}
fn collectTitle(
node: *Node,
arena: Allocator,
meta: *std.ArrayList(Property),
) !void {
var buf: std.Io.Writer.Allocating = .init(arena);
try node.getTextContent(&buf.writer);
const text = std.mem.trim(u8, buf.written(), &std.ascii.whitespace);
if (text.len > 0) {
try meta.append(arena, .{ .key = "title", .value = text });
}
}
fn collectLink(
el: *Element,
arena: Allocator,
page: *Page,
links: *std.ArrayList(Property),
alternate: *std.ArrayList(AlternateLink),
) !void {
const rel = el.getAttributeSafe(comptime .wrap("rel")) orelse return;
const raw_href = el.getAttributeSafe(comptime .wrap("href")) orelse return;
const href = URL.resolve(arena, page.base(), raw_href, .{ .encode = true }) catch raw_href;
if (std.ascii.eqlIgnoreCase(rel, "alternate")) {
try alternate.append(arena, .{
.href = href,
.hreflang = el.getAttributeSafe(comptime .wrap("hreflang")),
.type = el.getAttributeSafe(comptime .wrap("type")),
.title = el.getAttributeSafe(comptime .wrap("title")),
});
return;
}
const relevant_rels = [_][]const u8{
"canonical", "icon", "manifest", "shortcut icon",
"apple-touch-icon", "search", "author", "license",
"dns-prefetch", "preconnect",
};
for (relevant_rels) |known| {
if (std.ascii.eqlIgnoreCase(rel, known)) {
try links.append(arena, .{ .key = known, .value = href });
return;
}
}
}
// --- Tests ---
const testing = @import("../testing.zig");
fn testStructuredData(html: []const u8) !StructuredData {
const page = try testing.test_session.createPage();
defer testing.test_session.removePage();
const doc = page.window._document;
const div = try doc.createElement("div", null, page);
try page.parseHtmlAsChildren(div.asNode(), html);
return collectStructuredData(div.asNode(), page.call_arena, page);
}
fn findProperty(props: []const Property, key: []const u8) ?[]const u8 {
for (props) |p| {
if (std.mem.eql(u8, p.key, key)) return p.value;
}
return null;
}
test "structured_data: json-ld" {
const data = try testStructuredData(
\\<script type="application/ld+json">
\\{"@context":"https://schema.org","@type":"Article","headline":"Test"}
\\</script>
);
try testing.expectEqual(1, data.json_ld.len);
try testing.expect(std.mem.indexOf(u8, data.json_ld[0], "Article") != null);
}
test "structured_data: multiple json-ld" {
const data = try testStructuredData(
\\<script type="application/ld+json">{"@type":"Organization"}</script>
\\<script type="application/ld+json">{"@type":"BreadcrumbList"}</script>
\\<script type="text/javascript">var x = 1;</script>
);
try testing.expectEqual(2, data.json_ld.len);
}
test "structured_data: open graph" {
const data = try testStructuredData(
\\<meta property="og:title" content="My Page">
\\<meta property="og:description" content="A description">
\\<meta property="og:image" content="https://example.com/img.jpg">
\\<meta property="og:url" content="https://example.com">
\\<meta property="og:type" content="article">
\\<meta property="article:published_time" content="2026-03-10">
);
try testing.expectEqual(6, data.open_graph.len);
try testing.expectEqual("My Page", findProperty(data.open_graph, "title").?);
try testing.expectEqual("article", findProperty(data.open_graph, "type").?);
try testing.expectEqual("2026-03-10", findProperty(data.open_graph, "article:published_time").?);
}
test "structured_data: open graph duplicate keys" {
const data = try testStructuredData(
\\<meta property="og:title" content="My Page">
\\<meta property="og:image" content="https://example.com/img1.jpg">
\\<meta property="og:image" content="https://example.com/img2.jpg">
\\<meta property="og:image" content="https://example.com/img3.jpg">
);
// Duplicate keys are preserved as separate Property entries.
try testing.expectEqual(4, data.open_graph.len);
// Verify serialization groups duplicates into arrays.
const json = try std.json.Stringify.valueAlloc(testing.allocator, data, .{});
defer testing.allocator.free(json);
const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
defer parsed.deinit();
const og = parsed.value.object.get("openGraph").?.object;
// "title" appears once → string.
switch (og.get("title").?) {
.string => {},
else => return error.TestUnexpectedResult,
}
// "image" appears 3 times → array.
switch (og.get("image").?) {
.array => |arr| try testing.expectEqual(3, arr.items.len),
else => return error.TestUnexpectedResult,
}
}
test "structured_data: twitter card" {
const data = try testStructuredData(
\\<meta name="twitter:card" content="summary_large_image">
\\<meta name="twitter:site" content="@example">
\\<meta name="twitter:title" content="My Page">
);
try testing.expectEqual(3, data.twitter_card.len);
try testing.expectEqual("summary_large_image", findProperty(data.twitter_card, "card").?);
try testing.expectEqual("@example", findProperty(data.twitter_card, "site").?);
}
test "structured_data: meta tags" {
const data = try testStructuredData(
\\<title>Page Title</title>
\\<meta name="description" content="A test page">
\\<meta name="author" content="Test Author">
\\<meta name="keywords" content="test, example">
\\<meta name="robots" content="index, follow">
);
try testing.expectEqual("Page Title", findProperty(data.meta, "title").?);
try testing.expectEqual("A test page", findProperty(data.meta, "description").?);
try testing.expectEqual("Test Author", findProperty(data.meta, "author").?);
try testing.expectEqual("test, example", findProperty(data.meta, "keywords").?);
try testing.expectEqual("index, follow", findProperty(data.meta, "robots").?);
}
test "structured_data: link elements" {
const data = try testStructuredData(
\\<link rel="canonical" href="https://example.com/page">
\\<link rel="icon" href="/favicon.ico">
\\<link rel="manifest" href="/manifest.json">
\\<link rel="stylesheet" href="/style.css">
);
try testing.expectEqual(3, data.links.len);
try testing.expectEqual("https://example.com/page", findProperty(data.links, "canonical").?);
// stylesheet should be filtered out
try testing.expectEqual(null, findProperty(data.links, "stylesheet"));
}
test "structured_data: alternate links" {
const data = try testStructuredData(
\\<link rel="alternate" href="https://example.com/fr" hreflang="fr" title="French">
\\<link rel="alternate" href="https://example.com/de" hreflang="de">
);
try testing.expectEqual(2, data.alternate.len);
try testing.expectEqual("fr", data.alternate[0].hreflang.?);
try testing.expectEqual("French", data.alternate[0].title.?);
try testing.expectEqual("de", data.alternate[1].hreflang.?);
try testing.expectEqual(null, data.alternate[1].title);
}
test "structured_data: non-metadata elements ignored" {
const data = try testStructuredData(
\\<div>Just text</div>
\\<p>More text</p>
\\<a href="/link">Link</a>
);
try testing.expectEqual(0, data.json_ld.len);
try testing.expectEqual(0, data.open_graph.len);
try testing.expectEqual(0, data.twitter_card.len);
try testing.expectEqual(0, data.meta.len);
try testing.expectEqual(0, data.links.len);
}
test "structured_data: charset and http-equiv" {
const data = try testStructuredData(
\\<meta charset="utf-8">
\\<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
);
try testing.expectEqual("utf-8", findProperty(data.meta, "charset").?);
try testing.expectEqual("text/html; charset=utf-8", findProperty(data.meta, "Content-Type").?);
}
test "structured_data: mixed content" {
const data = try testStructuredData(
\\<title>My Site</title>
\\<meta property="og:title" content="OG Title">
\\<meta name="twitter:card" content="summary">
\\<meta name="description" content="A page">
\\<link rel="canonical" href="https://example.com">
\\<script type="application/ld+json">{"@type":"WebSite"}</script>
);
try testing.expectEqual(1, data.json_ld.len);
try testing.expectEqual(1, data.open_graph.len);
try testing.expectEqual(1, data.twitter_card.len);
try testing.expectEqual("My Site", findProperty(data.meta, "title").?);
try testing.expectEqual("A page", findProperty(data.meta, "description").?);
try testing.expectEqual(1, data.links.len);
}

View File

@@ -98,64 +98,6 @@
}
</script>
<script id=mime_parsing>
// MIME types are lowercased
{
const blob = new Blob([], { type: "TEXT/HTML" });
testing.expectEqual("text/html", blob.type);
}
{
const blob = new Blob([], { type: "Application/JSON" });
testing.expectEqual("application/json", blob.type);
}
// MIME with parameters - lowercased
{
const blob = new Blob([], { type: "text/html; charset=UTF-8" });
testing.expectEqual("text/html; charset=utf-8", blob.type);
}
// Any ASCII string is accepted and lowercased (no MIME structure validation)
{
const blob = new Blob([], { type: "invalid" });
testing.expectEqual("invalid", blob.type);
}
{
const blob = new Blob([], { type: "/" });
testing.expectEqual("/", blob.type);
}
// Non-ASCII characters cause empty string (chars outside U+0020-U+007E)
{
const blob = new Blob([], { type: "ý/x" });
testing.expectEqual("", blob.type);
}
{
const blob = new Blob([], { type: "text/plàin" });
testing.expectEqual("", blob.type);
}
// Control characters cause empty string
{
const blob = new Blob([], { type: "text/html\x00" });
testing.expectEqual("", blob.type);
}
// Empty type stays empty
{
const blob = new Blob([]);
testing.expectEqual("", blob.type);
}
{
const blob = new Blob([], { type: "" });
testing.expectEqual("", blob.type);
}
</script>
<script id=slice>
{
const parts = ["la", "symphonie", "des", "éclairs"];

View File

@@ -89,41 +89,6 @@
}
</script>
<script id="CanvasRenderingContext2D#getImageData">
{
const element = document.createElement("canvas");
element.width = 100;
element.height = 50;
const ctx = element.getContext("2d");
const imageData = ctx.getImageData(0, 0, 10, 20);
testing.expectEqual(true, imageData instanceof ImageData);
testing.expectEqual(imageData.width, 10);
testing.expectEqual(imageData.height, 20);
testing.expectEqual(imageData.data.length, 10 * 20 * 4);
testing.expectEqual(true, imageData.data instanceof Uint8ClampedArray);
// Undrawn canvas should return transparent black pixels.
testing.expectEqual(imageData.data[0], 0);
testing.expectEqual(imageData.data[1], 0);
testing.expectEqual(imageData.data[2], 0);
testing.expectEqual(imageData.data[3], 0);
}
</script>
<script id="CanvasRenderingContext2D#getImageData invalid">
{
const element = document.createElement("canvas");
const ctx = element.getContext("2d");
// Zero or negative width/height should throw IndexSizeError.
testing.expectError('Index or size', () => ctx.getImageData(0, 0, 0, 10));
testing.expectError('Index or size', () => ctx.getImageData(0, 0, 10, 0));
testing.expectError('Index or size', () => ctx.getImageData(0, 0, -5, 10));
testing.expectError('Index or size', () => ctx.getImageData(0, 0, 10, -5));
}
</script>
<script id="getter">
{

View File

@@ -62,26 +62,3 @@
testing.expectEqual(offscreen.height, 96);
}
</script>
<script id=OffscreenCanvasRenderingContext2D#getImageData>
{
const canvas = new OffscreenCanvas(100, 50);
const ctx = canvas.getContext("2d");
const imageData = ctx.getImageData(0, 0, 10, 20);
testing.expectEqual(true, imageData instanceof ImageData);
testing.expectEqual(imageData.width, 10);
testing.expectEqual(imageData.height, 20);
testing.expectEqual(imageData.data.length, 10 * 20 * 4);
// Undrawn canvas should return transparent black pixels.
testing.expectEqual(imageData.data[0], 0);
testing.expectEqual(imageData.data[1], 0);
testing.expectEqual(imageData.data[2], 0);
testing.expectEqual(imageData.data[3], 0);
// Zero or negative dimensions should throw.
testing.expectError('Index or size', () => ctx.getImageData(0, 0, 0, 10));
testing.expectError('Index or size', () => ctx.getImageData(0, 0, 10, -5));
}
</script>

View File

@@ -1,63 +0,0 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script id="constructor_basic">
{
const face = new FontFace("TestFont", "url(test.woff)");
testing.expectTrue(face instanceof FontFace);
}
</script>
<script id="constructor_name">
{
testing.expectEqual('FontFace', FontFace.name);
}
</script>
<script id="family_property">
{
const face = new FontFace("MyFont", "url(font.woff2)");
testing.expectEqual("MyFont", face.family);
}
</script>
<script id="status_is_loaded">
{
const face = new FontFace("F", "url(f.woff)");
testing.expectEqual("loaded", face.status);
}
</script>
<script id="loaded_is_promise">
{
const face = new FontFace("F", "url(f.woff)");
testing.expectTrue(face.loaded instanceof Promise);
}
</script>
<script id="load_returns_promise">
{
const face = new FontFace("F", "url(f.woff)");
testing.expectTrue(face.load() instanceof Promise);
}
</script>
<script id="default_descriptors">
{
const face = new FontFace("F", "url(f.woff)");
testing.expectEqual("normal", face.style);
testing.expectEqual("normal", face.weight);
testing.expectEqual("normal", face.stretch);
testing.expectEqual("normal", face.variant);
testing.expectEqual("normal", face.featureSettings);
testing.expectEqual("auto", face.display);
}
</script>
<script id="document_fonts_add">
{
const face = new FontFace("AddedFont", "url(added.woff)");
const result = document.fonts.add(face);
testing.expectTrue(result === document.fonts);
}
</script>

View File

@@ -56,25 +56,3 @@
testing.expectEqual('FontFaceSet', document.fonts.constructor.name);
}
</script>
<script id="document_fonts_addEventListener">
{
let loading = false;
document.fonts.addEventListener('loading', function() {
loading = true;
});
let loadingdone = false;
document.fonts.addEventListener('loadingdone', function() {
loadingdone = true;
});
document.fonts.load("italic bold 16px Roboto");
testing.eventually(() => {
testing.expectEqual(true, loading);
testing.expectEqual(true, loadingdone);
});
testing.expectEqual(true, true);
}
</script>

View File

@@ -275,147 +275,3 @@
testing.expectEqual('red', div.style.getPropertyValue('color'));
}
</script>
<script id="CSSStyleDeclaration_normalize_zero_to_0px">
{
// Per CSSOM spec, unitless zero in length properties should serialize as "0px"
const div = document.createElement('div');
div.style.width = '0';
testing.expectEqual('0px', div.style.width);
div.style.margin = '0';
testing.expectEqual('0px', div.style.margin);
div.style.padding = '0';
testing.expectEqual('0px', div.style.padding);
div.style.top = '0';
testing.expectEqual('0px', div.style.top);
// Scroll properties
div.style.scrollMarginTop = '0';
testing.expectEqual('0px', div.style.scrollMarginTop);
div.style.scrollPaddingBottom = '0';
testing.expectEqual('0px', div.style.scrollPaddingBottom);
// Multi-column
div.style.columnWidth = '0';
testing.expectEqual('0px', div.style.columnWidth);
div.style.columnRuleWidth = '0';
testing.expectEqual('0px', div.style.columnRuleWidth);
// Outline shorthand
div.style.outline = '0';
testing.expectEqual('0px', div.style.outline);
// Shapes
div.style.shapeMargin = '0';
testing.expectEqual('0px', div.style.shapeMargin);
// Non-length properties should not be affected
div.style.opacity = '0';
testing.expectEqual('0', div.style.opacity);
div.style.zIndex = '0';
testing.expectEqual('0', div.style.zIndex);
}
</script>
<script id="CSSStyleDeclaration_normalize_first_baseline">
{
// "first baseline" should serialize canonically as "baseline"
const div = document.createElement('div');
div.style.alignItems = 'first baseline';
testing.expectEqual('baseline', div.style.alignItems);
div.style.alignContent = 'first baseline';
testing.expectEqual('baseline', div.style.alignContent);
div.style.alignSelf = 'first baseline';
testing.expectEqual('baseline', div.style.alignSelf);
div.style.justifySelf = 'first baseline';
testing.expectEqual('baseline', div.style.justifySelf);
// "last baseline" should remain unchanged
div.style.alignItems = 'last baseline';
testing.expectEqual('last baseline', div.style.alignItems);
}
</script>
<script id="CSSStyleDeclaration_normalize_duplicate_values">
{
// For 2-value shorthand properties, "X X" should collapse to "X"
const div = document.createElement('div');
div.style.placeContent = 'center center';
testing.expectEqual('center', div.style.placeContent);
div.style.placeContent = 'start start';
testing.expectEqual('start', div.style.placeContent);
div.style.gap = '10px 10px';
testing.expectEqual('10px', div.style.gap);
// Different values should not collapse
div.style.placeContent = 'center start';
testing.expectEqual('center start', div.style.placeContent);
div.style.gap = '10px 20px';
testing.expectEqual('10px 20px', div.style.gap);
// New shorthands
div.style.overflow = 'hidden hidden';
testing.expectEqual('hidden', div.style.overflow);
div.style.scrollSnapAlign = 'start start';
testing.expectEqual('start', div.style.scrollSnapAlign);
div.style.overscrollBehavior = 'auto auto';
testing.expectEqual('auto', div.style.overscrollBehavior);
}
</script>
<script id="CSSStyleDeclaration_normalize_anchor_size">
{
// anchor-size() should serialize with dashed ident (anchor name) before size keyword
const div = document.createElement('div');
// Already canonical order - should stay the same
div.style.width = 'anchor-size(--foo width)';
testing.expectEqual('anchor-size(--foo width)', div.style.width);
// Non-canonical order - should be reordered
div.style.width = 'anchor-size(width --foo)';
testing.expectEqual('anchor-size(--foo width)', div.style.width);
// With fallback value
div.style.width = 'anchor-size(height --bar, 100px)';
testing.expectEqual('anchor-size(--bar height, 100px)', div.style.width);
// Different size keywords
div.style.width = 'anchor-size(block --baz)';
testing.expectEqual('anchor-size(--baz block)', div.style.width);
div.style.width = 'anchor-size(inline --qux)';
testing.expectEqual('anchor-size(--qux inline)', div.style.width);
div.style.width = 'anchor-size(self-block --test)';
testing.expectEqual('anchor-size(--test self-block)', div.style.width);
div.style.width = 'anchor-size(self-inline --test)';
testing.expectEqual('anchor-size(--test self-inline)', div.style.width);
// Without anchor name (implicit default anchor)
div.style.width = 'anchor-size(width)';
testing.expectEqual('anchor-size(width)', div.style.width);
// Nested anchor-size in fallback
div.style.width = 'anchor-size(width --foo, anchor-size(height --bar))';
testing.expectEqual('anchor-size(--foo width, anchor-size(--bar height))', div.style.width);
}
</script>

View File

@@ -72,59 +72,3 @@
testing.expectEqual(2, calls);
}
</script>
<div id=fragment_clone_container></div>
<script id=clone_fragment>
{
let calls = 0;
class MyFragmentCloneElement extends HTMLElement {
constructor() {
super();
calls += 1;
$('#fragment_clone_container').appendChild(this);
}
}
customElements.define('my-fragment-clone-element', MyFragmentCloneElement);
// Create a DocumentFragment with a custom element
const fragment = document.createDocumentFragment();
const customEl = document.createElement('my-fragment-clone-element');
fragment.appendChild(customEl);
// Clone the fragment - this should trigger the crash
// because the constructor will attach the element during cloning
const clonedFragment = fragment.cloneNode(true);
testing.expectEqual(2, calls);
}
</script>
<div id=range_clone_container></div>
<script id=clone_range>
{
let calls = 0;
class MyRangeCloneElement extends HTMLElement {
constructor() {
super();
calls += 1;
$('#range_clone_container').appendChild(this);
}
}
customElements.define('my-range-clone-element', MyRangeCloneElement);
// Create a container with a custom element
const container = document.createElement('div');
const customEl = document.createElement('my-range-clone-element');
container.appendChild(customEl);
// Create a range that includes the custom element
const range = document.createRange();
range.selectNodeContents(container);
// Clone the range contents - this should trigger the crash
// because the constructor will attach the element during cloning
const clonedContents = range.cloneContents();
testing.expectEqual(2, calls);
}
</script>

View File

@@ -1,66 +0,0 @@
<!DOCTYPE html>
<head>
<script src="../testing.js"></script>
<script>
// Test that document.open/write/close throw InvalidStateError during custom element
// reactions when the element is parsed from HTML
window.constructorOpenException = null;
window.constructorWriteException = null;
window.constructorCloseException = null;
window.constructorCalled = false;
class ThrowTestElement extends HTMLElement {
constructor() {
super();
window.constructorCalled = true;
// Try document.open on the same document during constructor - should throw
try {
document.open();
} catch (e) {
window.constructorOpenException = e;
}
// Try document.write on the same document during constructor - should throw
try {
document.write('<b>test</b>');
} catch (e) {
window.constructorWriteException = e;
}
// Try document.close on the same document during constructor - should throw
try {
document.close();
} catch (e) {
window.constructorCloseException = e;
}
}
}
customElements.define('throw-test-element', ThrowTestElement);
</script>
</head>
<body>
<!-- This element will be parsed from HTML, triggering the constructor -->
<throw-test-element id="test-element"></throw-test-element>
<script id="verify_throws">
{
// Verify the constructor was called
testing.expectEqual(true, window.constructorCalled);
// Verify document.open threw InvalidStateError
testing.expectEqual(true, window.constructorOpenException !== null);
testing.expectEqual('InvalidStateError', window.constructorOpenException.name);
// Verify document.write threw InvalidStateError
testing.expectEqual(true, window.constructorWriteException !== null);
testing.expectEqual('InvalidStateError', window.constructorWriteException.name);
// Verify document.close threw InvalidStateError
testing.expectEqual(true, window.constructorCloseException !== null);
testing.expectEqual('InvalidStateError', window.constructorCloseException.name);
}
</script>
</body>

View File

@@ -4,17 +4,9 @@
<script id=basic>
{
{
const parser = new DOMParser();
testing.expectEqual('object', typeof parser);
testing.expectEqual('function', typeof parser.parseFromString);
}
{
// Empty XML is a parse error (no root element)
const parser = new DOMParser();
testing.expectError('Error', () => parser.parseFromString('', 'text/xml'));
}
const parser = new DOMParser();
testing.expectEqual('object', typeof parser);
testing.expectEqual('function', typeof parser.parseFromString);
}
</script>
@@ -397,25 +389,3 @@
}
}
</script>
<script id=getElementsByTagName-xml>
{
const parser = new DOMParser();
const doc = parser.parseFromString('<layout><row><col>A</col><col>B</col></row></layout>', 'text/xml');
// Test getElementsByTagName on document
const rows = doc.getElementsByTagName('row');
testing.expectEqual(1, rows.length);
// Test getElementsByTagName on element
const row = rows[0];
const cols = row.getElementsByTagName('col');
testing.expectEqual(2, cols.length);
testing.expectEqual('A', cols[0].textContent);
testing.expectEqual('B', cols[1].textContent);
// Test getElementsByTagName('*') on element
const allElements = row.getElementsByTagName('*');
testing.expectEqual(2, allElements.length);
}
</script>

View File

@@ -23,22 +23,6 @@
}
</script>
<script id="action">
{
const form = document.createElement('form')
testing.expectEqual(testing.BASE_URL + 'element/html/form.html', form.action)
form.action = 'hello';
testing.expectEqual(testing.BASE_URL + 'element/html/hello', form.action)
form.action = '/hello';
testing.expectEqual(testing.ORIGIN + 'hello', form.action)
form.action = 'https://lightpanda.io/hello';
testing.expectEqual('https://lightpanda.io/hello', form.action)
}
</script>
<!-- Test fixtures for form.method -->
<form id="form_get" method="get"></form>
<form id="form_post" method="post"></form>

View File

@@ -1,61 +0,0 @@
<!DOCTYPE html>
<script src="../../../testing.js"></script>
<script id=force_async>
{
// Dynamically created scripts have async=true by default
let s = document.createElement('script');
testing.expectEqual(true, s.async);
// Setting async=false clears the force async flag and removes attribute
s.async = false;
testing.expectEqual(false, s.async);
testing.expectEqual(false, s.hasAttribute('async'));
// Setting async=true adds the attribute
s.async = true;
testing.expectEqual(true, s.async);
testing.expectEqual(true, s.hasAttribute('async'));
}
</script>
<script></script>
<script id=empty>
{
// Empty parser-inserted script should have async=true (force async retained)
let scripts = document.getElementsByTagName('script');
let emptyScript = scripts[scripts.length - 2];
testing.expectEqual(true, emptyScript.async);
}
</script>
<script id=text_content>
{
let s = document.createElement('script');
s.appendChild(document.createComment('COMMENT'));
s.appendChild(document.createTextNode(' TEXT '));
s.appendChild(document.createProcessingInstruction('P', 'I'));
let a = s.appendChild(document.createElement('a'));
a.appendChild(document.createTextNode('ELEMENT'));
// script.text should return only direct Text node children
testing.expectEqual(' TEXT ', s.text);
// script.textContent should return all descendant text
testing.expectEqual(' TEXT ELEMENT', s.textContent);
}
</script>
<script id=lazy_inline>
{
// Empty script in DOM, then append text - should execute
window.lazyScriptRan = false;
let s = document.createElement('script');
document.head.appendChild(s);
// Script is in DOM but empty, so not yet executed
testing.expectEqual(false, window.lazyScriptRan);
// Append text node with code
s.appendChild(document.createTextNode('window.lazyScriptRan = true;'));
// Now it should have executed
testing.expectEqual(true, window.lazyScriptRan);
}
</script>

View File

@@ -1,54 +0,0 @@
<!DOCTYPE html>
<head></head>
<script src="../../../testing.js"></script>
<script id=textContent_inline>
window.inline_executed = false;
const s1 = document.createElement('script');
s1.textContent = 'window.inline_executed = true;';
document.head.appendChild(s1);
testing.expectTrue(window.inline_executed);
</script>
<script id=text_property_inline>
window.text_executed = false;
const s2 = document.createElement('script');
s2.text = 'window.text_executed = true;';
document.head.appendChild(s2);
testing.expectTrue(window.text_executed);
</script>
<script id=innerHTML_inline>
window.innerHTML_executed = false;
const s3 = document.createElement('script');
s3.innerHTML = 'window.innerHTML_executed = true;';
document.head.appendChild(s3);
testing.expectTrue(window.innerHTML_executed);
</script>
<script id=no_double_execute_inline>
window.inline_counter = 0;
const s4 = document.createElement('script');
s4.textContent = 'window.inline_counter++;';
document.head.appendChild(s4);
document.head.appendChild(s4);
testing.expectEqual(1, window.inline_counter);
</script>
<script id=empty_script_no_execute>
window.empty_ran = false;
const s5 = document.createElement('script');
document.head.appendChild(s5);
testing.expectFalse(window.empty_ran);
</script>
<script id=module_inline>
window.module_executed = false;
const s6 = document.createElement('script');
s6.type = 'module';
s6.textContent = 'window.module_executed = true;';
document.head.appendChild(s6);
testing.eventually(() => {
testing.expectTrue(window.module_executed);
});
</script>

View File

@@ -81,17 +81,6 @@
}
</script>
<script id="is_empty">
{
// Empty :is() and :where() are valid per spec and match nothing
const isEmptyResult = document.querySelectorAll(':is()');
testing.expectEqual(0, isEmptyResult.length);
const whereEmptyResult = document.querySelectorAll(':where()');
testing.expectEqual(0, whereEmptyResult.length);
}
</script>
<div id=escaped class=":popover-open"></div>
<script id="escaped">
{

View File

@@ -12,6 +12,8 @@
// Empty functional pseudo-classes should error
testing.expectError("Error: InvalidPseudoClass", () => container.querySelector(':has()'));
testing.expectError("Error: InvalidPseudoClass", () => container.querySelector(':not()'));
testing.expectError("Error: InvalidPseudoClass", () => container.querySelector(':is()'));
testing.expectError("Error: InvalidPseudoClass", () => container.querySelector(':where()'));
testing.expectError("Error: InvalidPseudoClass", () => container.querySelector(':lang()'));
}
</script>

View File

@@ -7,69 +7,54 @@
}
</script>
<iframe id=f0></iframe>
<iframe id=f1 onload="frame1Onload()" src="support/sub 1.html"></iframe>
<iframe id=f1 onload="frame1Onload" src="support/sub 1.html"></iframe>
<iframe id=f2 src="support/sub2.html"></iframe>
<script id=empty>
{
const blank = document.createElement('iframe');
testing.expectEqual(null, blank.contentDocument);
document.documentElement.appendChild(blank);
testing.expectEqual('<html><head></head><body></body></html>', blank.contentDocument.documentElement.outerHTML);
const f0 = $('#f0')
testing.expectEqual('<html><head></head><body></body></html>', f0.contentDocument.documentElement.outerHTML);
}
</script>
<script id="basic">
// reload it
$('#f2').src = 'support/sub2.html';
testing.expectEqual(true, true);
testing.eventually(() => {
testing.expectEqual(undefined, window[20]);
testing.expectEqual(undefined, window[10]);
testing.expectEqual(window, window[0].top);
testing.expectEqual(window, window[0].parent);
testing.expectEqual(false, window === window[0]);
testing.expectEqual(window, window[1].top);
testing.expectEqual(window, window[1].parent);
testing.expectEqual(false, window === window[1]);
testing.expectEqual(window, window[2].top);
testing.expectEqual(window, window[2].parent);
testing.expectEqual(false, window === window[2]);
testing.expectEqual(false, window[1] === window[2]);
testing.expectEqual(false, window[0] === window[1]);
testing.expectEqual(0, $('#f1').childNodes.length);
testing.expectEqual(testing.BASE_URL + 'frames/support/sub%201.html', $('#f1').src);
testing.expectEqual(window[1], $('#f1').contentWindow);
testing.expectEqual(window[2], $('#f2').contentWindow);
testing.expectEqual(window[0], $('#f1').contentWindow);
testing.expectEqual(window[1], $('#f2').contentWindow);
testing.expectEqual(window[1].document, $('#f1').contentDocument);
testing.expectEqual(window[2].document, $('#f2').contentDocument);
testing.expectEqual(window[0].document, $('#f1').contentDocument);
testing.expectEqual(window[1].document, $('#f2').contentDocument);
// sibling frames share the same top
testing.expectEqual(window[1].top, window[2].top);
testing.expectEqual(window[0].top, window[1].top);
// child frames have no sub-frames
testing.expectEqual(0, window[0].length);
testing.expectEqual(0, window[1].length);
testing.expectEqual(0, window[2].length);
// self and window are self-referential on child frames
testing.expectEqual(window[0], window[0].self);
testing.expectEqual(window[0], window[0].window);
testing.expectEqual(window[1], window[1].self);
testing.expectEqual(window[1], window[1].window);
testing.expectEqual(window[2], window[2].self);
// child frame's top.parent is itself (root has no parent)
testing.expectEqual(window, window[0].top.parent);
// Cross-frame property access
testing.expectEqual(true, window.sub1_loaded);
testing.expectEqual(true, window.sub2_loaded);
testing.expectEqual(1, window.sub1_count);
// depends on how far the initial load got before it was cancelled.
testing.expectEqual(true, window.sub2_count == 1 || window.sub2_count == 2);
// Todo: Context security tokens
// testing.expectEqual(true, window.sub1_loaded);
// testing.expectEqual(true, window.sub2_loaded);
// testing.expectEqual(1, window.sub1_count);
// testing.expectEqual(2, window.sub2_count);
});
</script>
@@ -77,7 +62,6 @@
{
let f3_load_event = false;
let f3 = document.createElement('iframe');
f3.id = 'f3';
f3.addEventListener('load', () => {
f3_load_event = true;
});
@@ -91,10 +75,9 @@
}
</script>
<script id=about_blank>
<script id=onload>
{
let f4 = document.createElement('iframe');
f4.id = 'f4';
f4.src = "about:blank";
document.documentElement.appendChild(f4);
@@ -104,43 +87,8 @@
}
</script>
<script id=about_blank_renavigate>
{
let f5 = document.createElement('iframe');
f5.id = 'f5';
f5.src = "support/page.html";
document.documentElement.appendChild(f5);
f5.src = "about:blank";
testing.eventually(() => {
testing.expectEqual("<html><head></head><body></body></html>", f5.contentDocument.documentElement.outerHTML);
});
}
</script>
<script id=link_click>
testing.async(async (restore) => {
await new Promise((resolve) => {
let count = 0;
let f6 = document.createElement('iframe');
f6.id = 'f6';
f6.addEventListener('load', () => {
if (++count == 2) {
resolve();
return;
}
f6.contentDocument.querySelector('#link').click();
});
f6.src = "support/with_link.html";
document.documentElement.appendChild(f6);
});
restore();
testing.expectEqual("<html><head></head><body>It was clicked!\n</body></html>", f6.contentDocument.documentElement.outerHTML);
});
</script>
<script id=count>
testing.eventually(() => {
testing.expectEqual(8, window.length);
testing.expectEqual(4, window.length);
});
</script>

View File

@@ -1,2 +0,0 @@
<!DOCTYPE html>
It was clicked!

View File

@@ -1,2 +0,0 @@
<!DOCTYPE html>
a-page

View File

@@ -1,2 +0,0 @@
<!DOCTYPE html>
<a href="support/after_link.html" id=link>a link</a>

View File

@@ -1,42 +0,0 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<iframe name=f1 id=frame1></iframe>
<a id=l1 target=f1 href=support/page.html></a>
<script id=anchor>
$('#l1').click();
testing.eventually(() => {
testing.expectEqual('<html><head></head><body>a-page\n</body></html>', $('#frame1').contentDocument.documentElement.outerHTML);
});
</script>
<script id=form>
{
let frame2 = document.createElement('iframe');
frame2.name = 'frame2';
document.documentElement.appendChild(frame2);
let form = document.createElement('form');
form.target = 'frame2';
form.action = 'support/page.html';
form.submit();
testing.eventually(() => {
testing.expectEqual('<html><head></head><body>a-page\n</body></html>', frame2.contentDocument.documentElement.outerHTML);
});
}
</script>
<iframe name=frame3 id=f3></iframe>
<form target="_top" action="support/page.html">
<input type=submit id=submit1 formtarget="frame3">
</form>
<script id=formtarget>
{
$('#submit1').click();
testing.eventually(() => {
testing.expectEqual('<html><head></head><body>a-page\n</body></html>', $('#f3').contentDocument.documentElement.outerHTML);
});
}
</script>

View File

@@ -6,7 +6,6 @@
</html>
<script src="../testing.js"></script>
<applet></applet>
<script id=document>
testing.expectEqual('HTMLDocument', document.__proto__.constructor.name);
@@ -24,7 +23,7 @@
testing.expectEqual(2, document.scripts.length);
testing.expectEqual(0, document.forms.length);
testing.expectEqual(1, document.links.length);
testing.expectEqual(0, document.applets.length); // deprecated, always returns 0
testing.expectEqual(0, document.applets.length);
testing.expectEqual(0, document.anchors.length);
testing.expectEqual(7, document.all.length);
testing.expectEqual('document', document.currentScript.id);

View File

@@ -137,79 +137,3 @@
testing.expectEqual('PROPFIND', req.method);
}
</script>
<script id=body_methods>
testing.async(async () => {
const req = new Request('https://example.com/api', {
method: 'POST',
body: 'Hello, World!',
headers: { 'Content-Type': 'text/plain' }
});
const text = await req.text();
testing.expectEqual('Hello, World!', text);
});
testing.async(async () => {
const req = new Request('https://example.com/api', {
method: 'POST',
body: '{"name": "test"}',
headers: { 'Content-Type': 'application/json' }
});
const json = await req.json();
testing.expectEqual('test', json.name);
});
testing.async(async () => {
const req = new Request('https://example.com/api', {
method: 'POST',
body: 'binary data',
headers: { 'Content-Type': 'application/octet-stream' }
});
const buffer = await req.arrayBuffer();
testing.expectEqual(true, buffer instanceof ArrayBuffer);
testing.expectEqual(11, buffer.byteLength);
});
testing.async(async () => {
const req = new Request('https://example.com/api', {
method: 'POST',
body: 'blob content',
headers: { 'Content-Type': 'text/plain' }
});
const blob = await req.blob();
testing.expectEqual(true, blob instanceof Blob);
testing.expectEqual(12, blob.size);
testing.expectEqual('text/plain', blob.type);
});
testing.async(async () => {
const req = new Request('https://example.com/api', {
method: 'POST',
body: 'bytes'
});
const bytes = await req.bytes();
testing.expectEqual(true, bytes instanceof Uint8Array);
testing.expectEqual(5, bytes.length);
});
</script>
<script id=clone>
{
const req1 = new Request('https://example.com/api', {
method: 'POST',
body: 'test body',
headers: { 'X-Custom': 'value' }
});
const req2 = req1.clone();
testing.expectEqual(req1.url, req2.url);
testing.expectEqual(req1.method, req2.method);
testing.expectEqual('value', req2.headers.get('X-Custom'));
}
</script>

View File

@@ -2,113 +2,51 @@
<script src="../testing.js"></script>
<script id=response>
{
let response = new Response("Hello, World!");
testing.expectEqual(200, response.status);
testing.expectEqual("", response.statusText);
testing.expectEqual(true, response.ok);
testing.expectEqual("", response.url);
testing.expectEqual(false, response.redirected);
}
// let response = new Response("Hello, World!");
// testing.expectEqual(200, response.status);
// testing.expectEqual("", response.statusText);
// testing.expectEqual(true, response.ok);
// testing.expectEqual("", response.url);
// testing.expectEqual(false, response.redirected);
{
let response2 = new Response("Error occurred", {
status: 404,
statusText: "Not Found",
headers: {
"Content-Type": "text/plain",
"X-Custom": "test-value",
"Cache-Control": "no-cache"
}
});
testing.expectEqual(404, response2.status);
testing.expectEqual("Not Found", response2.statusText);
testing.expectEqual(false, response2.ok);
testing.expectEqual("test-value", response2.headers.get("X-Custom"));
testing.expectEqual("no-cache", response2.headers.get("cache-control"));
}
let response2 = new Response("Error occurred", {
status: 404,
statusText: "Not Found",
headers: {
"Content-Type": "text/plain",
"X-Custom": "test-value",
"Cache-Control": "no-cache"
}
});
testing.expectEqual(true, true);
// testing.expectEqual(404, response2.status);
// testing.expectEqual("Not Found", response2.statusText);
// testing.expectEqual(false, response2.ok);
// testing.expectEqual("text/plain", response2.headers);
// testing.expectEqual("test-value", response2.headers.get("X-Custom"));
testing.expectEqual("no-cache", response2.headers.get("cache-control"));
{
let response3 = new Response("Created", { status: 201, statusText: "Created" });
testing.expectEqual("basic", response3.type);
testing.expectEqual(201, response3.status);
testing.expectEqual("Created", response3.statusText);
testing.expectEqual(true, response3.ok);
}
// let response3 = new Response("Created", { status: 201, statusText: "Created" });
// testing.expectEqual("basic", response3.type);
// testing.expectEqual(201, response3.status);
// testing.expectEqual("Created", response3.statusText);
// testing.expectEqual(true, response3.ok);
{
let nullResponse = new Response(null);
testing.expectEqual(200, nullResponse.status);
testing.expectEqual("", nullResponse.statusText);
}
// let nullResponse = new Response(null);
// testing.expectEqual(200, nullResponse.status);
// testing.expectEqual("", nullResponse.statusText);
{
let emptyResponse = new Response("");
testing.expectEqual(200, emptyResponse.status);
}
// let emptyResponse = new Response("");
// testing.expectEqual(200, emptyResponse.status);
</script>
<script id=body_methods>
<!-- <script id=json>
testing.async(async () => {
const response = new Response('Hello, World!');
const text = await response.text();
testing.expectEqual('Hello, World!', text);
});
testing.async(async () => {
const response = new Response('{"name": "test"}');
const json = await response.json();
testing.expectEqual('test', json.name);
});
testing.async(async () => {
const response = new Response('binary data');
const buffer = await response.arrayBuffer();
testing.expectEqual(true, buffer instanceof ArrayBuffer);
testing.expectEqual(11, buffer.byteLength);
});
testing.async(async () => {
const response = new Response('blob content', {
headers: { 'Content-Type': 'text/plain' }
const json = await new Promise((resolve) => {
let response = new Response('[]');
response.json().then(resolve)
});
const blob = await response.blob();
testing.expectEqual(true, blob instanceof Blob);
testing.expectEqual(12, blob.size);
testing.expectEqual('text/plain', blob.type);
});
testing.async(async () => {
const response = new Response('bytes');
const bytes = await response.bytes();
testing.expectEqual(true, bytes instanceof Uint8Array);
testing.expectEqual(5, bytes.length);
});
</script>
<script id=clone>
{
const response1 = new Response('test body', {
status: 201,
statusText: 'Created',
headers: { 'X-Custom': 'value' }
});
const response2 = response1.clone();
testing.expectEqual(response1.status, response2.status);
testing.expectEqual(response1.statusText, response2.statusText);
testing.expectEqual('value', response2.headers.get('X-Custom'));
}
testing.async(async () => {
const response1 = new Response('cloned body');
const response2 = response1.clone();
const text1 = await response1.text();
const text2 = await response2.text();
testing.expectEqual('cloned body', text1);
testing.expectEqual('cloned body', text2);
testing.expectEqual([], json);
});
</script>
-->

View File

@@ -1,41 +0,0 @@
<!DOCTYPE html>
<body></body>
<script src="../testing.js"></script>
<script id="basic_blob_navigation">
{
const html = '<html><head></head><body><div id="test">Hello Blob</div></body></html>';
const blob = new Blob([html], { type: 'text/html' });
const blob_url = URL.createObjectURL(blob);
const iframe = document.createElement('iframe');
document.body.appendChild(iframe);
iframe.src = blob_url;
testing.eventually(() => {
testing.expectEqual('Hello Blob', iframe.contentDocument.getElementById('test').textContent);
});
}
</script>
<script id="multiple_blobs">
{
const blob1 = new Blob(['<html><body>First</body></html>'], { type: 'text/html' });
const blob2 = new Blob(['<html><body>Second</body></html>'], { type: 'text/html' });
const url1 = URL.createObjectURL(blob1);
const url2 = URL.createObjectURL(blob2);
const iframe1 = document.createElement('iframe');
document.body.appendChild(iframe1);
iframe1.src = url1;
const iframe2 = document.createElement('iframe');
document.body.appendChild(iframe2);
iframe2.src = url2;
testing.eventually(() => {
testing.expectEqual('First', iframe1.contentDocument.body.textContent);
testing.expectEqual('Second', iframe2.contentDocument.body.textContent);
});
}
</script>

View File

@@ -1022,50 +1022,3 @@
testing.expectEqual('Stnd', div.textContent);
}
</script>
<script id=getBoundingClientRect_collapsed>
{
const range = new Range();
const rect = range.getBoundingClientRect();
testing.expectTrue(rect instanceof DOMRect);
testing.expectEqual(0, rect.x);
testing.expectEqual(0, rect.y);
testing.expectEqual(0, rect.width);
testing.expectEqual(0, rect.height);
}
</script>
<script id=getBoundingClientRect_element>
{
const range = new Range();
const p = document.getElementById('p1');
range.selectNodeContents(p);
const rect = range.getBoundingClientRect();
testing.expectTrue(rect instanceof DOMRect);
// Non-collapsed range delegates to the container element
const elemRect = p.getBoundingClientRect();
testing.expectEqual(elemRect.x, rect.x);
testing.expectEqual(elemRect.y, rect.y);
testing.expectEqual(elemRect.width, rect.width);
testing.expectEqual(elemRect.height, rect.height);
}
</script>
<script id=getClientRects_collapsed>
{
const range = new Range();
const rects = range.getClientRects();
testing.expectEqual(0, rects.length);
}
</script>
<script id=getClientRects_element>
{
const range = new Range();
const p = document.getElementById('p1');
range.selectNodeContents(p);
const rects = range.getClientRects();
const elemRects = p.getClientRects();
testing.expectEqual(elemRects.length, rects.length);
}
</script>

View File

@@ -1,315 +0,0 @@
<!DOCTYPE html>
<script src="testing.js"></script>
<script id=insertData_adjusts_range_offsets>
{
const text = document.createTextNode('abcdef');
const div = document.createElement('div');
div.appendChild(text);
const range = document.createRange();
range.setStart(text, 2);
range.setEnd(text, 5);
// range covers "cde"
// Insert "XX" at offset 1 (before range start)
text.insertData(1, 'XX');
// "aXXbcdef" — range should shift right by 2
testing.expectEqual(4, range.startOffset);
testing.expectEqual(7, range.endOffset);
testing.expectEqual(text, range.startContainer);
}
</script>
<script id=insertData_at_range_start>
{
const text = document.createTextNode('abcdef');
const div = document.createElement('div');
div.appendChild(text);
const range = document.createRange();
range.setStart(text, 2);
range.setEnd(text, 5);
// Insert at exactly the start offset — should not shift start
text.insertData(2, 'YY');
// "abYYcdef" — start stays at 2, end shifts by 2
testing.expectEqual(2, range.startOffset);
testing.expectEqual(7, range.endOffset);
}
</script>
<script id=insertData_inside_range>
{
const text = document.createTextNode('abcdef');
const div = document.createElement('div');
div.appendChild(text);
const range = document.createRange();
range.setStart(text, 2);
range.setEnd(text, 5);
// Insert inside the range
text.insertData(3, 'Z');
// "abcZdef" — start unchanged, end shifts by 1
testing.expectEqual(2, range.startOffset);
testing.expectEqual(6, range.endOffset);
}
</script>
<script id=insertData_after_range>
{
const text = document.createTextNode('abcdef');
const div = document.createElement('div');
div.appendChild(text);
const range = document.createRange();
range.setStart(text, 2);
range.setEnd(text, 5);
// Insert after range end — no change
text.insertData(5, 'ZZ');
testing.expectEqual(2, range.startOffset);
testing.expectEqual(5, range.endOffset);
}
</script>
<script id=deleteData_before_range>
{
const text = document.createTextNode('abcdef');
const div = document.createElement('div');
div.appendChild(text);
const range = document.createRange();
range.setStart(text, 3);
range.setEnd(text, 5);
// range covers "de"
// Delete "ab" (offset 0, count 2) — before range
text.deleteData(0, 2);
// "cdef" — range shifts left by 2
testing.expectEqual(1, range.startOffset);
testing.expectEqual(3, range.endOffset);
}
</script>
<script id=deleteData_overlapping_range_start>
{
const text = document.createTextNode('abcdef');
const div = document.createElement('div');
div.appendChild(text);
const range = document.createRange();
range.setStart(text, 2);
range.setEnd(text, 5);
// Delete from offset 1, count 2 — overlaps range start
text.deleteData(1, 2);
// "adef" — start clamped to offset(1), end adjusted
testing.expectEqual(1, range.startOffset);
testing.expectEqual(3, range.endOffset);
}
</script>
<script id=deleteData_inside_range>
{
const text = document.createTextNode('abcdef');
const div = document.createElement('div');
div.appendChild(text);
const range = document.createRange();
range.setStart(text, 1);
range.setEnd(text, 5);
// Delete inside range: offset 2, count 2
text.deleteData(2, 2);
// "abef" — start unchanged, end shifts by -2
testing.expectEqual(1, range.startOffset);
testing.expectEqual(3, range.endOffset);
}
</script>
<script id=replaceData_adjusts_range>
{
const text = document.createTextNode('abcdef');
const div = document.createElement('div');
div.appendChild(text);
const range = document.createRange();
range.setStart(text, 2);
range.setEnd(text, 5);
// Replace "cd" (offset 2, count 2) with "XXXX" (4 chars)
text.replaceData(2, 2, 'XXXX');
// "abXXXXef" — start clamped to 2, end adjusted by (4-2)=+2
testing.expectEqual(2, range.startOffset);
testing.expectEqual(7, range.endOffset);
}
</script>
<script id=splitText_moves_range_to_new_node>
{
const text = document.createTextNode('abcdef');
const div = document.createElement('div');
div.appendChild(text);
const range = document.createRange();
range.setStart(text, 4);
range.setEnd(text, 6);
// range covers "ef"
const newText = text.splitText(3);
// text = "abc", newText = "def"
// Range was at (text, 4)-(text, 6), with offset > 3:
// start moves to (newText, 4-3=1), end moves to (newText, 6-3=3)
testing.expectEqual(newText, range.startContainer);
testing.expectEqual(1, range.startOffset);
testing.expectEqual(newText, range.endContainer);
testing.expectEqual(3, range.endOffset);
}
</script>
<script id=splitText_range_at_split_point>
{
const text = document.createTextNode('abcdef');
const div = document.createElement('div');
div.appendChild(text);
const range = document.createRange();
range.setStart(text, 0);
range.setEnd(text, 3);
// range covers "abc"
const newText = text.splitText(3);
// text = "abc", newText = "def"
// Range end is at exactly the split offset — should stay on original node
testing.expectEqual(text, range.startContainer);
testing.expectEqual(0, range.startOffset);
testing.expectEqual(text, range.endContainer);
testing.expectEqual(3, range.endOffset);
}
</script>
<script id=appendChild_does_not_affect_range>
{
const div = document.createElement('div');
const p1 = document.createElement('p');
const p2 = document.createElement('p');
div.appendChild(p1);
div.appendChild(p2);
const range = document.createRange();
range.setStart(div, 0);
range.setEnd(div, 2);
// Appending should not affect range offsets (spec: no update for append)
const p3 = document.createElement('p');
div.appendChild(p3);
testing.expectEqual(0, range.startOffset);
testing.expectEqual(2, range.endOffset);
}
</script>
<script id=insertBefore_shifts_range_offsets>
{
const div = document.createElement('div');
const p1 = document.createElement('p');
const p2 = document.createElement('p');
div.appendChild(p1);
div.appendChild(p2);
const range = document.createRange();
range.setStart(div, 1);
range.setEnd(div, 2);
// Insert before p1 (index 0) — range offsets > 0 should increment
const span = document.createElement('span');
div.insertBefore(span, p1);
testing.expectEqual(2, range.startOffset);
testing.expectEqual(3, range.endOffset);
}
</script>
<script id=removeChild_shifts_range_offsets>
{
const div = document.createElement('div');
const p1 = document.createElement('p');
const p2 = document.createElement('p');
const p3 = document.createElement('p');
div.appendChild(p1);
div.appendChild(p2);
div.appendChild(p3);
const range = document.createRange();
range.setStart(div, 1);
range.setEnd(div, 3);
// Remove p1 (index 0) — offsets > 0 should decrement
div.removeChild(p1);
testing.expectEqual(0, range.startOffset);
testing.expectEqual(2, range.endOffset);
}
</script>
<script id=removeChild_moves_range_from_descendant>
{
const div = document.createElement('div');
const p = document.createElement('p');
const text = document.createTextNode('hello');
p.appendChild(text);
div.appendChild(p);
const range = document.createRange();
range.setStart(text, 2);
range.setEnd(text, 4);
// Remove p (which contains text) — range should move to (div, index_of_p)
div.removeChild(p);
testing.expectEqual(div, range.startContainer);
testing.expectEqual(0, range.startOffset);
testing.expectEqual(div, range.endContainer);
testing.expectEqual(0, range.endOffset);
}
</script>
<script id=multiple_ranges_updated>
{
const text = document.createTextNode('abcdefgh');
const div = document.createElement('div');
div.appendChild(text);
const range1 = document.createRange();
range1.setStart(text, 1);
range1.setEnd(text, 3);
const range2 = document.createRange();
range2.setStart(text, 5);
range2.setEnd(text, 7);
// Insert at offset 0 — both ranges should shift
text.insertData(0, 'XX');
testing.expectEqual(3, range1.startOffset);
testing.expectEqual(5, range1.endOffset);
testing.expectEqual(7, range2.startOffset);
testing.expectEqual(9, range2.endOffset);
}
</script>
<script id=data_setter_updates_ranges>
{
const text = document.createTextNode('abcdef');
const div = document.createElement('div');
div.appendChild(text);
const range = document.createRange();
range.setStart(text, 2);
range.setEnd(text, 5);
// Setting data replaces all content — range collapses to offset 0
text.data = 'new content';
testing.expectEqual(text, range.startContainer);
testing.expectEqual(0, range.startOffset);
testing.expectEqual(text, range.endContainer);
testing.expectEqual(0, range.endOffset);
}
</script>

View File

@@ -118,7 +118,7 @@
BASE_URL: 'http://127.0.0.1:9582/src/browser/tests/',
};
if (window.navigator.userAgent.startsWith("Lightpanda/") == false) {
if (!IS_TEST_RUNNER) {
// The page is running in a different browser. Probably a developer making sure
// a test is correct. There are a few tweaks we need to do to make this a
// seemless, namely around adapting paths/urls.

View File

@@ -218,106 +218,6 @@
testing.expectEqual('', url.password);
}
{
const url = new URL('https://example.com/path');
url.username = 'newuser';
testing.expectEqual('newuser', url.username);
testing.expectEqual('https://newuser@example.com/path', url.href);
}
{
const url = new URL('https://olduser@example.com/path');
url.username = 'newuser';
testing.expectEqual('newuser', url.username);
testing.expectEqual('https://newuser@example.com/path', url.href);
}
{
const url = new URL('https://olduser:pass@example.com/path');
url.username = 'newuser';
testing.expectEqual('newuser', url.username);
testing.expectEqual('pass', url.password);
testing.expectEqual('https://newuser:pass@example.com/path', url.href);
}
{
const url = new URL('https://user@example.com/path');
url.password = 'secret';
testing.expectEqual('user', url.username);
testing.expectEqual('secret', url.password);
testing.expectEqual('https://user:secret@example.com/path', url.href);
}
{
const url = new URL('https://user:oldpass@example.com/path');
url.password = 'newpass';
testing.expectEqual('user', url.username);
testing.expectEqual('newpass', url.password);
testing.expectEqual('https://user:newpass@example.com/path', url.href);
}
{
const url = new URL('https://user:pass@example.com/path');
url.username = '';
url.password = '';
testing.expectEqual('', url.username);
testing.expectEqual('', url.password);
testing.expectEqual('https://example.com/path', url.href);
}
{
const url = new URL('https://example.com/path');
url.username = 'user@domain';
testing.expectEqual('user%40domain', url.username);
testing.expectEqual('https://user%40domain@example.com/path', url.href);
}
{
const url = new URL('https://example.com/path');
url.username = 'user:name';
testing.expectEqual('user%3Aname', url.username);
}
{
const url = new URL('https://example.com/path');
url.password = 'pass@word';
testing.expectEqual('pass%40word', url.password);
}
{
const url = new URL('https://example.com/path');
url.password = 'pass:word';
testing.expectEqual('pass%3Aword', url.password);
}
{
const url = new URL('https://example.com/path');
url.username = 'user/name';
testing.expectEqual('user%2Fname', url.username);
}
{
const url = new URL('https://example.com/path');
url.password = 'pass?word';
testing.expectEqual('pass%3Fword', url.password);
}
{
const url = new URL('https://user%40domain:pass%3Aword@example.com/path');
testing.expectEqual('user%40domain', url.username);
testing.expectEqual('pass%3Aword', url.password);
}
{
const url = new URL('https://example.com:8080/path?a=b#hash');
url.username = 'user';
url.password = 'pass';
testing.expectEqual('https://user:pass@example.com:8080/path?a=b#hash', url.href);
testing.expectEqual('8080', url.port);
testing.expectEqual('?a=b', url.search);
testing.expectEqual('#hash', url.hash);
}
{
const url = new URL('http://user:pass@example.com:8080/path?query=1#hash');
testing.expectEqual('http:', url.protocol);
@@ -537,9 +437,9 @@
{
const url = new URL('https://example.com:8080/path');
url.host = 'newhost.com';
testing.expectEqual('https://newhost.com:8080/path', url.href);
testing.expectEqual('https://newhost.com/path', url.href);
testing.expectEqual('newhost.com', url.hostname);
testing.expectEqual('8080', url.port);
testing.expectEqual('', url.port);
}
{

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<body onload="func1(event)"></body>
<body onload=func1></body>
<script src="../testing.js"></script>
<script id=bodyOnLoad1>
@@ -14,3 +14,4 @@
testing.expectEqual(1, called);
});
</script>

View File

@@ -82,7 +82,7 @@
testing.expectEqual('ceil', atob('Y2VpbA')); // 6 chars, len%4==2, needs '=='
// length % 4 == 1 must still throw
testing.expectError('InvalidCharacterError: Invalid Character', () => {
testing.expectError('Error: InvalidCharacterError', () => {
atob('Y');
});
</script>
@@ -125,143 +125,6 @@
testing.expectEqual(screen, window.screen);
</script>
<script id=structuredClone>
// Basic types
testing.expectEqual(42, structuredClone(42));
testing.expectEqual('hello', structuredClone('hello'));
testing.expectEqual(true, structuredClone(true));
testing.expectEqual(null, structuredClone(null));
testing.expectEqual(undefined, structuredClone(undefined));
// Objects and arrays (these work with JSON too, but verify they're cloned)
const obj = { a: 1, b: { c: 2 } };
const clonedObj = structuredClone(obj);
testing.expectEqual(1, clonedObj.a);
testing.expectEqual(2, clonedObj.b.c);
clonedObj.b.c = 999;
testing.expectEqual(2, obj.b.c); // original unchanged
const arr = [1, [2, 3]];
const clonedArr = structuredClone(arr);
testing.expectEqual(1, clonedArr[0]);
testing.expectEqual(2, clonedArr[1][0]);
clonedArr[1][0] = 999;
testing.expectEqual(2, arr[1][0]); // original unchanged
// Date - JSON would stringify to ISO string
const date = new Date('2024-01-15T12:30:00Z');
const clonedDate = structuredClone(date);
testing.expectEqual(true, clonedDate instanceof Date);
testing.expectEqual(date.getTime(), clonedDate.getTime());
testing.expectEqual(date.toISOString(), clonedDate.toISOString());
// RegExp - JSON would stringify to {}
const regex = /test\d+/gi;
const clonedRegex = structuredClone(regex);
testing.expectEqual(true, clonedRegex instanceof RegExp);
testing.expectEqual(regex.source, clonedRegex.source);
testing.expectEqual(regex.flags, clonedRegex.flags);
testing.expectEqual(true, clonedRegex.test('test123'));
// Map - JSON can't handle
const map = new Map([['a', 1], ['b', 2]]);
const clonedMap = structuredClone(map);
testing.expectEqual(true, clonedMap instanceof Map);
testing.expectEqual(2, clonedMap.size);
testing.expectEqual(1, clonedMap.get('a'));
testing.expectEqual(2, clonedMap.get('b'));
// Set - JSON can't handle
const set = new Set([1, 2, 3]);
const clonedSet = structuredClone(set);
testing.expectEqual(true, clonedSet instanceof Set);
testing.expectEqual(3, clonedSet.size);
testing.expectEqual(true, clonedSet.has(1));
testing.expectEqual(true, clonedSet.has(2));
testing.expectEqual(true, clonedSet.has(3));
// ArrayBuffer
const buffer = new ArrayBuffer(8);
const view = new Uint8Array(buffer);
view[0] = 42;
view[7] = 99;
const clonedBuffer = structuredClone(buffer);
testing.expectEqual(true, clonedBuffer instanceof ArrayBuffer);
testing.expectEqual(8, clonedBuffer.byteLength);
const clonedView = new Uint8Array(clonedBuffer);
testing.expectEqual(42, clonedView[0]);
testing.expectEqual(99, clonedView[7]);
// TypedArray
const typedArr = new Uint32Array([100, 200, 300]);
const clonedTypedArr = structuredClone(typedArr);
testing.expectEqual(true, clonedTypedArr instanceof Uint32Array);
testing.expectEqual(3, clonedTypedArr.length);
testing.expectEqual(100, clonedTypedArr[0]);
testing.expectEqual(200, clonedTypedArr[1]);
testing.expectEqual(300, clonedTypedArr[2]);
// Special number values - JSON can't preserve these
testing.expectEqual(true, Number.isNaN(structuredClone(NaN)));
testing.expectEqual(Infinity, structuredClone(Infinity));
testing.expectEqual(-Infinity, structuredClone(-Infinity));
// Object with undefined value - JSON would omit it
const objWithUndef = { a: 1, b: undefined, c: 3 };
const clonedObjWithUndef = structuredClone(objWithUndef);
testing.expectEqual(1, clonedObjWithUndef.a);
testing.expectEqual(undefined, clonedObjWithUndef.b);
testing.expectEqual(true, 'b' in clonedObjWithUndef);
testing.expectEqual(3, clonedObjWithUndef.c);
// Error objects
const error = new Error('test error');
const clonedError = structuredClone(error);
testing.expectEqual(true, clonedError instanceof Error);
testing.expectEqual('test error', clonedError.message);
// TypeError
const typeError = new TypeError('type error');
const clonedTypeError = structuredClone(typeError);
testing.expectEqual(true, clonedTypeError instanceof TypeError);
testing.expectEqual('type error', clonedTypeError.message);
// BigInt
const bigInt = BigInt('9007199254740993');
const clonedBigInt = structuredClone(bigInt);
testing.expectEqual(bigInt, clonedBigInt);
// Circular references ARE supported by structuredClone (unlike JSON)
const circular = { a: 1 };
circular.self = circular;
const clonedCircular = structuredClone(circular);
testing.expectEqual(1, clonedCircular.a);
testing.expectEqual(clonedCircular, clonedCircular.self); // circular ref preserved
// Functions cannot be cloned - should throw
{
let threw = false;
try {
structuredClone(() => {});
} catch (err) {
threw = true;
// Just verify an error was thrown - V8's message format may vary
}
testing.expectEqual(true, threw);
}
// Symbols cannot be cloned - should throw
{
let threw = false;
try {
structuredClone(Symbol('test'));
} catch (err) {
threw = true;
}
testing.expectEqual(true, threw);
}
</script>
<script id=unhandled_rejection>
{
let unhandledCalled = 0;

View File

@@ -76,11 +76,13 @@ pub fn abort(self: *AbortSignal, reason_: ?Reason, page: *Page) !void {
}
// Dispatch abort event
const target = self.asEventTarget();
if (page._event_manager.hasDirectListeners(target, "abort", self._on_abort)) {
const event = try Event.initTrusted(comptime .wrap("abort"), .{}, page);
try page._event_manager.dispatchDirect(target, event, self._on_abort, .{ .context = "abort signal" });
}
const event = try Event.initTrusted(comptime .wrap("abort"), .{}, page);
try page._event_manager.dispatchDirect(
self.asEventTarget(),
event,
self._on_abort,
.{ .context = "abort signal" },
);
}
// Static method to create an already-aborted signal

View File

@@ -19,51 +19,20 @@
const std = @import("std");
const js = @import("../js/js.zig");
const Session = @import("../Session.zig");
const Node = @import("Node.zig");
const Range = @import("Range.zig");
const Allocator = std.mem.Allocator;
const IS_DEBUG = @import("builtin").mode == .Debug;
const AbstractRange = @This();
pub const _prototype_root = true;
_rc: u8,
_type: Type,
_page_id: u32,
_arena: Allocator,
_end_offset: u32,
_start_offset: u32,
_end_container: *Node,
_start_container: *Node,
// Intrusive linked list node for tracking live ranges on the Page.
_range_link: std.DoublyLinkedList.Node = .{},
pub fn acquireRef(self: *AbstractRange) void {
self._rc += 1;
}
pub fn deinit(self: *AbstractRange, shutdown: bool, session: *Session) void {
_ = shutdown;
const rc = self._rc;
if (comptime IS_DEBUG) {
std.debug.assert(rc != 0);
}
if (rc == 1) {
if (session.findPageById(self._page_id)) |page| {
page._live_ranges.remove(&self._range_link);
}
session.releaseArena(self._arena);
return;
}
self._rc = rc - 1;
}
pub const Type = union(enum) {
range: *Range,
// TODO: static_range: *StaticRange,
@@ -246,91 +215,6 @@ fn isInclusiveAncestorOf(potential_ancestor: *Node, node: *Node) bool {
return isAncestorOf(potential_ancestor, node);
}
/// Update this range's boundaries after a replaceData mutation on target.
/// All parameters are in UTF-16 code unit offsets.
pub fn updateForCharacterDataReplace(self: *AbstractRange, target: *Node, offset: u32, count: u32, data_len: u32) void {
if (self._start_container == target) {
if (self._start_offset > offset and self._start_offset <= offset + count) {
self._start_offset = offset;
} else if (self._start_offset > offset + count) {
// Use i64 intermediate to avoid u32 underflow when count > data_len
self._start_offset = @intCast(@as(i64, self._start_offset) + @as(i64, data_len) - @as(i64, count));
}
}
if (self._end_container == target) {
if (self._end_offset > offset and self._end_offset <= offset + count) {
self._end_offset = offset;
} else if (self._end_offset > offset + count) {
self._end_offset = @intCast(@as(i64, self._end_offset) + @as(i64, data_len) - @as(i64, count));
}
}
}
/// Update this range's boundaries after a splitText operation.
/// Steps 7b-7e of the DOM spec splitText algorithm.
pub fn updateForSplitText(self: *AbstractRange, target: *Node, new_node: *Node, offset: u32, parent: *Node, node_index: u32) void {
// Step 7b: ranges on the original node with start > offset move to new node
if (self._start_container == target and self._start_offset > offset) {
self._start_container = new_node;
self._start_offset = self._start_offset - offset;
}
// Step 7c: ranges on the original node with end > offset move to new node
if (self._end_container == target and self._end_offset > offset) {
self._end_container = new_node;
self._end_offset = self._end_offset - offset;
}
// Step 7d: ranges on parent with start == node_index + 1 increment
if (self._start_container == parent and self._start_offset == node_index + 1) {
self._start_offset += 1;
}
// Step 7e: ranges on parent with end == node_index + 1 increment
if (self._end_container == parent and self._end_offset == node_index + 1) {
self._end_offset += 1;
}
}
/// Update this range's boundaries after a node insertion.
pub fn updateForNodeInsertion(self: *AbstractRange, parent: *Node, child_index: u32) void {
if (self._start_container == parent and self._start_offset > child_index) {
self._start_offset += 1;
}
if (self._end_container == parent and self._end_offset > child_index) {
self._end_offset += 1;
}
}
/// Update this range's boundaries after a node removal.
pub fn updateForNodeRemoval(self: *AbstractRange, parent: *Node, child: *Node, child_index: u32) void {
// Steps 4-5: ranges whose start/end is an inclusive descendant of child
// get moved to (parent, child_index).
if (isInclusiveDescendantOf(self._start_container, child)) {
self._start_container = parent;
self._start_offset = child_index;
}
if (isInclusiveDescendantOf(self._end_container, child)) {
self._end_container = parent;
self._end_offset = child_index;
}
// Steps 6-7: ranges on parent at offsets > child_index get decremented.
if (self._start_container == parent and self._start_offset > child_index) {
self._start_offset -= 1;
}
if (self._end_container == parent and self._end_offset > child_index) {
self._end_offset -= 1;
}
}
fn isInclusiveDescendantOf(node: *Node, potential_ancestor: *Node) bool {
var current: ?*Node = node;
while (current) |n| {
if (n == potential_ancestor) return true;
current = n.parentNode();
}
return false;
}
pub const JsApi = struct {
pub const bridge = js.Bridge(AbstractRange);
@@ -338,8 +222,6 @@ pub const JsApi = struct {
pub const name = "AbstractRange";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const weak = true;
pub const finalizer = bridge.finalizer(AbstractRange.deinit);
};
pub const startContainer = bridge.accessor(AbstractRange.getStartContainer, null, .{});

View File

@@ -21,11 +21,6 @@ const Writer = std.Io.Writer;
const js = @import("../js/js.zig");
const Page = @import("../Page.zig");
const Session = @import("../Session.zig");
const Mime = @import("../Mime.zig");
const Allocator = std.mem.Allocator;
/// https://w3c.github.io/FileAPI/#blob-section
/// https://developer.mozilla.org/en-US/docs/Web/API/Blob
@@ -35,8 +30,6 @@ pub const _prototype_root = true;
_type: Type,
_arena: Allocator,
/// Immutable slice of blob.
/// Note that another blob may hold a pointer/slice to this,
/// so its better to leave the deallocation of it to arena allocator.
@@ -57,58 +50,26 @@ const InitOptions = struct {
endings: []const u8 = "transparent",
};
/// Creates a new Blob (JS constructor).
/// Creates a new Blob.
pub fn init(
maybe_blob_parts: ?[]const []const u8,
maybe_options: ?InitOptions,
page: *Page,
) !*Blob {
return initWithMimeValidation(maybe_blob_parts, maybe_options, false, page);
}
/// Creates a new Blob with optional MIME validation.
/// When validate_mime is true, uses full MIME parsing (for Response/Request).
/// When false, uses simple ASCII validation per FileAPI spec (for Blob constructor).
pub fn initWithMimeValidation(
maybe_blob_parts: ?[]const []const u8,
maybe_options: ?InitOptions,
validate_mime: bool,
page: *Page,
) !*Blob {
const arena = try page.getArena(.{ .debug = "Blob" });
errdefer page.releaseArena(arena);
const options: InitOptions = maybe_options orelse .{};
// Setup MIME; This can be any string according to my observations.
const mime: []const u8 = blk: {
const t = options.type;
if (t.len == 0) {
break :blk "";
}
const buf = try arena.dupe(u8, t);
if (validate_mime) {
// Full MIME parsing per MIME sniff spec (for Content-Type headers)
_ = Mime.parse(buf) catch break :blk "";
} else {
// Simple validation per FileAPI spec (for Blob constructor):
// - If any char is outside U+0020-U+007E, return empty string
// - Otherwise lowercase
for (t) |c| {
if (c < 0x20 or c > 0x7E) {
break :blk "";
}
}
_ = std.ascii.lowerString(buf, buf);
}
break :blk buf;
break :blk try page.arena.dupe(u8, t);
};
const data = blk: {
if (maybe_blob_parts) |blob_parts| {
var w: Writer.Allocating = .init(arena);
var w: Writer.Allocating = .init(page.arena);
const use_native_endings = std.mem.eql(u8, options.endings, "native");
try writeBlobParts(&w.writer, blob_parts, use_native_endings);
@@ -118,19 +79,11 @@ pub fn initWithMimeValidation(
break :blk "";
};
const self = try arena.create(Blob);
self.* = .{
._arena = arena,
return page._factory.create(Blob{
._type = .generic,
._slice = data,
._mime = mime,
};
return self;
}
pub fn deinit(self: *Blob, shutdown: bool, session: *Session) void {
_ = shutdown;
session.releaseArena(self._arena);
});
}
const largest_vector = @max(std.simd.suggestVectorLength(u8) orelse 1, 8);
@@ -281,31 +234,57 @@ pub fn bytes(self: *const Blob, page: *Page) !js.Promise {
/// from a subset of the blob on which it's called.
pub fn slice(
self: *const Blob,
start_: ?i32,
end_: ?i32,
content_type_: ?[]const u8,
maybe_start: ?i32,
maybe_end: ?i32,
maybe_content_type: ?[]const u8,
page: *Page,
) !*Blob {
const mime: []const u8 = blk: {
if (maybe_content_type) |content_type| {
if (content_type.len == 0) {
break :blk "";
}
break :blk try page.dupeString(content_type);
}
break :blk "";
};
const data = self._slice;
if (maybe_start) |_start| {
const start = blk: {
if (_start < 0) {
break :blk data.len -| @abs(_start);
}
const start = blk: {
const requested_start = start_ orelse break :blk 0;
if (requested_start < 0) {
break :blk data.len -| @abs(requested_start);
}
break :blk @min(data.len, @as(u31, @intCast(requested_start)));
};
break :blk @min(data.len, @as(u31, @intCast(_start)));
};
const end: usize = blk: {
const requested_end = end_ orelse break :blk data.len;
if (requested_end < 0) {
break :blk @max(start, data.len -| @abs(requested_end));
}
const end: usize = blk: {
if (maybe_end) |_end| {
if (_end < 0) {
break :blk @max(start, data.len -| @abs(_end));
}
break :blk @min(data.len, @max(start, @as(u31, @intCast(requested_end))));
};
break :blk @min(data.len, @max(start, @as(u31, @intCast(_end))));
}
return Blob.init(&.{data[start..end]}, .{ .type = content_type_ orelse "" }, page);
break :blk data.len;
};
return page._factory.create(Blob{
._type = .generic,
._slice = data[start..end],
._mime = mime,
});
}
return page._factory.create(Blob{
._type = .generic,
._slice = data,
._mime = mime,
});
}
/// Returns the size of the Blob in bytes.
@@ -325,8 +304,6 @@ pub const JsApi = struct {
pub const name = "Blob";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const weak = true;
pub const finalizer = bridge.finalizer(Blob.deinit);
};
pub const constructor = bridge.constructor(Blob.init, .{});

View File

@@ -37,7 +37,7 @@ _data: String = .empty,
/// Count UTF-16 code units in a UTF-8 string.
/// 4-byte UTF-8 sequences (codepoints >= U+10000) produce 2 UTF-16 code units (surrogate pair),
/// everything else produces 1.
pub fn utf16Len(data: []const u8) usize {
fn utf16Len(data: []const u8) usize {
var count: usize = 0;
var i: usize = 0;
while (i < data.len) {
@@ -151,13 +151,8 @@ pub fn asNode(self: *CData) *Node {
pub fn is(self: *CData, comptime T: type) ?*T {
inline for (@typeInfo(Type).@"union".fields) |f| {
if (@field(Type, f.name) == self._type) {
if (f.type == T) {
return &@field(self._type, f.name);
}
if (f.type == *T) {
return @field(self._type, f.name);
}
if (f.type == T and @field(Type, f.name) == self._type) {
return &@field(self._type, f.name);
}
}
return null;
@@ -237,13 +232,14 @@ pub fn setData(self: *CData, value: ?[]const u8, page: *Page) !void {
}
/// JS bridge wrapper for `data` setter.
/// Per spec, setting .data runs replaceData(0, this.length, value),
/// which includes live range updates.
/// Handles [LegacyNullToEmptyString]: null → "" per spec.
/// Handles [LegacyNullToEmptyString]: null → setData(null) → "".
/// Passes everything else (including undefined) through V8 toString,
/// so `undefined` becomes the string "undefined" per spec.
pub fn _setData(self: *CData, value: js.Value, page: *Page) !void {
const new_value: []const u8 = if (value.isNull()) "" else try value.toZig([]const u8);
const length = self.getLength();
try self.replaceData(0, length, new_value, page);
if (value.isNull()) {
return self.setData(null, page);
}
return self.setData(try value.toZig([]const u8), page);
}
pub fn format(self: *const CData, writer: *std.io.Writer) !void {
@@ -276,20 +272,15 @@ pub fn isEqualNode(self: *const CData, other: *const CData) bool {
}
pub fn appendData(self: *CData, data: []const u8, page: *Page) !void {
// Per DOM spec, appendData(data) is replaceData(length, 0, data).
const length = self.getLength();
try self.replaceData(length, 0, data, page);
const old_value = self._data;
self._data = try String.concat(page.arena, &.{ self._data.str(), data });
page.characterDataChange(self.asNode(), old_value);
}
pub fn deleteData(self: *CData, offset: usize, count: usize, page: *Page) !void {
const end_utf16 = std.math.add(usize, offset, count) catch std.math.maxInt(usize);
const range = try utf16RangeToUtf8(self._data.str(), offset, end_utf16);
// Update live ranges per DOM spec replaceData steps (deleteData = replaceData with data="")
const length = self.getLength();
const effective_count: u32 = @intCast(@min(count, length - offset));
page.updateRangesForCharacterDataReplace(self.asNode(), @intCast(offset), effective_count, 0);
const old_data = self._data;
const old_value = old_data.str();
if (range.start == 0) {
@@ -308,10 +299,6 @@ pub fn deleteData(self: *CData, offset: usize, count: usize, page: *Page) !void
pub fn insertData(self: *CData, offset: usize, data: []const u8, page: *Page) !void {
const byte_offset = try utf16OffsetToUtf8(self._data.str(), offset);
// Update live ranges per DOM spec replaceData steps (insertData = replaceData with count=0)
page.updateRangesForCharacterDataReplace(self.asNode(), @intCast(offset), 0, @intCast(utf16Len(data)));
const old_value = self._data;
const existing = old_value.str();
self._data = try String.concat(page.arena, &.{
@@ -325,12 +312,6 @@ pub fn insertData(self: *CData, offset: usize, data: []const u8, page: *Page) !v
pub fn replaceData(self: *CData, offset: usize, count: usize, data: []const u8, page: *Page) !void {
const end_utf16 = std.math.add(usize, offset, count) catch std.math.maxInt(usize);
const range = try utf16RangeToUtf8(self._data.str(), offset, end_utf16);
// Update live ranges per DOM spec replaceData steps
const length = self.getLength();
const effective_count: u32 = @intCast(@min(count, length - offset));
page.updateRangesForCharacterDataReplace(self.asNode(), @intCast(offset), effective_count, @intCast(utf16Len(data)));
const old_value = self._data;
const existing = old_value.str();
self._data = try String.concat(page.arena, &.{

View File

@@ -90,16 +90,15 @@ pub fn parseFromString(
return pe.err;
}
// If first node is a `ProcessingInstruction`, skip it.
const first_child = doc_node.firstChild() orelse {
// Empty XML or no root element - this is a parse error.
// TODO: Return a document with a <parsererror> element per spec.
return error.JsException;
// Parsing should fail if there aren't any nodes.
unreachable;
};
// If first node is a `ProcessingInstruction`, skip it.
if (first_child.getNodeType() == 7) {
// We're sure that firstChild exist, this cannot fail.
_ = try doc_node.removeChild(first_child, page);
_ = doc_node.removeChild(first_child, page) catch unreachable;
}
return doc.asDocument();

View File

@@ -40,8 +40,6 @@ const Selection = @import("Selection.zig");
pub const XMLDocument = @import("XMLDocument.zig");
pub const HTMLDocument = @import("HTMLDocument.zig");
const IS_DEBUG = @import("builtin").mode == .Debug;
const Document = @This();
_type: Type,
@@ -63,11 +61,6 @@ _script_created_parser: ?Parser.Streaming = null,
_adopted_style_sheets: ?js.Object.Global = null,
_selection: Selection = .init,
// https://html.spec.whatwg.org/multipage/dynamic-markup-insertion.html#throw-on-dynamic-markup-insertion-counter
// Incremented during custom element reactions when parsing. When > 0,
// document.open/close/write/writeln must throw InvalidStateError.
_throw_on_dynamic_markup_insertion_counter: u32 = 0,
_on_selectionchange: ?js.Function.Global = null,
pub fn getOnSelectionChange(self: *Document) ?js.Function.Global {
@@ -646,10 +639,6 @@ pub fn write(self: *Document, text: []const []const u8, page: *Page) !void {
return error.InvalidStateError;
}
if (self._throw_on_dynamic_markup_insertion_counter > 0) {
return error.InvalidStateError;
}
const html = blk: {
var joined: std.ArrayList(u8) = .empty;
for (text) |str| {
@@ -732,10 +721,6 @@ pub fn open(self: *Document, page: *Page) !*Document {
return error.InvalidStateError;
}
if (self._throw_on_dynamic_markup_insertion_counter > 0) {
return error.InvalidStateError;
}
if (page._load_state == .parsing) {
return self;
}
@@ -774,10 +759,6 @@ pub fn close(self: *Document, page: *Page) !void {
return error.InvalidStateError;
}
if (self._throw_on_dynamic_markup_insertion_counter > 0) {
return error.InvalidStateError;
}
if (self._script_created_parser == null) {
return;
}
@@ -956,32 +937,6 @@ fn validateElementName(name: []const u8) !void {
}
}
// When a page or frame's URL is about:blank, or as soon as a frame is
// programmatically created, it has this default "blank" content
pub fn injectBlank(self: *Document, page: *Page) error{InjectBlankError}!void {
self._injectBlank(page) catch |err| {
// we wrap _injectBlank like this so that injectBlank can only return an
// InjectBlankError. injectBlank is used in when nodes are inserted
// as since it inserts node itself, Zig can't infer the error set.
log.err(.browser, "inject blank", .{ .err = err });
return error.InjectBlankError;
};
}
fn _injectBlank(self: *Document, page: *Page) !void {
if (comptime IS_DEBUG) {
// should only be called on an empty document
std.debug.assert(self.asNode()._children == null);
}
const html = try page.createElementNS(.html, "html", null);
const head = try page.createElementNS(.html, "head", null);
const body = try page.createElementNS(.html, "body", null);
try page.appendNode(html, head, .{});
try page.appendNode(html, body, .{});
try page.appendNode(self.asNode(), html, .{});
}
const ReadyState = enum {
loading,
interactive,

View File

@@ -195,9 +195,8 @@ pub fn cloneFragment(self: *DocumentFragment, deep: bool, page: *Page) !*Node {
var child_it = node.childrenIterator();
while (child_it.next()) |child| {
if (try child.cloneNodeForAppending(true, page)) |cloned_child| {
try page.appendNode(fragment_node, cloned_child, .{ .child_already_connected = self_is_connected });
}
const cloned_child = try child.cloneNode(true, page);
try page.appendNode(fragment_node, cloned_child, .{ .child_already_connected = self_is_connected });
}
}

View File

@@ -1328,12 +1328,20 @@ pub fn clone(self: *Element, deep: bool, page: *Page) !*Node {
if (deep) {
var child_it = self.asNode().childrenIterator();
while (child_it.next()) |child| {
if (try child.cloneNodeForAppending(true, page)) |cloned_child| {
// We pass `true` to `child_already_connected` as a hacky optimization
// We _know_ this child isn't connected (Because the parent isn't connected)
// setting this to `true` skips all connection checks.
try page.appendNode(node, cloned_child, .{ .child_already_connected = true });
const cloned_child = try child.cloneNode(true, page);
if (cloned_child._parent != null) {
// This is almost always false, the only case where a cloned
// node would already have a parent is with a custom element
// that has a constructor (which is called during cloning) which
// inserts it somewhere. In that case, whatever parent was set
// in the constructor should not be changed.
continue;
}
// We pass `true` to `child_already_connected` as a hacky optimization
// We _know_ this child isn't connected (Because the parent isn't connected)
// setting this to `true` skips all connection checks.
try page.appendNode(node, cloned_child, .{ .child_already_connected = true });
}
}
@@ -1580,36 +1588,6 @@ pub const Tag = enum {
else => tag,
};
}
pub fn isBlock(self: Tag) bool {
// zig fmt: off
return switch (self) {
// Semantic Layout
.article, .aside, .footer, .header, .main, .nav, .section,
// Grouping / Containers
.address, .div, .fieldset, .figure, .p,
// Headings
.h1, .h2, .h3, .h4, .h5, .h6,
// Lists
.dl, .ol, .ul,
// Preformatted / Quotes
.blockquote, .pre,
// Tables
.table,
// Other
.hr,
=> true,
else => false,
};
// zig fmt: on
}
pub fn isMetadata(self: Tag) bool {
return switch (self) {
.base, .head, .link, .meta, .noscript, .script, .style, .template, .title => true,
else => false,
};
}
};
pub const JsApi = struct {

View File

@@ -20,7 +20,6 @@ const std = @import("std");
const js = @import("../js/js.zig");
const Page = @import("../Page.zig");
const Session = @import("../Session.zig");
const EventTarget = @import("EventTarget.zig");
const Node = @import("Node.zig");
const String = @import("../../string.zig").String;
@@ -140,9 +139,9 @@ pub fn acquireRef(self: *Event) void {
self._rc += 1;
}
pub fn deinit(self: *Event, shutdown: bool, session: *Session) void {
pub fn deinit(self: *Event, shutdown: bool, page: *Page) void {
if (shutdown) {
session.releaseArena(self._arena);
page.releaseArena(self._arena);
return;
}
@@ -152,7 +151,7 @@ pub fn deinit(self: *Event, shutdown: bool, session: *Session) void {
}
if (rc == 1) {
session.releaseArena(self._arena);
page.releaseArena(self._arena);
} else {
self._rc = rc - 1;
}

View File

@@ -44,7 +44,6 @@ pub const Type = union(enum) {
screen_orientation: *@import("Screen.zig").Orientation,
visual_viewport: *@import("VisualViewport.zig"),
file_reader: *@import("FileReader.zig"),
font_face_set: *@import("css/FontFaceSet.zig"),
};
pub fn init(page: *Page) !*EventTarget {
@@ -60,7 +59,7 @@ pub fn dispatchEvent(self: *EventTarget, event: *Event, page: *Page) !bool {
event._is_trusted = false;
event.acquireRef();
defer event.deinit(false, page._session);
defer event.deinit(false, page);
try page._event_manager.dispatch(self, event);
return !event._cancelable or !event._prevent_default;
}
@@ -139,8 +138,6 @@ pub fn format(self: *EventTarget, writer: *std.Io.Writer) !void {
.screen => writer.writeAll("<Screen>"),
.screen_orientation => writer.writeAll("<ScreenOrientation>"),
.visual_viewport => writer.writeAll("<VisualViewport>"),
.file_reader => writer.writeAll("<FileReader>"),
.font_face_set => writer.writeAll("<FontFaceSet>"),
};
}
@@ -159,7 +156,6 @@ pub fn toString(self: *EventTarget) []const u8 {
.screen_orientation => return "[object ScreenOrientation]",
.visual_viewport => return "[object VisualViewport]",
.file_reader => return "[object FileReader]",
.font_face_set => return "[object FontFaceSet]",
};
}

View File

@@ -18,11 +18,9 @@
const std = @import("std");
const js = @import("../js/js.zig");
const Page = @import("../Page.zig");
const Session = @import("../Session.zig");
const Blob = @import("Blob.zig");
const js = @import("../js/js.zig");
const File = @This();
@@ -31,13 +29,7 @@ _proto: *Blob,
// TODO: Implement File API.
pub fn init(page: *Page) !*File {
const arena = try page.getArena(.{ .debug = "File" });
errdefer page.releaseArena(arena);
return page._factory.blob(arena, File{ ._proto = undefined });
}
pub fn deinit(self: *File, shutdown: bool, session: *Session) void {
self._proto.deinit(shutdown, session);
return page._factory.blob(File{ ._proto = undefined });
}
pub const JsApi = struct {
@@ -47,8 +39,6 @@ pub const JsApi = struct {
pub const name = "File";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const weak = true;
pub const finalizer = bridge.finalizer(File.deinit);
};
pub const constructor = bridge.constructor(File.init, .{});

View File

@@ -1,28 +0,0 @@
const js = @import("../js/js.zig");
const FileList = @This();
/// Padding to avoid zero-size struct, which causes identity_map pointer collisions.
_pad: bool = false,
pub fn getLength(_: *const FileList) u32 {
return 0;
}
pub fn item(_: *const FileList, _: u32) ?*@import("File.zig") {
return null;
}
pub const JsApi = struct {
pub const bridge = js.Bridge(FileList);
pub const Meta = struct {
pub const name = "FileList";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const empty_with_no_proto = true;
};
pub const length = bridge.accessor(FileList.getLength, null, .{});
pub const item = bridge.function(FileList.item, .{});
};

View File

@@ -20,7 +20,6 @@ const std = @import("std");
const js = @import("../js/js.zig");
const Page = @import("../Page.zig");
const Session = @import("../Session.zig");
const EventTarget = @import("EventTarget.zig");
const ProgressEvent = @import("event/ProgressEvent.zig");
const Blob = @import("Blob.zig");
@@ -70,15 +69,17 @@ pub fn init(page: *Page) !*FileReader {
});
}
pub fn deinit(self: *FileReader, _: bool, session: *Session) void {
if (self._on_abort) |func| func.release();
if (self._on_error) |func| func.release();
if (self._on_load) |func| func.release();
if (self._on_load_end) |func| func.release();
if (self._on_load_start) |func| func.release();
if (self._on_progress) |func| func.release();
pub fn deinit(self: *FileReader, _: bool, page: *Page) void {
const js_ctx = page.js;
session.releaseArena(self._arena);
if (self._on_abort) |func| js_ctx.release(func);
if (self._on_error) |func| js_ctx.release(func);
if (self._on_load) |func| js_ctx.release(func);
if (self._on_load_end) |func| js_ctx.release(func);
if (self._on_load_start) |func| js_ctx.release(func);
if (self._on_progress) |func| js_ctx.release(func);
page.releaseArena(self._arena);
}
fn asEventTarget(self: *FileReader) *EventTarget {

View File

@@ -167,8 +167,9 @@ pub fn getEmbeds(self: *HTMLDocument, page: *Page) !collections.NodeLive(.tag) {
return collections.NodeLive(.tag).init(self.asNode(), .embed, page);
}
pub fn getApplets(_: *const HTMLDocument) collections.HTMLCollection {
return .{ ._data = .empty };
const applet_string = String.init(undefined, "applet", .{}) catch unreachable;
pub fn getApplets(self: *HTMLDocument, page: *Page) !collections.NodeLive(.tag_name) {
return collections.NodeLive(.tag_name).init(self.asNode(), applet_string, page);
}
pub fn getCurrentScript(self: *const HTMLDocument) ?*Element.Html.Script {
@@ -179,8 +180,8 @@ pub fn getLocation(self: *const HTMLDocument) ?*@import("Location.zig") {
return self._proto._location;
}
pub fn setLocation(self: *HTMLDocument, url: [:0]const u8, page: *Page) !void {
return page.scheduleNavigation(url, .{ .reason = .script, .kind = .{ .push = null } }, .{ .script = self._proto._page });
pub fn setLocation(_: *const HTMLDocument, url: [:0]const u8, page: *Page) !void {
return page.scheduleNavigation(url, .{ .reason = .script, .kind = .{ .push = null } }, .script);
}
pub fn getAll(self: *HTMLDocument, page: *Page) !*collections.HTMLAllCollection {

View File

@@ -79,11 +79,13 @@ fn goInner(delta: i32, page: *Page) !void {
if (entry._url) |url| {
if (try page.isSameOrigin(url)) {
const target = page.window.asEventTarget();
if (page._event_manager.hasDirectListeners(target, "popstate", page.window._on_popstate)) {
const event = (try PopStateEvent.initTrusted(comptime .wrap("popstate"), .{ .state = entry._state.value }, page)).asEvent();
try page._event_manager.dispatchDirect(target, event, page.window._on_popstate, .{ .context = "Pop State" });
}
const event = (try PopStateEvent.initTrusted(comptime .wrap("popstate"), .{ .state = entry._state.value }, page)).asEvent();
try page._event_manager.dispatchDirect(
page.window.asEventTarget(),
event,
page.window._on_popstate,
.{ .context = "Pop State" },
);
}
}

View File

@@ -52,7 +52,7 @@ pub const ConstructorSettings = struct {
/// ```
///
/// We currently support only the first 2.
pub fn init(
pub fn constructor(
width: u32,
height: u32,
maybe_settings: ?ConstructorSettings,
@@ -106,7 +106,7 @@ pub const JsApi = struct {
pub var class_id: bridge.ClassId = undefined;
};
pub const constructor = bridge.constructor(ImageData.init, .{ .dom_exception = true });
pub const constructor = bridge.constructor(ImageData.constructor, .{ .dom_exception = true });
pub const colorSpace = bridge.property("srgb", .{ .template = false, .readonly = true });
pub const pixelFormat = bridge.property("rgba-unorm8", .{ .template = false, .readonly = true });

View File

@@ -24,7 +24,6 @@ const IS_DEBUG = @import("builtin").mode == .Debug;
const Allocator = std.mem.Allocator;
const Page = @import("../Page.zig");
const Session = @import("../Session.zig");
const Element = @import("Element.zig");
const DOMRect = @import("DOMRect.zig");
@@ -92,13 +91,13 @@ pub fn init(callback: js.Function.Temp, options: ?ObserverInit, page: *Page) !*I
return self;
}
pub fn deinit(self: *IntersectionObserver, shutdown: bool, session: *Session) void {
self._callback.release();
pub fn deinit(self: *IntersectionObserver, shutdown: bool, page: *Page) void {
page.js.release(self._callback);
if ((comptime IS_DEBUG) and !shutdown) {
std.debug.assert(self._observing.items.len == 0);
}
session.releaseArena(self._arena);
page.releaseArena(self._arena);
}
pub fn observe(self: *IntersectionObserver, target: *Element, page: *Page) !void {
@@ -138,7 +137,7 @@ pub fn unobserve(self: *IntersectionObserver, target: *Element, page: *Page) voi
while (j < self._pending_entries.items.len) {
if (self._pending_entries.items[j]._target == target) {
const entry = self._pending_entries.swapRemove(j);
entry.deinit(false, page._session);
entry.deinit(false, page);
} else {
j += 1;
}
@@ -158,7 +157,7 @@ pub fn disconnect(self: *IntersectionObserver, page: *Page) void {
self._previous_states.clearRetainingCapacity();
for (self._pending_entries.items) |entry| {
entry.deinit(false, page._session);
entry.deinit(false, page);
}
self._pending_entries.clearRetainingCapacity();
page.js.safeWeakRef(self);
@@ -303,8 +302,8 @@ pub const IntersectionObserverEntry = struct {
_intersection_ratio: f64,
_is_intersecting: bool,
pub fn deinit(self: *IntersectionObserverEntry, _: bool, session: *Session) void {
session.releaseArena(self._arena);
pub fn deinit(self: *const IntersectionObserverEntry, _: bool, page: *Page) void {
page.releaseArena(self._arena);
}
pub fn getTarget(self: *const IntersectionObserverEntry) *Element {

View File

@@ -83,19 +83,19 @@ pub fn setHash(_: *const Location, hash: []const u8, page: *Page) !void {
return page.scheduleNavigation(normalized_hash, .{
.reason = .script,
.kind = .{ .replace = null },
}, .{ .script = page });
}, .script);
}
pub fn assign(_: *const Location, url: [:0]const u8, page: *Page) !void {
return page.scheduleNavigation(url, .{ .reason = .script, .kind = .{ .push = null } }, .{ .script = page });
return page.scheduleNavigation(url, .{ .reason = .script, .kind = .{ .push = null } }, .script);
}
pub fn replace(_: *const Location, url: [:0]const u8, page: *Page) !void {
return page.scheduleNavigation(url, .{ .reason = .script, .kind = .{ .replace = null } }, .{ .script = page });
return page.scheduleNavigation(url, .{ .reason = .script, .kind = .{ .replace = null } }, .script);
}
pub fn reload(_: *const Location, page: *Page) !void {
return page.scheduleNavigation(page.url, .{ .reason = .script, .kind = .reload }, .{ .script = page });
return page.scheduleNavigation(page.url, .{ .reason = .script, .kind = .reload }, .script);
}
pub fn toString(self: *const Location, page: *const Page) ![:0]const u8 {

View File

@@ -122,21 +122,23 @@ const PostMessageCallback = struct {
return null;
}
const target = self.port.asEventTarget();
if (page._event_manager.hasDirectListeners(target, "message", self.port._on_message)) {
const event = (MessageEvent.initTrusted(comptime .wrap("message"), .{
.data = self.message,
.origin = "",
.source = null,
}, page) catch |err| {
log.err(.dom, "MessagePort.postMessage", .{ .err = err });
return null;
}).asEvent();
const event = (MessageEvent.initTrusted(comptime .wrap("message"), .{
.data = self.message,
.origin = "",
.source = null,
}, page) catch |err| {
log.err(.dom, "MessagePort.postMessage", .{ .err = err });
return null;
}).asEvent();
page._event_manager.dispatchDirect(target, event, self.port._on_message, .{ .context = "MessagePort message" }) catch |err| {
log.err(.dom, "MessagePort.postMessage", .{ .err = err });
};
}
page._event_manager.dispatchDirect(
self.port.asEventTarget(),
event,
self.port._on_message,
.{ .context = "MessagePort message" },
) catch |err| {
log.err(.dom, "MessagePort.postMessage", .{ .err = err });
};
return null;
}

View File

@@ -21,7 +21,6 @@ const String = @import("../../string.zig").String;
const js = @import("../js/js.zig");
const Page = @import("../Page.zig");
const Session = @import("../Session.zig");
const Node = @import("Node.zig");
const Element = @import("Element.zig");
const log = @import("../../log.zig");
@@ -85,13 +84,13 @@ pub fn init(callback: js.Function.Temp, page: *Page) !*MutationObserver {
return self;
}
pub fn deinit(self: *MutationObserver, shutdown: bool, session: *Session) void {
self._callback.release();
pub fn deinit(self: *MutationObserver, shutdown: bool, page: *Page) void {
page.js.release(self._callback);
if ((comptime IS_DEBUG) and !shutdown) {
std.debug.assert(self._observing.items.len == 0);
}
session.releaseArena(self._arena);
page.releaseArena(self._arena);
}
pub fn observe(self: *MutationObserver, target: *Node, options: ObserveOptions, page: *Page) !void {
@@ -172,7 +171,7 @@ pub fn disconnect(self: *MutationObserver, page: *Page) void {
page.unregisterMutationObserver(self);
self._observing.clearRetainingCapacity();
for (self._pending_records.items) |record| {
record.deinit(false, page._session);
record.deinit(false, page);
}
self._pending_records.clearRetainingCapacity();
page.js.safeWeakRef(self);
@@ -364,8 +363,8 @@ pub const MutationRecord = struct {
characterData,
};
pub fn deinit(self: *MutationRecord, _: bool, session: *Session) void {
session.releaseArena(self._arena);
pub fn deinit(self: *const MutationRecord, _: bool, page: *Page) void {
page.releaseArena(self._arena);
}
pub fn getType(self: *const MutationRecord) []const u8 {

View File

@@ -285,19 +285,6 @@ pub fn getTextContentAlloc(self: *Node, allocator: Allocator) error{WriteFailed}
return data[0 .. data.len - 1 :0];
}
/// Returns the "child text content" which is the concatenation of the data
/// of all the Text node children of the node, in tree order.
/// This differs from textContent which includes all descendant text.
/// See: https://dom.spec.whatwg.org/#concept-child-text-content
pub fn getChildTextContent(self: *Node, writer: *std.Io.Writer) error{WriteFailed}!void {
var it = self.childrenIterator();
while (it.next()) |child| {
if (child.is(CData.Text)) |text| {
try writer.writeAll(text._proto._data.str());
}
}
}
pub fn setTextContent(self: *Node, data: []const u8, page: *Page) !void {
switch (self._type) {
.element => |el| {
@@ -306,8 +293,7 @@ pub fn setTextContent(self: *Node, data: []const u8, page: *Page) !void {
}
return el.replaceChildren(&.{.{ .text = data }}, page);
},
// Per spec, setting textContent on CharacterData runs replaceData(0, length, value)
.cdata => |c| try c.replaceData(0, c.getLength(), data, page),
.cdata => |c| c._data = try page.dupeSSO(data),
.document => {},
.document_type => {},
.document_fragment => |frag| {
@@ -506,11 +492,6 @@ pub fn ownerDocument(self: *const Node, page: *const Page) ?*Document {
return page.document;
}
pub fn ownerPage(self: *const Node, default: *Page) *Page {
const doc = self.ownerDocument(default) orelse return default;
return doc._page orelse default;
}
pub fn isSameDocumentAs(self: *const Node, other: *const Node, page: *const Page) bool {
// Get the root document for each node
const self_doc = if (self._type == .document) self._type.document else self.ownerDocument(page);
@@ -631,11 +612,7 @@ pub fn getNodeValue(self: *const Node) ?String {
pub fn setNodeValue(self: *const Node, value: ?String, page: *Page) !void {
switch (self._type) {
// Per spec, setting nodeValue on CharacterData runs replaceData(0, length, value)
.cdata => |c| {
const new_value: []const u8 = if (value) |v| v.str() else "";
try c.replaceData(0, c.getLength(), new_value, page);
},
.cdata => |c| try c.setData(if (value) |v| v.str() else null, page),
.attribute => |attr| try attr.setValue(value, page),
.element => {},
.document => {},
@@ -747,9 +724,6 @@ const CloneError = error{
TooManyContexts,
LinkLoadError,
StyleLoadError,
TypeError,
CompilationError,
JsException,
};
pub fn cloneNode(self: *Node, deep_: ?bool, page: *Page) CloneError!*Node {
const deep = deep_ orelse false;
@@ -777,29 +751,6 @@ pub fn cloneNode(self: *Node, deep_: ?bool, page: *Page) CloneError!*Node {
}
}
/// Clone a node for the purpose of appending to a parent.
/// Returns null if the cloned node was already attached somewhere by a custom element
/// constructor, indicating that the constructor's decision should be respected.
///
/// This helper is used when iterating over children to clone them. The typical pattern is:
/// while (child_it.next()) |child| {
/// if (try child.cloneNodeForAppending(true, page)) |cloned| {
/// try page.appendNode(parent, cloned, opts);
/// }
/// }
///
/// The only case where a cloned node would already have a parent is when a custom element
/// constructor (which runs during cloning per the HTML spec) explicitly attaches the element
/// somewhere. In that case, we respect the constructor's decision and return null to signal
/// that the cloned node should not be appended to our intended parent.
pub fn cloneNodeForAppending(self: *Node, deep: bool, page: *Page) CloneError!?*Node {
const cloned = try self.cloneNode(deep, page);
if (cloned._parent != null) {
return null;
}
return cloned;
}
pub fn compareDocumentPosition(self: *Node, other: *Node) u16 {
const DISCONNECTED: u16 = 0x01;
const PRECEDING: u16 = 0x02;

View File

@@ -268,7 +268,7 @@ pub const JsApi = struct {
pub const now = bridge.function(Performance.now, .{});
pub const mark = bridge.function(Performance.mark, .{});
pub const measure = bridge.function(Performance.measure, .{ .dom_exception = true });
pub const measure = bridge.function(Performance.measure, .{});
pub const clearMarks = bridge.function(Performance.clearMarks, .{});
pub const clearMeasures = bridge.function(Performance.clearMeasures, .{});
pub const getEntries = bridge.function(Performance.getEntries, .{});

View File

@@ -21,33 +21,23 @@ const String = @import("../../string.zig").String;
const js = @import("../js/js.zig");
const Page = @import("../Page.zig");
const Session = @import("../Session.zig");
const Node = @import("Node.zig");
const DocumentFragment = @import("DocumentFragment.zig");
const AbstractRange = @import("AbstractRange.zig");
const DOMRect = @import("DOMRect.zig");
const Allocator = std.mem.Allocator;
const Range = @This();
_proto: *AbstractRange,
pub fn init(page: *Page) !*Range {
const arena = try page.getArena(.{ .debug = "Range" });
errdefer page.releaseArena(arena);
return page._factory.abstractRange(arena, Range{ ._proto = undefined }, page);
}
pub fn deinit(self: *Range, shutdown: bool, session: *Session) void {
self._proto.deinit(shutdown, session);
}
pub fn asAbstractRange(self: *Range) *AbstractRange {
return self._proto;
}
pub fn init(page: *Page) !*Range {
return page._factory.abstractRange(Range{ ._proto = undefined }, page);
}
pub fn setStart(self: *Range, node: *Node, offset: u32) !void {
if (node._type == .document_type) {
return error.InvalidNodeType;
@@ -318,10 +308,7 @@ pub fn intersectsNode(self: *const Range, node: *Node) bool {
}
pub fn cloneRange(self: *const Range, page: *Page) !*Range {
const arena = try page.getArena(.{ .debug = "Range.clone" });
errdefer page.releaseArena(arena);
const clone = try page._factory.abstractRange(arena, Range{ ._proto = undefined }, page);
const clone = try page._factory.abstractRange(Range{ ._proto = undefined }, page);
clone._proto._end_offset = self._proto._end_offset;
clone._proto._start_offset = self._proto._start_offset;
clone._proto._end_container = self._proto._end_container;
@@ -334,11 +321,6 @@ pub fn insertNode(self: *Range, node: *Node, page: *Page) !void {
const container = self._proto._start_container;
const offset = self._proto._start_offset;
// Per spec: if range is collapsed, end offset should extend to include
// the inserted node. Capture before insertion since live range updates
// in the insert path will adjust non-collapsed ranges automatically.
const was_collapsed = self._proto.getCollapsed();
if (container.is(Node.CData)) |_| {
// If container is a text node, we need to split it
const parent = container.parentNode() orelse return error.InvalidNodeType;
@@ -368,10 +350,9 @@ pub fn insertNode(self: *Range, node: *Node, page: *Page) !void {
_ = try container.insertBefore(node, ref_child, page);
}
// Per spec step 11: if range was collapsed, extend end to include inserted node.
// Non-collapsed ranges are already handled by the live range update in the insert path.
if (was_collapsed) {
self._proto._end_offset = self._proto._start_offset + 1;
// Update range to be after the inserted node
if (self._proto._start_container == self._proto._end_container) {
self._proto._end_offset += 1;
}
}
@@ -393,12 +374,9 @@ pub fn deleteContents(self: *Range, page: *Page) !void {
);
page.characterDataChange(self._proto._start_container, old_value);
} else {
// Delete child nodes in range.
// Capture count before the loop: removeChild triggers live range
// updates that decrement _end_offset on each removal.
const count = self._proto._end_offset - self._proto._start_offset;
var i: u32 = 0;
while (i < count) : (i += 1) {
// Delete child nodes in range
var offset = self._proto._start_offset;
while (offset < self._proto._end_offset) : (offset += 1) {
if (self._proto._start_container.getChildAt(self._proto._start_offset)) |child| {
_ = try self._proto._start_container.removeChild(child, page);
}
@@ -468,9 +446,8 @@ pub fn cloneContents(self: *const Range, page: *Page) !*DocumentFragment {
var offset = self._proto._start_offset;
while (offset < self._proto._end_offset) : (offset += 1) {
if (self._proto._start_container.getChildAt(offset)) |child| {
if (try child.cloneNodeForAppending(true, page)) |cloned| {
_ = try fragment.asNode().appendChild(cloned, page);
}
const cloned = try child.cloneNode(true, page);
_ = try fragment.asNode().appendChild(cloned, page);
}
}
}
@@ -491,11 +468,9 @@ pub fn cloneContents(self: *const Range, page: *Page) !*DocumentFragment {
if (self._proto._start_container.parentNode() == self._proto._end_container.parentNode()) {
var current = self._proto._start_container.nextSibling();
while (current != null and current != self._proto._end_container) {
const next = current.?.nextSibling();
if (try current.?.cloneNodeForAppending(true, page)) |cloned| {
_ = try fragment.asNode().appendChild(cloned, page);
}
current = next;
const cloned = try current.?.cloneNode(true, page);
_ = try fragment.asNode().appendChild(cloned, page);
current = current.?.nextSibling();
}
}
@@ -665,33 +640,6 @@ fn nextAfterSubtree(node: *Node, root: *Node) ?*Node {
return null;
}
pub fn getBoundingClientRect(self: *const Range, page: *Page) DOMRect {
if (self._proto.getCollapsed()) {
return .{ ._x = 0, ._y = 0, ._width = 0, ._height = 0 };
}
const element = self.getContainerElement() orelse {
return .{ ._x = 0, ._y = 0, ._width = 0, ._height = 0 };
};
return element.getBoundingClientRect(page);
}
pub fn getClientRects(self: *const Range, page: *Page) ![]DOMRect {
if (self._proto.getCollapsed()) {
return &.{};
}
const element = self.getContainerElement() orelse {
return &.{};
};
return element.getClientRects(page);
}
fn getContainerElement(self: *const Range) ?*Node.Element {
const container = self._proto.getCommonAncestorContainer();
if (container.is(Node.Element)) |el| return el;
const parent = container.parentNode() orelse return null;
return parent.is(Node.Element);
}
pub const JsApi = struct {
pub const bridge = js.Bridge(Range);
@@ -699,8 +647,6 @@ pub const JsApi = struct {
pub const name = "Range";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const weak = true;
pub const finalizer = bridge.finalizer(Range.deinit);
};
// Constants for compareBoundaryPoints
@@ -732,14 +678,9 @@ pub const JsApi = struct {
pub const surroundContents = bridge.function(Range.surroundContents, .{ .dom_exception = true });
pub const createContextualFragment = bridge.function(Range.createContextualFragment, .{ .dom_exception = true });
pub const toString = bridge.function(Range.toString, .{ .dom_exception = true });
pub const getBoundingClientRect = bridge.function(Range.getBoundingClientRect, .{});
pub const getClientRects = bridge.function(Range.getClientRects, .{});
};
const testing = @import("../../testing.zig");
test "WebApi: Range" {
try testing.htmlRunner("range.html", .{});
}
test "WebApi: Range mutations" {
try testing.htmlRunner("range_mutations.html", .{});
}

View File

@@ -21,8 +21,6 @@ const log = @import("../../log.zig");
const js = @import("../js/js.zig");
const Page = @import("../Page.zig");
const Session = @import("../Session.zig");
const Range = @import("Range.zig");
const AbstractRange = @import("AbstractRange.zig");
const Node = @import("Node.zig");
@@ -39,22 +37,13 @@ _direction: SelectionDirection = .none,
pub const init: Selection = .{};
pub fn deinit(self: *Selection, shutdown: bool, session: *Session) void {
if (self._range) |r| {
r.deinit(shutdown, session);
self._range = null;
}
}
fn dispatchSelectionChangeEvent(page: *Page) !void {
const event = try Event.init("selectionchange", .{}, page);
try page._event_manager.dispatch(page.document.asEventTarget(), event);
}
fn isInTree(self: *const Selection) bool {
if (self._range == null) {
return false;
}
if (self._range == null) return false;
const anchor_node = self.getAnchorNode() orelse return false;
const focus_node = self.getFocusNode() orelse return false;
return anchor_node.isConnected() and focus_node.isConnected();
@@ -115,33 +104,21 @@ pub fn getIsCollapsed(self: *const Selection) bool {
}
pub fn getRangeCount(self: *const Selection) u32 {
if (self._range == null) {
return 0;
}
if (!self.isInTree()) {
return 0;
}
if (self._range == null) return 0;
if (!self.isInTree()) return 0;
return 1;
}
pub fn getType(self: *const Selection) []const u8 {
if (self._range == null) {
return "None";
}
if (!self.isInTree()) {
return "None";
}
if (self.getIsCollapsed()) {
return "Caret";
}
if (self._range == null) return "None";
if (!self.isInTree()) return "None";
if (self.getIsCollapsed()) return "Caret";
return "Range";
}
pub fn addRange(self: *Selection, range: *Range, page: *Page) !void {
if (self._range != null) {
return;
}
if (self._range != null) return;
// Only add the range if its root node is in the document associated with this selection
const start_node = range.asAbstractRange().getStartContainer();
@@ -149,25 +126,22 @@ pub fn addRange(self: *Selection, range: *Range, page: *Page) !void {
return;
}
self.setRange(range, page);
self._range = range;
try dispatchSelectionChangeEvent(page);
}
pub fn removeRange(self: *Selection, range: *Range, page: *Page) !void {
const existing_range = self._range orelse return error.NotFound;
if (existing_range != range) {
if (self._range == range) {
self._range = null;
try dispatchSelectionChangeEvent(page);
return;
} else {
return error.NotFound;
}
self.setRange(null, page);
try dispatchSelectionChangeEvent(page);
}
pub fn removeAllRanges(self: *Selection, page: *Page) !void {
if (self._range == null) {
return;
}
self.setRange(null, page);
self._range = null;
self._direction = .none;
try dispatchSelectionChangeEvent(page);
}
@@ -183,7 +157,7 @@ pub fn collapseToEnd(self: *Selection, page: *Page) !void {
try new_range.setStart(last_node, last_offset);
try new_range.setEnd(last_node, last_offset);
self.setRange(new_range, page);
self._range = new_range;
self._direction = .none;
try dispatchSelectionChangeEvent(page);
}
@@ -199,7 +173,7 @@ pub fn collapseToStart(self: *Selection, page: *Page) !void {
try new_range.setStart(first_node, first_offset);
try new_range.setEnd(first_node, first_offset);
self.setRange(new_range, page);
self._range = new_range;
self._direction = .none;
try dispatchSelectionChangeEvent(page);
}
@@ -281,7 +255,7 @@ pub fn extend(self: *Selection, node: *Node, _offset: ?u32, page: *Page) !void {
},
}
self.setRange(new_range, page);
self._range = new_range;
try dispatchSelectionChangeEvent(page);
}
@@ -586,8 +560,7 @@ fn applyModify(self: *Selection, alter: ModifyAlter, new_node: *Node, new_offset
const new_range = try Range.init(page);
try new_range.setStart(new_node, new_offset);
try new_range.setEnd(new_node, new_offset);
self.setRange(new_range, page);
self._range = new_range;
self._direction = .none;
try dispatchSelectionChangeEvent(page);
},
@@ -609,7 +582,7 @@ pub fn selectAllChildren(self: *Selection, parent: *Node, page: *Page) !void {
const child_count = parent.getChildrenCount();
try range.setEnd(parent, @intCast(child_count));
self.setRange(range, page);
self._range = range;
self._direction = .forward;
try dispatchSelectionChangeEvent(page);
}
@@ -657,7 +630,7 @@ pub fn setBaseAndExtent(
},
}
self.setRange(range, page);
self._range = range;
try dispatchSelectionChangeEvent(page);
}
@@ -683,7 +656,7 @@ pub fn collapse(self: *Selection, _node: ?*Node, _offset: ?u32, page: *Page) !vo
try range.setStart(node, offset);
try range.setEnd(node, offset);
self.setRange(range, page);
self._range = range;
self._direction = .none;
try dispatchSelectionChangeEvent(page);
}
@@ -693,16 +666,6 @@ pub fn toString(self: *const Selection, page: *Page) ![]const u8 {
return try range.toString(page);
}
fn setRange(self: *Selection, new_range: ?*Range, page: *Page) void {
if (self._range) |existing| {
existing.deinit(false, page._session);
}
if (new_range) |nr| {
nr.asAbstractRange().acquireRef();
}
self._range = new_range;
}
pub const JsApi = struct {
pub const bridge = js.Bridge(Selection);
@@ -710,7 +673,6 @@ pub const JsApi = struct {
pub const name = "Selection";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const finalizer = bridge.finalizer(Selection.deinit);
};
pub const anchorNode = bridge.accessor(Selection.getAnchorNode, null, .{});

View File

@@ -66,20 +66,10 @@ pub fn getUsername(self: *const URL) []const u8 {
return U.getUsername(self._raw);
}
pub fn setUsername(self: *URL, value: []const u8) !void {
const allocator = self._arena orelse return error.NoAllocator;
self._raw = try U.setUsername(self._raw, value, allocator);
}
pub fn getPassword(self: *const URL) []const u8 {
return U.getPassword(self._raw);
}
pub fn setPassword(self: *URL, value: []const u8) !void {
const allocator = self._arena orelse return error.NoAllocator;
self._raw = try U.setPassword(self._raw, value, allocator);
}
pub fn getPathname(self: *const URL) []const u8 {
return U.getPathname(self._raw);
}
@@ -243,14 +233,13 @@ pub fn createObjectURL(blob: *Blob, page: *Page) ![]const u8 {
var uuid_buf: [36]u8 = undefined;
@import("../../id.zig").uuidv4(&uuid_buf);
const origin = (try page.getOrigin(page.call_arena)) orelse "null";
const blob_url = try std.fmt.allocPrint(
page.arena,
"blob:{s}/{s}",
.{ page.origin orelse "null", uuid_buf },
.{ origin, uuid_buf },
);
try page._blob_urls.put(page.arena, blob_url, blob);
// prevent GC from cleaning up the blob while it's in the registry
page.js.strongRef(blob);
return blob_url;
}
@@ -260,10 +249,8 @@ pub fn revokeObjectURL(url: []const u8, page: *Page) void {
return;
}
// Remove from registry and release strong ref (no-op if not found)
if (page._blob_urls.fetchRemove(url)) |entry| {
page.js.weakRef(entry.value);
}
// Remove from registry (no-op if not found)
_ = page._blob_urls.remove(url);
}
pub const JsApi = struct {
@@ -285,8 +272,8 @@ pub const JsApi = struct {
pub const search = bridge.accessor(URL.getSearch, URL.setSearch, .{});
pub const hash = bridge.accessor(URL.getHash, URL.setHash, .{});
pub const pathname = bridge.accessor(URL.getPathname, URL.setPathname, .{});
pub const username = bridge.accessor(URL.getUsername, URL.setUsername, .{});
pub const password = bridge.accessor(URL.getPassword, URL.setPassword, .{});
pub const username = bridge.accessor(URL.getUsername, null, .{});
pub const password = bridge.accessor(URL.getPassword, null, .{});
pub const hostname = bridge.accessor(URL.getHostname, URL.setHostname, .{});
pub const host = bridge.accessor(URL.getHost, URL.setHost, .{});
pub const port = bridge.accessor(URL.getPort, URL.setPort, .{});

View File

@@ -160,8 +160,8 @@ pub fn getSelection(self: *const Window) *Selection {
return &self._document._selection;
}
pub fn setLocation(self: *Window, url: [:0]const u8, page: *Page) !void {
return page.scheduleNavigation(url, .{ .reason = .script, .kind = .{ .push = null } }, .{ .script = self._page });
pub fn setLocation(_: *const Window, url: [:0]const u8, page: *Page) !void {
return page.scheduleNavigation(url, .{ .reason = .script, .kind = .{ .push = null } }, .script);
}
pub fn getHistory(_: *Window, page: *Page) *History {
@@ -412,10 +412,6 @@ pub fn atob(_: *const Window, input: []const u8, page: *Page) ![]const u8 {
return decoded;
}
pub fn structuredClone(_: *const Window, value: js.Value) !js.Value {
return value.structuredClone();
}
pub fn getFrame(self: *Window, idx: usize) !?*Window {
const page = self._page;
const frames = page.frames.items;
@@ -555,14 +551,17 @@ pub fn unhandledPromiseRejection(self: *Window, rejection: js.PromiseRejection,
});
}
const target = self.asEventTarget();
if (page._event_manager.hasDirectListeners(target, "unhandledrejection", self._on_unhandled_rejection)) {
const event = (try @import("event/PromiseRejectionEvent.zig").init("unhandledrejection", .{
.reason = if (rejection.reason()) |r| try r.temp() else null,
.promise = try rejection.promise().temp(),
}, page)).asEvent();
try page._event_manager.dispatchDirect(target, event, self._on_unhandled_rejection, .{ .context = "window.unhandledrejection" });
}
const event = (try @import("event/PromiseRejectionEvent.zig").init("unhandledrejection", .{
.reason = if (rejection.reason()) |r| try r.temp() else null,
.promise = try rejection.promise().temp(),
}, page)).asEvent();
try page._event_manager.dispatchDirect(
self.asEventTarget(),
event,
self._on_unhandled_rejection,
.{ .inject_target = true, .context = "window.unhandledrejection" },
);
}
const ScheduleOpts = struct {
@@ -650,9 +649,9 @@ const ScheduleCallback = struct {
}
fn deinit(self: *ScheduleCallback) void {
self.cb.release();
self.page.js.release(self.cb);
for (self.params) |param| {
param.release();
self.page.js.release(param);
}
self.page.releaseArena(self.arena);
}
@@ -799,9 +798,8 @@ pub const JsApi = struct {
pub const matchMedia = bridge.function(Window.matchMedia, .{});
pub const postMessage = bridge.function(Window.postMessage, .{});
pub const btoa = bridge.function(Window.btoa, .{});
pub const atob = bridge.function(Window.atob, .{ .dom_exception = true });
pub const atob = bridge.function(Window.atob, .{});
pub const reportError = bridge.function(Window.reportError, .{});
pub const structuredClone = bridge.function(Window.structuredClone, .{});
pub const getComputedStyle = bridge.function(Window.getComputedStyle, .{});
pub const getSelection = bridge.function(Window.getSelection, .{});

View File

@@ -20,7 +20,6 @@ const std = @import("std");
const log = @import("../../../log.zig");
const js = @import("../../js/js.zig");
const Page = @import("../../Page.zig");
const Session = @import("../../Session.zig");
const Allocator = std.mem.Allocator;
@@ -62,8 +61,8 @@ pub fn init(page: *Page) !*Animation {
return self;
}
pub fn deinit(self: *Animation, _: bool, session: *Session) void {
session.releaseArena(self._arena);
pub fn deinit(self: *Animation, _: bool, page: *Page) void {
page.releaseArena(self._arena);
}
pub fn play(self: *Animation, page: *Page) !void {

View File

@@ -64,30 +64,15 @@ pub fn createImageData(
switch (width_or_image_data) {
.width => |width| {
const height = maybe_height orelse return error.TypeError;
return ImageData.init(width, height, maybe_settings, page);
return ImageData.constructor(width, height, maybe_settings, page);
},
.image_data => |image_data| {
return ImageData.init(image_data._width, image_data._height, null, page);
return ImageData.constructor(image_data._width, image_data._height, null, page);
},
}
}
pub fn putImageData(_: *const CanvasRenderingContext2D, _: *ImageData, _: f64, _: f64, _: ?f64, _: ?f64, _: ?f64, _: ?f64) void {}
pub fn getImageData(
_: *const CanvasRenderingContext2D,
_: i32, // sx
_: i32, // sy
sw: i32,
sh: i32,
page: *Page,
) !*ImageData {
if (sw <= 0 or sh <= 0) {
return error.IndexSizeError;
}
return ImageData.init(@intCast(sw), @intCast(sh), null, page);
}
pub fn save(_: *CanvasRenderingContext2D) void {}
pub fn restore(_: *CanvasRenderingContext2D) void {}
pub fn scale(_: *CanvasRenderingContext2D, _: f64, _: f64) void {}
@@ -140,7 +125,6 @@ pub const JsApi = struct {
pub const createImageData = bridge.function(CanvasRenderingContext2D.createImageData, .{ .dom_exception = true });
pub const putImageData = bridge.function(CanvasRenderingContext2D.putImageData, .{ .noop = true });
pub const getImageData = bridge.function(CanvasRenderingContext2D.getImageData, .{ .dom_exception = true });
pub const save = bridge.function(CanvasRenderingContext2D.save, .{ .noop = true });
pub const restore = bridge.function(CanvasRenderingContext2D.restore, .{ .noop = true });
pub const scale = bridge.function(CanvasRenderingContext2D.scale, .{ .noop = true });

View File

@@ -63,30 +63,15 @@ pub fn createImageData(
switch (width_or_image_data) {
.width => |width| {
const height = maybe_height orelse return error.TypeError;
return ImageData.init(width, height, maybe_settings, page);
return ImageData.constructor(width, height, maybe_settings, page);
},
.image_data => |image_data| {
return ImageData.init(image_data._width, image_data._height, null, page);
return ImageData.constructor(image_data._width, image_data._height, null, page);
},
}
}
pub fn putImageData(_: *const OffscreenCanvasRenderingContext2D, _: *ImageData, _: f64, _: f64, _: ?f64, _: ?f64, _: ?f64, _: ?f64) void {}
pub fn getImageData(
_: *const OffscreenCanvasRenderingContext2D,
_: i32, // sx
_: i32, // sy
sw: i32,
sh: i32,
page: *Page,
) !*ImageData {
if (sw <= 0 or sh <= 0) {
return error.IndexSizeError;
}
return ImageData.init(@intCast(sw), @intCast(sh), null, page);
}
pub fn save(_: *OffscreenCanvasRenderingContext2D) void {}
pub fn restore(_: *OffscreenCanvasRenderingContext2D) void {}
pub fn scale(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64) void {}
@@ -139,7 +124,6 @@ pub const JsApi = struct {
pub const createImageData = bridge.function(OffscreenCanvasRenderingContext2D.createImageData, .{ .dom_exception = true });
pub const putImageData = bridge.function(OffscreenCanvasRenderingContext2D.putImageData, .{ .noop = true });
pub const getImageData = bridge.function(OffscreenCanvasRenderingContext2D.getImageData, .{ .dom_exception = true });
pub const save = bridge.function(OffscreenCanvasRenderingContext2D.save, .{ .noop = true });
pub const restore = bridge.function(OffscreenCanvasRenderingContext2D.restore, .{ .noop = true });
pub const scale = bridge.function(OffscreenCanvasRenderingContext2D.scale, .{ .noop = true });

View File

@@ -43,26 +43,16 @@ pub fn splitText(self: *Text, offset: usize, page: *Page) !*Text {
const new_node = try page.createTextNode(new_data);
const new_text = new_node.as(Text);
const node = self._proto.asNode();
const old_data = data[0..byte_offset];
try self._proto.setData(old_data, page);
// Per DOM spec splitText: insert first (step 7a), then update ranges (7b-7e),
// then truncate original node (step 8).
// If this node has a parent, insert the new node right after this one
const node = self._proto.asNode();
if (node.parentNode()) |parent| {
const next_sibling = node.nextSibling();
_ = try parent.insertBefore(new_node, next_sibling, page);
// splitText-specific range updates (steps 7b-7e)
if (parent.getChildIndex(node)) |node_index| {
page.updateRangesForSplitText(node, new_node, @intCast(offset), parent, node_index);
}
}
// Step 8: truncate original node via replaceData(offset, count, "").
// Use replaceData instead of setData so live range updates fire
// (matters for detached text nodes where steps 7b-7e were skipped).
const length = self._proto.getLength();
try self._proto.replaceData(offset, length - offset, "", page);
return new_text;
}

View File

@@ -20,15 +20,14 @@ pub const NodeLive = @import("collections/node_live.zig").NodeLive;
pub const ChildNodes = @import("collections/ChildNodes.zig");
pub const DOMTokenList = @import("collections/DOMTokenList.zig");
pub const RadioNodeList = @import("collections/RadioNodeList.zig");
pub const HTMLCollection = @import("collections/HTMLCollection.zig");
pub const HTMLAllCollection = @import("collections/HTMLAllCollection.zig");
pub const HTMLOptionsCollection = @import("collections/HTMLOptionsCollection.zig");
pub const HTMLFormControlsCollection = @import("collections/HTMLFormControlsCollection.zig");
pub fn registerTypes() []const type {
return &.{
HTMLCollection,
HTMLCollection.Iterator,
@import("collections/HTMLCollection.zig"),
@import("collections/HTMLCollection.zig").Iterator,
@import("collections/NodeList.zig"),
@import("collections/NodeList.zig").KeyIterator,
@import("collections/NodeList.zig").ValueIterator,

View File

@@ -20,7 +20,6 @@ const std = @import("std");
const Node = @import("../Node.zig");
const Page = @import("../../Page.zig");
const Session = @import("../../Session.zig");
const GenericIterator = @import("iterator.zig").Entry;
// Optimized for node.childNodes, which has to be a live list.
@@ -54,8 +53,8 @@ pub fn init(node: *Node, page: *Page) !*ChildNodes {
return self;
}
pub fn deinit(self: *const ChildNodes, session: *Session) void {
session.releaseArena(self._arena);
pub fn deinit(self: *const ChildNodes, page: *Page) void {
page.releaseArena(self._arena);
}
pub fn length(self: *ChildNodes, page: *Page) !u32 {

Some files were not shown because too many files have changed in this diff Show More