mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-03-28 07:33:16 +00:00
Compare commits
12 Commits
v0.2.6
...
ci-web-bot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e42cbe3336 | ||
|
|
1f2dd7e6e5 | ||
|
|
02f3b8899b | ||
|
|
b18c0311d0 | ||
|
|
9754c2830c | ||
|
|
e4b32a1a91 | ||
|
|
6161c0d701 | ||
|
|
5107395917 | ||
|
|
91254eb365 | ||
|
|
79c6b1ed0a | ||
|
|
48b00634c6 | ||
|
|
201e445ca8 |
2
.github/actions/install/action.yml
vendored
2
.github/actions/install/action.yml
vendored
@@ -13,7 +13,7 @@ inputs:
|
||||
zig-v8:
|
||||
description: 'zig v8 version to install'
|
||||
required: false
|
||||
default: 'v0.3.3'
|
||||
default: 'v0.3.1'
|
||||
v8:
|
||||
description: 'v8 version to install'
|
||||
required: false
|
||||
|
||||
25
.github/workflows/e2e-test.yml
vendored
25
.github/workflows/e2e-test.yml
vendored
@@ -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
|
||||
|
||||
5
.github/workflows/wpt.yml
vendored
5
.github/workflows/wpt.yml
vendored
@@ -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
|
||||
|
||||
@@ -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 && \
|
||||
|
||||
34
build.zig
34
build.zig
@@ -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"));
|
||||
|
||||
@@ -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",
|
||||
|
||||
48
src/App.zig
48
src/App.zig
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" {
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
114
src/Server.zig
114
src/Server.zig
@@ -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 .{
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)) = &.{};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
¶meter_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));
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"];
|
||||
|
||||
@@ -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">
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
It was clicked!
|
||||
@@ -1,2 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
a-page
|
||||
@@ -1,2 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<a href="support/after_link.html" id=link>a link</a>
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
-->
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, .{});
|
||||
|
||||
@@ -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, .{});
|
||||
|
||||
@@ -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, &.{
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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]",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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, .{});
|
||||
|
||||
@@ -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, .{});
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, .{});
|
||||
|
||||
@@ -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", .{});
|
||||
}
|
||||
|
||||
@@ -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, .{});
|
||||
|
||||
@@ -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, .{});
|
||||
|
||||
@@ -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, .{});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user