mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-10-08 12:21:44 -04:00
Merge branch 'main' into agent-fixes
# Conflicts: # build.zig.zon # src/SemanticTree.zig
This commit is contained in:
commit
b5896c259b
111 files changed
+11728
-6068
No files matched your search
@@ -13,7 +13,7 @@ inputs:
|
||||
zig-v8:
|
||||
description: 'zig v8 version to install'
|
||||
required: false
|
||||
default: 'v0.5.6'
|
||||
default: 'v0.5.7'
|
||||
v8:
|
||||
description: 'v8 version to install'
|
||||
required: false
|
||||
|
||||
@@ -13,7 +13,7 @@ inputs:
|
||||
zig-v8:
|
||||
description: 'zig-v8 release tag the prebuilt lib came from'
|
||||
required: false
|
||||
default: 'v0.5.6'
|
||||
default: 'v0.5.7'
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
|
||||
@@ -274,6 +274,8 @@ jobs:
|
||||
# $ sudo chmod o+w /sys/fs/cgroup/cgroup.procs
|
||||
# $ sudo mkdir -p /sys/fs/cgroup/actions-runner
|
||||
# $ sudo chown -R actions-runner:actions-runner /sys/fs/cgroup/actions-runner
|
||||
# $ echo +memory | sudo tee /sys/fs/cgroup/actions-runner/cgroup.subtree_control
|
||||
# /sys/fs/cgroup is reset on reboot: run these again after a host restart.
|
||||
CG_ROOT: /sys/fs/cgroup
|
||||
CG: actions-runner/lpd_${{ github.run_id }}_${{ github.run_attempt }}
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ FROM debian:stable-slim
|
||||
ARG MINISIG=0.12
|
||||
ARG ZIG_MINISIG=RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U
|
||||
ARG V8=14.9.207.35
|
||||
ARG ZIG_V8=v0.5.6
|
||||
ARG ZIG_V8=v0.5.7
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
RUN apt-get update -yq && \
|
||||
|
||||
+2
-2
@@ -5,8 +5,8 @@
|
||||
.minimum_zig_version = "0.16.0",
|
||||
.dependencies = .{
|
||||
.v8 = .{
|
||||
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/0cc0b28d18f021c560d6f84b7e27e5a7ade9f8c8.tar.gz",
|
||||
.hash = "v8-0.0.0-xddH6_g0AwBnqBwDOD22Mks8ODHFsH6SffwcpFoUSq3X",
|
||||
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/ebd2fabfb001294ed43ef65286c7db17245416e9.tar.gz",
|
||||
.hash = "v8-0.0.0-xddH66lEAwB-5Ec92QAp19-aZ6-oB8UnnUm-w3fBUEaX",
|
||||
},
|
||||
// .v8 = .{ .path = "../zig-v8-fork" },
|
||||
.brotli = .{
|
||||
|
||||
+2684
-2687
File diff suppressed because it is too large.
Load diff
@@ -29,6 +29,8 @@
|
||||
# OUT scratch directory (default /tmp/orderfile-regen)
|
||||
# RAMDIR tmpfs the binary is benched from (default /dev/shm)
|
||||
set -euo pipefail
|
||||
# profile() runs in a command substitution, which otherwise drops set -e.
|
||||
shopt -s inherit_errexit
|
||||
|
||||
DEMO_DIR=${DEMO_DIR:-../demo}
|
||||
RUNS=${RUNS:-100}
|
||||
@@ -92,8 +94,12 @@ profile() {
|
||||
set_fault_around 4096
|
||||
"$ram" serve --insecure-disable-tls-host-verification > /dev/null 2>&1 &
|
||||
local pid=$!
|
||||
# This subshell's own trap: the script's cleanup doesn't know this pid.
|
||||
trap "kill $pid 2> /dev/null || true" EXIT
|
||||
sleep 1
|
||||
(cd "$DEMO_DIR" && RUNS=$RUNS node puppeteer/cdp.js > "$OUT/bench.out")
|
||||
# A bench that dies early still leaves a resident set, just the wrong one.
|
||||
grep -q '^total runs' "$OUT/bench.out" || { echo "bench did not complete, see $OUT/bench.out" >&2; exit 1; }
|
||||
sleep 2
|
||||
python3 "$TOOLS/pagemap.py" "$pid" "$resident" >&2
|
||||
local hwm
|
||||
|
||||
+1061
-1171
File diff suppressed because it is too large.
Load diff
@@ -253,6 +253,8 @@ pub const ExperimentalFeatures = packed struct(u2) {
|
||||
/// Common CLI args.
|
||||
const CommonOptions = .{
|
||||
.{ .name = "obey_robots", .type = bool },
|
||||
.{ .name = "robot_store_entry_limit", .type = ?u32, .default = 1000 },
|
||||
.{ .name = "cors_store_entry_limit", .type = ?u32, .default = 1000 },
|
||||
.{ .name = "proxy_bearer_token", .type = ?[:0]const u8 },
|
||||
.{ .name = "http_proxy", .type = ?[:0]const u8 },
|
||||
.{ .name = "http_max_concurrent", .type = ?u8 },
|
||||
@@ -565,6 +567,20 @@ pub fn obeyRobots(self: *const Config) bool {
|
||||
};
|
||||
}
|
||||
|
||||
pub fn robotStoreEntryLimit(self: *const Config) u32 {
|
||||
return switch (self.mode) {
|
||||
inline .serve, .fetch, .mcp, .agent => |opts| opts.robot_store_entry_limit.?,
|
||||
else => 1000,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn corsStoreEntryLimit(self: *const Config) u32 {
|
||||
return switch (self.mode) {
|
||||
inline .serve, .fetch, .mcp, .agent => |opts| opts.cors_store_entry_limit.?,
|
||||
else => 1000,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn httpVersion(self: *const Config) HttpVersion {
|
||||
return switch (self.mode) {
|
||||
inline .serve, .fetch, .mcp, .agent => |opts| opts.http_version,
|
||||
|
||||
+3
-1
@@ -92,7 +92,8 @@ http_navigation_delay_ms: Histogram(&.{
|
||||
}) = .{},
|
||||
robots_status: CounterEnum("category", @import("network/http.zig").StatusCategory) = .{},
|
||||
robots_access: CounterEnum("result", enum { allow, deny }) = .{},
|
||||
cors_check: CounterEnum("result", enum { same_origin, no_cors, simple, preflight }) = .{},
|
||||
robots_evictions: Counter = .{},
|
||||
cors_check: CounterEnum("result", enum { same_origin, no_cors, simple, preflight, cached }) = .{},
|
||||
cors_preflight: CounterEnum("result", enum { allowed, blocked }) = .{},
|
||||
cors_response: CounterEnum("result", enum { allowed, blocked }) = .{},
|
||||
adblock_verdicts: CounterEnum("verdict", @import("network/adblock/AdBlocker.zig").Verdict) = .{},
|
||||
@@ -130,6 +131,7 @@ const help = .{
|
||||
.http_navigation_delay_ms = "Time in milliseconds a throttled top-level navigation waited",
|
||||
.robots_status = "robots.txt response status",
|
||||
.robots_access = "robots.txt result",
|
||||
.robots_evictions = "robots.txt cache entries evicted to stay within limit",
|
||||
.cors_check = "CORS initial classification: same_origin/no_cors need no CORS handling, simple needs response validation only, preflight needs an OPTIONS round-trip first",
|
||||
.cors_preflight = "CORS preflight (OPTIONS) results, one per request that required one",
|
||||
.cors_response = "CORS actual-response validation results",
|
||||
|
||||
+91
-35
@@ -81,7 +81,7 @@ fn visitAll(self: @This(), visitor: anytype) error{WriteFailed}!void {
|
||||
.listener_targets = listener_targets,
|
||||
.label_index = &label_index,
|
||||
};
|
||||
self.walk(&ctx, self.dom_node, null, visitor, 1, 0) catch |err| {
|
||||
self.walk(&ctx, visitor) catch |err| {
|
||||
log.err(.app, "semantic tree walk failed", .{ .err = err });
|
||||
return error.WriteFailed;
|
||||
};
|
||||
@@ -123,15 +123,60 @@ const WalkContext = struct {
|
||||
label_index: *Label.LabelByForIndex,
|
||||
};
|
||||
|
||||
fn walk(
|
||||
// A node whose children are still being walked
|
||||
const Open = struct {
|
||||
next_child: ?*Node,
|
||||
// for the children's xpath index
|
||||
tag_counts: std.StringArrayHashMapUnmanaged(usize) = .empty,
|
||||
name: ?[]const u8, // The children's parent_name
|
||||
xpath_len: usize,
|
||||
visited: bool,
|
||||
};
|
||||
|
||||
fn walk(self: @This(), ctx: *WalkContext, visitor: anytype) !void {
|
||||
var stack: std.ArrayList(Open) = .empty;
|
||||
defer stack.deinit(self.arena);
|
||||
|
||||
try self.visitNode(ctx, &stack, self.dom_node, null, visitor, 1);
|
||||
while (stack.items.len > 0) {
|
||||
// Everything read from `top` is read before visitNode, which can grow (move) the stack.
|
||||
const top = &stack.items[stack.items.len - 1];
|
||||
if (top.next_child) |child| {
|
||||
top.next_child = child._next;
|
||||
|
||||
var tag: []const u8 = "text()";
|
||||
if (child.is(Element)) |el| {
|
||||
tag = el.getTagNameLower();
|
||||
}
|
||||
const gop = try top.tag_counts.getOrPut(self.arena, tag);
|
||||
if (!gop.found_existing) {
|
||||
gop.value_ptr.* = 0;
|
||||
}
|
||||
gop.value_ptr.* += 1;
|
||||
|
||||
try self.visitNode(ctx, &stack, child, top.name, visitor, gop.value_ptr.*);
|
||||
continue;
|
||||
}
|
||||
|
||||
const done = stack.pop().?;
|
||||
if (done.visited) {
|
||||
try visitor.leave();
|
||||
}
|
||||
ctx.xpath_buffer.shrinkRetainingCapacity(done.xpath_len);
|
||||
}
|
||||
}
|
||||
|
||||
// Every ancestor of `node` below the root is open, so the stack's length is its depth.
|
||||
fn visitNode(
|
||||
self: @This(),
|
||||
ctx: *WalkContext,
|
||||
stack: *std.ArrayList(Open),
|
||||
node: *Node,
|
||||
parent_name: ?[]const u8,
|
||||
visitor: anytype,
|
||||
index: usize,
|
||||
current_depth: u32,
|
||||
) !void {
|
||||
const current_depth = stack.items.len;
|
||||
if (current_depth > self.max_depth) return;
|
||||
|
||||
// 1. Skip non-content nodes
|
||||
@@ -209,8 +254,6 @@ fn walk(
|
||||
try appendXPathSegment(node, ctx.xpath_buffer, self.arena, index);
|
||||
const xpath = ctx.xpath_buffer.items;
|
||||
|
||||
var name = try axn.getName(self.frame, self.arena, ctx.label_index);
|
||||
|
||||
const has_explicit_label = if (node.is(Element)) |el|
|
||||
el.getAttributeInterned("aria-label") != null or el.getAttributeInterned("title") != null
|
||||
else
|
||||
@@ -218,12 +261,14 @@ fn walk(
|
||||
|
||||
const structural = isStructuralRole(role);
|
||||
|
||||
// Filter out computed concatenated names for generic containers without explicit labels.
|
||||
// No 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;
|
||||
}
|
||||
// Not computing it also keeps a deep chain of containers from being O(depth²).
|
||||
const name = if (structural and !has_explicit_label)
|
||||
null
|
||||
else
|
||||
try axn.getName(self.frame, self.arena, ctx.label_index);
|
||||
|
||||
var should_visit = true;
|
||||
if (self.interactive_only) {
|
||||
@@ -278,32 +323,12 @@ fn walk(
|
||||
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.StringArrayHashMapUnmanaged(usize) = .empty;
|
||||
while (it.next()) |child| {
|
||||
var tag: []const u8 = "text()";
|
||||
if (child.is(Element)) |el| {
|
||||
tag = el.getTagNameLower();
|
||||
}
|
||||
|
||||
const gop = try tag_counts.getOrPut(self.arena, tag);
|
||||
if (!gop.found_existing) {
|
||||
gop.value_ptr.* = 0;
|
||||
}
|
||||
gop.value_ptr.* += 1;
|
||||
|
||||
try self.walk(ctx, child, name, visitor, gop.value_ptr.*, current_depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (did_visit) {
|
||||
try visitor.leave();
|
||||
}
|
||||
|
||||
ctx.xpath_buffer.shrinkRetainingCapacity(initial_xpath_len);
|
||||
try stack.append(self.arena, .{
|
||||
.next_child = if (should_walk_children) node._first_child else null,
|
||||
.name = name,
|
||||
.xpath_len = initial_xpath_len,
|
||||
.visited = did_visit,
|
||||
});
|
||||
}
|
||||
|
||||
fn extractSelectOptions(node: *Node, frame: *Frame, arena: std.mem.Allocator) ![]OptionData {
|
||||
@@ -780,3 +805,34 @@ test "SemanticTree max_depth" {
|
||||
|
||||
try testing.expect(std.mem.indexOf(u8, text_str, "other") == null);
|
||||
}
|
||||
|
||||
test "SemanticTree: deep nesting doesn't overflow the native stack" {
|
||||
var registry: NodeRegistry = .init(testing.allocator);
|
||||
defer registry.deinit();
|
||||
|
||||
const frame = try testing.createFrame();
|
||||
defer testing.test_session.closeAllPages();
|
||||
|
||||
// The link's name comes from its content: the whole chain. The <g>s are
|
||||
// pruned, so the JSON only nests link > text. SVG, as an HTML element's
|
||||
// pointer-events lookup walks its ancestors: O(depth²).
|
||||
const depth = 50_000;
|
||||
const doc = frame.window._document;
|
||||
var top = try doc.createTextNode("deep");
|
||||
for (0..depth) |_| {
|
||||
const parent = (try doc.createElementNS("http://www.w3.org/2000/svg", "g", frame)).asNode();
|
||||
_ = try parent.appendChild(top, frame);
|
||||
top = parent;
|
||||
}
|
||||
const link = try doc.createElement("a", null, frame);
|
||||
try link.setAttribute(.wrap("href"), .wrap("#"), frame);
|
||||
_ = try link.asNode().appendChild(top, frame);
|
||||
|
||||
const st: Self = try .init(testing.arena_allocator, link.asNode(), ®istry, frame, .{});
|
||||
const json_str = try std.json.Stringify.valueAlloc(testing.allocator, st, .{});
|
||||
defer testing.allocator.free(json_str);
|
||||
|
||||
try testing.expect(std.mem.indexOf(u8, json_str, "\"role\":\"link\",\"name\":\"deep\"") != null);
|
||||
try testing.expectEqual(depth, std.mem.count(u8, json_str, "/g[1]"));
|
||||
try testing.expect(std.mem.endsWith(u8, json_str, "/text()[1]\",\"nodeType\":3,\"nodeValue\":\"deep\",\"children\":[]}]}"));
|
||||
}
|
||||
@@ -155,7 +155,7 @@ fn deviceLogin(allocator: std.mem.Allocator, interrupt: ?*zenai.http.Interrupt)
|
||||
|
||||
const code_res = try post(a, interrupt, device_code_url, "application/json", "{\"client_id\":\"" ++ client_id ++ "\"}");
|
||||
if (code_res.status != .ok) {
|
||||
log.warn(.app, "codex device-code request failed", .{ .status = @intFromEnum(code_res.status), .body = code_res.body });
|
||||
log.warn(.app, "codex device-code failed", .{ .status = @intFromEnum(code_res.status), .body = code_res.body });
|
||||
return error.DeviceCodeRequestFailed;
|
||||
}
|
||||
const dc = try std.json.parseFromSliceLeaky(DeviceCode, a, code_res.body, .{ .ignore_unknown_fields = true });
|
||||
|
||||
@@ -262,14 +262,15 @@ pub fn runMicrotasks(self: *Browser) void {
|
||||
self.env.runMicrotasks();
|
||||
}
|
||||
|
||||
pub fn runMacrotasks(self: *Browser) !void {
|
||||
pub fn runMacrotasks(self: *Browser) !bool {
|
||||
const env = &self.env;
|
||||
|
||||
try self.env.runMacrotasks();
|
||||
env.pumpMessageLoop();
|
||||
const ran_platform_task = env.pumpMessageLoop();
|
||||
|
||||
// either of the above could have queued more microtasks
|
||||
env.runMicrotasks();
|
||||
return ran_platform_task;
|
||||
}
|
||||
|
||||
pub fn hasBackgroundTasks(self: *Browser) bool {
|
||||
|
||||
+61
-6
@@ -71,6 +71,7 @@ const GlobalScope = @import("global_scope.zig").GlobalScope;
|
||||
|
||||
const GlobalEventHandlersLookup = @import("webapi/global_event_handlers.zig").Lookup;
|
||||
|
||||
const framing = @import("frame/framing.zig");
|
||||
pub const parse = @import("frame/parse.zig");
|
||||
pub const preload = @import("frame/preload.zig");
|
||||
pub const resource_load = @import("frame/resource_load.zig");
|
||||
@@ -133,6 +134,10 @@ _event_target_attr_listeners: GlobalEventHandlersLookup = .empty,
|
||||
// File objects (reference counted via their Blob proto); released at teardown.
|
||||
_file_lists: std.ArrayList(*FileList) = .empty,
|
||||
|
||||
// List of Documents which called document.open() and potentially need to have
|
||||
// the parser freed.
|
||||
_script_created_parser_docs: std.ArrayList(*Document) = .empty,
|
||||
|
||||
// Every matchMedia() result of this document, so a viewport change can fire
|
||||
// their `change`.
|
||||
_media_query_lists: std.ArrayList(*MediaQueryList) = .empty,
|
||||
@@ -146,6 +151,8 @@ _queued_events_1: std.ArrayList(QueuedEvent) = .empty,
|
||||
_queued_events_2: std.ArrayList(QueuedEvent) = .empty,
|
||||
_queued_events: *std.ArrayList(QueuedEvent) = undefined,
|
||||
|
||||
_focus_fixup_pending: bool = false,
|
||||
|
||||
_style_manager: StyleManager,
|
||||
_script_manager: ScriptManager,
|
||||
|
||||
@@ -456,6 +463,16 @@ pub fn deinit(self: *Frame) void {
|
||||
|
||||
self._parse_state.deinit(self);
|
||||
|
||||
for (self._script_created_parser_docs.items) |doc| {
|
||||
const parser = &(doc._script_created_parser orelse continue);
|
||||
if (parser.parser.frame != self) {
|
||||
// The document was closed and re-opened on another frame
|
||||
continue;
|
||||
}
|
||||
parser.deinit();
|
||||
doc._script_created_parser = null;
|
||||
}
|
||||
|
||||
// Unregister CookieStore from session notifications before the JS
|
||||
// context (and thus the scheduler) is destroyed, otherwise a late
|
||||
// mutation could schedule a callback that never runs.
|
||||
@@ -1444,6 +1461,15 @@ fn frameHeaderDoneCallback(transfer: *HttpClient.Transfer) !HttpClient.Transfer.
|
||||
self.url = try self.arena.dupeZ(u8, response_url);
|
||||
self.origin = try URL.getOrigin(self.arena, self.url);
|
||||
}
|
||||
|
||||
if (self.parent != null and framing.allowed(self, transfer) == false) {
|
||||
log.warn(.frame, "x-frame-options blocked", .{ .url = self.url });
|
||||
// give this an opaque origin so that any request to the error page
|
||||
// is treated as being cross-origin
|
||||
self.origin = null;
|
||||
try self.js.setOrigin(null);
|
||||
return error.XFrameOptionsDenied;
|
||||
}
|
||||
try self.js.setOrigin(self.origin);
|
||||
|
||||
// After any redirect, drop the original method/body/header so a later
|
||||
@@ -1487,7 +1513,7 @@ fn frameHeaderDoneCallback(transfer: *HttpClient.Transfer) !HttpClient.Transfer.
|
||||
.name = try self.arena.dupe(u8, hdr.name),
|
||||
.value = try self.arena.dupe(u8, hdr.value),
|
||||
});
|
||||
if (std.ascii.eqlIgnoreCase(hdr.name, "referrer-policy")) {
|
||||
if (std.mem.eql(u8, hdr.name, "referrer-policy")) {
|
||||
if (referrer.parseHeader(hdr.value)) |rp| {
|
||||
self.referrer_policy = rp;
|
||||
}
|
||||
@@ -1533,7 +1559,7 @@ fn maybeStartDownload(self: *Frame, transfer: *HttpClient.Transfer) !bool {
|
||||
const disposition: HttpClient.Header = blk: {
|
||||
var it = transfer.responseHeaderIterator();
|
||||
while (it.next()) |hdr| {
|
||||
if (std.ascii.eqlIgnoreCase(hdr.name, "content-disposition")) {
|
||||
if (std.mem.eql(u8, hdr.name, "content-disposition")) {
|
||||
break :blk hdr;
|
||||
}
|
||||
}
|
||||
@@ -2041,6 +2067,9 @@ pub fn iframeAddedCallback(self: *Frame, iframe: *IFrame) !void {
|
||||
try Frame.init(new_frame, frame_id, self.page, .{ .parent = self });
|
||||
errdefer new_frame.deinit();
|
||||
|
||||
// until the navigate commits, the iframe is about:blank and inherits the parent's origin
|
||||
try new_frame.js.setOrigin(self.origin);
|
||||
|
||||
const delays_load = iframe.isLazyLoading() == false;
|
||||
new_frame._delays_parent_load = delays_load;
|
||||
if (delays_load) {
|
||||
@@ -2356,6 +2385,25 @@ pub fn queueElementEvent(self: *Frame, element: *Element.Html, kind: QueuedEvent
|
||||
}
|
||||
}
|
||||
|
||||
// An element that becomes inert can't stay focused. Fire its blur on the next tick
|
||||
fn scheduleFocusFixup(self: *Frame) !void {
|
||||
if (self._focus_fixup_pending or self.document._active_element == null) {
|
||||
return;
|
||||
}
|
||||
try self.js.scheduler.add(self, struct {
|
||||
fn run(ctx: *anyopaque) !?u32 {
|
||||
const f: *Frame = @ptrCast(@alignCast(ctx));
|
||||
f._focus_fixup_pending = false;
|
||||
const active = f.document._active_element orelse return null;
|
||||
if (active.asNode().isInert(f)) {
|
||||
try active.blur(f);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}.run, 5, .{ .name = "frame.focusFixup" });
|
||||
self._focus_fixup_pending = true;
|
||||
}
|
||||
|
||||
const HashChangeCallback = struct {
|
||||
frame: *Frame,
|
||||
old_url: []const u8,
|
||||
@@ -3135,6 +3183,10 @@ pub fn attributeChange(self: *Frame, element: *Element, name: String, value: Str
|
||||
} else if (name.eql(comptime .wrap("style"))) {
|
||||
element._flags.has_inline_style = true;
|
||||
self.styleAttributeChanged(element, value.str());
|
||||
} else if (name.eql(comptime .wrap("inert"))) {
|
||||
self.scheduleFocusFixup() catch |err| {
|
||||
log.err(.frame, "scheduleFocusFixup", .{ .err = err, .type = self._type, .url = self.url });
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3282,10 +3334,13 @@ fn nodeIsReady(self: *Frame, comptime from_parser: bool, node: *Node) !void {
|
||||
// Scripts, iframes, links and styles activate on becoming connected;
|
||||
// appending them to a detached parent does nothing (they run/load later
|
||||
// if the subtree gets inserted into the document).
|
||||
if (comptime from_parser == false) {
|
||||
switch (node._type) {
|
||||
.element => if (!node.isConnected()) return,
|
||||
else => {},
|
||||
if (node._type == .element) {
|
||||
if (comptime from_parser) {
|
||||
if (node.getDocument(self)._frame == null) {
|
||||
return;
|
||||
}
|
||||
} else if (!node.isConnected()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -83,6 +83,10 @@ pub const Slotted = struct {
|
||||
assigned: []const *Node,
|
||||
fallback: Children,
|
||||
|
||||
pub fn init(children_: Children) Slotted {
|
||||
return .{ .tree = children_.tree, .assigned = &.{}, .fallback = children_ };
|
||||
}
|
||||
|
||||
pub fn next(self: *Slotted) ?Child {
|
||||
while (self.assigned.len > 0) {
|
||||
const node = self.assigned[0];
|
||||
|
||||
+12
-2
@@ -35,6 +35,7 @@ const Runner = @This();
|
||||
session: *Session,
|
||||
browser: *Browser,
|
||||
http_client: *HttpClient,
|
||||
background_poll_ms: u32 = 0,
|
||||
|
||||
pub const Opts = struct {};
|
||||
|
||||
@@ -223,8 +224,9 @@ fn _tick(self: *Runner, comptime is_cdp: bool, timeout_ms: u32, conditions: []Wa
|
||||
|
||||
const has_runnable_page = hasRunnablePage(session);
|
||||
|
||||
var ran_platform_task = false;
|
||||
if (has_runnable_page) {
|
||||
try browser.runMacrotasks();
|
||||
ran_platform_task = try browser.runMacrotasks();
|
||||
}
|
||||
|
||||
const activity = http_client.activity();
|
||||
@@ -318,8 +320,16 @@ fn _tick(self: *Runner, comptime is_cdp: bool, timeout_ms: u32, conditions: []Wa
|
||||
break :blk 200;
|
||||
}
|
||||
if (browser.hasBackgroundTasks()) {
|
||||
// if our last runMacrotasks() ran something and we now have
|
||||
// a background, then don't linger in the http client waiting
|
||||
// for I/O, instead, hurry back to run more tasks.
|
||||
// Else, backoff to 10ms between runs.
|
||||
// TODO: this is a temporary solution to ensuring background
|
||||
// tasks are run promptly.The better solution is to have v8
|
||||
// wakeup the http client when there's work to do.
|
||||
self.background_poll_ms = if (ran_platform_task) 0 else @min(10, @max(1, self.background_poll_ms * 2));
|
||||
// msToNextTask could be less than this, but 10ms drift is ok
|
||||
break :blk 10;
|
||||
break :blk self.background_poll_ms;
|
||||
}
|
||||
break :blk browser.msToNextTask() orelse 200;
|
||||
};
|
||||
|
||||
@@ -199,7 +199,7 @@ fn waitForPreload(self: *ScriptManager, url: [:0]const u8) ?*Script {
|
||||
_ = client.tickSync(200) catch return null;
|
||||
continue;
|
||||
},
|
||||
.done => |script| {
|
||||
.done, .failed => |script| {
|
||||
// Preload scripts are single-use. We return it and it becomes
|
||||
// the caller's responsibility to free.
|
||||
_ = self.preloaded_scripts.remove(url);
|
||||
@@ -318,7 +318,7 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
|
||||
if (mode != .normal) {
|
||||
var preloaded = self.takePreload(remote_url);
|
||||
if (preloaded == null and kind == .module) {
|
||||
preloaded = self.base.takeModuleHint(remote_url);
|
||||
preloaded = try self.base.takeModuleHint(remote_url);
|
||||
}
|
||||
if (preloaded) |pre| {
|
||||
if (comptime lp.IS_DEBUG) {
|
||||
@@ -545,6 +545,7 @@ const PreloadedScript = struct {
|
||||
const State = union(enum) {
|
||||
loading: *Script,
|
||||
done: *Script,
|
||||
failed: *Script,
|
||||
};
|
||||
|
||||
pub fn deinit(self: PreloadedScript) void {
|
||||
@@ -583,10 +584,12 @@ const PreloadedScript = struct {
|
||||
log.warn(.http, "script fetch error", .{ .err = err, .req = script.url, .extra = "preload", .status = script.status });
|
||||
}
|
||||
|
||||
script.status = 0; // status == 0 is correctly treated as an error throughout
|
||||
script.complete = true;
|
||||
|
||||
const self: *ScriptManager = @fieldParentPtr("base", script.manager);
|
||||
_ = self.preloaded_scripts.remove(script.url);
|
||||
self.preloaded_scripts.getPtr(script.url).?.state = .{ .failed = script };
|
||||
script.queueHintEvent(.@"error");
|
||||
script.deinit();
|
||||
}
|
||||
|
||||
// Owner-driven teardown killed this preload fetch via Transfer.kill, which
|
||||
@@ -726,6 +729,52 @@ test "ScriptManager: preload whose submit fails synchronously releases its arena
|
||||
const url = "http://127.0.0.1:9582/fails-at-submit.js";
|
||||
// A fetch was started (and failed), so the hint's error event fires.
|
||||
try testing.expectEqual(true, try sm.preloadScript(null, url));
|
||||
// errorCallback consumed the entry; nothing dangles in the map.
|
||||
try testing.expectEqual(false, sm.preloaded_scripts.contains(url));
|
||||
// The failed entry stays for a <script> to consume; reset() frees it.
|
||||
try testing.expect(sm.preloaded_scripts.getPtr(url).?.state == .failed);
|
||||
}
|
||||
|
||||
// A failed preload used to be dropped, so the <script> consuming it fetched
|
||||
// again: a blocked script logged "blocked url" and counted in
|
||||
// adblock_verdicts twice. Unblocking before the <script>s and import() run
|
||||
// makes a refetch observable: it would succeed and run the script.
|
||||
test "ScriptManager: a failed preload is consumed, not refetched" {
|
||||
const client = &testing.test_session.browser.http_client;
|
||||
try client.setBlockedUrls(&.{ "*/preload_failed.js", "*/preload_failed_module.js" });
|
||||
defer client.setBlockedUrls(&.{}) catch unreachable;
|
||||
|
||||
// Both hints' fetch errors, and nothing else.
|
||||
testing.expectLog(&.{ .http, .http });
|
||||
|
||||
const page = try testing.pageTest("fixtures/preload_failed.html", .{});
|
||||
defer page.close();
|
||||
try client.setBlockedUrls(&.{});
|
||||
|
||||
{
|
||||
const frame = page.frame().?;
|
||||
var ls: js.Local.Scope = undefined;
|
||||
frame.js.localScope(&ls);
|
||||
defer ls.deinit();
|
||||
|
||||
try ls.local.eval(
|
||||
\\const classic = document.createElement('script');
|
||||
\\classic.src = 'preload_failed.js';
|
||||
\\classic.onerror = () => window.classic_error = true;
|
||||
\\document.head.appendChild(classic);
|
||||
\\const module = document.createElement('script');
|
||||
\\module.type = 'module';
|
||||
\\module.src = 'preload_failed_module.js';
|
||||
\\module.onerror = () => window.module_error = true;
|
||||
\\document.head.appendChild(module);
|
||||
\\const dynamic = document.createElement('script');
|
||||
\\dynamic.textContent = "import('./preload_failed_module.js').catch(() => window.import_error = true);";
|
||||
\\document.head.appendChild(dynamic);
|
||||
, null);
|
||||
}
|
||||
|
||||
var runner = testing.test_session.runner(.{});
|
||||
try runner.waitForScript(page.frame_id,
|
||||
\\window.classic_hint_error && window.module_hint_error &&
|
||||
\\window.classic_error && window.module_error && window.import_error &&
|
||||
\\!window.failed_classic_ran && !window.failed_module_ran
|
||||
, 2000);
|
||||
}
|
||||
@@ -253,8 +253,14 @@ pub fn preloadModuleHint(self: *ScriptManagerBase, element: ?*Element.Html, url:
|
||||
|
||||
// A <script type=module src=...> whose URL was hinted (modulepreload link or
|
||||
// prescan)
|
||||
pub fn takeModuleHint(self: *ScriptManagerBase, url: [:0]const u8) ?*Script {
|
||||
pub fn takeModuleHint(self: *ScriptManagerBase, url: [:0]const u8) !?*Script {
|
||||
const entry = self.imported_modules.getEntry(url) orelse return null;
|
||||
if (entry.value_ptr.state == .err) {
|
||||
// for loading/done, we'll remove the entry (because the script will
|
||||
// get consumed). For err, we can keep the failure in the map to
|
||||
// prevent a 2nd loader from needlessly trying to load this script
|
||||
return try self.failedScript(url, .import);
|
||||
}
|
||||
if (entry.value_ptr.hint == false) {
|
||||
// The script was preloaded, but not because of a hint. It came from v8
|
||||
// telling us to preload the module. We cannot take it here because we know
|
||||
@@ -268,15 +274,31 @@ pub fn takeModuleHint(self: *ScriptManagerBase, url: [:0]const u8) ?*Script {
|
||||
break :blk script;
|
||||
},
|
||||
.done => |script| script,
|
||||
// The hint's fetch failed; give the script its own attempt.
|
||||
// I'm not sure if this is the right behavior. Why would a preload fail
|
||||
// but the "real" load work? But it's definetly safer.
|
||||
.err => return null,
|
||||
.err => unreachable, // handled above
|
||||
};
|
||||
self.imported_modules.removeByPtr(entry.key_ptr);
|
||||
return script;
|
||||
}
|
||||
|
||||
// A dummy script for a module whose fetch already failed, to trigger the
|
||||
// consumer's failure path (Script.eval fails on status == 0)
|
||||
fn failedScript(self: *ScriptManagerBase, url: [:0]const u8, extra: Script.Extra) !*Script {
|
||||
const arena = try self.acquireArena(.tiny, "SM.failedScript");
|
||||
errdefer arena.release();
|
||||
const script = try arena.create(Script);
|
||||
script.* = .{
|
||||
.arena = arena,
|
||||
.url = url,
|
||||
.status = 0,
|
||||
.node = .{},
|
||||
.manager = self,
|
||||
.complete = true,
|
||||
.source = .{ .remote = .empty },
|
||||
.extra = extra,
|
||||
};
|
||||
return script;
|
||||
}
|
||||
|
||||
pub fn waitForImport(self: *ScriptManagerBase, url: [:0]const u8) !ModuleSource {
|
||||
const was_evaluating = self.is_evaluating;
|
||||
self.is_evaluating = true;
|
||||
@@ -354,6 +376,12 @@ pub fn releaseImport(self: *ScriptManagerBase, url: [:0]const u8) void {
|
||||
pub fn getAsyncImport(self: *ScriptManagerBase, url: [:0]const u8, cb: ImportAsync.Callback, cb_data: *anyopaque, referrer: []const u8) !void {
|
||||
// A <link rel=modulepreload> hint may already be fetching/fetched this module
|
||||
if (self.imported_modules.getEntry(url)) |entry| {
|
||||
if (entry.value_ptr.state == .err) {
|
||||
const script = try self.failedScript(url, .{ .import_async = .{ .callback = cb, .data = cb_data } });
|
||||
self.ready_scripts.append(&script.node);
|
||||
self.evaluate();
|
||||
return;
|
||||
}
|
||||
if (entry.value_ptr.hint) {
|
||||
switch (entry.value_ptr.state) {
|
||||
.loading => |script| {
|
||||
@@ -381,8 +409,7 @@ pub fn getAsyncImport(self: *ScriptManagerBase, url: [:0]const u8, cb: ImportAsy
|
||||
self.evaluate();
|
||||
return;
|
||||
},
|
||||
// The hint's fetch failed; give the import its own attempt.
|
||||
.err => {},
|
||||
.err => unreachable, // handled above
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -852,6 +879,12 @@ pub const Script = struct {
|
||||
return;
|
||||
}
|
||||
|
||||
if (self.source == .remote and (self.status < 200 or self.status > 299)) {
|
||||
// An adopted preload / module hint that had already failed.
|
||||
self.executeCallback(comptime .wrap("error"));
|
||||
return;
|
||||
}
|
||||
|
||||
const previous_script = frame.document._current_script;
|
||||
frame.document._current_script = fe.script_element;
|
||||
defer frame.document._current_script = previous_script;
|
||||
|
||||
+502
-301
File diff suppressed because it is too large.
Load diff
@@ -26,7 +26,7 @@ const KeyboardEvent = @import("webapi/event/KeyboardEvent.zig");
|
||||
const Frame = @import("Frame.zig");
|
||||
const Session = @import("Session.zig");
|
||||
|
||||
fn dispatchInputAndChangeEvents(el: *Element, frame: *Frame) !void {
|
||||
pub fn dispatchInputAndChangeEvents(el: *Element, frame: *Frame) !void {
|
||||
const input_evt: *Event = try .initTrusted(comptime .wrap("input"), .{ .bubbles = true }, frame.page);
|
||||
frame._event_manager.dispatch(el.asEventTarget(), input_evt) catch |err| {
|
||||
lp.log.err(.app, "dispatch input event failed", .{ .err = err });
|
||||
|
||||
@@ -25,11 +25,59 @@ pub const Declaration = struct {
|
||||
important: bool,
|
||||
};
|
||||
|
||||
pub const OverflowValues = struct { x: []const u8, y: []const u8 };
|
||||
pub const AxisPair = struct { x: []const u8, y: []const u8 };
|
||||
|
||||
/// `overflow: <x> [<y>]`; a single value applies to both axes. More than two
|
||||
/// values is invalid and null, as is an empty declaration.
|
||||
pub fn splitOverflow(value: []const u8) ?OverflowValues {
|
||||
pub const AxisShorthand = struct {
|
||||
name: []const u8,
|
||||
x: []const u8,
|
||||
y: []const u8,
|
||||
};
|
||||
|
||||
// The `<x> [<y>]` shorthands whose longhands the style cascade tracks. Both the
|
||||
// CSSOM object and the cascade store these expanded: setting one sets both
|
||||
// longhands, reading or serializing it recombines them.
|
||||
pub const axis_shorthands = [_]AxisShorthand{
|
||||
.{ .name = "overflow", .x = "overflow-x", .y = "overflow-y" },
|
||||
.{ .name = "overscroll-behavior", .x = "overscroll-behavior-x", .y = "overscroll-behavior-y" },
|
||||
};
|
||||
|
||||
/// The axis shorthand `name` names, if it names one.
|
||||
pub fn axisShorthand(name: []const u8) ?AxisShorthand {
|
||||
for (axis_shorthands) |shorthand| {
|
||||
if (std.ascii.eqlIgnoreCase(name, shorthand.name)) {
|
||||
return shorthand;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
pub const AxisLonghand = struct {
|
||||
shorthand: AxisShorthand,
|
||||
is_x: bool,
|
||||
|
||||
/// The longhand on the other axis.
|
||||
pub fn partner(self: AxisLonghand) []const u8 {
|
||||
return if (self.is_x) self.shorthand.y else self.shorthand.x;
|
||||
}
|
||||
};
|
||||
|
||||
/// The axis shorthand `name` is a longhand of, if it is one.
|
||||
pub fn axisLonghand(name: []const u8) ?AxisLonghand {
|
||||
for (axis_shorthands) |shorthand| {
|
||||
if (std.ascii.eqlIgnoreCase(name, shorthand.x)) {
|
||||
return .{ .shorthand = shorthand, .is_x = true };
|
||||
}
|
||||
if (std.ascii.eqlIgnoreCase(name, shorthand.y)) {
|
||||
return .{ .shorthand = shorthand, .is_x = false };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// An `<x> [<y>]` axis shorthand such as `overflow` or `overscroll-behavior`;
|
||||
/// a single value applies to both axes. More than two values is invalid and
|
||||
/// null, as is an empty declaration.
|
||||
pub fn splitAxisPair(value: []const u8) ?AxisPair {
|
||||
var it = std.mem.tokenizeAny(u8, value, &std.ascii.whitespace);
|
||||
const x = it.next() orelse return null;
|
||||
const y = it.next() orelse x;
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
// 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 Frame = @import("../Frame.zig");
|
||||
const HttpClient = @import("../../network/HttpClient.zig");
|
||||
|
||||
// https://html.spec.whatwg.org/multipage/document-lifecycle.html#the-x-frame-options-header
|
||||
pub fn allowed(frame: *const Frame, transfer: *HttpClient.Transfer) bool {
|
||||
var options: XFrameOptions = .{};
|
||||
var it = transfer.responseHeaderIterator();
|
||||
while (it.next()) |hdr| {
|
||||
if (std.mem.eql(u8, hdr.name, "content-security-policy")) {
|
||||
if (hasFrameAncestors(hdr.value)) {
|
||||
// has priority over any x-frame-options
|
||||
return true;
|
||||
}
|
||||
} else if (std.mem.eql(u8, hdr.name, "x-frame-options")) {
|
||||
options.add(hdr.value);
|
||||
}
|
||||
}
|
||||
|
||||
switch (options.policy()) {
|
||||
.allow => return true,
|
||||
.deny => return false,
|
||||
.same_origin => {
|
||||
const origin = frame.origin orelse return false;
|
||||
var ancestor = frame.parent;
|
||||
while (ancestor) |a| : (ancestor = a.parent) {
|
||||
// with a same-origin value, every ancestor has to be
|
||||
// the same origin
|
||||
const ancestor_origin = a.origin orelse return false;
|
||||
if (std.mem.eql(u8, origin, ancestor_origin) == false) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn hasFrameAncestors(csp: []const u8) bool {
|
||||
const name = "frame-ancestors";
|
||||
var pos: usize = 0;
|
||||
while (std.ascii.indexOfIgnoreCasePos(csp, pos, name)) |start| {
|
||||
pos = start + name.len;
|
||||
|
||||
// A directive name starts a policy (',') or a directive (';'), so
|
||||
// `script-src frame-ancestors` (a host source) doesn't count.
|
||||
const before = std.mem.trimEnd(u8, csp[0..start], HTTP_WHITESPACE);
|
||||
if (before.len != 0) {
|
||||
const last = before[before.len - 1];
|
||||
if (last != ';' and last != ',') {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (pos == csp.len or std.mem.indexOfScalar(u8, HTTP_WHITESPACE ++ ";,", csp[pos]) != null) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const HTTP_WHITESPACE = " \t\r\n";
|
||||
|
||||
const XFrameOptions = struct {
|
||||
first: ?[]const u8 = null,
|
||||
conflict: bool = false,
|
||||
has_keyword: bool = false,
|
||||
|
||||
const Policy = enum { allow, deny, same_origin };
|
||||
|
||||
fn add(self: *XFrameOptions, value: []const u8) void {
|
||||
var it = std.mem.splitScalar(u8, value, ',');
|
||||
while (it.next()) |token| {
|
||||
const v = std.mem.trim(u8, token, HTTP_WHITESPACE);
|
||||
if (keyword(v) != null) {
|
||||
self.has_keyword = true;
|
||||
}
|
||||
if (self.first) |first| {
|
||||
// we care about the first value and if any subsequent values are different
|
||||
if (std.ascii.eqlIgnoreCase(first, v) == false) {
|
||||
self.conflict = true;
|
||||
}
|
||||
} else {
|
||||
self.first = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn policy(self: *const XFrameOptions) Policy {
|
||||
const first = self.first orelse return .allow;
|
||||
if (self.conflict) {
|
||||
// conflict is a fail, unless they all had meaningless values
|
||||
return if (self.has_keyword) .deny else .allow;
|
||||
}
|
||||
return switch (keyword(first) orelse return .allow) {
|
||||
.deny => .deny,
|
||||
.sameorigin => .same_origin,
|
||||
.allowall => .allow,
|
||||
};
|
||||
}
|
||||
|
||||
fn keyword(value: []const u8) ?enum { deny, sameorigin, allowall } {
|
||||
if (std.ascii.eqlIgnoreCase(value, "deny")) {
|
||||
return .deny;
|
||||
}
|
||||
if (std.ascii.eqlIgnoreCase(value, "sameorigin")) {
|
||||
return .sameorigin;
|
||||
}
|
||||
if (std.ascii.eqlIgnoreCase(value, "allowall")) {
|
||||
return .allowall;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "framing: XFrameOptions" {
|
||||
const expectPolicy = struct {
|
||||
fn expectPolicy(expected: XFrameOptions.Policy, values: []const []const u8) !void {
|
||||
var xfo: XFrameOptions = .{};
|
||||
for (values) |v| xfo.add(v);
|
||||
try testing.expectEqual(expected, xfo.policy());
|
||||
}
|
||||
}.expectPolicy;
|
||||
|
||||
try expectPolicy(.allow, &.{});
|
||||
try expectPolicy(.allow, &.{""});
|
||||
try expectPolicy(.allow, &.{"INVALID"});
|
||||
try expectPolicy(.allow, &.{"ALLOWALL"});
|
||||
try expectPolicy(.allow, &.{"\x0bDENY"});
|
||||
try expectPolicy(.allow, &.{ "INVALID", "" });
|
||||
try expectPolicy(.deny, &.{" denY "});
|
||||
try expectPolicy(.deny, &.{ "DENY", "deny" });
|
||||
try expectPolicy(.deny, &.{",SAMEORIGIN,,DENY,"});
|
||||
try expectPolicy(.deny, &.{ "SAMEORIGIN", "DENY" });
|
||||
try expectPolicy(.deny, &.{"ALLOWALL,"});
|
||||
try expectPolicy(.deny, &.{ "INVALID", "allowAll" });
|
||||
try expectPolicy(.same_origin, &.{ "SAMEORIGIN", "sameOrigin" });
|
||||
|
||||
try testing.expect(hasFrameAncestors("default-src 'self'; frame-ancestors 'self'"));
|
||||
try testing.expect(hasFrameAncestors("default-src 'self', FRAME-ANCESTORS"));
|
||||
try testing.expect(hasFrameAncestors("default-src 'self'") == false);
|
||||
try testing.expect(hasFrameAncestors("frame-ancestors-x 'self'") == false);
|
||||
try testing.expect(hasFrameAncestors("frame-ancestors"));
|
||||
try testing.expect(hasFrameAncestors("frame-ancestors;"));
|
||||
try testing.expect(hasFrameAncestors("script-src frame-ancestors") == false);
|
||||
try testing.expect(hasFrameAncestors("x-frame-ancestors 'self'") == false);
|
||||
try testing.expect(hasFrameAncestors("script-src frame-ancestors; frame-ancestors 'none'"));
|
||||
}
|
||||
@@ -38,7 +38,7 @@ const String = lp.String;
|
||||
const IFrame = Element.Html.IFrame;
|
||||
|
||||
pub fn createElementNS(document: *const Node.Document, namespace: Element.Namespace, name: []const u8, attribute_iterator: anytype) !*Node {
|
||||
const from_parser = @TypeOf(attribute_iterator) == Parser.AttributeIterator;
|
||||
const from_parser = @TypeOf(attribute_iterator) == Parser.AttributeIterator or @TypeOf(attribute_iterator) == Parser.RebuiltAttributes;
|
||||
const from_clone = @TypeOf(attribute_iterator) == *Element.Attribute.List or @TypeOf(attribute_iterator) == *const Element.Attribute.List;
|
||||
const frame = frameOf(document);
|
||||
|
||||
@@ -849,6 +849,7 @@ pub fn createElementNS(document: *const Node.Document, namespace: Element.Namesp
|
||||
const node = try createHtmlElementT(document, Element.Html.Custom, namespace, attribute_iterator, .{
|
||||
._tag_name = tag_name,
|
||||
._definition = definition,
|
||||
._upgrade_candidate = creation == .construct,
|
||||
});
|
||||
if (creation == .construct) {
|
||||
try realm._undefined_custom_elements.append(realm.arena, node.as(Element).is(Element.Html.Custom).?);
|
||||
@@ -1070,7 +1071,7 @@ fn createHtmlElementT(document: *const Node.Document, comptime E: type, namespac
|
||||
|
||||
const node = element.asNode();
|
||||
if (@hasDecl(E, "Build") and @hasDecl(E.Build, "created")) {
|
||||
if (comptime @TypeOf(attribute_iterator) == Parser.AttributeIterator and @hasDecl(E.Build, "parser_created_on_insert")) {
|
||||
if (comptime (@TypeOf(attribute_iterator) == Parser.AttributeIterator or @TypeOf(attribute_iterator) == Parser.RebuiltAttributes) and @hasDecl(E.Build, "parser_created_on_insert")) {
|
||||
// The element wants its parent, it'll do this work when inserted.
|
||||
return node;
|
||||
}
|
||||
@@ -1120,6 +1121,13 @@ fn populateElementAttributes(frame: *Frame, element: *Element, list: anytype) !v
|
||||
return;
|
||||
}
|
||||
|
||||
if (@TypeOf(list) == Parser.RebuiltAttributes) {
|
||||
// from the parser, rebuilding an element in another document
|
||||
try element._attributes.cloneFrom(list.list, frame);
|
||||
element.noteStyleAttribute();
|
||||
return;
|
||||
}
|
||||
|
||||
// from the parser
|
||||
if (@TypeOf(list) == @TypeOf(null)) {
|
||||
return;
|
||||
|
||||
@@ -171,7 +171,7 @@ pub fn scheduleIntersectionChecks(frame: *Frame) void {
|
||||
frame._intersection.check_scheduled = true;
|
||||
frame.js.queueIntersectionChecks() catch |err| {
|
||||
frame._intersection.check_scheduled = false;
|
||||
log.err(.frame, "frame.scheduleIntersectionChecks", .{ .err = err, .type = frame._type, .url = frame.url });
|
||||
log.err(.frame, "scheduleIntersectionChecks", .{ .err = err, .type = frame._type, .url = frame.url });
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -16,12 +16,15 @@
|
||||
// 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 Frame = @import("../Frame.zig");
|
||||
const Parser = @import("../parser/Parser.zig");
|
||||
|
||||
const Node = @import("../webapi/Node.zig");
|
||||
const Element = @import("../webapi/Element.zig");
|
||||
const Document = @import("../webapi/Document.zig");
|
||||
const HTMLDocument = @import("../webapi/HTMLDocument.zig");
|
||||
const ShadowRoot = @import("../webapi/ShadowRoot.zig");
|
||||
const slotting = @import("../webapi/element/slotting.zig");
|
||||
|
||||
@@ -101,6 +104,44 @@ pub fn fragment(frame: *Frame, node: *Node, html: []const u8, opts: FragmentPars
|
||||
try Element.Html.Picture.childrenInserted(node, frame);
|
||||
}
|
||||
|
||||
pub const HtmlDocumentOpts = struct {
|
||||
allow_declarative_shadow: bool = false,
|
||||
};
|
||||
|
||||
// Build a detached HTMLDocument from `html` (DOMParser.parseFromString and
|
||||
// Document.parseHTML). The caller sets its URL.
|
||||
pub fn htmlDocument(frame: *Frame, html: []const u8, opts: HtmlDocumentOpts) !*HTMLDocument {
|
||||
const arena = try frame.getArena(.medium, "parse.htmlDocument");
|
||||
defer arena.release();
|
||||
|
||||
// Frame-side hooks triggered from `Build.created` / `nodeIsReady`
|
||||
// (external stylesheet fetches, script execution, mutation-observer
|
||||
// fan-out, default-script injection) treat the parsed nodes as detached
|
||||
// and skip side effects on the live document.
|
||||
const previous_parse_mode = frame._parse_mode;
|
||||
frame._parse_mode = .fragment;
|
||||
defer frame._parse_mode = previous_parse_mode;
|
||||
|
||||
const doc = try frame._factory.document(HTMLDocument{ ._proto = undefined });
|
||||
|
||||
var normalized = std.mem.trim(u8, html, &std.ascii.whitespace);
|
||||
if (normalized.len == 0) {
|
||||
normalized = "<html></html>";
|
||||
}
|
||||
|
||||
var parser = Parser.init(arena.allocator(), doc.asNode(), frame, .{
|
||||
.allow_declarative_shadow = opts.allow_declarative_shadow,
|
||||
});
|
||||
parser.parse(normalized);
|
||||
if (parser.terminated) {
|
||||
return error.ExecutionTerminated;
|
||||
}
|
||||
if (parser.err) |pe| {
|
||||
return pe.err;
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
// Build a detached XMLDocument from `xml` (DOMParser.parseFromString and
|
||||
// XMLHttpRequest.responseXML). Returns null when the input isn't well-formed
|
||||
// XML.
|
||||
@@ -123,13 +164,5 @@ pub fn xmlDocument(frame: *Frame, xml: []const u8) !?*Document.XMLDocument {
|
||||
if (parser.err != null or parser.xml_error or doc_node.firstChild() == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If first node is a `ProcessingInstruction` (e.g. the <?xml?>
|
||||
// declaration), skip it.
|
||||
const first_child = doc_node.firstChild().?;
|
||||
if (first_child.getNodeType() == 7) {
|
||||
_ = try doc_node.removeChild(first_child, frame);
|
||||
}
|
||||
|
||||
return doc;
|
||||
}
|
||||
@@ -487,16 +487,44 @@ pub fn wheel(frame: *Frame, target: *Element, x: f64, y: f64, delta_x: f64, delt
|
||||
}
|
||||
|
||||
// Deltas come from the wire, so guard NaN and saturate the addition.
|
||||
try wheelScroll(target, deltaToScroll(delta_x), deltaToScroll(delta_y), owner);
|
||||
try scrollAxis(target, .width, deltaToScroll(delta_x), owner);
|
||||
try scrollAxis(target, .height, deltaToScroll(delta_y), owner);
|
||||
}
|
||||
|
||||
/// Each axis scrolls the nearest ancestor-or-self scroll container along it,
|
||||
/// else the viewport. Relative deltas may land on different scrollers per
|
||||
/// axis, unlike an absolute position.
|
||||
fn wheelScroll(target: *Element, delta_x: i32, delta_y: i32, frame: *Frame) !void {
|
||||
// A zero delta resolves to .viewport and scrolls it by nothing.
|
||||
try target.scrollContainer(.{ .x = delta_x != 0 }, frame).scrollBy(delta_x, 0, frame);
|
||||
try target.scrollContainer(.{ .y = delta_y != 0 }, frame).scrollBy(0, delta_y, frame);
|
||||
/// A wheel latches to a single scroller and a delta is never split across two,
|
||||
/// as in Chrome's FindNodeToLatch (cc/input/input_handler.cc): the whole delta
|
||||
/// goes to the nearest ancestor-or-self container that can still move along
|
||||
/// this axis. One whose overscroll-behavior doesn't propagate takes the latch
|
||||
/// even when it can't move, which ends the walk.
|
||||
fn scrollAxis(target: *Element, comptime axis: Element.Axis, delta: i32, frame: *Frame) !void {
|
||||
if (delta == 0) {
|
||||
return;
|
||||
}
|
||||
const axes: Element.ScrollAxes = switch (axis) {
|
||||
.width => .{ .x = true },
|
||||
.height => .{ .y = true },
|
||||
};
|
||||
|
||||
var current: ?*Element = target;
|
||||
while (current) |el| {
|
||||
const container = switch (el.scrollContainer(axes, frame)) {
|
||||
.container => |c| c,
|
||||
.viewport => break,
|
||||
};
|
||||
if (try container.scrollByAxis(axis, delta, frame)) {
|
||||
return;
|
||||
}
|
||||
if (container.containsOverscroll(axes, frame)) {
|
||||
return;
|
||||
}
|
||||
current = container.parentElement();
|
||||
}
|
||||
|
||||
const opts: Element.ScrollToOpts = switch (axis) {
|
||||
.width => .{ .opts = .{ .left = delta } },
|
||||
.height => .{ .opts = .{ .top = delta } },
|
||||
};
|
||||
return frame.window.scrollBy(opts, null, frame);
|
||||
}
|
||||
|
||||
fn deltaToScroll(d: f64) i32 {
|
||||
|
||||
@@ -443,7 +443,7 @@ pub fn hideServiceWorker(self: *const Env, comptime is_frame: bool, v8_context:
|
||||
var deleted: v8.MaybeBool = undefined;
|
||||
v8.v8__Object__Delete(global_obj, v8_context, @ptrCast(self.disabled_api_names.get(self.isolate.handle, "caches")), &deleted);
|
||||
if (deleted.has_value == false or deleted.value == false) {
|
||||
log.warn(.js, "failed to hide experimental API", .{ .interface = "global", .member = "caches" });
|
||||
log.warn(.js, "experimental API not hidden", .{ .interface = "global", .member = "caches" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -457,7 +457,7 @@ fn deletePrototypeMember(self: *const Env, v8_context: *const v8.Context, global
|
||||
var deleted: v8.MaybeBool = undefined;
|
||||
v8.v8__Object__Delete(@ptrCast(prototype), v8_context, @ptrCast(names.get(isolate, member)), &deleted);
|
||||
if (deleted.has_value == false or deleted.value == false) {
|
||||
log.warn(.js, "failed to hide experimental API", .{ .interface = interface, .member = member });
|
||||
log.warn(.js, "experimental API not hidden", .{ .interface = interface, .member = member });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -567,14 +567,18 @@ pub fn msToNextTask(self: *Env) ?u64 {
|
||||
return if (next_task == std.math.maxInt(u64)) null else next_task;
|
||||
}
|
||||
|
||||
pub fn pumpMessageLoop(self: *const Env) void {
|
||||
pub fn pumpMessageLoop(self: *const Env) bool {
|
||||
var hs: v8.HandleScope = undefined;
|
||||
v8.v8__HandleScope__CONSTRUCT(&hs, self.isolate.handle);
|
||||
defer v8.v8__HandleScope__DESTRUCT(&hs);
|
||||
|
||||
const isolate = self.isolate.handle;
|
||||
const platform = self.platform.handle;
|
||||
while (v8.v8__Platform__PumpMessageLoop(platform, isolate, false)) {}
|
||||
var ran = false;
|
||||
while (v8.v8__Platform__PumpMessageLoop(platform, isolate, false)) {
|
||||
ran = true;
|
||||
}
|
||||
return ran;
|
||||
}
|
||||
|
||||
pub fn hasBackgroundTasks(self: *const Env) bool {
|
||||
|
||||
@@ -121,7 +121,7 @@ pub fn newCallback(
|
||||
|
||||
pub fn runMacrotasks(self: *const Local) void {
|
||||
const env = self.ctx.env;
|
||||
env.pumpMessageLoop();
|
||||
_ = env.pumpMessageLoop();
|
||||
env.runMicrotasks(); // macrotasks can cause microtasks to queue
|
||||
}
|
||||
|
||||
|
||||
+49
-18
@@ -275,10 +275,11 @@ fn createSnapshotContext(
|
||||
.data = null,
|
||||
.flags = v8.kOnlyInterceptStrings | v8.kNonMasking,
|
||||
});
|
||||
const window_index = @import("../webapi/Window.zig").JsApi.index;
|
||||
v8.v8__ObjectTemplate__SetIndexedHandler(global_template, &.{
|
||||
.getter = @import("../webapi/Window.zig").JsApi.index.getter,
|
||||
.getter = window_index.getter,
|
||||
.setter = null,
|
||||
.query = null,
|
||||
.query = window_index.query,
|
||||
.deleter = null,
|
||||
.enumerator = null,
|
||||
.definer = null,
|
||||
@@ -325,22 +326,18 @@ fn createSnapshotContext(
|
||||
const template_index = comptime bridge.JsApiLookup.getId(JsApi);
|
||||
const func = v8.v8__FunctionTemplate__GetFunction(templates[template_index], context);
|
||||
if (@hasDecl(JsApi.Meta, "name")) {
|
||||
const name = JsApi.Meta.name;
|
||||
const v8_class_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len));
|
||||
var maybe_result: v8.MaybeBool = undefined;
|
||||
// Web IDL: interface objects on the global are non-enumerable.
|
||||
v8.v8__Object__DefineOwnProperty(global_obj, context, v8_class_name, func, v8.DontEnum, &maybe_result);
|
||||
|
||||
if (@hasDecl(JsApi.Meta, "constructor_alias")) {
|
||||
const alias = JsApi.Meta.constructor_alias;
|
||||
const v8_class_name = v8.v8__String__NewFromUtf8(isolate, alias.ptr, v8.kNormal, @intCast(alias.len));
|
||||
var maybe_result: v8.MaybeBool = undefined;
|
||||
v8.v8__Object__Set(global_obj, context, v8_class_name, func, &maybe_result);
|
||||
|
||||
const name = JsApi.Meta.name;
|
||||
const illegal_class_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len));
|
||||
var maybe_result2: v8.MaybeBool = undefined;
|
||||
v8.v8__Object__DefineOwnProperty(global_obj, context, illegal_class_name, func, 0, &maybe_result2);
|
||||
} else {
|
||||
const name = JsApi.Meta.name;
|
||||
const v8_class_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len));
|
||||
var maybe_result: v8.MaybeBool = undefined;
|
||||
// Web IDL: interface objects on the global are non-enumerable.
|
||||
v8.v8__Object__DefineOwnProperty(global_obj, context, v8_class_name, func, v8.DontEnum, &maybe_result);
|
||||
const alias_func = generateLegacyFactoryFunction(JsApi, isolate, templates[template_index], func.?, context.?);
|
||||
const v8_alias_name = v8.v8__String__NewFromUtf8(isolate, alias.ptr, v8.kNormal, @intCast(alias.len));
|
||||
var maybe_alias_result: v8.MaybeBool = undefined;
|
||||
v8.v8__Object__DefineOwnProperty(global_obj, context, v8_alias_name, @ptrCast(alias_func), v8.DontEnum, &maybe_alias_result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -671,7 +668,7 @@ fn illegalConstructorCallback(raw_info: ?*const v8.FunctionCallbackInfo) callcon
|
||||
if (v8.v8__Function__GetName(func)) |name_value| {
|
||||
if (v8.v8__Value__IsString(name_value)) {
|
||||
const str: *const v8.String = @ptrCast(name_value);
|
||||
const n = v8.v8__String__WriteUtf8(str, isolate, &name_buf, name_buf.len, v8.NO_NULL_TERMINATION | v8.REPLACE_INVALID_UTF8);
|
||||
const n = v8.v8__String__WriteUtf8(str, isolate, &name_buf, name_buf.len, v8.WRITE_REPLACE_INVALID_UTF8, null);
|
||||
name = name_buf[0..@intCast(n)];
|
||||
}
|
||||
}
|
||||
@@ -720,7 +717,9 @@ fn protoIndexLookup(comptime JsApi: type) ?u16 {
|
||||
// Generate a constructor template for a JsApi type (public for reuse)
|
||||
fn generateConstructor(comptime JsApi: type, isolate: *v8.Isolate) *const v8.FunctionTemplate {
|
||||
const callback, const arity = comptime blk: {
|
||||
if (@hasDecl(JsApi, "constructor")) {
|
||||
// The constructor belongs to the legacy factory function (`Image`),
|
||||
// see generateLegacyFactoryFunction.
|
||||
if (@hasDecl(JsApi, "constructor") and !@hasDecl(JsApi.Meta, "constructor_alias")) {
|
||||
break :blk .{ JsApi.constructor.func, JsApi.constructor.arity };
|
||||
}
|
||||
if (inheritsFromHtmlElement(JsApi)) {
|
||||
@@ -748,6 +747,38 @@ fn generateConstructor(comptime JsApi: type, isolate: *v8.Isolate) *const v8.Fun
|
||||
return template;
|
||||
}
|
||||
|
||||
// https://webidl.spec.whatwg.org/#legacy-factory-functions
|
||||
// `Image`, `Audio`, `Option`: a function distinct from the interface object
|
||||
// (so `new HTMLImageElement()` stays illegal and `Image.name` is "Image"),
|
||||
// whose `prototype` is the interface's prototype object.
|
||||
fn generateLegacyFactoryFunction(comptime JsApi: type, isolate: *v8.Isolate, interface_template: *const v8.FunctionTemplate, interface_func: *const v8.Function, context: *const v8.Context) *const v8.Function {
|
||||
const alias = JsApi.Meta.constructor_alias;
|
||||
const template = v8.v8__FunctionTemplate__New__Config(isolate, &.{
|
||||
.length = JsApi.constructor.arity,
|
||||
.callback = JsApi.constructor.func,
|
||||
.behavior = v8.kConstructorBehavior_Allow,
|
||||
}).?;
|
||||
// Inherit so that the objects we construct pass the interface's
|
||||
// accessor and method signature checks.
|
||||
v8.v8__FunctionTemplate__Inherit(template, interface_template);
|
||||
{
|
||||
const internal_field_count = comptime countInternalFields(JsApi);
|
||||
if (internal_field_count > 0) {
|
||||
const instance_template = v8.v8__FunctionTemplate__InstanceTemplate(template);
|
||||
v8.v8__ObjectTemplate__SetInternalFieldCount(instance_template, internal_field_count);
|
||||
}
|
||||
}
|
||||
const class_name = v8.v8__String__NewFromUtf8(isolate, alias.ptr, v8.kNormal, @intCast(alias.len));
|
||||
v8.v8__FunctionTemplate__SetClassName(template, class_name);
|
||||
|
||||
const func = v8.v8__FunctionTemplate__GetFunction(template, context).?;
|
||||
const prototype_key = v8.v8__String__NewFromUtf8(isolate, "prototype", v8.kNormal, 9);
|
||||
const interface_prototype = v8.v8__Object__Get(@ptrCast(interface_func), context, prototype_key).?;
|
||||
var maybe_result: v8.MaybeBool = undefined;
|
||||
v8.v8__Object__DefineOwnProperty(@ptrCast(func), context, prototype_key, interface_prototype, v8.ReadOnly + v8.DontEnum + v8.DontDelete, &maybe_result);
|
||||
return func;
|
||||
}
|
||||
|
||||
// hard-coded special case for HtmlElement which can be extended but not
|
||||
// instantiated.
|
||||
fn inheritsFromHtmlElement(comptime JsApi: type) bool {
|
||||
|
||||
@@ -65,7 +65,7 @@ fn _toSlice(self: String, comptime null_terminate: bool, allocator: Allocator) !
|
||||
|
||||
const l = v8.v8__String__Utf8Length(handle, isolate);
|
||||
const buf = try (if (comptime null_terminate) allocator.allocSentinel(u8, @intCast(l), 0) else allocator.alloc(u8, @intCast(l)));
|
||||
const n = v8.v8__String__WriteUtf8(handle, isolate, buf.ptr, buf.len, v8.NO_NULL_TERMINATION | v8.REPLACE_INVALID_UTF8);
|
||||
const n = v8.v8__String__WriteUtf8(handle, isolate, buf.ptr, buf.len, v8.WRITE_REPLACE_INVALID_UTF8, null);
|
||||
if (comptime lp.IS_DEBUG) {
|
||||
std.debug.assert(n == l);
|
||||
}
|
||||
@@ -87,7 +87,7 @@ pub fn toSSOWithAlloc(self: String, allocator: Allocator) !lp.String {
|
||||
|
||||
if (l <= 12) {
|
||||
var content: [12]u8 = undefined;
|
||||
const n = v8.v8__String__WriteUtf8(handle, isolate, &content[0], content.len, v8.NO_NULL_TERMINATION | v8.REPLACE_INVALID_UTF8);
|
||||
const n = v8.v8__String__WriteUtf8(handle, isolate, &content[0], content.len, v8.WRITE_REPLACE_INVALID_UTF8, null);
|
||||
if (comptime lp.IS_DEBUG) {
|
||||
std.debug.assert(n == l);
|
||||
}
|
||||
@@ -103,7 +103,7 @@ pub fn toSSOWithAlloc(self: String, allocator: Allocator) !lp.String {
|
||||
}
|
||||
|
||||
const buf = try allocator.alloc(u8, l);
|
||||
const n = v8.v8__String__WriteUtf8(handle, isolate, buf.ptr, buf.len, v8.NO_NULL_TERMINATION | v8.REPLACE_INVALID_UTF8);
|
||||
const n = v8.v8__String__WriteUtf8(handle, isolate, buf.ptr, buf.len, v8.WRITE_REPLACE_INVALID_UTF8, null);
|
||||
if (comptime lp.IS_DEBUG) {
|
||||
std.debug.assert(n == l);
|
||||
}
|
||||
@@ -124,7 +124,7 @@ pub fn format(self: String, writer: *std.Io.Writer) !void {
|
||||
const l = v8.v8__String__Utf8Length(handle, isolate);
|
||||
var buf = if (l < 1024) &small else local.call_arena.alloc(u8, @intCast(l)) catch return error.WriteFailed;
|
||||
|
||||
const n = v8.v8__String__WriteUtf8(handle, isolate, buf.ptr, buf.len, v8.NO_NULL_TERMINATION | v8.REPLACE_INVALID_UTF8);
|
||||
const n = v8.v8__String__WriteUtf8(handle, isolate, buf.ptr, buf.len, v8.WRITE_REPLACE_INVALID_UTF8, null);
|
||||
return writer.writeAll(buf[0..n]);
|
||||
}
|
||||
|
||||
|
||||
+68
-12
@@ -398,20 +398,25 @@ pub const Indexed = struct {
|
||||
}.wrap;
|
||||
}
|
||||
|
||||
if (@typeInfo(@TypeOf(query)) != .null) {
|
||||
indexed.query = struct {
|
||||
fn wrap(idx: u32, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 {
|
||||
const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?;
|
||||
var caller: Caller = undefined;
|
||||
if (!caller.init(v8_isolate)) {
|
||||
return js.Intercepted.no;
|
||||
}
|
||||
defer caller.deinit();
|
||||
const query_func = if (@typeInfo(@TypeOf(query)) != .null)
|
||||
query
|
||||
else
|
||||
// Generate a Query handler by wrapping getter. With no setter, this
|
||||
// gets the ReadOnly attribute
|
||||
GetterQuery(getter, if (@typeInfo(@TypeOf(setter)) == .null) v8.ReadOnly else v8.None).query;
|
||||
|
||||
return caller.getIndexQuery(T, query, idx, handle.?);
|
||||
indexed.query = struct {
|
||||
fn wrap(idx: u32, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 {
|
||||
const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?;
|
||||
var caller: Caller = undefined;
|
||||
if (!caller.init(v8_isolate)) {
|
||||
return js.Intercepted.no;
|
||||
}
|
||||
}.wrap;
|
||||
}
|
||||
defer caller.deinit();
|
||||
|
||||
return caller.getIndexQuery(T, query_func, idx, handle.?);
|
||||
}
|
||||
}.wrap;
|
||||
|
||||
if (@typeInfo(@TypeOf(definer)) != .null) {
|
||||
indexed.definer = struct {
|
||||
@@ -433,6 +438,56 @@ pub const Indexed = struct {
|
||||
}
|
||||
};
|
||||
|
||||
fn hasNotHandled(comptime E: type) bool {
|
||||
// anyerror includes it
|
||||
const errors = @typeInfo(E).error_set orelse return true;
|
||||
for (errors) |e| {
|
||||
if (std.mem.eql(u8, e.name, "NotHandled")) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Default index query if one isn't provided. Uses the getter to determine the result
|
||||
fn GetterQuery(comptime getter: anytype, comptime attrs: u32) type {
|
||||
const params = @typeInfo(@TypeOf(getter)).@"fn".params;
|
||||
const Self = params[0].type.?;
|
||||
const Index = params[1].type.?;
|
||||
|
||||
// A getter that can return neither null nor error.NotHandled would report
|
||||
// every index as present.
|
||||
const can_be_absent = switch (@typeInfo(@typeInfo(@TypeOf(getter)).@"fn".return_type.?)) {
|
||||
.optional => true,
|
||||
.error_union => |eu| @typeInfo(eu.payload) == .optional or hasNotHandled(eu.error_set),
|
||||
else => false,
|
||||
};
|
||||
if (can_be_absent == false) {
|
||||
@compileError(@typeName(Self) ++ ": an indexed getter that can't return null or error.NotHandled needs an explicit query");
|
||||
}
|
||||
|
||||
return struct {
|
||||
const query = if (params.len == 3) withGlobal else plain;
|
||||
|
||||
fn plain(self: Self, idx: Index) !u32 {
|
||||
return attributes(getter(self, idx));
|
||||
}
|
||||
|
||||
fn withGlobal(self: Self, idx: Index, global: params[2].type.?) !u32 {
|
||||
return attributes(getter(self, idx, global));
|
||||
}
|
||||
|
||||
fn attributes(ret: anytype) !u32 {
|
||||
const value = switch (@typeInfo(@TypeOf(ret))) {
|
||||
.error_union => try ret,
|
||||
else => ret,
|
||||
};
|
||||
if (@typeInfo(@TypeOf(value)) == .optional and value == null) {
|
||||
return error.NotHandled;
|
||||
}
|
||||
return attrs;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
pub const NamedIndexed = struct {
|
||||
getter: *const fn (c_name: ?*const v8.Name, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32,
|
||||
setter: ?*const fn (c_name: ?*const v8.Name, c_value: ?*const v8.Value, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 = null,
|
||||
@@ -1271,6 +1326,7 @@ pub const PageJsApis = flattenTypes(&.{
|
||||
@import("../webapi/XPathExpression.zig"),
|
||||
@import("../webapi/XPathEvaluator.zig"),
|
||||
@import("../webapi/collections/DOMStringList.zig"),
|
||||
@import("../webapi/Sanitizer.zig"),
|
||||
});
|
||||
|
||||
// APIs available on EVERY worker global — dedicated, shared and service. This
|
||||
|
||||
@@ -598,7 +598,7 @@ pub fn writeStackTrace(isolate: *v8.Isolate, stack_handle: *const v8.StackTrace,
|
||||
const frame_handle = v8.v8__StackTrace__GetFrame(stack_handle, isolate, @intCast(i)).?;
|
||||
if (v8.v8__StackFrame__GetFunctionName(frame_handle)) |name| {
|
||||
var buf: [1024]u8 = undefined;
|
||||
const n = v8.v8__String__WriteUtf8(name, isolate, &buf, buf.len, v8.NO_NULL_TERMINATION | v8.REPLACE_INVALID_UTF8);
|
||||
const n = v8.v8__String__WriteUtf8(name, isolate, &buf, buf.len, v8.WRITE_REPLACE_INVALID_UTF8, null);
|
||||
try writer.print("{s}{s}:{d}", .{ separator, buf[0..n], v8.v8__StackFrame__GetLineNumber(frame_handle) });
|
||||
} else {
|
||||
try writer.print("{s}<anonymous>:{d}", .{ separator, v8.v8__StackFrame__GetLineNumber(frame_handle) });
|
||||
|
||||
+114
-59
@@ -36,6 +36,8 @@ pub const Opts = struct {
|
||||
|
||||
const truncation_marker = LimitedWriter.truncation_marker;
|
||||
|
||||
const Error = error{ WriteFailed, OutOfMemory };
|
||||
|
||||
const State = struct {
|
||||
const ListType = enum { ordered, unordered };
|
||||
const ListState = struct {
|
||||
@@ -63,11 +65,31 @@ fn getAnchorLabel(el: *Element) ?[]const u8 {
|
||||
return el.getAttributeInterned("aria-label") orelse el.getAttributeInterned("title");
|
||||
}
|
||||
|
||||
// Iterative else large trees will stackoverflow
|
||||
const Context = struct {
|
||||
state: State,
|
||||
writer: *std.Io.Writer,
|
||||
frame: *Frame,
|
||||
tree: RenderTree,
|
||||
stack: std.ArrayList(Open) = .empty,
|
||||
|
||||
// Content still to render, followed by what closes the element.
|
||||
const Open = struct {
|
||||
iter: RenderTree.Slotted,
|
||||
epilogue: Epilogue,
|
||||
};
|
||||
|
||||
// what follows after the children
|
||||
const Epilogue = union(enum) {
|
||||
none,
|
||||
element: Element.Tag,
|
||||
block_anchor: struct { href: ?[]const u8, label: ?[]const u8 },
|
||||
inline_anchor: struct { href: ?[]const u8, standalone: bool },
|
||||
};
|
||||
|
||||
fn deinit(self: *Context) void {
|
||||
self.stack.deinit(self.frame.local_arena);
|
||||
}
|
||||
|
||||
fn ensureNewline(self: *Context) !void {
|
||||
if (!self.state.last_char_was_newline) {
|
||||
@@ -76,18 +98,32 @@ const Context = struct {
|
||||
}
|
||||
}
|
||||
|
||||
fn render(self: *Context, node: *Node) error{WriteFailed}!void {
|
||||
fn render(self: *Context, node: *Node) Error!void {
|
||||
switch (node._type) {
|
||||
.document, .document_fragment => try self.renderChildren(node, false),
|
||||
.document, .document_fragment => try self.open(.init(self.tree.children(node, false)), .none),
|
||||
else => {
|
||||
if (self.tree.classify(node, .{})) |child| {
|
||||
try self.renderChild(child);
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
while (self.stack.items.len > 0) {
|
||||
// renderChild can grow the stack, so we can't re-use top on continue
|
||||
const top = &self.stack.items[self.stack.items.len - 1];
|
||||
if (top.iter.next()) |child| {
|
||||
try self.renderChild(child);
|
||||
continue;
|
||||
}
|
||||
try self.close(self.stack.pop().?.epilogue);
|
||||
}
|
||||
}
|
||||
|
||||
fn renderChild(self: *Context, child: RenderTree.Child) error{WriteFailed}!void {
|
||||
fn open(self: *Context, iter: RenderTree.Slotted, epilogue: Epilogue) Error!void {
|
||||
return self.stack.append(self.frame.local_arena, .{ .iter = iter, .epilogue = epilogue });
|
||||
}
|
||||
|
||||
fn renderChild(self: *Context, child: RenderTree.Child) Error!void {
|
||||
switch (child.what) {
|
||||
.element => |display| {
|
||||
const el = child.node.subtype(Node.Element);
|
||||
@@ -105,28 +141,8 @@ const Context = struct {
|
||||
}
|
||||
}
|
||||
|
||||
fn renderChildren(self: *Context, parent: *Node, boxed: bool) error{WriteFailed}!void {
|
||||
var it = self.tree.children(parent, boxed);
|
||||
while (it.next()) |child| {
|
||||
try self.renderChild(child);
|
||||
}
|
||||
}
|
||||
|
||||
fn renderContent(self: *Context, el: *Element, boxed: bool) error{WriteFailed}!void {
|
||||
var it = self.tree.content(el, boxed);
|
||||
while (it.next()) |child| {
|
||||
try self.renderChild(child);
|
||||
}
|
||||
}
|
||||
|
||||
fn renderSlotContent(self: *Context, slot: *Slot) error{WriteFailed}!void {
|
||||
var it = self.tree.slotted(slot);
|
||||
while (it.next()) |child| {
|
||||
try self.renderChild(child);
|
||||
}
|
||||
}
|
||||
|
||||
fn renderElement(self: *Context, el: *Element, display: StyleManager.Display) !void {
|
||||
// write the element's opening markers and push its contents
|
||||
fn renderElement(self: *Context, el: *Element, display: StyleManager.Display) Error!void {
|
||||
const tag = el.getTag();
|
||||
const boxed = display == .flex or display == .grid;
|
||||
|
||||
@@ -247,22 +263,17 @@ const Context = struct {
|
||||
const label = getAnchorLabel(el);
|
||||
const href_raw = el.getAttributeInterned("href");
|
||||
|
||||
if (!info.has_visible and label == null and href_raw == null) return;
|
||||
if (!info.has_visible and label == null and href_raw == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const href = if (href_raw) |h| URL.resolve(frame.local_arena, frame.base(), h, .{ .encoding = frame.charset }) catch h else null;
|
||||
|
||||
if (info.has_block) {
|
||||
try self.renderContent(el, boxed);
|
||||
if (href) |h| {
|
||||
if (!self.state.last_char_was_newline) try self.writer.writeByte('\n');
|
||||
try self.writer.writeByte('[');
|
||||
try self.writer.writeAll(label orelse h);
|
||||
try self.writer.writeAll("](");
|
||||
try self.writer.writeAll(h);
|
||||
try self.writer.writeAll(")\n");
|
||||
self.state.last_char_was_newline = true;
|
||||
}
|
||||
return;
|
||||
return self.open(.init(self.tree.content(el, boxed)), .{ .block_anchor = .{
|
||||
.href = href,
|
||||
.label = label,
|
||||
} });
|
||||
}
|
||||
|
||||
const standalone = RenderTree.isStandaloneAnchor(el, frame);
|
||||
@@ -270,23 +281,13 @@ const Context = struct {
|
||||
if (!self.state.last_char_was_newline) try self.writer.writeByte('\n');
|
||||
}
|
||||
try self.writer.writeByte('[');
|
||||
|
||||
const epilogue: Epilogue = .{ .inline_anchor = .{ .href = href, .standalone = standalone } };
|
||||
if (info.has_visible) {
|
||||
try self.renderContent(el, boxed);
|
||||
} else {
|
||||
try self.writer.writeAll(label orelse "");
|
||||
return self.open(.init(self.tree.content(el, boxed)), epilogue);
|
||||
}
|
||||
try self.writer.writeAll("](");
|
||||
if (href) |h| {
|
||||
try self.writer.writeAll(h);
|
||||
}
|
||||
try self.writer.writeByte(')');
|
||||
if (standalone) {
|
||||
try self.writer.writeByte('\n');
|
||||
self.state.last_char_was_newline = true;
|
||||
} else {
|
||||
self.state.last_char_was_newline = false;
|
||||
}
|
||||
return;
|
||||
try self.writer.writeAll(label orelse "");
|
||||
return self.close(epilogue);
|
||||
},
|
||||
.input => {
|
||||
const type_attr = el.getAttributeInterned("type") orelse return;
|
||||
@@ -297,20 +298,49 @@ const Context = struct {
|
||||
}
|
||||
return;
|
||||
},
|
||||
.slot => return self.renderSlotContent(el.as(Slot)),
|
||||
.slot => return self.open(self.tree.slotted(el.as(Slot)), .none),
|
||||
else => {},
|
||||
}
|
||||
|
||||
try self.renderContent(el, boxed);
|
||||
return self.open(.init(self.tree.content(el, boxed)), .{ .element = tag });
|
||||
}
|
||||
|
||||
// Finish the element after renderElement has written the children
|
||||
fn close(self: *Context, epilogue: Epilogue) Error!void {
|
||||
const tag = switch (epilogue) {
|
||||
.none => return,
|
||||
.element => |t| t,
|
||||
.block_anchor => |anchor| {
|
||||
const href = anchor.href orelse return;
|
||||
try self.ensureNewline();
|
||||
try self.writer.writeByte('[');
|
||||
try self.writer.writeAll(anchor.label orelse href);
|
||||
try self.writer.writeAll("](");
|
||||
try self.writer.writeAll(href);
|
||||
try self.writer.writeAll(")\n");
|
||||
return;
|
||||
},
|
||||
.inline_anchor => |anchor| {
|
||||
try self.writer.writeAll("](");
|
||||
if (anchor.href) |h| {
|
||||
try self.writer.writeAll(h);
|
||||
}
|
||||
try self.writer.writeByte(')');
|
||||
if (anchor.standalone) {
|
||||
try self.writer.writeByte('\n');
|
||||
self.state.last_char_was_newline = true;
|
||||
} else {
|
||||
self.state.last_char_was_newline = false;
|
||||
}
|
||||
return;
|
||||
},
|
||||
};
|
||||
|
||||
switch (tag) {
|
||||
.pre => {
|
||||
if (!self.state.last_char_was_newline) {
|
||||
try self.writer.writeByte('\n');
|
||||
}
|
||||
try self.ensureNewline();
|
||||
try self.writer.writeAll("```\n");
|
||||
self.state.pre_node = null;
|
||||
self.state.last_char_was_newline = true;
|
||||
},
|
||||
.code => {
|
||||
if (self.state.pre_node == null) {
|
||||
@@ -425,7 +455,9 @@ pub fn dump(state: RenderTree.State, opts: Opts, writer: *std.Io.Writer, frame:
|
||||
.frame = frame,
|
||||
.tree = .{ .frame = frame, .state = state },
|
||||
};
|
||||
defer ctx.deinit();
|
||||
ctx.render(node) catch |err| switch (err) {
|
||||
error.OutOfMemory => return err,
|
||||
error.WriteFailed => {
|
||||
if (!lw.truncated) return err;
|
||||
try writer.writeAll(truncation_marker);
|
||||
@@ -444,6 +476,7 @@ pub fn dump(state: RenderTree.State, opts: Opts, writer: *std.Io.Writer, frame:
|
||||
.frame = frame,
|
||||
.tree = .{ .frame = frame, .state = state },
|
||||
};
|
||||
defer ctx.deinit();
|
||||
try ctx.render(node);
|
||||
if (!ctx.state.last_char_was_newline) {
|
||||
try writer.writeByte('\n');
|
||||
@@ -919,7 +952,7 @@ test "browser.markdown: declarative shadow DOM renders through piercing" {
|
||||
const host = try doc.createElement("div", null, frame);
|
||||
try host.setHTMLUnsafe(
|
||||
\\<div><template shadowrootmode="open"><p>shadow content</p></template></div>
|
||||
, frame);
|
||||
, null, frame);
|
||||
|
||||
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
|
||||
defer aw.deinit();
|
||||
@@ -927,3 +960,25 @@ test "browser.markdown: declarative shadow DOM renders through piercing" {
|
||||
|
||||
try testing.expectString("\nshadow content\n", aw.written());
|
||||
}
|
||||
|
||||
test "browser.markdown: deep nesting doesn't overflow the native stack" {
|
||||
const frame = try testing.createFrame();
|
||||
defer testing.test_session.closeAllPages();
|
||||
|
||||
const depth = 50_000;
|
||||
const doc = frame.window._document;
|
||||
var top = (try doc.createElement("i", null, frame)).asNode();
|
||||
for (1..depth) |_| {
|
||||
const parent = (try doc.createElement("i", null, frame)).asNode();
|
||||
_ = try parent.appendChild(top, frame);
|
||||
top = parent;
|
||||
}
|
||||
|
||||
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
|
||||
defer aw.deinit();
|
||||
try dump(.{ .root = top }, .{}, &aw.writer, frame);
|
||||
|
||||
// Every <i> opens and closes with a '*', then dump's trailing newline.
|
||||
try testing.expectEqual(depth * 2 + 1, aw.written().len);
|
||||
try testing.expectString("**", aw.written()[0..2]);
|
||||
}
|
||||
+124
-22
@@ -42,6 +42,17 @@ pub const ParsedNode = struct {
|
||||
// html5ever should never ask us for this data on a non-element, and we'll
|
||||
// assert that, with this optional, to make sure our assumption is correct.
|
||||
data: ?*anyopaque,
|
||||
|
||||
// Set once html5ever has put the node in the tree or given it children.
|
||||
// Until then the node is unobservable and its document is only a guess.
|
||||
placed: bool = false,
|
||||
};
|
||||
|
||||
// Attributes of an element the parser rebuilds (see settleDocument): the ones
|
||||
// html5ever gave the original, read back from it. node_factory treats this
|
||||
// like AttributeIterator, i.e. as a creation by the parser.
|
||||
pub const RebuiltAttributes = struct {
|
||||
list: *const Element.Attribute.List,
|
||||
};
|
||||
|
||||
// html5ever's tokenizer flushes the script-data character buffer on every '<'
|
||||
@@ -63,6 +74,13 @@ err: ?Error,
|
||||
arena: Allocator,
|
||||
container: ParsedNode,
|
||||
document: *Node.Document,
|
||||
// The document new nodes are created in. The spec creates an element for a
|
||||
// token in its intended parent's document, and a <template>'s content belongs
|
||||
// to the inert template contents owner document (Document.templateContentsOwner)
|
||||
// where custom elements are never constructed and nothing runs or loads.
|
||||
// html5ever creates an element before it says where it goes, so this is the
|
||||
// document of the last insertion point it did tell us about.
|
||||
creation_document: *Node.Document,
|
||||
strings: std.StringHashMapUnmanaged(void),
|
||||
pending_text: ?PendingText,
|
||||
// One buffer reused across every text run in this parser. clearRetainingCapacity
|
||||
@@ -98,15 +116,18 @@ pub const Options = struct {
|
||||
};
|
||||
|
||||
pub fn init(arena: Allocator, node: *Node, frame: *Frame, opts: Options) Parser {
|
||||
const document = node.getDocument(frame);
|
||||
return .{
|
||||
.err = null,
|
||||
.frame = frame,
|
||||
.document = node.getDocument(frame),
|
||||
.document = document,
|
||||
.creation_document = document,
|
||||
.strings = .empty,
|
||||
.arena = arena,
|
||||
.container = ParsedNode{
|
||||
.data = null,
|
||||
.node = node,
|
||||
.placed = true,
|
||||
},
|
||||
.pending_text = null,
|
||||
.buf = .empty,
|
||||
@@ -161,7 +182,7 @@ fn appendTextChunk(self: *Parser, parent: *Node, txt: []const u8) !void {
|
||||
|
||||
// Fresh text run: the first chunk lives on _data only. buf stays empty
|
||||
// until (and unless) a second chunk arrives.
|
||||
const new_text = try Frame.node_factory.createTextNode(self.document, txt);
|
||||
const new_text = try Frame.node_factory.createTextNode(parent.getDocument(self.frame), txt);
|
||||
try self.frame.appendNew(parent, new_text);
|
||||
self.inserted_since_checkpoint +|= 1;
|
||||
self.pending_text = .{
|
||||
@@ -530,7 +551,7 @@ fn _createElementCallback(self: *Parser, data: *anyopaque, qname: h5e.QualName,
|
||||
} else local;
|
||||
const namespace_string = qname.ns.slice();
|
||||
const namespace = if (namespace_string.len == 0) default_namespace else Element.Namespace.parse(namespace_string);
|
||||
const node = try Frame.node_factory.createElementNS(self.document, namespace, name, attributes);
|
||||
const node = try Frame.node_factory.createElementNS(self.creation_document, namespace, name, attributes);
|
||||
if (namespace == .unknown and namespace_string.len > 0) {
|
||||
// Same as Document.createElementNS: keep the URI so namespaceURI and
|
||||
// lookupNamespaceURI can return it.
|
||||
@@ -556,7 +577,7 @@ fn createCommentCallback(ctx: *anyopaque, str: h5e.StringSlice) callconv(.c) ?*a
|
||||
};
|
||||
}
|
||||
fn _createCommentCallback(self: *Parser, str: []const u8) !*anyopaque {
|
||||
const node = try Frame.node_factory.createComment(self.document, str);
|
||||
const node = try Frame.node_factory.createComment(self.creation_document, str);
|
||||
const pn = try self.arena.create(ParsedNode);
|
||||
pn.* = .{
|
||||
.data = null,
|
||||
@@ -575,7 +596,7 @@ fn createProcessingInstruction(ctx: *anyopaque, target: h5e.StringSlice, data: h
|
||||
};
|
||||
}
|
||||
fn _createProcessingInstruction(self: *Parser, target: []const u8, data: []const u8) !*anyopaque {
|
||||
const node = try Frame.node_factory.createProcessingInstruction(self.document, target, data);
|
||||
const node = try Frame.node_factory.createProcessingInstruction(self.creation_document, target, data);
|
||||
const pn = try self.arena.create(ParsedNode);
|
||||
pn.* = .{
|
||||
.data = null,
|
||||
@@ -649,11 +670,15 @@ fn _getTemplateContentsCallback(self: *Parser, node: *Node) !*anyopaque {
|
||||
const template = element.subtype(Element.Html).is(Element.Html.Template) orelse unreachable;
|
||||
const content_node = template.getContent().asNode();
|
||||
|
||||
// html5ever asks for this to insert there next.
|
||||
self.creation_document = content_node.getDocument(self.frame);
|
||||
|
||||
// Create a ParsedNode wrapper for the content DocumentFragment
|
||||
const pn = try self.arena.create(ParsedNode);
|
||||
pn.* = .{
|
||||
.data = null,
|
||||
.node = content_node,
|
||||
.placed = true,
|
||||
};
|
||||
return pn;
|
||||
}
|
||||
@@ -708,11 +733,11 @@ fn appendCallback(ctx: *anyopaque, parent_ref: *anyopaque, node_or_text: h5e.Nod
|
||||
|
||||
const cp = self.frame._ce_reactions.push();
|
||||
defer self.frame._ce_reactions.popAndInvoke(cp, self.frame);
|
||||
self._appendCallback(getNode(parent_ref), node_or_text) catch |err| {
|
||||
self._appendCallback(getParsed(parent_ref), node_or_text) catch |err| {
|
||||
self.err = .{ .err = err, .source = .append };
|
||||
};
|
||||
}
|
||||
fn _appendCallback(self: *Parser, parent: *Node, node_or_text: h5e.NodeOrText) !void {
|
||||
fn _appendCallback(self: *Parser, parent_pn: *ParsedNode, node_or_text: h5e.NodeOrText) !void {
|
||||
// child node is guaranteed not to belong to another parent
|
||||
switch (node_or_text.toUnion()) {
|
||||
.node => |cpn| {
|
||||
@@ -722,7 +747,8 @@ fn _appendCallback(self: *Parser, parent: *Node, node_or_text: h5e.NodeOrText) !
|
||||
try self.flushPendingText();
|
||||
self.maybeCheckpoint();
|
||||
self.inserted_since_checkpoint +|= 1;
|
||||
const child = getNode(cpn);
|
||||
const child = try self.settleDocument(parent_pn, getParsed(cpn));
|
||||
const parent = parent_pn.node;
|
||||
if (child._parent) |previous_parent| {
|
||||
// html5ever says this can't happen, but we might be screwing up
|
||||
// the node on our side. We shouldn't be, but we're seeing this
|
||||
@@ -736,11 +762,66 @@ fn _appendCallback(self: *Parser, parent: *Node, node_or_text: h5e.NodeOrText) !
|
||||
}
|
||||
try self.frame.appendNew(parent, child);
|
||||
},
|
||||
.text => |txt| try self.appendTextChunk(parent, txt),
|
||||
.text => |txt| {
|
||||
const parent = parent_pn.node;
|
||||
self.creation_document = parent.getDocument(self.frame);
|
||||
try self.appendTextChunk(parent, txt);
|
||||
},
|
||||
.failed => {},
|
||||
}
|
||||
}
|
||||
|
||||
fn settleDocument(self: *Parser, parent_pn: *ParsedNode, child_pn: *ParsedNode) !*Node {
|
||||
const frame = self.frame;
|
||||
const parent = parent_pn.node;
|
||||
const child = child_pn.node;
|
||||
|
||||
// A custom element constructor may have put the child in the tree
|
||||
// itself; that is as observable as being placed by html5ever.
|
||||
if (child._parent != null) {
|
||||
child_pn.placed = true;
|
||||
}
|
||||
|
||||
var document = parent.getDocument(frame);
|
||||
const child_document = child.getDocument(frame);
|
||||
if (child_document != document) {
|
||||
if (child_pn.placed == false) {
|
||||
child_pn.node = try self.rebuildIn(child, document);
|
||||
} else if (parent_pn.placed == false) {
|
||||
try frame.adoptNodeTree(parent, document, child_document);
|
||||
document = child_document;
|
||||
} else {
|
||||
try frame.adoptNodeTree(child, child_document, document);
|
||||
}
|
||||
}
|
||||
|
||||
parent_pn.placed = true;
|
||||
child_pn.placed = true;
|
||||
self.creation_document = document;
|
||||
return child_pn.node;
|
||||
}
|
||||
|
||||
// A copy of the never-placed `node` in `document`, created like the parser
|
||||
// created `node`, in the wrong document.
|
||||
fn rebuildIn(self: *Parser, node: *Node, document: *Node.Document) !*Node {
|
||||
const frame = self.frame;
|
||||
const element = node.is(Element) orelse {
|
||||
// A comment or processing instruction: nothing to construct or load.
|
||||
try frame.adoptNodeTree(node, node.getDocument(frame), document);
|
||||
return node;
|
||||
};
|
||||
|
||||
const copy = try Frame.node_factory.createElementNS(document, element._namespace, element.getTagNameDump(), RebuiltAttributes{ .list = &element._attributes });
|
||||
if (element._namespace == .unknown) {
|
||||
// The URI lives in a side table, see _createElementCallback.
|
||||
const page = self.document._page;
|
||||
if (page.element_namespace_uris.fetchRemove(element)) |entry| {
|
||||
try page.element_namespace_uris.put(page.frame_arena, copy.as(Element), entry.value);
|
||||
}
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
fn removeFromParentCallback(ctx: *anyopaque, target_ref: *anyopaque) callconv(.c) void {
|
||||
const self: *Parser = @ptrCast(@alignCast(ctx));
|
||||
if (self.terminated) {
|
||||
@@ -769,15 +850,28 @@ fn reparentChildrenCallback(ctx: *anyopaque, node_ref: *anyopaque, new_parent_re
|
||||
}
|
||||
const cp = self.frame._ce_reactions.push();
|
||||
defer self.frame._ce_reactions.popAndInvoke(cp, self.frame);
|
||||
self._reparentChildrenCallback(getNode(node_ref), getNode(new_parent_ref)) catch |err| {
|
||||
self._reparentChildrenCallback(getParsed(node_ref), getParsed(new_parent_ref)) catch |err| {
|
||||
self.err = .{ .err = err, .source = .reparent_children };
|
||||
};
|
||||
}
|
||||
fn _reparentChildrenCallback(self: *Parser, node: *Node, new_parent: *Node) !void {
|
||||
fn _reparentChildrenCallback(self: *Parser, node_pn: *ParsedNode, new_parent_pn: *ParsedNode) !void {
|
||||
// Reparenting can move the pending text node out from under us — the
|
||||
// node's _parent changes but pending_text.parent does not. Flush so the
|
||||
// accumulator commits before the tree is rearranged.
|
||||
try self.flushPendingText();
|
||||
|
||||
// The new parent is a fresh element (adoption agency algorithm); the
|
||||
// children it takes settle its document, not the other way around.
|
||||
const node = node_pn.node;
|
||||
const new_parent = new_parent_pn.node;
|
||||
const document = node.getDocument(self.frame);
|
||||
if (new_parent_pn.placed == false) {
|
||||
const new_parent_document = new_parent.getDocument(self.frame);
|
||||
if (new_parent_document != document) {
|
||||
try self.frame.adoptNodeTree(new_parent, new_parent_document, document);
|
||||
}
|
||||
new_parent_pn.placed = true;
|
||||
}
|
||||
try self.frame.appendAllChildren(node, new_parent);
|
||||
}
|
||||
|
||||
@@ -789,19 +883,21 @@ fn appendBeforeSiblingCallback(ctx: *anyopaque, sibling_ref: *anyopaque, node_or
|
||||
|
||||
const cp = self.frame._ce_reactions.push();
|
||||
defer self.frame._ce_reactions.popAndInvoke(cp, self.frame);
|
||||
self._appendBeforeSiblingCallback(getNode(sibling_ref), node_or_text) catch |err| {
|
||||
self._appendBeforeSiblingCallback(getParsed(sibling_ref), node_or_text) catch |err| {
|
||||
self.err = .{ .err = err, .source = .append_before_sibling };
|
||||
};
|
||||
}
|
||||
fn _appendBeforeSiblingCallback(self: *Parser, sibling: *Node, node_or_text: h5e.NodeOrText) !void {
|
||||
fn _appendBeforeSiblingCallback(self: *Parser, sibling_pn: *ParsedNode, node_or_text: h5e.NodeOrText) !void {
|
||||
// Foster parenting / before-sibling insertions interrupt any pending text
|
||||
// run (the new node lands at a different position from the pending text's
|
||||
// tail). Flush before reading the parent's structure.
|
||||
try self.flushPendingText();
|
||||
const sibling = sibling_pn.node;
|
||||
const parent = sibling.parentNode() orelse return error.NoParent;
|
||||
const node: *Node = switch (node_or_text.toUnion()) {
|
||||
.node => |cpn| blk: {
|
||||
const child = getNode(cpn);
|
||||
var parent_pn = ParsedNode{ .node = parent, .data = null, .placed = true };
|
||||
const child = try self.settleDocument(&parent_pn, getParsed(cpn));
|
||||
if (child._parent) |previous_parent| {
|
||||
// A custom element constructor may have inserted the node into the
|
||||
// DOM before the parser officially places it (e.g. via foster
|
||||
@@ -810,7 +906,10 @@ fn _appendBeforeSiblingCallback(self: *Parser, sibling: *Node, node_or_text: h5e
|
||||
}
|
||||
break :blk child;
|
||||
},
|
||||
.text => |txt| try Frame.node_factory.createTextNode(self.document, txt),
|
||||
.text => |txt| blk: {
|
||||
self.creation_document = parent.getDocument(self.frame);
|
||||
break :blk try Frame.node_factory.createTextNode(self.creation_document, txt);
|
||||
},
|
||||
.failed => return,
|
||||
};
|
||||
try self.frame.insertNodeRelative(parent, node, .{ .before = sibling }, .{});
|
||||
@@ -824,21 +923,24 @@ fn appendBasedOnParentNodeCallback(ctx: *anyopaque, element_ref: *anyopaque, pre
|
||||
|
||||
const cp = self.frame._ce_reactions.push();
|
||||
defer self.frame._ce_reactions.popAndInvoke(cp, self.frame);
|
||||
self._appendBasedOnParentNodeCallback(getNode(element_ref), getNode(prev_element_ref), node_or_text) catch |err| {
|
||||
self._appendBasedOnParentNodeCallback(getParsed(element_ref), getParsed(prev_element_ref), node_or_text) catch |err| {
|
||||
self.err = .{ .err = err, .source = .append_based_on_parent_node };
|
||||
};
|
||||
}
|
||||
fn _appendBasedOnParentNodeCallback(self: *Parser, element: *Node, prev_element: *Node, node_or_text: h5e.NodeOrText) !void {
|
||||
if (element.parentNode()) |_| {
|
||||
try self._appendBeforeSiblingCallback(element, node_or_text);
|
||||
fn _appendBasedOnParentNodeCallback(self: *Parser, element_pn: *ParsedNode, prev_element_pn: *ParsedNode, node_or_text: h5e.NodeOrText) !void {
|
||||
if (element_pn.node.parentNode()) |_| {
|
||||
try self._appendBeforeSiblingCallback(element_pn, node_or_text);
|
||||
} else {
|
||||
try self._appendCallback(prev_element, node_or_text);
|
||||
try self._appendCallback(prev_element_pn, node_or_text);
|
||||
}
|
||||
}
|
||||
|
||||
fn getParsed(ref: *anyopaque) *ParsedNode {
|
||||
return @ptrCast(@alignCast(ref));
|
||||
}
|
||||
|
||||
fn getNode(ref: *anyopaque) *Node {
|
||||
const pn: *ParsedNode = @ptrCast(@alignCast(ref));
|
||||
return pn.node;
|
||||
return getParsed(ref).node;
|
||||
}
|
||||
|
||||
fn asUint(comptime string: anytype) std.meta.Int(
|
||||
|
||||
+145
-71
@@ -155,7 +155,7 @@ pub fn collect(arena: Allocator, state: RenderTree.State, frame: *Frame) ![]cons
|
||||
// Any non-zero rc means nothing usable was written, so the caller has to fail
|
||||
// rather than hand back a truncated file. WriteFailed is the only error
|
||||
// jsonStringify's signature can carry, hence the log line.
|
||||
fn renderFailed(what: []const u8, rc: i32) error{WriteFailed} {
|
||||
fn renderFailed(comptime what: []const u8, rc: i32) error{WriteFailed} {
|
||||
log.err(.browser, what, .{ .reason = switch (rc) {
|
||||
RC_WRITE_REFUSED => "write refused",
|
||||
RC_INVALID => "invalid options",
|
||||
@@ -567,11 +567,15 @@ extern "c" fn lp_layout_new(
|
||||
extern "c" fn lp_layout_free(h: *LayoutHandle) void;
|
||||
extern "c" fn lp_render_abi(out: *LpAbi) void;
|
||||
|
||||
// Iterative else large trees will stackoverflow
|
||||
const Builder = struct {
|
||||
frame: *Frame,
|
||||
arena: Allocator,
|
||||
tree: RenderTree,
|
||||
|
||||
// Content still to render, followed by what closes the element.
|
||||
stack: std.ArrayList(Open) = .empty,
|
||||
|
||||
blocks: std.ArrayList(LpBlock) = .empty,
|
||||
|
||||
// The block being built.
|
||||
@@ -618,6 +622,30 @@ const Builder = struct {
|
||||
index: u32,
|
||||
};
|
||||
|
||||
const Open = struct {
|
||||
iter: RenderTree.Slotted,
|
||||
epilogue: Epilogue,
|
||||
};
|
||||
|
||||
// what follows after the children
|
||||
const Epilogue = union(enum) {
|
||||
none,
|
||||
block,
|
||||
pre: ?*Node,
|
||||
list: bool,
|
||||
list_item: bool,
|
||||
blockquote,
|
||||
anchor: Anchor,
|
||||
cell: bool,
|
||||
element: Element.Tag,
|
||||
|
||||
const Anchor = struct {
|
||||
linked: bool,
|
||||
prev_href: []const u8,
|
||||
standalone: bool,
|
||||
};
|
||||
};
|
||||
|
||||
const Error = Allocator.Error;
|
||||
|
||||
fn currentFlags(self: *const Builder) u32 {
|
||||
@@ -758,9 +786,23 @@ const Builder = struct {
|
||||
|
||||
fn render(self: *Builder, node: *Node) Error!void {
|
||||
switch (node._type) {
|
||||
.document, .document_fragment => try self.renderChildren(node, false),
|
||||
.document, .document_fragment => try self.open(.init(self.tree.children(node, false)), .none),
|
||||
else => if (self.tree.classify(node, .{})) |child| try self.renderChild(child),
|
||||
}
|
||||
|
||||
while (self.stack.items.len > 0) {
|
||||
// renderChild can grow the stack, so we can't re-use top on continue
|
||||
const top = &self.stack.items[self.stack.items.len - 1];
|
||||
if (top.iter.next()) |child| {
|
||||
try self.renderChild(child);
|
||||
continue;
|
||||
}
|
||||
try self.close(self.stack.pop().?.epilogue);
|
||||
}
|
||||
}
|
||||
|
||||
fn open(self: *Builder, iter: RenderTree.Slotted, epilogue: Epilogue) Error!void {
|
||||
return self.stack.append(self.arena, .{ .iter = iter, .epilogue = epilogue });
|
||||
}
|
||||
|
||||
fn renderChild(self: *Builder, child: RenderTree.Child) Error!void {
|
||||
@@ -771,21 +813,6 @@ const Builder = struct {
|
||||
}
|
||||
}
|
||||
|
||||
fn renderChildren(self: *Builder, parent: *Node, boxed: bool) Error!void {
|
||||
var it = self.tree.children(parent, boxed);
|
||||
while (it.next()) |child| try self.renderChild(child);
|
||||
}
|
||||
|
||||
fn renderContent(self: *Builder, el: *Element, boxed: bool) Error!void {
|
||||
var it = self.tree.content(el, boxed);
|
||||
while (it.next()) |child| try self.renderChild(child);
|
||||
}
|
||||
|
||||
fn renderSlotContent(self: *Builder, slot: *Slot) Error!void {
|
||||
var it = self.tree.slotted(slot);
|
||||
while (it.next()) |child| try self.renderChild(child);
|
||||
}
|
||||
|
||||
fn renderText(self: *Builder, text: []const u8) Error!void {
|
||||
if (text.len == 0) return;
|
||||
|
||||
@@ -824,16 +851,13 @@ const Builder = struct {
|
||||
else => 6,
|
||||
};
|
||||
try self.openBlock(.heading, level);
|
||||
try self.renderContent(el, boxed);
|
||||
return self.closeBlock();
|
||||
return self.open(.init(self.tree.content(el, boxed)), .block);
|
||||
},
|
||||
.pre => {
|
||||
try self.openBlock(.pre, 0);
|
||||
const prev = self.pre_node;
|
||||
self.pre_node = el.asNode();
|
||||
try self.renderContent(el, boxed);
|
||||
self.pre_node = prev;
|
||||
return self.closeBlock();
|
||||
return self.open(.init(self.tree.content(el, boxed)), .{ .pre = prev });
|
||||
},
|
||||
.hr => {
|
||||
try self.openBlock(.rule, 0);
|
||||
@@ -854,10 +878,7 @@ const Builder = struct {
|
||||
self.list_stack[self.list_depth] = .{ .ordered = tag == .ol, .index = 1 };
|
||||
self.list_depth += 1;
|
||||
}
|
||||
try self.renderContent(el, boxed);
|
||||
try self.closeBlock();
|
||||
if (pushed) self.list_depth -= 1;
|
||||
return;
|
||||
return self.open(.init(self.tree.content(el, boxed)), .{ .list = pushed });
|
||||
},
|
||||
.li => {
|
||||
try self.closeBlock();
|
||||
@@ -871,19 +892,12 @@ const Builder = struct {
|
||||
} else {
|
||||
self.pending_marker = "•";
|
||||
}
|
||||
try self.renderContent(el, boxed);
|
||||
try self.closeBlock();
|
||||
self.pending_marker = "";
|
||||
if (stray) self.list_depth = 0;
|
||||
return;
|
||||
return self.open(.init(self.tree.content(el, boxed)), .{ .list_item = stray });
|
||||
},
|
||||
.blockquote => {
|
||||
try self.closeBlock();
|
||||
self.quote_depth +|= 1;
|
||||
try self.renderContent(el, boxed);
|
||||
try self.closeBlock();
|
||||
self.quote_depth -= 1;
|
||||
return;
|
||||
return self.open(.init(self.tree.content(el, boxed)), .blockquote);
|
||||
},
|
||||
.img => {
|
||||
const alt = el.getAttributeInterned("alt") orelse return;
|
||||
@@ -926,24 +940,19 @@ const Builder = struct {
|
||||
// point outside the document.
|
||||
self.href = URL.resolve(self.arena, self.frame.base(), h, .{ .encoding = self.frame.charset }) catch h;
|
||||
}
|
||||
|
||||
const epilogue: Epilogue = .{ .anchor = .{
|
||||
.linked = href != null,
|
||||
.prev_href = prev_href,
|
||||
.standalone = standalone,
|
||||
} };
|
||||
if (info.has_visible) {
|
||||
try self.renderContent(el, boxed);
|
||||
} else {
|
||||
try self.renderText(label.?);
|
||||
return self.open(.init(self.tree.content(el, boxed)), epilogue);
|
||||
}
|
||||
if (href != null) {
|
||||
self.link -= 1;
|
||||
self.href = prev_href;
|
||||
}
|
||||
if (standalone) {
|
||||
try self.closeBlock();
|
||||
self.tight -= 1;
|
||||
} else {
|
||||
self.after_anchor = true;
|
||||
}
|
||||
return;
|
||||
try self.renderText(label.?);
|
||||
return self.close(epilogue);
|
||||
},
|
||||
.slot => return self.renderSlotContent(el.as(Slot)),
|
||||
.slot => return self.open(self.tree.slotted(el.as(Slot)), .none),
|
||||
.td, .th => {
|
||||
if (self.has_content) {
|
||||
self.pending_space = true;
|
||||
@@ -953,19 +962,12 @@ const Builder = struct {
|
||||
self.pending_space = true;
|
||||
}
|
||||
if (tag == .th) self.bold += 1;
|
||||
try self.renderContent(el, boxed);
|
||||
if (tag == .th) self.bold -= 1;
|
||||
self.pending_space = true;
|
||||
return;
|
||||
return self.open(.init(self.tree.content(el, boxed)), .{ .cell = tag == .th });
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
|
||||
const block = tag.isBlock() or switch (tag) {
|
||||
.tr, .dt, .dd, .details, .summary, .caption, .legend, .option, .textarea => true,
|
||||
else => false,
|
||||
};
|
||||
if (block) try self.closeBlock();
|
||||
if (isBlockTag(tag)) try self.closeBlock();
|
||||
|
||||
switch (tag) {
|
||||
.b, .strong => self.bold += 1,
|
||||
@@ -975,22 +977,70 @@ const Builder = struct {
|
||||
.code => self.mono += 1,
|
||||
else => {},
|
||||
}
|
||||
try self.renderContent(el, boxed);
|
||||
switch (tag) {
|
||||
.b, .strong => self.bold -= 1,
|
||||
.i, .em, .dfn => self.italic -= 1,
|
||||
.ins => self.underline -= 1,
|
||||
.s, .del => self.strike -= 1,
|
||||
.code => self.mono -= 1,
|
||||
else => {},
|
||||
}
|
||||
return self.open(.init(self.tree.content(el, boxed)), .{ .element = tag });
|
||||
}
|
||||
|
||||
if (block) {
|
||||
try self.closeBlock();
|
||||
// Finish the element after renderElement has written the children
|
||||
fn close(self: *Builder, epilogue: Epilogue) Error!void {
|
||||
switch (epilogue) {
|
||||
.none => {},
|
||||
.block => try self.closeBlock(),
|
||||
.pre => |prev| {
|
||||
self.pre_node = prev;
|
||||
try self.closeBlock();
|
||||
},
|
||||
.list => |pushed| {
|
||||
try self.closeBlock();
|
||||
if (pushed) self.list_depth -= 1;
|
||||
},
|
||||
.list_item => |stray| {
|
||||
try self.closeBlock();
|
||||
self.pending_marker = "";
|
||||
if (stray) self.list_depth = 0;
|
||||
},
|
||||
.blockquote => {
|
||||
try self.closeBlock();
|
||||
self.quote_depth -= 1;
|
||||
},
|
||||
.anchor => |anchor| {
|
||||
if (anchor.linked) {
|
||||
self.link -= 1;
|
||||
self.href = anchor.prev_href;
|
||||
}
|
||||
if (anchor.standalone) {
|
||||
try self.closeBlock();
|
||||
self.tight -= 1;
|
||||
} else {
|
||||
self.after_anchor = true;
|
||||
}
|
||||
},
|
||||
.cell => |header| {
|
||||
if (header) self.bold -= 1;
|
||||
self.pending_space = true;
|
||||
},
|
||||
.element => |tag| {
|
||||
switch (tag) {
|
||||
.b, .strong => self.bold -= 1,
|
||||
.i, .em, .dfn => self.italic -= 1,
|
||||
.ins => self.underline -= 1,
|
||||
.s, .del => self.strike -= 1,
|
||||
.code => self.mono -= 1,
|
||||
else => {},
|
||||
}
|
||||
if (isBlockTag(tag)) try self.closeBlock();
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Tags that get a block of their own, beyond the ones CSS calls blocks.
|
||||
fn isBlockTag(tag: Element.Tag) bool {
|
||||
return tag.isBlock() or switch (tag) {
|
||||
.tr, .dt, .dd, .details, .summary, .caption, .legend, .option, .textarea => true,
|
||||
else => false,
|
||||
};
|
||||
}
|
||||
|
||||
const testing = @import("../testing.zig");
|
||||
test "browser.screenshot: rust abi matches" {
|
||||
var got: LpAbi = undefined;
|
||||
@@ -1418,7 +1468,7 @@ test "browser.screenshot: shadow dom and slots" {
|
||||
const div = try doc.createElement("div", null, frame);
|
||||
try div.setHTMLUnsafe(
|
||||
\\<x-host><template shadowrootmode="open"><p>shadow <slot></slot></p></template>light</x-host>
|
||||
, frame);
|
||||
, null, frame);
|
||||
|
||||
var builder: Builder = .{ .arena = testing.arena_allocator, .frame = frame, .tree = .{ .frame = frame, .state = .{ .root = div.asNode() } } };
|
||||
try builder.render(div.asNode());
|
||||
@@ -1535,3 +1585,27 @@ test "browser.screenshot: collect honours strip flags" {
|
||||
try testing.expectEqual(1, stripped.len);
|
||||
try testing.expectEqual("Body text", try S.text(stripped[0], arena));
|
||||
}
|
||||
|
||||
test "browser.screenshot: deep nesting doesn't overflow the native stack" {
|
||||
defer testing.test_session.closeAllPages();
|
||||
const frame = try testing.createFrame();
|
||||
|
||||
// <div>, not an inline tag: the inline style counters are u8, and nesting
|
||||
// one 256 deep overflows them long before the walk is the problem.
|
||||
const depth = 50_000;
|
||||
const doc = frame.window._document;
|
||||
const innermost = (try doc.createElement("div", null, frame)).asNode();
|
||||
_ = try innermost.appendChild(try doc.createTextNode("deep"), frame);
|
||||
|
||||
var top = innermost;
|
||||
for (1..depth) |_| {
|
||||
const parent = (try doc.createElement("div", null, frame)).asNode();
|
||||
_ = try parent.appendChild(top, frame);
|
||||
top = parent;
|
||||
}
|
||||
|
||||
const blocks = try collect(testing.arena_allocator, .{ .root = top }, frame);
|
||||
try testing.expectEqual(1, blocks.len);
|
||||
try testing.expectEqual(1, blocks[0].spans_len);
|
||||
try testing.expectEqual("deep", blocks[0].spans[0].text[0..blocks[0].spans[0].len]);
|
||||
}
|
||||
@@ -236,7 +236,7 @@ fn collectLinkHeaders(
|
||||
const header_link_rels = [_][]const u8{ "service-doc", "service-desc", "api" };
|
||||
|
||||
for (frame._http_headers.items) |header| {
|
||||
if (!std.ascii.eqlIgnoreCase(header.name, "link")) {
|
||||
if (std.mem.eql(u8, header.name, "link") == false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -705,7 +705,7 @@ test "structured_data: link headers from response" {
|
||||
defer testing.test_session.closeAllPages();
|
||||
|
||||
// Stand in for what frameHeaderDoneCallback records from the navigation.
|
||||
try frame._http_headers.append(frame.arena, .{ .name = "Link", .value =
|
||||
try frame._http_headers.append(frame.arena, .{ .name = "link", .value =
|
||||
\\<https://docs.example.com/>; rel="service-doc"
|
||||
});
|
||||
try frame._http_headers.append(frame.arena, .{ .name = "link", .value =
|
||||
|
||||
@@ -1 +1 @@
|
||||
<!doctype html><title>Parent jobs</title><iframe src="/redirect-cross-origin/src/browser/tests/cdp/isolated_world_one.html?delay_ms=1000"></iframe>
|
||||
<!doctype html><title>Parent jobs</title><iframe src="/redirect-cross-origin/src/browser/tests/cdp/isolated_world_one.html?delay_ms=300"></iframe>
|
||||
@@ -0,0 +1,54 @@
|
||||
<!DOCTYPE html>
|
||||
<script src="../testing.js"></script>
|
||||
<form id=f><input name=i1><select id=s><option>a</option><option>b</option></select></form>
|
||||
<div id=d class="x y" data-a=1></div>
|
||||
<iframe></iframe>
|
||||
|
||||
<script id=supported_indices>
|
||||
{
|
||||
// Web IDL: a supported index is enumerable, configurable and, unless
|
||||
// there's an indexed setter (HTMLOptionsCollection), read-only. An
|
||||
// unsupported one isn't there at all.
|
||||
const d = document.getElementById('d');
|
||||
const cases = {
|
||||
HTMLCollection: document.getElementsByTagName('div'),
|
||||
NodeList: document.querySelectorAll('div'),
|
||||
NamedNodeMap: d.attributes,
|
||||
HTMLOptionsCollection: document.getElementById('s').options,
|
||||
HTMLAllCollection: document.all,
|
||||
HTMLFormControlsCollection: document.getElementById('f').elements,
|
||||
DOMTokenList: d.classList,
|
||||
Window: window,
|
||||
};
|
||||
for (const [name, obj] of Object.entries(cases)) {
|
||||
const desc = Object.getOwnPropertyDescriptor(obj, 0);
|
||||
const writable = name === 'HTMLOptionsCollection';
|
||||
testing.expectEqual([name, true, true, writable].join(' '), [name, desc.enumerable, desc.configurable, desc.writable].join(' '));
|
||||
testing.expectEqual(name + ' true', name + ' ' + obj.propertyIsEnumerable(0));
|
||||
testing.expectEqual(name + ' true', name + ' ' + (0 in obj));
|
||||
testing.expectEqual(name + ' false', name + ' ' + (9999 in obj));
|
||||
testing.expectEqual(name + ' true', name + ' ' + (Object.getOwnPropertyDescriptor(obj, 9999) === undefined));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=enumeration>
|
||||
{
|
||||
// the query must not filter supported indices out of enumeration
|
||||
const list = document.querySelectorAll('div');
|
||||
testing.expectEqual(['0'], Object.keys(list));
|
||||
const seen = [];
|
||||
for (const k in list) seen.push(k);
|
||||
testing.expectEqual('0', seen[0]);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=read_only>
|
||||
(function() {
|
||||
'use strict';
|
||||
const list = document.querySelectorAll('div');
|
||||
const first = list[0];
|
||||
testing.expectError('TypeError', () => { list[0] = 'x'; });
|
||||
testing.expectEqual(first, list[0]);
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,211 @@
|
||||
<!DOCTYPE html>
|
||||
<body>
|
||||
<script src="../testing.js"></script>
|
||||
<script>
|
||||
// Elements inside <template> content live in the inert "template contents
|
||||
// owner document", which has no browsing context and no custom element
|
||||
// registry. They must never be constructed/upgraded, and no reactions may run
|
||||
// on them, even if a matching definition already exists. Only a copy stamped
|
||||
// into the real document (importNode/cloneNode + insert) gets upgraded.
|
||||
//
|
||||
// Polymer relies on this: its `html` tag sets template.innerHTML with binding
|
||||
// annotations such as items="[[list]]", then strips those attributes from the
|
||||
// template content *before* stamping. If the element is upgraded while still
|
||||
// in the template, attributeChangedCallback receives the raw "[[list]]" text
|
||||
// and JSON.parse fails:
|
||||
// Polymer::Attributes: couldn't decode Array as JSON: [[list]]
|
||||
// (lib/mixins/property-accessors.js `_deserializeValue`, case Array).
|
||||
let calls = [];
|
||||
|
||||
class TplElement extends HTMLElement {
|
||||
static get observedAttributes() {
|
||||
return ['items'];
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
calls.push('ctor');
|
||||
}
|
||||
|
||||
attributeChangedCallback(name, oldValue, newValue) {
|
||||
calls.push(`acc:${name}=${newValue}`);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
calls.push('connected');
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('tpl-element', TplElement);
|
||||
</script>
|
||||
|
||||
<template id="markup"><tpl-element items="[[fromMarkup]]"></tpl-element></template>
|
||||
|
||||
<script id="template_content">
|
||||
{
|
||||
// Parsed from document markup, defined before the <template> was parsed.
|
||||
const tpl = document.getElementById('markup');
|
||||
const inner = tpl.content.firstChild;
|
||||
testing.expectEqual('TPL-ELEMENT', inner.tagName);
|
||||
testing.expectFalse(inner instanceof TplElement);
|
||||
testing.expectEqual('[]', JSON.stringify(calls));
|
||||
}
|
||||
|
||||
{
|
||||
// Polymer's html`` path: template.innerHTML with binding annotations.
|
||||
calls = [];
|
||||
const tpl = document.createElement('template');
|
||||
tpl.innerHTML = '<tpl-element items="[[list]]"></tpl-element>';
|
||||
const inner = tpl.content.firstChild;
|
||||
testing.expectEqual('TPL-ELEMENT', inner.tagName);
|
||||
testing.expectFalse(inner instanceof TplElement);
|
||||
testing.expectEqual('[]', JSON.stringify(calls));
|
||||
|
||||
// Polymer strips the binding attribute from the template content before
|
||||
// stamping; that must not trigger a reaction either.
|
||||
inner.removeAttribute('items');
|
||||
inner.setAttribute('items', '["a"]');
|
||||
testing.expectEqual('[]', JSON.stringify(calls));
|
||||
|
||||
// Stamping a copy into the document upgrades the copy, which sees only
|
||||
// the current attribute values.
|
||||
const frag = document.importNode(tpl.content, true);
|
||||
document.body.appendChild(frag);
|
||||
testing.expectEqual('["ctor","acc:items=[\\"a\\"]","connected"]', JSON.stringify(calls));
|
||||
|
||||
// The template content itself is still untouched.
|
||||
testing.expectFalse(inner instanceof TplElement);
|
||||
}
|
||||
|
||||
{
|
||||
// Template content belongs to a separate, inert document, shared by all
|
||||
// templates: https://html.spec.whatwg.org/#template-contents-owner-document
|
||||
const tpl = document.createElement('template');
|
||||
tpl.innerHTML = '<tpl-element></tpl-element>';
|
||||
const owner = tpl.content.ownerDocument;
|
||||
testing.expectTrue(owner !== document);
|
||||
testing.expectTrue(owner === document.createElement('template').content.ownerDocument);
|
||||
testing.expectTrue(owner instanceof HTMLDocument);
|
||||
testing.expectEqual(null, owner.defaultView);
|
||||
testing.expectEqual(null, owner.documentElement);
|
||||
testing.expectEqual('about:blank', owner.URL);
|
||||
testing.expectEqual('complete', owner.readyState);
|
||||
testing.expectTrue(tpl.content.firstChild.ownerDocument === owner);
|
||||
}
|
||||
|
||||
{
|
||||
// Polymer's <template is="dom-repeat"> wrapping: an element created by
|
||||
// the template contents owner document is never upgraded, and neither
|
||||
// are the binding attributes copied onto it.
|
||||
calls = [];
|
||||
const tpl = document.createElement('template');
|
||||
tpl.innerHTML = '<template items="[[tabHeaders]]"></template>';
|
||||
const inner = tpl.content.firstChild;
|
||||
const wrapper = inner.ownerDocument.createElement('tpl-element');
|
||||
testing.expectTrue(wrapper.ownerDocument === inner.ownerDocument);
|
||||
testing.expectFalse(wrapper instanceof TplElement);
|
||||
testing.expectEqual('[]', JSON.stringify(calls));
|
||||
|
||||
inner.parentNode.replaceChild(wrapper, inner);
|
||||
wrapper.appendChild(inner);
|
||||
wrapper.setAttribute('items', inner.getAttribute('items'));
|
||||
inner.removeAttribute('items');
|
||||
testing.expectFalse(wrapper instanceof TplElement);
|
||||
testing.expectEqual('[]', JSON.stringify(calls));
|
||||
|
||||
// A fragment created by the inert document keeps moved content inert.
|
||||
const holder = tpl.content.ownerDocument.createDocumentFragment();
|
||||
holder.appendChild(tpl.content);
|
||||
testing.expectTrue(holder.ownerDocument === wrapper.ownerDocument);
|
||||
wrapper.setAttribute('items', '[[still]]');
|
||||
testing.expectEqual('[]', JSON.stringify(calls));
|
||||
|
||||
// Stamping still works and only sees the final attributes.
|
||||
wrapper.setAttribute('items', '["x"]');
|
||||
const stamped = document.importNode(holder, true);
|
||||
testing.expectTrue(stamped.ownerDocument === document);
|
||||
testing.expectTrue(stamped.firstChild instanceof TplElement);
|
||||
testing.expectEqual('["ctor","acc:items=[\\"x\\"]"]', JSON.stringify(calls));
|
||||
|
||||
// Moving the inert original into the document adopts and upgrades it.
|
||||
calls = [];
|
||||
document.body.appendChild(wrapper);
|
||||
testing.expectTrue(wrapper.ownerDocument === document);
|
||||
testing.expectTrue(wrapper instanceof TplElement);
|
||||
testing.expectEqual('["ctor","acc:items=[\\"x\\"]","connected"]', JSON.stringify(calls));
|
||||
}
|
||||
|
||||
{
|
||||
// Cloning template content keeps the clone inert until it is stamped.
|
||||
calls = [];
|
||||
const tpl = document.createElement('template');
|
||||
tpl.innerHTML = '<tpl-element items="[[clone]]"></tpl-element>';
|
||||
const clone = tpl.content.cloneNode(true);
|
||||
testing.expectEqual('[]', JSON.stringify(calls));
|
||||
testing.expectFalse(clone.firstChild instanceof TplElement);
|
||||
}
|
||||
|
||||
{
|
||||
// Defining the element after the template exists does not reach into
|
||||
// template content.
|
||||
calls = [];
|
||||
const tpl = document.createElement('template');
|
||||
tpl.innerHTML = '<late-tpl-element items="[[late]]"></late-tpl-element>';
|
||||
|
||||
class LateTplElement extends HTMLElement {
|
||||
static get observedAttributes() {
|
||||
return ['items'];
|
||||
}
|
||||
|
||||
attributeChangedCallback(name, oldValue, newValue) {
|
||||
calls.push(`late:${name}=${newValue}`);
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('late-tpl-element', LateTplElement);
|
||||
testing.expectFalse(tpl.content.firstChild instanceof LateTplElement);
|
||||
testing.expectEqual('[]', JSON.stringify(calls));
|
||||
|
||||
// Nor does an explicit upgrade.
|
||||
customElements.upgrade(tpl.content);
|
||||
testing.expectFalse(tpl.content.firstChild instanceof LateTplElement);
|
||||
testing.expectEqual('[]', JSON.stringify(calls));
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
let adopted = 0;
|
||||
|
||||
class AdoptElement extends HTMLElement {
|
||||
adoptedCallback() {
|
||||
adopted += 1;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('adopt-element', AdoptElement);
|
||||
</script>
|
||||
|
||||
<div id="adoption"><a><b><div><adopt-element></adopt-element><template>t</template></a></div></b></div>
|
||||
|
||||
<script id="adoption_agency">
|
||||
{
|
||||
// The parser creates an element in the document of the last insertion
|
||||
// point, here the <template>'s content (hence the "t"). </a> then runs
|
||||
// the adoption agency algorithm, which creates a clone of <b> (in that
|
||||
// wrong document) and moves the <div>, already in this document, into
|
||||
// it. The clone must join the <div>'s document, not the other way
|
||||
// around, so nothing that was placed gets adopted.
|
||||
const root = document.getElementById('adoption');
|
||||
const shape = (el) => el.tagName + (el.children.length ? `(${[...el.children].map(shape).join(',')})` : '');
|
||||
testing.expectEqual('DIV(A(B),B(DIV(A(ADOPT-ELEMENT,TEMPLATE))))', shape(root));
|
||||
testing.expectEqual(0, adopted);
|
||||
for (const el of root.querySelectorAll('*')) {
|
||||
testing.expectTrue(el.ownerDocument === document);
|
||||
}
|
||||
testing.expectTrue(root.querySelector('adopt-element') instanceof AdoptElement);
|
||||
|
||||
const tpl = root.querySelector('template');
|
||||
testing.expectTrue(tpl.content.ownerDocument !== document);
|
||||
testing.expectEqual('t', tpl.content.textContent);
|
||||
}
|
||||
</script>
|
||||
@@ -521,6 +521,11 @@
|
||||
'<a>�</a>',
|
||||
'<a><?1x?></a>', // invalid processing instruction target
|
||||
'<?1x?><a/>',
|
||||
'<?xml version="2.0"?><a/>', // only 1.x versions are accepted
|
||||
'<?xml version="10.0"?><a/>',
|
||||
'<?xml version="1."?><a/>',
|
||||
'<?xml version=1.0?><a/>',
|
||||
'<?xml encoding="UTF-8"?><a/>', // version is required
|
||||
]) {
|
||||
testing.expectEqual(bad + ' -> error', bad + (isError(p.parseFromString(bad, 'text/xml')) ? ' -> error' : ' -> ok'));
|
||||
}
|
||||
@@ -529,6 +534,8 @@
|
||||
for (const good of [
|
||||
'<a/>',
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n<a/>\n',
|
||||
"<?xml version = '1.1075' ?><a/>",
|
||||
'<?xml version="1.000" standalone="yes"?><a/>',
|
||||
'\uFEFF<a/>',
|
||||
'<a><![CDATA[<x>]]><!-- c --><?pi d?></a><!-- trailing -->',
|
||||
'<a b="x"y'<©"/>',
|
||||
@@ -548,6 +555,12 @@
|
||||
testing.expectEqual('svg', svg.doctype.name);
|
||||
testing.expectEqual('-//W3C//DTD SVG 1.1//EN', svg.doctype.publicId);
|
||||
testing.expectEqual('svg', svg.documentElement.localName);
|
||||
|
||||
// the XML declaration creates no node, a leading processing instruction does
|
||||
const decl = p.parseFromString('<?xml version="1.0"?><?xml-stylesheet href="a.css"?><a/>', 'text/xml');
|
||||
testing.expectEqual(2, decl.childNodes.length);
|
||||
testing.expectEqual('xml-stylesheet', decl.firstChild.target);
|
||||
testing.expectEqual('xml-stylesheet', p.parseFromString('<?xml-stylesheet href="a.css"?><a/>', 'text/xml').firstChild.target);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -179,3 +179,23 @@
|
||||
img.src = 'data:image/gif;base64,R0lGODlhAQABAAAAACw=';
|
||||
testing.expectEqual(undefined, await img.decode());
|
||||
</script>
|
||||
|
||||
<script id="legacy_factory_function">
|
||||
// https://webidl.spec.whatwg.org/#legacy-factory-functions
|
||||
testing.expectEqual(true, Image !== HTMLImageElement)
|
||||
testing.expectEqual('Image', Image.name)
|
||||
testing.expectEqual(0, Image.length)
|
||||
testing.expectEqual(HTMLImageElement.prototype, Image.prototype)
|
||||
testing.expectEqual(HTMLImageElement, new Image().constructor)
|
||||
testing.expectEqual('img', new Image().localName)
|
||||
testing.expectError('TypeError', () => new HTMLImageElement())
|
||||
testing.expectError('TypeError', () => Image())
|
||||
|
||||
const desc = Object.getOwnPropertyDescriptor(Image, 'prototype')
|
||||
testing.expectEqual(false, desc.writable)
|
||||
testing.expectEqual(false, desc.enumerable)
|
||||
testing.expectEqual(false, desc.configurable)
|
||||
|
||||
testing.expectEqual(false, Object.keys(window).includes('Image'))
|
||||
testing.expectEqual(false, Object.keys(window).includes('HTMLImageElement'))
|
||||
</script>
|
||||
@@ -308,3 +308,23 @@
|
||||
testing.expectEqual(true, video.paused);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="legacy_factory_function">
|
||||
// https://webidl.spec.whatwg.org/#legacy-factory-functions
|
||||
testing.expectEqual(true, Audio !== HTMLAudioElement)
|
||||
testing.expectEqual('Audio', Audio.name)
|
||||
testing.expectEqual(0, Audio.length)
|
||||
testing.expectEqual(HTMLAudioElement.prototype, Audio.prototype)
|
||||
testing.expectEqual(HTMLAudioElement, new Audio().constructor)
|
||||
testing.expectEqual('audio', new Audio().localName)
|
||||
testing.expectError('TypeError', () => new HTMLAudioElement())
|
||||
testing.expectError('TypeError', () => Audio())
|
||||
|
||||
const desc = Object.getOwnPropertyDescriptor(Audio, 'prototype')
|
||||
testing.expectEqual(false, desc.writable)
|
||||
testing.expectEqual(false, desc.enumerable)
|
||||
testing.expectEqual(false, desc.configurable)
|
||||
|
||||
testing.expectEqual(false, Object.keys(window).includes('Audio'))
|
||||
testing.expectEqual(false, Object.keys(window).includes('HTMLAudioElement'))
|
||||
</script>
|
||||
@@ -191,3 +191,89 @@
|
||||
$('#opt4').disabled = false
|
||||
testing.expectEqual(false, $('#opt4').disabled)
|
||||
</script>
|
||||
|
||||
<script id="constructor">
|
||||
{
|
||||
const opt = new Option()
|
||||
testing.expectEqual('OPTION', opt.tagName)
|
||||
testing.expectEqual(true, opt instanceof HTMLOptionElement)
|
||||
testing.expectEqual(0, opt.childNodes.length)
|
||||
testing.expectEqual(false, opt.hasAttribute('value'))
|
||||
testing.expectEqual(false, opt.defaultSelected)
|
||||
testing.expectEqual(false, opt.selected)
|
||||
}
|
||||
|
||||
{
|
||||
const opt = new Option('Label', 'v1')
|
||||
testing.expectEqual('Label', opt.text)
|
||||
testing.expectEqual(1, opt.childNodes.length)
|
||||
testing.expectEqual('v1', opt.getAttribute('value'))
|
||||
testing.expectEqual('v1', opt.value)
|
||||
}
|
||||
|
||||
{
|
||||
// An empty text appends no Text node; null is stringified.
|
||||
testing.expectEqual(0, new Option('').childNodes.length)
|
||||
testing.expectEqual('null', new Option(null).text)
|
||||
testing.expectEqual('', new Option('x', '').value)
|
||||
|
||||
const undef = new Option(undefined, undefined)
|
||||
testing.expectEqual(0, undef.childNodes.length)
|
||||
testing.expectEqual(false, undef.hasAttribute('value'))
|
||||
|
||||
const falsy = new Option(false, false)
|
||||
testing.expectEqual('false', falsy.text)
|
||||
testing.expectEqual('false', falsy.getAttribute('value'))
|
||||
}
|
||||
|
||||
{
|
||||
// WebIDL boolean conversion
|
||||
const falsy = new Option('text', 'value', 0, '')
|
||||
testing.expectEqual(false, falsy.hasAttribute('selected'))
|
||||
testing.expectEqual(false, falsy.selected)
|
||||
|
||||
const truthy = new Option('text', 'value', {}, 1)
|
||||
testing.expectEqual(true, truthy.hasAttribute('selected'))
|
||||
testing.expectEqual(true, truthy.selected)
|
||||
}
|
||||
|
||||
{
|
||||
// defaultSelected sets the attribute, selectedness comes from `selected` only.
|
||||
const opt = new Option('a', 'b', true)
|
||||
testing.expectEqual(true, opt.defaultSelected)
|
||||
testing.expectEqual('', opt.getAttribute('selected'))
|
||||
testing.expectEqual(false, opt.selected)
|
||||
|
||||
const opt2 = new Option('a', 'b', false, true)
|
||||
testing.expectEqual(false, opt2.defaultSelected)
|
||||
testing.expectEqual(true, opt2.selected)
|
||||
}
|
||||
|
||||
{
|
||||
const select = document.createElement('select')
|
||||
select.add(new Option('One', '1'))
|
||||
select.add(new Option('Two', '2', false, true))
|
||||
testing.expectEqual('2', select.value)
|
||||
testing.expectEqual(1, select.selectedIndex)
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="legacy_factory_function">
|
||||
// https://webidl.spec.whatwg.org/#legacy-factory-functions
|
||||
testing.expectEqual(true, Option !== HTMLOptionElement)
|
||||
testing.expectEqual('Option', Option.name)
|
||||
testing.expectEqual(0, Option.length)
|
||||
testing.expectEqual(HTMLOptionElement.prototype, Option.prototype)
|
||||
testing.expectEqual(HTMLOptionElement, new Option().constructor)
|
||||
testing.expectEqual('option', new Option().localName)
|
||||
testing.expectError('TypeError', () => new HTMLOptionElement())
|
||||
testing.expectError('TypeError', () => Option())
|
||||
|
||||
const desc = Object.getOwnPropertyDescriptor(Option, 'prototype')
|
||||
testing.expectEqual(false, desc.writable)
|
||||
testing.expectEqual(false, desc.enumerable)
|
||||
testing.expectEqual(false, desc.configurable)
|
||||
|
||||
testing.expectEqual(false, Object.keys(window).includes('Option'))
|
||||
testing.expectEqual(false, Object.keys(window).includes('HTMLOptionElement'))
|
||||
</script>
|
||||
@@ -275,6 +275,63 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="options_indexed_setter">
|
||||
{
|
||||
const sel = document.createElement('select')
|
||||
const opts = sel.options
|
||||
const make = (v) => { const o = document.createElement('option'); o.value = v; return o }
|
||||
|
||||
// past the end pads with blank options, then appends
|
||||
opts[2] = make('c')
|
||||
testing.expectEqual(3, opts.length)
|
||||
testing.expectEqual(['', '', 'c'], Array.from(opts, (o) => o.value))
|
||||
|
||||
// in range replaces in place, including inside an optgroup (Firefox;
|
||||
// Chrome removes it and add()s the new one before the next option)
|
||||
opts[0] = make('a')
|
||||
testing.expectEqual(['a', '', 'c'], Array.from(opts, (o) => o.value))
|
||||
const group = document.createElement('optgroup')
|
||||
group.appendChild(opts[1])
|
||||
sel.appendChild(group)
|
||||
opts[2] = make('b')
|
||||
testing.expectEqual(group, opts[2].parentNode)
|
||||
testing.expectEqual(['a', 'c', 'b'], Array.from(opts, (o) => o.value))
|
||||
|
||||
// null removes; null past the end is a no-op
|
||||
opts[1] = null
|
||||
testing.expectEqual(['a', 'b'], Array.from(opts, (o) => o.value))
|
||||
opts[10] = null
|
||||
testing.expectEqual(2, opts.length)
|
||||
|
||||
// only options (or null) are accepted
|
||||
testing.expectError('TypeError', () => { opts[0] = document.createElement('div') })
|
||||
testing.expectEqual(2, opts.length)
|
||||
|
||||
const desc = Object.getOwnPropertyDescriptor(opts, 0)
|
||||
testing.expectEqual(true, desc.writable)
|
||||
testing.expectEqual(true, desc.enumerable)
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="options_indexed_setter_padding">
|
||||
{
|
||||
const sel = document.createElement('select')
|
||||
const make = (v) => { const o = document.createElement('option'); o.value = v; return o }
|
||||
const records = []
|
||||
new MutationObserver((r) => records.push(...r)).observe(sel, { childList: true })
|
||||
|
||||
// The spec pads with one DocumentFragment (we do, so 2 records); Chrome
|
||||
// appends each blank option on its own. Only what was added is common.
|
||||
sel.options[3] = make('d')
|
||||
testing.expectEqual(4, sel.options.length)
|
||||
|
||||
testing.onload(() => {
|
||||
const added = records.flatMap((r) => Array.from(r.addedNodes, (n) => n.value))
|
||||
testing.expectEqual(['', '', '', 'd'], added)
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="select_add">
|
||||
{
|
||||
const sel = document.createElement('select')
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<!DOCTYPE html>
|
||||
<script src="../testing.js"></script>
|
||||
<body>
|
||||
<button id="outside">outside</button>
|
||||
<div id="container" inert>
|
||||
<input id="inner" type="text">
|
||||
<span id="text">hidden</span>
|
||||
</div>
|
||||
<div id="selectable">
|
||||
<p>shown</p>
|
||||
<span inert>hidden</span>
|
||||
<math inert><mi>math</mi></math>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
<script id="inert_property">
|
||||
{
|
||||
testing.expectEqual(false, $('#outside').inert);
|
||||
testing.expectEqual(true, $('#container').inert);
|
||||
// inherited inertness isn't reflected
|
||||
testing.expectEqual(false, $('#inner').inert);
|
||||
|
||||
const outside = $('#outside');
|
||||
outside.inert = true;
|
||||
testing.expectEqual('', outside.getAttribute('inert'));
|
||||
outside.inert = false;
|
||||
testing.expectEqual(false, outside.hasAttribute('inert'));
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="inert_unfocusable">
|
||||
{
|
||||
const outside = $('#outside');
|
||||
outside.focus();
|
||||
testing.expectEqual(outside, document.activeElement);
|
||||
|
||||
$('#inner').focus();
|
||||
testing.expectEqual(outside, document.activeElement);
|
||||
|
||||
$('#container').inert = false;
|
||||
$('#inner').focus();
|
||||
testing.expectEqual($('#inner'), document.activeElement);
|
||||
$('#container').inert = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="inert_selection">
|
||||
{
|
||||
const selection = getSelection();
|
||||
selection.selectAllChildren($('#selectable'));
|
||||
// inert only applies to HTML elements, so the MathML text stays
|
||||
testing.expectEqual('shown math', selection.toString().trim().replace(/\s+/g, ' '));
|
||||
selection.removeAllRanges();
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="inert_focus_fixup" type=module>
|
||||
const state = await testing.async();
|
||||
const nextRendering = () => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
|
||||
const outside = $('#outside');
|
||||
outside.focus();
|
||||
const events = [];
|
||||
outside.addEventListener('blur', () => events.push('blur'));
|
||||
outside.addEventListener('focusout', () => events.push('focusout'));
|
||||
outside.inert = true;
|
||||
// like browsers, the fixup waits for the next rendering update
|
||||
const syncActive = document.activeElement;
|
||||
await nextRendering();
|
||||
const fixedActive = document.activeElement;
|
||||
outside.inert = false;
|
||||
|
||||
const host = document.createElement('div');
|
||||
host.innerHTML = '<button id="slotted" slot="s">slotted</button>';
|
||||
document.body.appendChild(host);
|
||||
const shadow = host.attachShadow({ mode: 'open' });
|
||||
const slot = document.createElement('slot');
|
||||
slot.name = 's';
|
||||
slot.inert = true;
|
||||
shadow.appendChild(slot);
|
||||
const shadowButton = document.createElement('button');
|
||||
shadow.appendChild(shadowButton);
|
||||
|
||||
$('#slotted').focus();
|
||||
const slottedActive = document.activeElement;
|
||||
shadowButton.focus();
|
||||
const shadowActive = document.activeElement;
|
||||
host.inert = true;
|
||||
await nextRendering();
|
||||
const hostFixedActive = document.activeElement;
|
||||
host.remove();
|
||||
|
||||
state.resolve();
|
||||
await state.done(() => {
|
||||
testing.expectEqual(outside, syncActive);
|
||||
testing.expectEqual(document.body, fixedActive);
|
||||
testing.expectEqual(['blur', 'focusout'], events);
|
||||
testing.expectEqual(document.body, slottedActive);
|
||||
testing.expectEqual(host, shadowActive);
|
||||
testing.expectEqual(document.body, hostFixedActive);
|
||||
});
|
||||
</script>
|
||||
@@ -600,3 +600,67 @@
|
||||
testing.expectEqual(targetY, window.scrollY);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div id="clampBox" style="width: 100px; height: 100px; overflow: scroll;">
|
||||
<div style="width: 500px; height: 500px;">content</div>
|
||||
</div>
|
||||
|
||||
<script id="scrollClampsToExtent">
|
||||
{
|
||||
// The limit is scrollHeight - clientHeight, whatever those measure: a real
|
||||
// browser reserves scrollbar space in clientHeight and lands a few px lower.
|
||||
const box = $('#clampBox');
|
||||
const maxTop = box.scrollHeight - box.clientHeight;
|
||||
const maxLeft = box.scrollWidth - box.clientWidth;
|
||||
testing.expectTrue(maxTop > 0);
|
||||
|
||||
box.scrollTop = 9999;
|
||||
box.scrollLeft = 9999;
|
||||
testing.expectEqual(maxTop, box.scrollTop);
|
||||
testing.expectEqual(maxLeft, box.scrollLeft);
|
||||
|
||||
box.scrollTo(10, 10);
|
||||
box.scrollBy(9999, 9999);
|
||||
testing.expectEqual(maxTop, box.scrollTop);
|
||||
testing.expectEqual(maxLeft, box.scrollLeft);
|
||||
|
||||
box.scrollBy(-9999, -9999);
|
||||
testing.expectEqual(0, box.scrollTop);
|
||||
testing.expectEqual(0, box.scrollLeft);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.sheetBox { height: 100px; overflow: auto; }
|
||||
#sheetAuto { height: auto; }
|
||||
.sheetVh { height: 10vh; }
|
||||
</style>
|
||||
<div id="sheetBox" class="sheetBox"><div style="height: 500px;">content</div></div>
|
||||
<div id="sheetAuto" class="sheetBox"><div style="height: 500px;">content</div></div>
|
||||
<div id="inlineAuto" class="sheetBox" style="height: 50%"><div style="height: 500px;">content</div></div>
|
||||
<div id="sheetVh" class="sheetVh"></div>
|
||||
|
||||
<script id="sheetSizedBox">
|
||||
{
|
||||
// A sheet size is as explicit as an inline one
|
||||
const box = $('#sheetBox');
|
||||
testing.expectEqual(100, box.clientHeight);
|
||||
testing.expectEqual(500, box.scrollHeight);
|
||||
testing.expectEqual('100px', getComputedStyle(box).height);
|
||||
|
||||
box.scrollTop = 9999;
|
||||
testing.expectEqual(400, box.scrollTop);
|
||||
|
||||
// A stronger rule's `auto` wins, leaving no size to clamp to. Chrome
|
||||
// clamps to 0; we can't tell auto from max-height, so we don't clamp.
|
||||
const auto = $('#sheetAuto');
|
||||
testing.expectEqual(500, auto.clientHeight);
|
||||
auto.scrollTop = 9999;
|
||||
testing.expectEqual(9999, auto.scrollTop);
|
||||
|
||||
// So does an unresolvable inline value
|
||||
testing.expectEqual(500, $('#inlineAuto').clientHeight);
|
||||
|
||||
testing.expectEqual(window.innerHeight / 10, $('#sheetVh').clientHeight);
|
||||
}
|
||||
</script>
|
||||
@@ -113,6 +113,25 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="overscrollBehaviorShorthand">
|
||||
{
|
||||
const div = document.createElement('div');
|
||||
div.style.cssText = 'overscroll-behavior: contain auto';
|
||||
testing.expectEqual('contain', div.style.overscrollBehaviorX);
|
||||
testing.expectEqual('auto', div.style.overscrollBehaviorY);
|
||||
testing.expectEqual('contain auto', div.style.overscrollBehavior);
|
||||
testing.expectEqual(2, div.style.length);
|
||||
|
||||
div.style.overscrollBehaviorY = 'contain';
|
||||
testing.expectEqual('contain', div.style.overscrollBehavior);
|
||||
testing.expectEqual('overscroll-behavior: contain;', div.style.cssText);
|
||||
|
||||
div.style.setProperty('overscroll-behavior', 'none');
|
||||
testing.expectEqual('none', div.style.overscrollBehaviorX);
|
||||
testing.expectEqual('none', div.style.overscrollBehaviorY);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="overflowShorthand">
|
||||
{
|
||||
const div = document.createElement('div');
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<!DOCTYPE html>
|
||||
<!--
|
||||
Driven by the "a failed preload is consumed, not refetched" test in
|
||||
ScriptManager.zig, which blocks these URLs while the hints fetch, then
|
||||
unblocks them and inserts the matching <script> elements.
|
||||
-->
|
||||
<link rel="preload" as="script" href="preload_failed.js" onerror="window.classic_hint_error = true">
|
||||
<link rel="modulepreload" href="preload_failed_module.js" onerror="window.module_hint_error = true">
|
||||
@@ -0,0 +1 @@
|
||||
window.failed_classic_ran = true;
|
||||
@@ -0,0 +1 @@
|
||||
window.failed_module_ran = true;
|
||||
@@ -36,7 +36,7 @@
|
||||
testing.expectEqual(200, req.status);
|
||||
testing.expectEqual('OK', req.statusText);
|
||||
testing.expectEqual('text/html; charset=utf-8', req.getResponseHeader('Content-Type'));
|
||||
testing.expectEqual('content-length: 100\r\nContent-Type: text/html; charset=utf-8\r\n', req.getAllResponseHeaders());
|
||||
testing.expectEqual('content-length: 100\r\ncontent-type: text/html; charset=utf-8\r\n', req.getAllResponseHeaders());
|
||||
testing.expectEqual(100, req.responseText.length);
|
||||
testing.expectEqual(req.responseText.length, req.response.length);
|
||||
testing.expectEqual('http://127.0.0.1:9582/xhr', req.responseURL);
|
||||
|
||||
@@ -0,0 +1,603 @@
|
||||
<!DOCTYPE html>
|
||||
<script src="testing.js"></script>
|
||||
<body></body>
|
||||
|
||||
<script id=constructor>
|
||||
{
|
||||
testing.expectEqual('function', typeof Sanitizer);
|
||||
testing.expectEqual(true, new Sanitizer() instanceof Sanitizer);
|
||||
testing.expectEqual(true, new Sanitizer({}) instanceof Sanitizer);
|
||||
testing.expectEqual(true, new Sanitizer(null) instanceof Sanitizer);
|
||||
testing.expectEqual(true, new Sanitizer(undefined) instanceof Sanitizer);
|
||||
testing.expectEqual(true, new Sanitizer('default') instanceof Sanitizer);
|
||||
testing.expectEqual(true, new Sanitizer({unknown: [1, 2]}) instanceof Sanitizer);
|
||||
testing.expectError('TypeError', () => new Sanitizer('nope'));
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=emptyConfig>
|
||||
{
|
||||
// Neither half of a pair given: canonicalization fills in the empty
|
||||
// remove-lists, which is what "allow everything" looks like.
|
||||
testing.expectEqual({
|
||||
removeElements: [],
|
||||
removeAttributes: [],
|
||||
removeProcessingInstructions: [],
|
||||
comments: true,
|
||||
javascriptURLs: true,
|
||||
}, new Sanitizer({}).get());
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=canonicalization>
|
||||
{
|
||||
const get = (config) => new Sanitizer(config).get();
|
||||
|
||||
testing.expectEqual([{name: 'div', namespace: 'http://www.w3.org/1999/xhtml', removeAttributes: []}],
|
||||
get({elements: ['div']}).elements);
|
||||
testing.expectEqual([{name: 'b', namespace: 'http://www.w3.org/1999/xhtml'}],
|
||||
get({removeElements: [{name: 'b'}]}).removeElements);
|
||||
testing.expectEqual([{name: 'b', namespace: null}],
|
||||
get({removeElements: [{name: 'b', namespace: null}]}).removeElements);
|
||||
// An empty namespace is the null namespace.
|
||||
testing.expectEqual([{name: 'b', namespace: null}],
|
||||
get({removeElements: [{name: 'b', namespace: ''}]}).removeElements);
|
||||
|
||||
// Attributes default to the null namespace, elements to XHTML.
|
||||
testing.expectEqual([{name: 'href', namespace: null}], get({attributes: ['href']}).attributes);
|
||||
testing.expectEqual([{target: 'xml-stylesheet'}],
|
||||
get({processingInstructions: ['xml-stylesheet']}).processingInstructions);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=sorting>
|
||||
{
|
||||
// Namespace first, with null below every URI; then local name.
|
||||
const config = new Sanitizer({attributes: [
|
||||
{name: 'b', namespace: 'http://example.org/'},
|
||||
{name: 'z'},
|
||||
{name: 'a', namespace: 'http://example.org/'},
|
||||
{name: 'a'},
|
||||
]}).get();
|
||||
|
||||
testing.expectEqual([
|
||||
{name: 'a', namespace: null},
|
||||
{name: 'z', namespace: null},
|
||||
{name: 'a', namespace: 'http://example.org/'},
|
||||
{name: 'b', namespace: 'http://example.org/'},
|
||||
], config.attributes);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=booleanDefaults>
|
||||
{
|
||||
testing.expectEqual(false, new Sanitizer().get().comments);
|
||||
testing.expectEqual(true, new Sanitizer({}).get().comments);
|
||||
testing.expectEqual(false, new Sanitizer({comments: false}).get().comments);
|
||||
|
||||
testing.expectEqual(false, new Sanitizer().get().dataAttributes);
|
||||
testing.expectEqual(true, new Sanitizer({attributes: []}).get().dataAttributes);
|
||||
// dataAttributes only exists alongside an attribute allow-list.
|
||||
testing.expectEqual(false, 'dataAttributes' in new Sanitizer({}).get());
|
||||
testing.expectEqual(false, 'dataAttributes' in new Sanitizer({removeAttributes: []}).get());
|
||||
|
||||
// Web IDL: an explicit null is ToBoolean(null) = false, while an explicit
|
||||
// undefined leaves the member absent and so takes the default.
|
||||
testing.expectEqual(false, new Sanitizer({comments: null}).get().comments);
|
||||
testing.expectEqual(true, new Sanitizer({comments: 'abc'}).get().comments);
|
||||
|
||||
// The setters report whether they changed anything.
|
||||
const s = new Sanitizer();
|
||||
testing.expectEqual(true, s.setComments(true));
|
||||
testing.expectEqual(false, s.setComments(true));
|
||||
testing.expectEqual(true, s.get().comments);
|
||||
|
||||
testing.expectEqual(true, s.setDataAttributes(true));
|
||||
testing.expectEqual(false, s.setDataAttributes(true));
|
||||
testing.expectEqual(true, s.get().dataAttributes);
|
||||
|
||||
// Without an attribute allow-list there is no dataAttributes to set.
|
||||
testing.expectEqual(false, new Sanitizer({removeAttributes: []}).setDataAttributes(true));
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=javascriptUrls>
|
||||
{
|
||||
testing.expectEqual(false, new Sanitizer().get().javascriptURLs);
|
||||
testing.expectEqual(true, new Sanitizer({}).get().javascriptURLs);
|
||||
testing.expectEqual(false, new Sanitizer({javascriptURLs: false}).get().javascriptURLs);
|
||||
testing.expectEqual(false, new Sanitizer({javascriptURLs: null}).get().javascriptURLs);
|
||||
testing.expectEqual(true, new Sanitizer({javascriptURLs: undefined}).get().javascriptURLs);
|
||||
|
||||
const s = new Sanitizer();
|
||||
testing.expectEqual(true, s.setJavascriptURLs(true));
|
||||
testing.expectEqual(false, s.setJavascriptURLs(true));
|
||||
testing.expectEqual(true, s.get().javascriptURLs);
|
||||
// removeUnsafe turns them back off, and reports that it changed something.
|
||||
testing.expectEqual(true, s.removeUnsafe());
|
||||
testing.expectEqual(false, s.get().javascriptURLs);
|
||||
}
|
||||
|
||||
{
|
||||
// Allowing every data attribute subsumes any named individually, and the
|
||||
// config has to stay valid.
|
||||
const s = new Sanitizer({attributes: ['data-x', 'id'], elements: [{name: 'div', attributes: ['data-y']}]});
|
||||
testing.expectEqual(true, s.setDataAttributes(true));
|
||||
testing.expectEqual([{name: 'id', namespace: null}], s.get().attributes);
|
||||
testing.expectEqual([], s.get().elements[0].attributes);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=invalidConfigs>
|
||||
{
|
||||
// Each pair is mutually exclusive.
|
||||
testing.expectError('TypeError', () => new Sanitizer({elements: [], removeElements: []}));
|
||||
testing.expectError('TypeError', () => new Sanitizer({attributes: [], removeAttributes: []}));
|
||||
|
||||
// No duplicates, whichever spelling they arrive in.
|
||||
testing.expectError('TypeError', () => new Sanitizer({elements: ['abc', 'abc']}));
|
||||
testing.expectError('TypeError', () => new Sanitizer({elements: ['abc', {name: 'abc'}]}));
|
||||
testing.expectError('TypeError', () => new Sanitizer({attributes: ['abc', {name: 'abc', namespace: null}]}));
|
||||
|
||||
// <html>, <svg> and <math> can never be replaced with their children.
|
||||
testing.expectError('TypeError', () => new Sanitizer({replaceWithChildrenElements: ['html']}));
|
||||
testing.expectError('TypeError', () => new Sanitizer({
|
||||
replaceWithChildrenElements: [{name: 'svg', namespace: 'http://www.w3.org/2000/svg'}],
|
||||
}));
|
||||
// ... but in another namespace it is just an element.
|
||||
testing.expectEqual(1, new Sanitizer({replaceWithChildrenElements: ['svg']}).get().replaceWithChildrenElements.length);
|
||||
|
||||
testing.expectError('TypeError', () => new Sanitizer({elements: ['p'], replaceWithChildrenElements: ['p']}));
|
||||
testing.expectError('TypeError', () => new Sanitizer({removeElements: ['p'], replaceWithChildrenElements: ['p']}));
|
||||
|
||||
// A per-element list may not restate a global one.
|
||||
testing.expectError('TypeError', () => new Sanitizer({
|
||||
attributes: ['id'],
|
||||
elements: [{name: 'div', attributes: ['id']}],
|
||||
}));
|
||||
// A per-element removeAttributes must be a subset of the global allow-list.
|
||||
testing.expectError('TypeError', () => new Sanitizer({
|
||||
attributes: ['class'],
|
||||
elements: [{name: 'div', removeAttributes: ['title']}],
|
||||
}));
|
||||
testing.expectError('TypeError', () => new Sanitizer({
|
||||
attributes: ['data-bar'],
|
||||
dataAttributes: true,
|
||||
}));
|
||||
testing.expectError('TypeError', () => new Sanitizer({removeAttributes: [], dataAttributes: false}));
|
||||
testing.expectError('TypeError', () => new Sanitizer({
|
||||
removeAttributes: [],
|
||||
elements: [{name: 'div', attributes: [], removeAttributes: []}],
|
||||
}));
|
||||
|
||||
testing.expectError('TypeError', () => new Sanitizer({processingInstructions: [], removeProcessingInstructions: []}));
|
||||
testing.expectError('TypeError', () => new Sanitizer({removeProcessingInstructions: ['x', {target: 'x'}]}));
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=perElementAttributes>
|
||||
{
|
||||
{
|
||||
const s = new Sanitizer({elements: [{name: 'div', attributes: ['href', 'src']}]});
|
||||
testing.expectEqual(true, 'attributes' in s.get().elements[0]);
|
||||
testing.expectEqual(false, 'removeAttributes' in s.get().elements[0]);
|
||||
testing.expectEqual(2, s.get().elements[0].attributes.length);
|
||||
|
||||
// allowElement overwrites the per-element list rather than merging.
|
||||
s.allowElement({name: 'div', namespace: 'http://www.w3.org/1999/xhtml', attributes: ['class']});
|
||||
testing.expectEqual([{name: 'class', namespace: null}], s.get().elements[0].attributes);
|
||||
}
|
||||
|
||||
{
|
||||
// An element with neither list still reports an empty remove-list.
|
||||
const s = new Sanitizer({elements: ['div']});
|
||||
testing.expectEqual([], s.get().elements[0].removeAttributes);
|
||||
testing.expectEqual(false, 'attributes' in s.get().elements[0]);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=elementModifiers>
|
||||
{
|
||||
const s = new Sanitizer({elements: ['div', 'p']});
|
||||
testing.expectEqual(2, s.get().elements.length);
|
||||
testing.expectEqual(true, s.allowElement('bla'));
|
||||
testing.expectEqual(false, s.allowElement('bla'));
|
||||
testing.expectEqual(3, s.get().elements.length);
|
||||
testing.expectEqual(true, s.removeElement({name: 'div'}));
|
||||
testing.expectEqual(2, s.get().elements.length);
|
||||
testing.expectEqual(true, s.replaceElementWithChildren({name: 'p', namespace: 'http://www.w3.org/1999/xhtml'}));
|
||||
testing.expectEqual([{name: 'bla', namespace: 'http://www.w3.org/1999/xhtml', removeAttributes: []}],
|
||||
s.get().elements);
|
||||
testing.expectEqual([{name: 'p', namespace: 'http://www.w3.org/1999/xhtml'}],
|
||||
s.get().replaceWithChildrenElements);
|
||||
testing.expectEqual(false, s.replaceElementWithChildren('html'));
|
||||
}
|
||||
|
||||
{
|
||||
// With a remove-list the same calls flip meaning.
|
||||
const s = new Sanitizer({removeElements: ['div', 'p']});
|
||||
testing.expectEqual(true, s.removeElement('bla'));
|
||||
testing.expectEqual(3, s.get().removeElements.length);
|
||||
testing.expectEqual(true, s.allowElement('p'));
|
||||
testing.expectEqual(2, s.get().removeElements.length);
|
||||
// A remove-list config has nowhere to put per-element attributes.
|
||||
testing.expectEqual(false, s.allowElement({name: 'div', attributes: ['id']}));
|
||||
}
|
||||
|
||||
{
|
||||
// An element with neither attribute list is the same as one with an empty
|
||||
// removeAttributes, so re-allowing it either way is not a change.
|
||||
const s = new Sanitizer({elements: ['div', {name: 'p', removeAttributes: []}]});
|
||||
testing.expectEqual(false, s.allowElement({name: 'div', removeAttributes: []}));
|
||||
testing.expectEqual(false, s.allowElement('p'));
|
||||
testing.expectEqual(true, s.allowElement('span'));
|
||||
testing.expectEqual(false, s.allowElement({name: 'span', removeAttributes: []}));
|
||||
testing.expectEqual(true, s.allowElement({name: 'span', attributes: []}));
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=attributeModifiers>
|
||||
{
|
||||
const s = new Sanitizer({attributes: ['href', 'src']});
|
||||
testing.expectEqual(true, s.allowAttribute('id'));
|
||||
testing.expectEqual(false, s.allowAttribute('id'));
|
||||
testing.expectEqual(3, s.get().attributes.length);
|
||||
// A different namespace is a different attribute.
|
||||
testing.expectEqual(false, s.removeAttribute({name: 'href', namespace: 'http://example.org/'}));
|
||||
testing.expectEqual(true, s.removeAttribute({name: 'href'}));
|
||||
testing.expectEqual(true, s.removeAttribute({name: 'src', namespace: null}));
|
||||
testing.expectEqual([{name: 'id', namespace: null}], s.get().attributes);
|
||||
}
|
||||
|
||||
{
|
||||
const s = new Sanitizer({removeAttributes: ['href', 'src']});
|
||||
testing.expectEqual(true, s.removeAttribute('id'));
|
||||
testing.expectEqual(3, s.get().removeAttributes.length);
|
||||
testing.expectEqual(false, s.allowAttribute({name: 'href', namespace: 'http://example.org/'}));
|
||||
testing.expectEqual(true, s.allowAttribute('href'));
|
||||
testing.expectEqual(true, s.allowAttribute({name: 'src', namespace: null}));
|
||||
testing.expectEqual([{name: 'id', namespace: null}], s.get().removeAttributes);
|
||||
}
|
||||
|
||||
{
|
||||
// A global allow subsumes the per-element lists.
|
||||
const s = new Sanitizer({
|
||||
attributes: ['id', 'title'],
|
||||
elements: [{name: 'div', attributes: ['class', 'dir'], removeAttributes: ['title']}],
|
||||
});
|
||||
testing.expectEqual(true, s.removeAttribute('dir'));
|
||||
testing.expectEqual([{name: 'class', namespace: null}], s.get().elements[0].attributes);
|
||||
testing.expectEqual(true, s.removeAttribute('title'));
|
||||
testing.expectEqual([{name: 'id', namespace: null}], s.get().attributes);
|
||||
testing.expectEqual([], s.get().elements[0].removeAttributes);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=processingInstructionModifiers>
|
||||
{
|
||||
const s = new Sanitizer({processingInstructions: ['target-1', 'target-2']});
|
||||
testing.expectEqual(true, s.allowProcessingInstruction('target-3'));
|
||||
testing.expectEqual(false, s.allowProcessingInstruction('target-3'));
|
||||
testing.expectEqual(false, s.removeProcessingInstruction({target: 'target-4'}));
|
||||
testing.expectEqual(true, s.removeProcessingInstruction({target: 'target-1'}));
|
||||
testing.expectEqual(true, s.removeProcessingInstruction({target: 'target-2'}));
|
||||
testing.expectEqual([{target: 'target-3'}], s.get().processingInstructions);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=removeUnsafe>
|
||||
{
|
||||
const s = new Sanitizer({});
|
||||
testing.expectEqual(true, s.removeUnsafe());
|
||||
testing.expectEqual(false, s.removeUnsafe());
|
||||
|
||||
const config = s.get();
|
||||
testing.expectEqual(false, 'elements' in config);
|
||||
testing.expectEqual(false, 'attributes' in config);
|
||||
testing.expectEqual([
|
||||
{name: 'base', namespace: 'http://www.w3.org/1999/xhtml'},
|
||||
{name: 'embed', namespace: 'http://www.w3.org/1999/xhtml'},
|
||||
{name: 'frame', namespace: 'http://www.w3.org/1999/xhtml'},
|
||||
{name: 'iframe', namespace: 'http://www.w3.org/1999/xhtml'},
|
||||
{name: 'object', namespace: 'http://www.w3.org/1999/xhtml'},
|
||||
{name: 'script', namespace: 'http://www.w3.org/1999/xhtml'},
|
||||
{name: 'script', namespace: 'http://www.w3.org/2000/svg'},
|
||||
{name: 'use', namespace: 'http://www.w3.org/2000/svg'},
|
||||
], config.removeElements);
|
||||
|
||||
const names = config.removeAttributes.map((a) => a.name);
|
||||
testing.expectEqual(true, names.length > 0);
|
||||
for (const attribute of config.removeAttributes) {
|
||||
testing.expectEqual(null, attribute.namespace);
|
||||
testing.expectEqual(true, attribute.name.startsWith('on'));
|
||||
}
|
||||
testing.expectEqual(true, names.every((n, i) => i === 0 || names[i - 1] < n));
|
||||
|
||||
testing.expectEqual(true, names.includes('onclick'));
|
||||
}
|
||||
|
||||
{
|
||||
// Nothing in the default config is unsafe, so removeUnsafe is a no-op on it.
|
||||
const before = new Sanitizer('default').get();
|
||||
const after = new Sanitizer('default');
|
||||
testing.expectEqual(false, after.removeUnsafe());
|
||||
|
||||
testing.expectEqual(true, before.elements.length > 0);
|
||||
testing.expectEqual(before.elements.length, after.get().elements.length);
|
||||
testing.expectEqual(before.attributes.length, after.get().attributes.length);
|
||||
testing.expectEqual(false, 'removeElements' in after.get());
|
||||
testing.expectEqual(false, 'removeAttributes' in after.get());
|
||||
}
|
||||
|
||||
{
|
||||
// lightpanda-specific: our event handler list is HTML's merged with the
|
||||
// handlers we compile ourselves, so neither side can go missing. Another
|
||||
// engine ships only its own set and is expected to fail this.
|
||||
const s = new Sanitizer({});
|
||||
s.removeUnsafe();
|
||||
const names = s.get().removeAttributes.map((a) => a.name);
|
||||
for (const name of ['onunload', 'onmouseenter', 'onbeforematch', 'onfullscreenchange']) {
|
||||
testing.expectEqual(true, names.includes(name), {script_id: 'removeUnsafe'});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=defaultConfig>
|
||||
{
|
||||
const config = new Sanitizer().get();
|
||||
|
||||
testing.expectEqual(121, config.elements.length);
|
||||
testing.expectEqual(58, config.attributes.length);
|
||||
testing.expectEqual(false, config.comments);
|
||||
testing.expectEqual(false, config.dataAttributes);
|
||||
|
||||
// Sorted by namespace URI, which puts MathML (1998) first and SVG (2000) last.
|
||||
testing.expectEqual({name: 'math', namespace: 'http://www.w3.org/1998/Math/MathML', attributes: []},
|
||||
config.elements[0]);
|
||||
testing.expectEqual('http://www.w3.org/2000/svg', config.elements[config.elements.length - 1].namespace);
|
||||
|
||||
// Nothing that runs script is in there.
|
||||
const names = config.elements.map((e) => e.name);
|
||||
for (const unsafe of ['script', 'iframe', 'object', 'embed', 'frame']) {
|
||||
testing.expectEqual(false, names.includes(unsafe));
|
||||
}
|
||||
testing.expectEqual(false, config.attributes.some((a) => a.name.startsWith('on')));
|
||||
|
||||
testing.expectEqual([], config.processingInstructions);
|
||||
testing.expectEqual(false, config.javascriptURLs);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=setHTML>
|
||||
{
|
||||
const div = document.createElement('div');
|
||||
div.setHTML('<p id=a onclick="x()" title=t data-x=1>hi<script>boom()<\/script><!-- c --></p><iframe></iframe>');
|
||||
testing.expectEqual('<p title="t">hi</p>', div.innerHTML);
|
||||
|
||||
// an explicit "default" is the same thing
|
||||
div.setHTML('<b onclick="x()">b</b>', {sanitizer: 'default'});
|
||||
testing.expectEqual('<b>b</b>', div.innerHTML);
|
||||
|
||||
testing.expectError('TypeError', () => div.setHTML('x', {sanitizer: 'nope'}));
|
||||
testing.expectError('TypeError', () => div.setHTML('x', {sanitizer: {elements: [], removeElements: []}}));
|
||||
|
||||
div.setHTML('');
|
||||
testing.expectEqual('', div.innerHTML);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=setHTMLConfig>
|
||||
{
|
||||
const div = document.createElement('div');
|
||||
|
||||
// A dictionary on a safe method defaults to not keeping comments or data-*.
|
||||
div.setHTML('<p data-x=1 id=i>a<!--c--></p>', {sanitizer: {attributes: ['id']}});
|
||||
testing.expectEqual('<p id="i">a</p>', div.innerHTML);
|
||||
|
||||
// ... but unsafe methods, like the constructor, default to keeping them.
|
||||
div.setHTMLUnsafe('<p data-x=1 id=i>a<!--c--></p>', {sanitizer: {attributes: ['id']}});
|
||||
testing.expectEqual('<p data-x="1" id="i">a<!--c--></p>', div.innerHTML);
|
||||
|
||||
div.setHTML('<div><p>a<b>b</b></p><i>i</i></div>', {sanitizer: {elements: ['div', 'p']}});
|
||||
testing.expectEqual('<div><p>a</p></div>', div.innerHTML);
|
||||
|
||||
div.setHTML('<div><p>a<b>b<i>i</i></b>c</p></div>', {sanitizer: {replaceWithChildrenElements: ['b', 'p']}});
|
||||
testing.expectEqual('<div>ab<i>i</i>c</div>', div.innerHTML);
|
||||
|
||||
div.setHTML('<div><span>s</span><em>e</em></div>', {sanitizer: {removeElements: ['span']}});
|
||||
testing.expectEqual('<div><em>e</em></div>', div.innerHTML);
|
||||
|
||||
// per-element attribute lists
|
||||
div.setHTML('<p id=1 title=2>a</p><em id=3 title=4>b</em>', {sanitizer: {
|
||||
elements: [{name: 'p', attributes: ['id']}, {name: 'em', removeAttributes: ['title']}],
|
||||
attributes: ['title'],
|
||||
}});
|
||||
testing.expectEqual('<p id="1" title="2">a</p><em>b</em>', div.innerHTML);
|
||||
|
||||
// an element's removeAttributes must be a subset of the global attributes
|
||||
testing.expectError('TypeError', () => div.setHTML('x', {sanitizer: {
|
||||
elements: [{name: 'em', removeAttributes: ['id']}],
|
||||
attributes: ['title'],
|
||||
}}));
|
||||
|
||||
// A safe method still removes unsafe markup the config allows.
|
||||
div.setHTML('<script>x()<\/script><b onclick=x()>b</b>', {sanitizer: {elements: ['script', 'b'], attributes: ['onclick']}});
|
||||
testing.expectEqual('<b>b</b>', div.innerHTML);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=setHTMLUnsafe>
|
||||
{
|
||||
const div = document.createElement('div');
|
||||
div.setHTMLUnsafe('<b onclick="x()">b</b><script>x()<\/script><!--c-->');
|
||||
testing.expectEqual('<b onclick="x()">b</b><script>x()<\/script><!--c-->', div.innerHTML);
|
||||
|
||||
// The sanitizer's own configuration is used as-is.
|
||||
const sanitizer = new Sanitizer({removeElements: ['i']});
|
||||
const before = sanitizer.get();
|
||||
div.setHTMLUnsafe('<b onclick="x()">b</b><i>i</i>', {sanitizer});
|
||||
testing.expectEqual('<b onclick="x()">b</b>', div.innerHTML);
|
||||
|
||||
// A safe method must not remove unsafe from the caller's Sanitizer.
|
||||
div.setHTML('<b onclick="x()">b</b><i>i</i>', {sanitizer});
|
||||
testing.expectEqual('<b>b</b>', div.innerHTML);
|
||||
testing.expectEqual(before, sanitizer.get());
|
||||
|
||||
// The default configuration on an unsafe method is still the default one
|
||||
div.setHTMLUnsafe('<b onclick="x()">b</b><script><\/script>', {sanitizer: 'default'});
|
||||
testing.expectEqual('<b>b</b>', div.innerHTML);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=textMerge>
|
||||
{
|
||||
// Text left adjacent by a removal is merged, as it would be had the node
|
||||
// never been parsed.
|
||||
const div = document.createElement('div');
|
||||
div.setHTML('a <!-- c --> b');
|
||||
testing.expectEqual(1, div.childNodes.length);
|
||||
testing.expectEqual('a b', div.firstChild.data);
|
||||
|
||||
div.setHTML('<p>x<script>s<\/script>y<!--c-->z<b>b</b></p>');
|
||||
testing.expectEqual(2, div.firstChild.childNodes.length);
|
||||
testing.expectEqual('xyz', div.firstChild.firstChild.data);
|
||||
|
||||
// replaced elements merge at both edges
|
||||
div.setHTML('<p>x<span>y<i>i</i>z</span>w</p>', {sanitizer: {replaceWithChildrenElements: ['span']}});
|
||||
const p = div.firstChild;
|
||||
testing.expectEqual(3, p.childNodes.length);
|
||||
testing.expectEqual('xy', p.childNodes[0].data);
|
||||
testing.expectEqual('zw', p.childNodes[2].data);
|
||||
|
||||
div.setHTML('<p>x<b><i>y</i></b>z</p>', {sanitizer: {replaceWithChildrenElements: ['b', 'i']}});
|
||||
testing.expectEqual(1, div.firstChild.childNodes.length);
|
||||
testing.expectEqual('xyz', div.firstChild.firstChild.data);
|
||||
|
||||
// nothing removed: nothing touched
|
||||
div.setHTMLUnsafe('<p>x<!--c-->y</p>');
|
||||
testing.expectEqual(3, div.firstChild.childNodes.length);
|
||||
|
||||
// in the other trees too
|
||||
const doc = Document.parseHTML('a<!--c-->b<template>c<!--c-->d</template>', {sanitizer: {elements: ['html', 'head', 'body', 'template']}});
|
||||
testing.expectEqual(2, doc.body.childNodes.length);
|
||||
testing.expectEqual('ab', doc.body.firstChild.data);
|
||||
testing.expectEqual(1, doc.body.lastChild.content.childNodes.length);
|
||||
testing.expectEqual('cd', doc.body.lastChild.content.firstChild.data);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=scriptContext>
|
||||
{
|
||||
const script = document.createElement('script');
|
||||
script.textContent = 'untouched';
|
||||
script.setHTML('<b>b</b>');
|
||||
testing.expectEqual('untouched', script.textContent);
|
||||
|
||||
script.setHTMLUnsafe('<b>b</b>');
|
||||
testing.expectEqual('<b>b</b>', script.innerHTML);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=javascriptURLs>
|
||||
{
|
||||
const div = document.createElement('div');
|
||||
|
||||
// safe: always removed, whatever the config says
|
||||
div.setHTML('<a href="javascript:x()">a</a><a href="https://x/">b</a>', {sanitizer: {javascriptURLs: true}});
|
||||
testing.expectEqual('<a>a</a><a href="https://x/">b</a>', div.innerHTML);
|
||||
|
||||
// unsafe: removed only when the config says so
|
||||
div.setHTMLUnsafe('<a href="javascript:x()">a</a>', {sanitizer: {}});
|
||||
testing.expectEqual('<a href="javascript:x()">a</a>', div.innerHTML);
|
||||
|
||||
const off = {sanitizer: {javascriptURLs: false}};
|
||||
for (const url of ['javascript:x()', ' JaVaScRiPt:x()', '\x01java\nscript:x()', 'java\tscript:x()']) {
|
||||
const a = document.createElement('a');
|
||||
a.setAttribute('href', url);
|
||||
div.setHTMLUnsafe(a.outerHTML, off);
|
||||
testing.expectEqual(null, div.firstChild.getAttribute('href'), {script_id: 'javascriptURLs'});
|
||||
}
|
||||
div.setHTMLUnsafe('<a href="javascriptx:x()">a</a><a href="xjavascript:x()">b</a><p href="javascript:x()"></p>', off);
|
||||
testing.expectEqual('<a href="javascriptx:x()">a</a><a href="xjavascript:x()">b</a><p href="javascript:x()"></p>', div.innerHTML);
|
||||
|
||||
div.setHTMLUnsafe('<area href="javascript:x()"><form action="javascript:x()"><button formaction="javascript:x()"></button></form>', off);
|
||||
testing.expectEqual('<area><form><button></button></form>', div.innerHTML);
|
||||
|
||||
div.setHTMLUnsafe('<svg><a href="javascript:x()"></a><a xlink:href="javascript:x()"></a></svg>', off);
|
||||
testing.expectEqual('<svg><a></a><a></a></svg>', div.innerHTML);
|
||||
|
||||
div.setHTMLUnsafe('<math><mi href="javascript:x()"></mi></math>', off);
|
||||
testing.expectEqual('<math><mi></mi></math>', div.innerHTML);
|
||||
|
||||
div.setHTMLUnsafe('<svg><animate attributeName="href"></animate><set attributeName="xlink:href"></set><animate attributeName="x"></animate></svg>', off);
|
||||
testing.expectEqual('<svg><animate></animate><set></set><animate attributeName="x"></animate></svg>', div.innerHTML);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=nestedTrees>
|
||||
{
|
||||
const div = document.createElement('div');
|
||||
div.setHTML('<template><b onclick=x()>b</b><script><\/script></template>', {sanitizer: {elements: ['template', 'b', 'script']}});
|
||||
testing.expectEqual('<b>b</b>', div.firstChild.innerHTML);
|
||||
|
||||
const host = document.createElement('div');
|
||||
host.setHTMLUnsafe('<div><template shadowrootmode=open><i onclick=x()>i</i><script><\/script></template></div>', {sanitizer: {removeElements: ['script'], removeAttributes: ['onclick']}});
|
||||
testing.expectEqual('<i>i</i>', host.firstChild.shadowRoot.innerHTML);
|
||||
|
||||
// setHTML on a template sets its contents
|
||||
const template = document.createElement('template');
|
||||
template.setHTML('<b onclick=x()>b</b>');
|
||||
testing.expectEqual(0, template.childNodes.length);
|
||||
testing.expectEqual('<b>b</b>', template.content.firstChild.outerHTML);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=shadowRoot>
|
||||
{
|
||||
const host = document.createElement('div');
|
||||
const root = host.attachShadow({mode: 'open'});
|
||||
root.setHTML('<b onclick=x()>b</b><script><\/script>');
|
||||
testing.expectEqual('<b>b</b>', root.innerHTML);
|
||||
|
||||
root.setHTMLUnsafe('<b onclick=x()>b</b>');
|
||||
testing.expectEqual('<b onclick="x()">b</b>', root.innerHTML);
|
||||
|
||||
root.setHTMLUnsafe('<b onclick=x()>b</b>', {sanitizer: {}});
|
||||
testing.expectEqual('<b onclick="x()">b</b>', root.innerHTML);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=mutationRecord>
|
||||
{
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = '<p>old</p>';
|
||||
const observer = new MutationObserver(() => {});
|
||||
observer.observe(div, {childList: true});
|
||||
div.setHTML('<b>a</b><i>b</i><script><\/script>');
|
||||
const records = observer.takeRecords();
|
||||
testing.expectEqual(1, records.length);
|
||||
testing.expectEqual(1, records[0].removedNodes.length);
|
||||
testing.expectEqual(2, records[0].addedNodes.length);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=parseHTML>
|
||||
{
|
||||
const doc = Document.parseHTML('<title>t</title><p onclick=x() data-x=1>a<script>x()<\/script></p>');
|
||||
testing.expectEqual(true, doc instanceof Document);
|
||||
testing.expectEqual('about:blank', doc.URL);
|
||||
testing.expectEqual('<p>a</p>', doc.body.innerHTML);
|
||||
testing.expectEqual('t', doc.title);
|
||||
|
||||
const unsafe = Document.parseHTMLUnsafe('<p onclick=x()>a<script>x()<\/script></p>');
|
||||
testing.expectEqual('<p onclick="x()">a<script>x()<\/script></p>', unsafe.body.innerHTML);
|
||||
|
||||
const filtered = Document.parseHTMLUnsafe('<p>a<i>b</i></p>', {sanitizer: {removeElements: ['i']}});
|
||||
testing.expectEqual('<p>a</p>', filtered.body.innerHTML);
|
||||
|
||||
// html isn't allowed, so it all goes
|
||||
testing.expectEqual(null, Document.parseHTML('<div>text', {sanitizer: {elements: ['body', 'div']}}).documentElement);
|
||||
testing.expectError('TypeError', () => Document.parseHTML('x', {sanitizer: {replaceWithChildrenElements: ['html']}}));
|
||||
}
|
||||
</script>
|
||||
@@ -13,3 +13,4 @@
|
||||
<option id=opt_a value=a selected>A</option>
|
||||
<option id=opt_b value=b>B</option>
|
||||
</select>
|
||||
<iframe id=child srcdoc="<p id=inner>inner</p>"></iframe>
|
||||
@@ -0,0 +1,26 @@
|
||||
<!DOCTYPE html>
|
||||
<title>webdriver input</title>
|
||||
<button id=btn>go</button>
|
||||
<button id=gone style="display: none">hidden</button>
|
||||
<input id=name value="old">
|
||||
<input id=ro value="fixed" readonly>
|
||||
<input id=check type=checkbox>
|
||||
<textarea id=area>text</textarea>
|
||||
<div id=plain>not focusable</div>
|
||||
<select id=pick>
|
||||
<option id=opt_a value=a selected>A</option>
|
||||
<option id=opt_b value=b>B</option>
|
||||
</select>
|
||||
<a id=next href="elements.html">next</a>
|
||||
<form action="elements.html">
|
||||
<input id=q name=q>
|
||||
</form>
|
||||
<script>
|
||||
window.events = [];
|
||||
for (const type of ['click', 'input', 'change', 'keydown', 'focus', 'blur']) {
|
||||
document.addEventListener(type, (e) => {
|
||||
const key = type === 'keydown' ? ':' + (e.shiftKey ? 'S-' : '') + e.key : '';
|
||||
window.events.push(type + key + '@' + e.target.id);
|
||||
}, true);
|
||||
}
|
||||
</script>
|
||||
@@ -19,7 +19,6 @@
|
||||
const std = @import("std");
|
||||
const js = @import("../js/js.zig");
|
||||
const Frame = @import("../Frame.zig");
|
||||
const units = @import("../css/units.zig");
|
||||
const Tokenizer = @import("../css/Tokenizer.zig");
|
||||
|
||||
const CSS = @This();
|
||||
@@ -27,18 +26,6 @@ _pad: bool = false,
|
||||
|
||||
pub const init: CSS = .{};
|
||||
|
||||
// parseDimension plus viewport-relative units, which the faux layout
|
||||
// resolves against the page viewport.
|
||||
pub fn parseDimensionViewport(value: []const u8, frame: *Frame) ?f64 {
|
||||
const parsed = units.parse(value) catch return null;
|
||||
return switch (parsed.unit) {
|
||||
.none, .px => parsed.value,
|
||||
.vh => parsed.value * @as(f64, @floatFromInt(frame.page.getViewport().height)) / 100.0,
|
||||
.vw => parsed.value * @as(f64, @floatFromInt(frame.page.getViewport().width)) / 100.0,
|
||||
else => null,
|
||||
};
|
||||
}
|
||||
|
||||
// Extract the X value from a transfrom. This could come from a translate,
|
||||
// translatex, translate3d or matrix function.
|
||||
pub fn parseTranslateX(value: []const u8) f64 {
|
||||
|
||||
@@ -149,6 +149,9 @@ pub fn getName(self: *CustomElementRegistry, constructor: js.Function) ?[]const
|
||||
}
|
||||
|
||||
pub fn upgrade(self: *CustomElementRegistry, root: *Node, frame: *Frame) !void {
|
||||
if (root.getDocument(frame)._frame == null) {
|
||||
return;
|
||||
}
|
||||
try upgradeNode(self, root, frame);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,11 +21,9 @@ const std = @import("std");
|
||||
const js = @import("../js/js.zig");
|
||||
|
||||
const Frame = @import("../Frame.zig");
|
||||
const Parser = @import("../parser/Parser.zig");
|
||||
|
||||
const Node = @import("Node.zig");
|
||||
const Document = @import("Document.zig");
|
||||
const HTMLDocument = @import("HTMLDocument.zig");
|
||||
|
||||
const DOMParser = @This();
|
||||
|
||||
@@ -45,42 +43,8 @@ pub fn parseFromString(
|
||||
|
||||
switch (target_mime) {
|
||||
.@"text/html" => {
|
||||
const arena = try frame.getArena(.medium, "DOMParser.parseFromString");
|
||||
defer arena.release();
|
||||
|
||||
// DOMParser builds a detached Document. Borrow the same fragment
|
||||
// parse-mode that `Frame.parse` uses so frame-side hooks
|
||||
// triggered from `Build.created` / `nodeIsReady` (external
|
||||
// stylesheet fetches, script execution, mutation-observer fan-out,
|
||||
// default-script injection) treat the parsed nodes as detached and
|
||||
// skip side effects on the live document. The frame's
|
||||
// `_parse_mode` is restored on exit.
|
||||
const previous_parse_mode = frame._parse_mode;
|
||||
frame._parse_mode = .fragment;
|
||||
defer frame._parse_mode = previous_parse_mode;
|
||||
|
||||
// Create a new HTMLDocument
|
||||
const doc = try frame._factory.document(HTMLDocument{
|
||||
._proto = undefined,
|
||||
});
|
||||
const doc = try Frame.parse.htmlDocument(frame, html, .{});
|
||||
doc.asDocument()._url = frame.url;
|
||||
|
||||
var normalized = std.mem.trim(u8, html, &std.ascii.whitespace);
|
||||
if (normalized.len == 0) {
|
||||
normalized = "<html></html>";
|
||||
}
|
||||
|
||||
// Parse HTML into the document
|
||||
var parser = Parser.init(arena.allocator(), doc.asNode(), frame, .{});
|
||||
parser.parse(normalized);
|
||||
if (parser.terminated) {
|
||||
return error.ExecutionTerminated;
|
||||
}
|
||||
|
||||
if (parser.err) |pe| {
|
||||
return pe.err;
|
||||
}
|
||||
|
||||
return doc.asDocument();
|
||||
},
|
||||
else => {
|
||||
|
||||
@@ -40,6 +40,7 @@ const DOMImplementation = @import("DOMImplementation.zig");
|
||||
const StyleSheetList = @import("css/StyleSheetList.zig");
|
||||
const FontFaceSet = @import("css/FontFaceSet.zig");
|
||||
const Selection = @import("Selection.zig");
|
||||
const Sanitizer = @import("Sanitizer.zig");
|
||||
const XPathResult = @import("XPathResult.zig");
|
||||
const XPathExpression = @import("XPathExpression.zig");
|
||||
|
||||
@@ -58,6 +59,7 @@ _proto: *Node,
|
||||
_page: *Page,
|
||||
_index: u32, // browser.documents index
|
||||
_frame: ?*Frame = null,
|
||||
_template_contents_owner: ?*Document = null,
|
||||
_url: ?[:0]const u8 = null, // URL for documents created via DOMImplementation (about:blank)
|
||||
// content type override for documents created via DOMImplementation.createDocument
|
||||
_content_type: ?[]const u8 = null,
|
||||
@@ -196,7 +198,7 @@ fn getLastModified(self: *const Document, frame: *Frame) ![]const u8 {
|
||||
const timestamp = blk: {
|
||||
if (self._frame) |owner| {
|
||||
for (owner._http_headers.items) |header| {
|
||||
if (std.ascii.eqlIgnoreCase(header.name, "last-modified")) {
|
||||
if (std.mem.eql(u8, header.name, "last-modified")) {
|
||||
if (dt.DateTime.parse(header.value, .rfc822)) |parsed| {
|
||||
break :blk parsed.unix(.seconds);
|
||||
} else |_| {}
|
||||
@@ -517,6 +519,32 @@ fn createDocumentFragment(self: *Document, frame: *Frame) !*Node.DocumentFragmen
|
||||
return Node.DocumentFragment.init(self, frame);
|
||||
}
|
||||
|
||||
// https://html.spec.whatwg.org/multipage/scripting.html#appropriate-template-contents-owner-document
|
||||
// A <template>'s content lives in a document with no browsing context, shared
|
||||
// by every template of this document (and by the templates nested in that
|
||||
// content). Nothing in there is connected, scripts never run and, having no
|
||||
// custom element registry, custom elements are never constructed.
|
||||
//
|
||||
// Only a copy stamped into a real document gets upgraded.
|
||||
pub fn templateContentsOwner(self: *Document, frame: *Frame) !*Document {
|
||||
if (self._template_contents_owner) |owner| {
|
||||
return owner;
|
||||
}
|
||||
|
||||
const owner: *Document = if (self._type == .html)
|
||||
(try frame._factory.document(HTMLDocument{ ._proto = undefined })).asDocument()
|
||||
else
|
||||
try frame._factory.genericDocument(.{});
|
||||
owner._url = "about:blank";
|
||||
owner._charset = "UTF-8";
|
||||
owner._ready_state = .complete;
|
||||
// Its own templates' content stays in it.
|
||||
owner._template_contents_owner = owner;
|
||||
|
||||
self._template_contents_owner = owner;
|
||||
return owner;
|
||||
}
|
||||
|
||||
pub fn createComment(self: *Document, data: []const u8) !*Node {
|
||||
return Frame.node_factory.createComment(self, data);
|
||||
}
|
||||
@@ -1157,7 +1185,15 @@ pub fn open(self: *Document, call_frame: *Frame) !*Document {
|
||||
// gone for good, as in Chrome.
|
||||
frame.cancelQueuedNavigation();
|
||||
|
||||
if (std.mem.indexOfScalar(*Document, frame._script_created_parser_docs.items, self) == null) {
|
||||
// have the page track this document (if it isn't already)
|
||||
// so that, on shutdown, it can close the parser if needed.
|
||||
try frame._script_created_parser_docs.append(frame.arena, self);
|
||||
}
|
||||
self._script_created_parser = Parser.Streaming.init(frame.arena, doc_node, frame, .{ .allow_declarative_shadow = true });
|
||||
// on start() failure the internal `handle` isn't yet create. So we can't
|
||||
// call done() and we don't want any subsequent cleanup to call done().
|
||||
errdefer self._script_created_parser = null;
|
||||
try self._script_created_parser.?.start();
|
||||
frame._parse_mode = .document;
|
||||
|
||||
@@ -1516,6 +1552,16 @@ pub const JsApi = struct {
|
||||
return frame._factory.genericDocument(.{ .url = "about:blank", .charset = "UTF-8" });
|
||||
}
|
||||
|
||||
pub const parseHTML = bridge.function(_parseHTML, .{ .static = true });
|
||||
fn _parseHTML(html: []const u8, options: ?Sanitizer.Options, frame: *Frame) !*Document {
|
||||
return Sanitizer.parseHTML(html, options, true, frame);
|
||||
}
|
||||
|
||||
pub const parseHTMLUnsafe = bridge.function(_parseHTMLUnsafe, .{ .static = true });
|
||||
fn _parseHTMLUnsafe(html: []const u8, options: ?Sanitizer.Options, frame: *Frame) !*Document {
|
||||
return Sanitizer.parseHTML(html, options, false, frame);
|
||||
}
|
||||
|
||||
pub const onselectionchange = bridge.accessor(Document.getOnSelectionChange, Document.setOnSelectionChange, .{});
|
||||
pub const onclick = bridge.accessor(Document.getOnClick, Document.setOnClick, .{});
|
||||
pub const ontouchstart = bridge.accessor(handlerAccessor(.ontouchstart).get, handlerAccessor(.ontouchstart).set, .{});
|
||||
|
||||
@@ -165,12 +165,6 @@ pub fn setInnerHTML(self: *DocumentFragment, html: []const u8, frame: *Frame) !v
|
||||
return parent.setHTML(html, .{}, frame);
|
||||
}
|
||||
|
||||
/// allows declarative shadow dom
|
||||
pub fn setHTMLUnsafe(self: *DocumentFragment, html: []const u8, frame: *Frame) !void {
|
||||
const parent = self.asNode();
|
||||
return parent.setHTML(html, .{ .allow_declarative_shadow = true }, frame);
|
||||
}
|
||||
|
||||
pub fn cloneFragment(self: *DocumentFragment, deep: bool, document: *const Node.Document, frame: *Frame) !*Node {
|
||||
const fragment = try DocumentFragment.init(document, frame);
|
||||
const fragment_node = fragment.asNode();
|
||||
|
||||
+170
-78
@@ -29,6 +29,7 @@ const Node = @import("Node.zig");
|
||||
const ShadowRoot = @import("ShadowRoot.zig");
|
||||
const EventTarget = @import("EventTarget.zig");
|
||||
const collections = @import("collections.zig");
|
||||
const Sanitizer = @import("Sanitizer.zig");
|
||||
|
||||
const Selector = @import("selector/Selector.zig");
|
||||
const Animation = @import("animation/Animation.zig");
|
||||
@@ -627,10 +628,20 @@ pub fn setInnerHTML(self: *Element, html: []const u8, frame: *Frame) !void {
|
||||
return parent.setHTML(html, .{}, frame);
|
||||
}
|
||||
|
||||
pub fn setHTML(self: *Element, html: []const u8, options: ?Sanitizer.Options, frame: *Frame) !void {
|
||||
return Sanitizer.setAndFilterHTML(self.htmlTarget(), self, html, options, true, frame);
|
||||
}
|
||||
|
||||
/// allows declarative shadow dom
|
||||
pub fn setHTMLUnsafe(self: *Element, html: []const u8, frame: *Frame) !void {
|
||||
const parent = self.asNode();
|
||||
return parent.setHTML(html, .{ .allow_declarative_shadow = true }, frame);
|
||||
pub fn setHTMLUnsafe(self: *Element, html: []const u8, options: ?Sanitizer.Options, frame: *Frame) !void {
|
||||
return Sanitizer.setAndFilterHTML(self.htmlTarget(), self, html, options, false, frame);
|
||||
}
|
||||
|
||||
fn htmlTarget(self: *Element) *Node {
|
||||
if (self.is(Html.Template)) |template| {
|
||||
return template.getContent().asNode();
|
||||
}
|
||||
return self.asNode();
|
||||
}
|
||||
|
||||
pub fn getId(self: *const Element) ?[]const u8 {
|
||||
@@ -1233,12 +1244,17 @@ pub fn focusTabIndex(self: *Element) ?i32 {
|
||||
return null;
|
||||
}
|
||||
|
||||
// A focusable area that can take focus right now: connected and being rendered.
|
||||
// A focusable area that can take focus right now: connected, not inert and
|
||||
// being rendered.
|
||||
pub fn isFocusable(self: *Element, frame: *Frame) bool {
|
||||
if (self.focusTabIndex() == null) {
|
||||
return false;
|
||||
}
|
||||
if (self.asNode().isConnected() == false) {
|
||||
const node = self.asNode();
|
||||
if (node.isConnected() == false) {
|
||||
return false;
|
||||
}
|
||||
if (node.isInert(frame)) {
|
||||
return false;
|
||||
}
|
||||
return self.isVisible(frame);
|
||||
@@ -1481,27 +1497,34 @@ pub const Axis = enum {
|
||||
};
|
||||
|
||||
pub fn getElementAxis(self: *Element, frame: *Frame, comptime axis: Axis) Axis.State {
|
||||
if (self.inlineStyle(frame)) |style| {
|
||||
const decl = style.asCSSStyleDeclaration();
|
||||
if (CSS.parseDimensionViewport(decl.getPropertyValue(@tagName(axis), frame), frame)) |v| {
|
||||
const tag = self.getTag();
|
||||
const root = tag == .html or tag == .body;
|
||||
|
||||
if (self.ownerFrame(frame)) |owner| {
|
||||
const style_manager = &owner._style_manager;
|
||||
// Roots take only an inline size: a sheet's `height: 100vh` on body
|
||||
// would shrink the box every synthetic position must fit in.
|
||||
const size = if (root) style_manager.inlineSize(self, axis) else style_manager.declaredSize(self, axis);
|
||||
if (size) |v| {
|
||||
return .{ .value = v, .explicit = true };
|
||||
}
|
||||
}
|
||||
|
||||
switch (self.getTag()) {
|
||||
// Root containers get large default size to contain descendant positions.
|
||||
// With calculateDocumentPosition using linear depth scaling (100px per level),
|
||||
// even very deep trees (100 levels) stay within 10,000px.
|
||||
// 100M pixels is plausible for very long documents.
|
||||
.html, .body => return .{ .value = if (axis == .width) 1920.0 else 100_000_000.0 },
|
||||
.img, .iframe => {
|
||||
if (self.getAttributeSafe(comptime .wrap(@tagName(axis)))) |attr| {
|
||||
if (std.fmt.parseFloat(f64, attr)) |parsed| {
|
||||
return .{ .value = parsed, .explicit = true };
|
||||
} else |_| {}
|
||||
}
|
||||
},
|
||||
else => {},
|
||||
// Root containers get large default size to contain descendant positions.
|
||||
// With calculateDocumentPosition using linear depth scaling (100px per level),
|
||||
// even very deep trees (100 levels) stay within 10,000px.
|
||||
// 100M pixels is plausible for very long documents.
|
||||
if (root) {
|
||||
return .{ .value = if (axis == .width) 1920.0 else 100_000_000.0 };
|
||||
}
|
||||
|
||||
// Presentational attributes lose to CSS sizes.
|
||||
if (tag == .img or tag == .iframe) {
|
||||
if (self.getAttributeSafe(comptime .wrap(@tagName(axis)))) |attr| {
|
||||
if (std.fmt.parseFloat(f64, attr)) |parsed| {
|
||||
return .{ .value = parsed, .explicit = true };
|
||||
} else |_| {}
|
||||
}
|
||||
}
|
||||
|
||||
return .{ .value = 5.0 };
|
||||
@@ -1604,16 +1627,7 @@ pub fn getScrollTop(self: *Element, frame: *Frame) u32 {
|
||||
}
|
||||
|
||||
pub fn setScrollTop(self: *Element, value: i32, frame: *Frame) !void {
|
||||
const owner = self.ownerFrame(frame) orelse return;
|
||||
const gop = try owner.page.element_scroll_positions.getOrPut(owner.page.frame_arena, self);
|
||||
if (!gop.found_existing) {
|
||||
gop.value_ptr.* = .{};
|
||||
}
|
||||
const new_y: u32 = @intCast(@max(0, value));
|
||||
if (gop.value_ptr.y != new_y) {
|
||||
gop.value_ptr.y = new_y;
|
||||
try self.scheduleScrollEvents(owner);
|
||||
}
|
||||
_ = try self.writeScroll(.{ .to = .{ .left = null, .top = value } }, frame);
|
||||
}
|
||||
|
||||
pub fn getScrollLeft(self: *Element, frame: *Frame) u32 {
|
||||
@@ -1623,16 +1637,7 @@ pub fn getScrollLeft(self: *Element, frame: *Frame) u32 {
|
||||
}
|
||||
|
||||
pub fn setScrollLeft(self: *Element, value: i32, frame: *Frame) !void {
|
||||
const owner = self.ownerFrame(frame) orelse return;
|
||||
const gop = try owner.page.element_scroll_positions.getOrPut(owner.page.frame_arena, self);
|
||||
if (!gop.found_existing) {
|
||||
gop.value_ptr.* = .{};
|
||||
}
|
||||
const new_x: u32 = @intCast(@max(0, value));
|
||||
if (gop.value_ptr.x != new_x) {
|
||||
gop.value_ptr.x = new_x;
|
||||
try self.scheduleScrollEvents(owner);
|
||||
}
|
||||
_ = try self.writeScroll(.{ .to = .{ .left = value, .top = null } }, frame);
|
||||
}
|
||||
|
||||
pub const ScrollAxes = struct { x: bool = false, y: bool = false };
|
||||
@@ -1642,14 +1647,6 @@ pub const ScrollAxes = struct { x: bool = false, y: bool = false };
|
||||
const ScrollTarget = union(enum) {
|
||||
viewport,
|
||||
container: *Element,
|
||||
|
||||
pub fn scrollBy(self: ScrollTarget, left: i32, top: i32, frame: *Frame) !void {
|
||||
const opts: ScrollToOpts = .{ .opts = .{ .left = left, .top = top } };
|
||||
return switch (self) {
|
||||
.container => |el| el.scrollBy(opts, null, frame),
|
||||
.viewport => frame.window.scrollBy(opts, null, frame),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/// Nearest ancestor-or-self scroll container along any of `axes`. The walk
|
||||
@@ -1669,6 +1666,14 @@ pub fn scrollContainer(self: *Element, axes: ScrollAxes, frame: *Frame) ScrollTa
|
||||
return .viewport;
|
||||
}
|
||||
|
||||
/// Whether the element's own overscroll-behavior keeps a scroll from chaining
|
||||
/// out of it.
|
||||
pub fn containsOverscroll(self: *Element, axes: ScrollAxes, frame: *Frame) bool {
|
||||
const owner = self.ownerFrame(frame) orelse return false;
|
||||
const contains = owner._style_manager.overscrollContainAxes(self);
|
||||
return (axes.x and contains.x) or (axes.y and contains.y);
|
||||
}
|
||||
|
||||
fn scrollsViewport(self: *const Element) bool {
|
||||
return switch (self.getTag()) {
|
||||
.html, .body => true,
|
||||
@@ -1711,6 +1716,26 @@ pub fn getScrollWidth(self: *Element, frame: *Frame) f64 {
|
||||
return @max(width, self.contentAxis(frame, .width));
|
||||
}
|
||||
|
||||
/// Null where we can't prove a limit, which leaves the offset unbounded:
|
||||
/// without an explicit size the client and content measurements collapse onto
|
||||
/// the same sum, and html and body carry giant defaults that would fabricate
|
||||
/// an extent against the real viewport. Refusing a scroll we can't prove
|
||||
/// impossible is worse than allowing one too many.
|
||||
fn scrollExtent(self: *Element, frame: *Frame, comptime axis: Axis) ?f64 {
|
||||
if (self.scrollsViewport() or !self.getElementAxis(frame, axis).explicit) {
|
||||
return null;
|
||||
}
|
||||
const client = self.clientAxis(frame, axis);
|
||||
const content = switch (axis) {
|
||||
.width => self.getScrollWidth(frame),
|
||||
.height => self.getScrollHeight(frame),
|
||||
};
|
||||
if (content <= client) {
|
||||
return null;
|
||||
}
|
||||
return content - client;
|
||||
}
|
||||
|
||||
// One axis of the direct child elements' size: laid end to end on a single
|
||||
// row for the width, stacked for the height.
|
||||
//
|
||||
@@ -2040,55 +2065,97 @@ pub const ScrollToOpts = union(enum) {
|
||||
|
||||
pub fn scrollTo(self: *Element, opts: ?ScrollToOpts, y: ?i32, frame: *Frame) !void {
|
||||
const o = (opts orelse return).offsets(y);
|
||||
const owner = self.ownerFrame(frame) orelse return;
|
||||
const gop = try owner.page.element_scroll_positions.getOrPut(owner.page.frame_arena, self);
|
||||
if (!gop.found_existing) {
|
||||
gop.value_ptr.* = .{};
|
||||
}
|
||||
const old_x = gop.value_ptr.x;
|
||||
const old_y = gop.value_ptr.y;
|
||||
if (o.left) |left| gop.value_ptr.x = @intCast(@max(0, left));
|
||||
if (o.top) |top| gop.value_ptr.y = @intCast(@max(0, top));
|
||||
if (gop.value_ptr.x != old_x or gop.value_ptr.y != old_y) {
|
||||
try self.scheduleScrollEvents(owner);
|
||||
}
|
||||
_ = try self.writeScroll(.{ .to = o }, frame);
|
||||
}
|
||||
|
||||
// scrollBy(): like scrollTo() but relative to the current position.
|
||||
pub fn scrollBy(self: *Element, opts: ?ScrollToOpts, y: ?i32, frame: *Frame) !void {
|
||||
const o = (opts orelse return).offsets(y);
|
||||
const owner = self.ownerFrame(frame) orelse return;
|
||||
_ = try self.writeScroll(.{ .by = o }, frame);
|
||||
}
|
||||
|
||||
/// Reports whether the container moved: a wheel walks outward until one does.
|
||||
pub fn scrollByAxis(self: *Element, comptime axis: Axis, delta: i32, frame: *Frame) !bool {
|
||||
return self.writeScroll(.{ .by = switch (axis) {
|
||||
.width => .{ .left = delta, .top = null },
|
||||
.height => .{ .left = null, .top = delta },
|
||||
} }, frame);
|
||||
}
|
||||
|
||||
const ScrollWrite = union(enum) {
|
||||
to: ScrollToOpts.Offsets,
|
||||
by: ScrollToOpts.Offsets,
|
||||
|
||||
// Null leaves that axis alone.
|
||||
fn target(self: ScrollWrite, comptime axis: Axis, current: u32) ?i64 {
|
||||
const offsets = switch (self) {
|
||||
inline else => |o| o,
|
||||
};
|
||||
const value = switch (axis) {
|
||||
.width => offsets.left,
|
||||
.height => offsets.top,
|
||||
} orelse return null;
|
||||
return switch (self) {
|
||||
.to => value,
|
||||
.by => @as(i64, current) + value,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/// Every scroll write goes through here. Reports whether anything moved; one
|
||||
/// that lands where the offsets already are doesn't even take a map entry.
|
||||
fn writeScroll(self: *Element, write: ScrollWrite, frame: *Frame) !bool {
|
||||
const owner = self.ownerFrame(frame) orelse return false;
|
||||
const current: ScrollPosition = owner.page.element_scroll_positions.get(self) orelse .{};
|
||||
|
||||
var x = current.x;
|
||||
var y = current.y;
|
||||
if (write.target(.width, current.x)) |target| {
|
||||
x = self.clampScroll(frame, .width, target);
|
||||
}
|
||||
if (write.target(.height, current.y)) |target| {
|
||||
y = self.clampScroll(frame, .height, target);
|
||||
}
|
||||
if (x == current.x and y == current.y) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const gop = try owner.page.element_scroll_positions.getOrPut(owner.page.frame_arena, self);
|
||||
if (!gop.found_existing) {
|
||||
gop.value_ptr.* = .{};
|
||||
}
|
||||
const old_x = gop.value_ptr.x;
|
||||
const old_y = gop.value_ptr.y;
|
||||
gop.value_ptr.x = @intCast(@max(0, @as(i32, @intCast(gop.value_ptr.x)) +| (o.left orelse 0)));
|
||||
gop.value_ptr.y = @intCast(@max(0, @as(i32, @intCast(gop.value_ptr.y)) +| (o.top orelse 0)));
|
||||
if (gop.value_ptr.x != old_x or gop.value_ptr.y != old_y) {
|
||||
try self.scheduleScrollEvents(owner);
|
||||
gop.value_ptr.x = x;
|
||||
gop.value_ptr.y = y;
|
||||
try self.scheduleScrollEvents(gop.value_ptr, owner);
|
||||
return true;
|
||||
}
|
||||
|
||||
fn clampScroll(self: *Element, frame: *Frame, comptime axis: Axis, target: i64) u32 {
|
||||
var clamped = target;
|
||||
if (clamped < 0) {
|
||||
clamped = 0;
|
||||
} else if (self.scrollExtent(frame, axis)) |extent| {
|
||||
const max: i64 = @floor(extent);
|
||||
clamped = @min(clamped, max);
|
||||
}
|
||||
return @intCast(@min(clamped, std.math.maxInt(u32)));
|
||||
}
|
||||
|
||||
// Scrolling an element fires a scroll event and then a scrollend event,
|
||||
// asynchronously and throttled, mirroring Window.scrollTo. Scrolls of the
|
||||
// scrolling element (the root) are fired at the document instead.
|
||||
// `frame` is the element's owner frame (resolved by the public accessors).
|
||||
fn scheduleScrollEvents(self: *Element, frame: *Frame) !void {
|
||||
const gop = try frame.page.element_scroll_positions.getOrPut(frame.page.frame_arena, self);
|
||||
if (!gop.found_existing) {
|
||||
gop.value_ptr.* = .{};
|
||||
}
|
||||
const task_pending = gop.value_ptr.state != .done;
|
||||
gop.value_ptr.state = .scroll;
|
||||
// `frame` is the element's owner frame and `pos` its entry there, both
|
||||
// resolved by writeScroll.
|
||||
fn scheduleScrollEvents(self: *Element, pos: *ScrollPosition, frame: *Frame) !void {
|
||||
const task_pending = pos.state != .done;
|
||||
pos.state = .scroll;
|
||||
if (task_pending) {
|
||||
return;
|
||||
}
|
||||
|
||||
const task = try frame._factory.create(ScrollEventTask{ .frame = frame, .element = self });
|
||||
errdefer {
|
||||
gop.value_ptr.state = .done;
|
||||
pos.state = .done;
|
||||
frame._factory.destroy(task);
|
||||
}
|
||||
try frame.js.scheduler.add(task, ScrollEventTask.run, 10, .{
|
||||
@@ -2613,6 +2680,7 @@ pub const JsApi = struct {
|
||||
pub const assignedSlot = bridge.accessor(Element.getAssignedSlot, null, .{});
|
||||
pub const attachShadow = bridge.function(_attachShadow, .{});
|
||||
pub const insertAdjacentHTML = bridge.function(Element.insertAdjacentHTML, .{ .ce_reactions = true });
|
||||
pub const setHTML = bridge.function(Element.setHTML, .{ .ce_reactions = true });
|
||||
pub const setHTMLUnsafe = bridge.function(Element.setHTMLUnsafe, .{ .ce_reactions = true });
|
||||
pub const insertAdjacentElement = bridge.function(Element.insertAdjacentElement, .{ .ce_reactions = true });
|
||||
pub const insertAdjacentText = bridge.function(Element.insertAdjacentText, .{ .ce_reactions = true });
|
||||
@@ -2748,6 +2816,30 @@ test "WebApi: Element" {
|
||||
try testing.htmlRunner("element", .{});
|
||||
}
|
||||
|
||||
test "Element: scroll extent" {
|
||||
const frame = try testing.createFrame();
|
||||
defer testing.test_session.closeAllPages();
|
||||
|
||||
const root = try frame.window._document.createElement("div", null, frame);
|
||||
try Frame.parse.htmlAsChildren(frame, root.asNode(),
|
||||
\\<div style="height: 100px; overflow: auto"><div style="height: 500px"></div></div>
|
||||
\\<div style="height: 100px; overflow: auto">text, and no element child to measure</div>
|
||||
);
|
||||
const box = root.asNode().firstChild().?.as(Element);
|
||||
const text = box.nextElementSibling().?;
|
||||
|
||||
try box.setScrollTop(9999, frame);
|
||||
try testing.expectEqual(400, box.getScrollTop(frame));
|
||||
try box.setScrollTop(-1, frame);
|
||||
try testing.expectEqual(0, box.getScrollTop(frame));
|
||||
|
||||
// A real browser clamps this one too, to the height of its text. contentAxis
|
||||
// sums element children only, so we have no extent to clamp against and the
|
||||
// offset stays unbounded.
|
||||
try text.setScrollTop(9999, frame);
|
||||
try testing.expectEqual(9999, text.getScrollTop(frame));
|
||||
}
|
||||
|
||||
test "Element: div chain slot size" {
|
||||
// Guard against accidental growth: new Element fields (e.g. _flags) must
|
||||
// fit in existing padding. Debug is larger from the _proto_canary fields.
|
||||
|
||||
@@ -1613,6 +1613,28 @@ pub fn setHTML(self: *Node, html: []const u8, opts: Frame.parse.FragmentParseOpt
|
||||
}
|
||||
}
|
||||
|
||||
pub fn replaceAllWithFragment(self: *Node, fragment: *Node, frame: *Frame) !void {
|
||||
frame.domChanged();
|
||||
|
||||
const notify = Frame.observers.hasMutationObservers(frame);
|
||||
var added: std.ArrayList(*Node) = .empty;
|
||||
if (notify) {
|
||||
var it = fragment.childrenIterator();
|
||||
while (it.next()) |child| {
|
||||
try added.append(frame.call_arena, child);
|
||||
}
|
||||
}
|
||||
|
||||
const removed = try self.removeAllChildrenCollecting(notify, frame);
|
||||
try frame.moveAllChildren(fragment, self, null, .silent_parent);
|
||||
|
||||
if (notify and (removed.items.len > 0 or added.items.len > 0)) {
|
||||
// The point here is to batch all of the adds/remove and get a combined
|
||||
// mutation record
|
||||
Frame.observers.notifyChildListChange(frame, self, added.items, removed.items, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
// Writes a JSON representation of the node and its children
|
||||
pub fn jsonStringify(self: *const Node, writer: *std.json.Stringify) !void {
|
||||
// stupid json api requires this to be const,
|
||||
@@ -1699,6 +1721,27 @@ pub fn assignedSlot(self: *Node, frame: *const Frame) ?*Element.Html.Slot {
|
||||
return frame.page._assigned_slots.get(self);
|
||||
}
|
||||
|
||||
// An inert element applies to all its chidren, so walk up to see if we have
|
||||
// an inert parent
|
||||
pub fn isInert(self: *Node, frame: *const Frame) bool {
|
||||
var current: ?*Node = self;
|
||||
while (current) |node| {
|
||||
if (node.is(Element)) |el| {
|
||||
if (el._namespace == .html and el.hasAttributeSafe(comptime .wrap("inert"))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (node.assignedSlot(frame)) |slot| {
|
||||
current = slot.asNode();
|
||||
} else if (node.is(ShadowRoot)) |shadow| {
|
||||
current = shadow._host.asNode();
|
||||
} else {
|
||||
current = node._parent;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
pub const JsApi = struct {
|
||||
pub const bridge = js.Bridge(Node);
|
||||
|
||||
|
||||
@@ -617,7 +617,7 @@ fn scheduleBufferFull(self: *Performance) !void {
|
||||
const exec = perf._exec;
|
||||
const event = try Event.initTrusted(.wrap(BUFFER_FULL), .{}, exec.page);
|
||||
try exec.dispatch(perf.asEventTarget(), event, perf._on_buffer_full, .{
|
||||
.context = "Performance.resourcetimingbufferfull",
|
||||
.context = "Performance.bufferfull",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -719,11 +719,19 @@ fn createContextualFragment(self: *const Range, html: []const u8, frame: *Frame)
|
||||
pub fn toString(self: *const Range, frame: *Frame) ![]const u8 {
|
||||
// Simplified implementation: just extract text content
|
||||
var buf = std.Io.Writer.Allocating.init(frame.local_arena);
|
||||
try self.writeTextContent(&buf.writer);
|
||||
try self.writeTextContent(&buf.writer, null);
|
||||
return buf.written();
|
||||
}
|
||||
|
||||
fn writeTextContent(self: *const Range, writer: *std.Io.Writer) !void {
|
||||
// Selection.toString is almost like Range.toString, except it does not include
|
||||
// the text that an inert element hides.
|
||||
pub fn toSelectionString(self: *const Range, frame: *Frame) ![]const u8 {
|
||||
var buf = std.Io.Writer.Allocating.init(frame.local_arena);
|
||||
try self.writeTextContent(&buf.writer, frame);
|
||||
return buf.written();
|
||||
}
|
||||
|
||||
fn writeTextContent(self: *const Range, writer: *std.Io.Writer, skip_inert: ?*const Frame) !void {
|
||||
if (self._proto.getCollapsed()) return;
|
||||
|
||||
const start_node = self._proto._start_container;
|
||||
@@ -734,7 +742,7 @@ fn writeTextContent(self: *const Range, writer: *std.Io.Writer) !void {
|
||||
// Same text node — just substring
|
||||
if (start_node == end_node) {
|
||||
if (start_node.is(Node.CData)) |cdata| {
|
||||
if (!isCommentOrPI(cdata)) {
|
||||
if (includeText(cdata, skip_inert)) {
|
||||
const data = cdata.getData().str();
|
||||
const s = byteOffset(data, start_offset);
|
||||
const e = byteOffset(data, end_offset);
|
||||
@@ -748,7 +756,7 @@ fn writeTextContent(self: *const Range, writer: *std.Io.Writer) !void {
|
||||
|
||||
// Partial start: if start container is a text node, write from offset to end
|
||||
if (start_node.is(Node.CData)) |cdata| {
|
||||
if (!isCommentOrPI(cdata)) {
|
||||
if (includeText(cdata, skip_inert)) {
|
||||
const data = cdata.getData().str();
|
||||
const s = byteOffset(data, start_offset);
|
||||
try writer.writeAll(data[s..]);
|
||||
@@ -775,7 +783,7 @@ fn writeTextContent(self: *const Range, writer: *std.Io.Writer) !void {
|
||||
if (n == we) break;
|
||||
}
|
||||
if (n.is(Node.CData)) |cdata| {
|
||||
if (!isCommentOrPI(cdata)) {
|
||||
if (includeText(cdata, skip_inert)) {
|
||||
try writer.writeAll(cdata.getData().str());
|
||||
}
|
||||
}
|
||||
@@ -786,7 +794,7 @@ fn writeTextContent(self: *const Range, writer: *std.Io.Writer) !void {
|
||||
// Partial end: if end container is a different text node, write from start to offset
|
||||
if (start_node != end_node) {
|
||||
if (end_node.is(Node.CData)) |cdata| {
|
||||
if (!isCommentOrPI(cdata)) {
|
||||
if (includeText(cdata, skip_inert)) {
|
||||
const data = cdata.getData().str();
|
||||
const e = byteOffset(data, end_offset);
|
||||
try writer.writeAll(data[0..e]);
|
||||
@@ -795,6 +803,14 @@ fn writeTextContent(self: *const Range, writer: *std.Io.Writer) !void {
|
||||
}
|
||||
}
|
||||
|
||||
fn includeText(cdata: *Node.CData, skip_inert: ?*const Frame) bool {
|
||||
if (isCommentOrPI(cdata)) {
|
||||
return false;
|
||||
}
|
||||
const frame = skip_inert orelse return true;
|
||||
return cdata.asNode().isInert(frame) == false;
|
||||
}
|
||||
|
||||
fn isCommentOrPI(cdata: *Node.CData) bool {
|
||||
return cdata.is(Node.CData.Comment) != null or cdata.is(Node.CData.ProcessingInstruction) != null;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -714,7 +714,7 @@ pub fn collapse(self: *Selection, _node: ?*Node, _offset: ?u32, frame: *Frame) !
|
||||
|
||||
pub fn toString(self: *const Selection, frame: *Frame) ![]const u8 {
|
||||
const range = self._range orelse return "";
|
||||
return try range.toString(frame);
|
||||
return try range.toSelectionString(frame);
|
||||
}
|
||||
|
||||
fn setRange(self: *Selection, new_range: ?*Range, frame: *Frame) void {
|
||||
|
||||
@@ -404,7 +404,7 @@ fn dispatchExtendable(
|
||||
self._pending_event = event;
|
||||
errdefer self.releasePendingEvent();
|
||||
|
||||
try wgs.dispatch(wgs.asEventTarget(), base, handler, .{ .context = "ServiceWorkerGlobalScope lifecycle" });
|
||||
try wgs.dispatch(wgs.asEventTarget(), base, handler, .{ .context = "service worker lifecycle" });
|
||||
|
||||
// Seal only after the handlers have run, so a synchronous waitUntil is
|
||||
// counted before an empty pending set can complete the phase.
|
||||
|
||||
@@ -24,6 +24,7 @@ const Frame = @import("../Frame.zig");
|
||||
|
||||
const Node = @import("Node.zig");
|
||||
const Element = @import("Element.zig");
|
||||
const Sanitizer = @import("Sanitizer.zig");
|
||||
const DocumentFragment = @import("DocumentFragment.zig");
|
||||
|
||||
const ShadowRoot = @This();
|
||||
@@ -110,8 +111,12 @@ fn getSerializable(self: *const ShadowRoot) bool {
|
||||
return self._serializable;
|
||||
}
|
||||
|
||||
pub fn setHTMLUnsafe(self: *ShadowRoot, html: []const u8, frame: *Frame) !void {
|
||||
return self.asDocumentFragment().setHTMLUnsafe(html, frame);
|
||||
pub fn setHTML(self: *ShadowRoot, html: []const u8, options: ?Sanitizer.Options, frame: *Frame) !void {
|
||||
return Sanitizer.setAndFilterHTML(self.asNode(), self._host, html, options, true, frame);
|
||||
}
|
||||
|
||||
pub fn setHTMLUnsafe(self: *ShadowRoot, html: []const u8, options: ?Sanitizer.Options, frame: *Frame) !void {
|
||||
return Sanitizer.setAndFilterHTML(self.asNode(), self._host, html, options, false, frame);
|
||||
}
|
||||
|
||||
pub fn getHTML(self: *ShadowRoot, opts: dump.Opts.Shadow.Declarative, writer: *std.Io.Writer, frame: *Frame) !void {
|
||||
@@ -219,6 +224,7 @@ pub const JsApi = struct {
|
||||
return self.getElementById(try value.toZig([]const u8), frame);
|
||||
}
|
||||
pub const adoptedStyleSheets = bridge.accessor(ShadowRoot.getAdoptedStyleSheets, ShadowRoot.setAdoptedStyleSheets, .{});
|
||||
pub const setHTML = bridge.function(ShadowRoot.setHTML, .{ .ce_reactions = true });
|
||||
pub const setHTMLUnsafe = bridge.function(ShadowRoot.setHTMLUnsafe, .{ .ce_reactions = true });
|
||||
pub const getHTML = bridge.function(_getHTML, .{});
|
||||
const GetHTMLOpts = struct {
|
||||
|
||||
@@ -319,7 +319,7 @@ fn releaseScriptArena(self: *SharedWorkerGlobalScope) void {
|
||||
fn drainPendingConnects(self: *SharedWorkerGlobalScope) void {
|
||||
for (self._pending_connects.items) |port| {
|
||||
self.scheduleConnect(port) catch |err| {
|
||||
log.warn(.browser, "shared worker drain connect failed", .{ .err = err });
|
||||
log.warn(.browser, "shared worker drain failed", .{ .err = err });
|
||||
};
|
||||
}
|
||||
self._pending_connects.clearRetainingCapacity();
|
||||
@@ -382,7 +382,7 @@ const ConnectCallback = struct {
|
||||
.cancelable = false,
|
||||
}, wgs.page)).asEvent();
|
||||
|
||||
try wgs.dispatch(target, event, on_connect, .{ .context = "SharedWorkerGlobalScope.connect" });
|
||||
try wgs.dispatch(target, event, on_connect, .{ .context = "shared worker connect" });
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1016,8 +1016,10 @@ pub fn scrollTo(self: *Window, opts: Element.ScrollToOpts, y: ?i32, frame: *Fram
|
||||
|
||||
pub fn scrollBy(self: *Window, opts: Element.ScrollToOpts, y: ?i32, frame: *Frame) !void {
|
||||
const o = opts.offsets(y);
|
||||
const absx = @as(i32, @intCast(self._scroll_pos.x)) +| (o.left orelse 0);
|
||||
const absy = @as(i32, @intCast(self._scroll_pos.y)) +| (o.top orelse 0);
|
||||
// The viewport has no honest extent, so a stored offset can sit above
|
||||
// maxInt(i32): widen before saturating back down.
|
||||
const absx: i32 = @intCast(@min(@as(i64, self._scroll_pos.x) + (o.left orelse 0), std.math.maxInt(i32)));
|
||||
const absy: i32 = @intCast(@min(@as(i64, self._scroll_pos.y) + (o.top orelse 0), std.math.maxInt(i32)));
|
||||
return self.scrollTo(.{ .x = absx }, absy, frame);
|
||||
}
|
||||
|
||||
|
||||
@@ -347,3 +347,8 @@ pub const JsApi = struct {
|
||||
pub const namedItem = bridge.function(HTMLCollection.getByName, .{});
|
||||
pub const symbol_iterator = bridge.iterator(HTMLCollection.iterator, .{});
|
||||
};
|
||||
|
||||
const testing = @import("../../../testing.zig");
|
||||
test "WebApi: indexed properties" {
|
||||
try testing.htmlRunner("collections/indexed_properties.html", .{});
|
||||
}
|
||||
@@ -16,6 +16,8 @@
|
||||
// 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 lp = @import("lightpanda");
|
||||
|
||||
const js = @import("../../js/js.zig");
|
||||
const Page = @import("../../Page.zig");
|
||||
const Frame = @import("../../Frame.zig");
|
||||
@@ -104,6 +106,50 @@ pub fn remove(self: *HTMLOptionsCollection, index: i32, frame: *Frame) void {
|
||||
}
|
||||
}
|
||||
|
||||
// Chrome's cap (kMaxListItems): padding up to a huge index would otherwise
|
||||
// create that many options. Past it, Chrome ignores the set (with a console
|
||||
// warning); Firefox has no cap.
|
||||
const max_list_items = 100_000;
|
||||
|
||||
// The indexed setter: null removes the option at index; an index past the
|
||||
// end pads with blank options and then appends; otherwise the option at
|
||||
// index is replaced.
|
||||
fn setAtIndex(self: *HTMLOptionsCollection, index: u32, option_: ?*Option, frame: *Frame) !void {
|
||||
const existing = self.getAtIndex(index, frame);
|
||||
const option = (option_ orelse {
|
||||
if (existing) |element| {
|
||||
element.remove(frame);
|
||||
}
|
||||
return;
|
||||
}).asElement().asNode();
|
||||
|
||||
if (existing) |element| {
|
||||
const old = element.asNode();
|
||||
_ = try old.parentNode().?.replaceChild(option, old, frame);
|
||||
return;
|
||||
}
|
||||
|
||||
if (index >= max_list_items) {
|
||||
lp.log.warn(.js, "select overflow", .{ .max_list_items = max_list_items, .request = index });
|
||||
return;
|
||||
}
|
||||
|
||||
const select_node = self._select.asNode();
|
||||
const len = self.length(frame);
|
||||
if (index > len) {
|
||||
// Per spec, the padding goes in as one DocumentFragment, so observers
|
||||
// get one record rather than one per blank option.
|
||||
const doc = select_node.ownerDocument(frame).?;
|
||||
const fragment = (try Node.DocumentFragment.init(doc, frame)).asNode();
|
||||
for (len..index) |_| {
|
||||
const blank = try doc.createElementNS("http://www.w3.org/1999/xhtml", "option", frame);
|
||||
_ = try fragment.appendChild(blank.asNode(), frame);
|
||||
}
|
||||
_ = try select_node.appendChild(fragment, frame);
|
||||
}
|
||||
_ = try select_node.appendChild(option, frame);
|
||||
}
|
||||
|
||||
pub const JsApi = struct {
|
||||
pub const bridge = js.Bridge(HTMLOptionsCollection);
|
||||
|
||||
@@ -117,7 +163,7 @@ pub const JsApi = struct {
|
||||
pub const length = bridge.accessor(HTMLOptionsCollection.length, null, .{});
|
||||
|
||||
// Indexed access
|
||||
pub const @"[int]" = bridge.indexed(HTMLOptionsCollection.getAtIndex, null, .{ .null_as_undefined = true });
|
||||
pub const @"[int]" = bridge.indexedReadWrite(HTMLOptionsCollection.getAtIndex, setAtIndex, null, null, null, .{ .null_as_undefined = true, .ce_reactions = true });
|
||||
pub const @"[str]" = bridge.namedIndexed(HTMLOptionsCollection.getByName, null, null, null, struct {
|
||||
fn wrap(self: *HTMLOptionsCollection, name: []const u8, frame: *Frame) !u32 {
|
||||
if (self.getByName(name, frame) != null) {
|
||||
|
||||
@@ -136,13 +136,13 @@ pub fn getPropertyValue(self: *const CSSStyleDeclaration, property_name: []const
|
||||
return getDefaultPropertyValue(self, wrapped);
|
||||
}
|
||||
|
||||
/// The value of a declared property, or null when it isn't declared. The
|
||||
/// `overflow` shorthand reads as its longhands when both are present with
|
||||
/// the same priority, the way the CSSOM serializes a shorthand.
|
||||
/// The value of a declared property, or null when it isn't declared. An axis
|
||||
/// shorthand reads as its longhands when both are present with the same
|
||||
/// priority, the way the CSSOM serializes a shorthand.
|
||||
pub fn declaredValue(self: *const CSSStyleDeclaration, name: String, frame: *Frame) ?[]const u8 {
|
||||
if (name.eql(overflow_shorthand)) {
|
||||
const x = self.findProperty(comptime .wrap("overflow-x")) orelse return null;
|
||||
const pair = self.overflowPair(x) orelse return null;
|
||||
if (CssParser.axisShorthand(name.str())) |shorthand| {
|
||||
const x = self.findProperty(.wrap(shorthand.x)) orelse return null;
|
||||
const pair = self.axisPair(x) orelse return null;
|
||||
var buf = std.Io.Writer.Allocating.init(frame.local_arena);
|
||||
pair.formatValue(&buf.writer) catch return null;
|
||||
return buf.written();
|
||||
@@ -151,19 +151,14 @@ pub fn declaredValue(self: *const CSSStyleDeclaration, name: String, frame: *Fra
|
||||
return prop._value.str();
|
||||
}
|
||||
|
||||
// `overflow` is the one shorthand whose longhands the style cascade folds
|
||||
// (StyleManager tracks overflow-x and overflow-y), so it is the one this
|
||||
// object stores expanded, as the CSSOM does for every shorthand: setting it
|
||||
// sets both longhands, reading or serializing it recombines them.
|
||||
const overflow_shorthand: String = .wrap("overflow");
|
||||
|
||||
/// Both overflow longhands, declared with the same priority: the pair reads
|
||||
/// and serializes as the shorthand.
|
||||
const OverflowPair = struct {
|
||||
/// Both longhands of an axis shorthand, declared with the same priority: the
|
||||
/// pair reads and serializes as the shorthand.
|
||||
const AxisPair = struct {
|
||||
name: []const u8,
|
||||
x: *const Property,
|
||||
y: *const Property,
|
||||
|
||||
fn formatValue(self: OverflowPair, writer: *std.Io.Writer) !void {
|
||||
fn formatValue(self: AxisPair, writer: *std.Io.Writer) !void {
|
||||
try self.x._value.format(writer);
|
||||
if (!self.x._value.eql(self.y._value)) {
|
||||
try writer.writeByte(' ');
|
||||
@@ -171,26 +166,24 @@ const OverflowPair = struct {
|
||||
}
|
||||
}
|
||||
|
||||
fn format(self: OverflowPair, writer: *std.Io.Writer) !void {
|
||||
try writer.writeAll("overflow: ");
|
||||
fn format(self: AxisPair, writer: *std.Io.Writer) !void {
|
||||
try writer.writeAll(self.name);
|
||||
try writer.writeAll(": ");
|
||||
try self.formatValue(writer);
|
||||
try formatDeclarationEnd(self.x._important, writer);
|
||||
}
|
||||
};
|
||||
|
||||
/// The pair `prop` belongs to, when it is an overflow longhand and the other
|
||||
/// is declared with the same priority.
|
||||
fn overflowPair(self: *const CSSStyleDeclaration, prop: *const Property) ?OverflowPair {
|
||||
const is_x = prop._name.eql(comptime .wrap("overflow-x"));
|
||||
if (!is_x and !prop._name.eql(comptime .wrap("overflow-y"))) {
|
||||
return null;
|
||||
}
|
||||
const other: String = if (is_x) comptime .wrap("overflow-y") else comptime .wrap("overflow-x");
|
||||
const partner = self.findProperty(other) orelse return null;
|
||||
/// The pair `prop` belongs to, when it is an axis longhand and the other is
|
||||
/// declared with the same priority.
|
||||
fn axisPair(self: *const CSSStyleDeclaration, prop: *const Property) ?AxisPair {
|
||||
const longhand = CssParser.axisLonghand(prop._name.str()) orelse return null;
|
||||
const partner = self.findProperty(.wrap(longhand.partner())) orelse return null;
|
||||
if (partner._important != prop._important) {
|
||||
return null;
|
||||
}
|
||||
return if (is_x) .{ .x = prop, .y = partner } else .{ .x = partner, .y = prop };
|
||||
const name = longhand.shorthand.name;
|
||||
return if (longhand.is_x) .{ .name = name, .x = prop, .y = partner } else .{ .name = name, .x = partner, .y = prop };
|
||||
}
|
||||
|
||||
fn resolvedDimension(element: *Element, dimension: enum { width, height }, frame: *Frame) []const u8 {
|
||||
@@ -207,9 +200,9 @@ fn resolvedDimension(element: *Element, dimension: enum { width, height }, frame
|
||||
pub fn getPropertyPriority(self: *const CSSStyleDeclaration, property_name: []const u8, frame: *Frame) []const u8 {
|
||||
const normalized = normalizePropertyName(property_name, &frame.buf);
|
||||
const wrapped = String.wrap(normalized);
|
||||
if (wrapped.eql(overflow_shorthand)) {
|
||||
const x = self.findProperty(comptime .wrap("overflow-x")) orelse return "";
|
||||
const pair = self.overflowPair(x) orelse return "";
|
||||
if (CssParser.axisShorthand(normalized)) |shorthand| {
|
||||
const x = self.findProperty(.wrap(shorthand.x)) orelse return "";
|
||||
const pair = self.axisPair(x) orelse return "";
|
||||
return if (pair.x._important) "important" else "";
|
||||
}
|
||||
const prop = self.findProperty(wrapped) orelse return "";
|
||||
@@ -240,10 +233,10 @@ pub fn setProperty(self: *CSSStyleDeclaration, property_name: []const u8, value:
|
||||
/// not override an earlier !important one (CSS cascade precedence).
|
||||
fn applyParsedDeclaration(self: *CSSStyleDeclaration, declaration: CssParser.Declaration, frame: *Frame) !void {
|
||||
const normalized = normalizePropertyName(declaration.name, &frame.buf);
|
||||
if (overflow_shorthand.eqlSlice(normalized)) {
|
||||
const values = CssParser.splitOverflow(declaration.value) orelse return;
|
||||
try self.applyParsedDeclaration(.{ .name = "overflow-x", .value = values.x, .important = declaration.important }, frame);
|
||||
try self.applyParsedDeclaration(.{ .name = "overflow-y", .value = values.y, .important = declaration.important }, frame);
|
||||
if (CssParser.axisShorthand(normalized)) |shorthand| {
|
||||
const values = CssParser.splitAxisPair(declaration.value) orelse return;
|
||||
try self.applyParsedDeclaration(.{ .name = shorthand.x, .value = values.x, .important = declaration.important }, frame);
|
||||
try self.applyParsedDeclaration(.{ .name = shorthand.y, .value = values.y, .important = declaration.important }, frame);
|
||||
return;
|
||||
}
|
||||
if (!declaration.important) {
|
||||
@@ -266,10 +259,10 @@ fn setPropertyImpl(self: *CSSStyleDeclaration, property_name: []const u8, value:
|
||||
}
|
||||
|
||||
const normalized = normalizePropertyName(property_name, &frame.buf);
|
||||
if (overflow_shorthand.eqlSlice(normalized)) {
|
||||
const values = CssParser.splitOverflow(value) orelse return false;
|
||||
const x = try self.setPropertyImpl("overflow-x", values.x, important, frame);
|
||||
const y = try self.setPropertyImpl("overflow-y", values.y, important, frame);
|
||||
if (CssParser.axisShorthand(normalized)) |shorthand| {
|
||||
const values = CssParser.splitAxisPair(value) orelse return false;
|
||||
const x = try self.setPropertyImpl(shorthand.x, values.x, important, frame);
|
||||
const y = try self.setPropertyImpl(shorthand.y, values.y, important, frame);
|
||||
return x or y;
|
||||
}
|
||||
|
||||
@@ -309,10 +302,10 @@ pub fn removeProperty(self: *CSSStyleDeclaration, property_name: []const u8, fra
|
||||
|
||||
fn removePropertyImpl(self: *CSSStyleDeclaration, property_name: []const u8, frame: *Frame) !?[]const u8 {
|
||||
const normalized = normalizePropertyName(property_name, &frame.buf);
|
||||
if (overflow_shorthand.eqlSlice(normalized)) {
|
||||
const old_value = self.declaredValue(overflow_shorthand, frame) orelse "";
|
||||
const x = try self.removePropertyImpl("overflow-x", frame);
|
||||
const y = try self.removePropertyImpl("overflow-y", frame);
|
||||
if (CssParser.axisShorthand(normalized)) |shorthand| {
|
||||
const old_value = self.declaredValue(.wrap(shorthand.name), frame) orelse "";
|
||||
const x = try self.removePropertyImpl(shorthand.x, frame);
|
||||
const y = try self.removePropertyImpl(shorthand.y, frame);
|
||||
if (x == null and y == null) {
|
||||
return null;
|
||||
}
|
||||
@@ -402,7 +395,7 @@ pub fn replaceCssText(self: *CSSStyleDeclaration, text: []const u8, frame: *Fram
|
||||
|
||||
pub fn format(self: *const CSSStyleDeclaration, writer: *std.Io.Writer) !void {
|
||||
var first = true;
|
||||
// An overflow pair serializes once, where its first longhand sits.
|
||||
// An axis pair serializes once, where its first longhand sits.
|
||||
var skip: ?*const Property = null;
|
||||
var it = self.iterator();
|
||||
while (it.next()) |prop| {
|
||||
@@ -413,7 +406,7 @@ pub fn format(self: *const CSSStyleDeclaration, writer: *std.Io.Writer) !void {
|
||||
try writer.writeByte(' ');
|
||||
}
|
||||
first = false;
|
||||
if (self.overflowPair(prop)) |pair| {
|
||||
if (self.axisPair(prop)) |pair| {
|
||||
try pair.format(writer);
|
||||
skip = if (pair.x == prop) pair.y else pair.x;
|
||||
} else {
|
||||
|
||||
@@ -452,6 +452,18 @@ pub fn setHidden(self: *HtmlElement, hidden: bool, frame: *Frame) !void {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn getInert(self: *HtmlElement) bool {
|
||||
return self.asElement().hasAttributeSafe(comptime .wrap("inert"));
|
||||
}
|
||||
|
||||
pub fn setInert(self: *HtmlElement, inert: bool, frame: *Frame) !void {
|
||||
if (inert) {
|
||||
try self.asElement().setAttributeSafe(comptime .wrap("inert"), .wrap(""), frame);
|
||||
} else {
|
||||
try self.asElement().removeAttribute(comptime .wrap("inert"), frame);
|
||||
}
|
||||
}
|
||||
|
||||
// The translate IDL attribute reflects the element's translation mode:
|
||||
// translate="yes"/"" enables it, "no" disables it, anything else (or no
|
||||
// attribute) inherits from the parent, defaulting to enabled.
|
||||
@@ -1862,6 +1874,7 @@ pub const JsApi = struct {
|
||||
pub const dir = reflect.enumerated("dir", &.{ "ltr", "rtl", "auto" }, .{});
|
||||
pub const draggable = bridge.accessor(HtmlElement.getDraggable, HtmlElement.setDraggable, .{ .ce_reactions = true });
|
||||
pub const hidden = bridge.accessor(HtmlElement.getHidden, HtmlElement.setHidden, .{ .ce_reactions = true });
|
||||
pub const inert = bridge.accessor(HtmlElement.getInert, HtmlElement.setInert, .{ .ce_reactions = true });
|
||||
pub const translate = bridge.accessor(HtmlElement.getTranslate, HtmlElement.setTranslate, .{ .ce_reactions = true });
|
||||
pub const accessKeyLabel = bridge.accessor(HtmlElement.getAccessKeyLabel, null, .{});
|
||||
pub const popover = bridge.accessor(HtmlElement.getPopover, HtmlElement.setPopover, .{ .ce_reactions = true });
|
||||
|
||||
@@ -45,6 +45,7 @@ _connected_callback_invoked: bool = false,
|
||||
_disconnected_callback_invoked: bool = false,
|
||||
_upgrade_failed: bool = false, // a failed upgrade is never retried
|
||||
_upgrade_in_progress: bool = false,
|
||||
_upgrade_candidate: bool = false, // listed in a frame's _undefined_custom_elements
|
||||
|
||||
pub fn asElement(self: *Custom) *Element {
|
||||
return Factory.protoOf(self).asElement();
|
||||
@@ -85,7 +86,11 @@ pub fn enqueueConnectedCallbackOnElement(comptime from_parser: bool, element: *E
|
||||
CustomElementRegistry.upgradeCustomElement(custom, definition, frame) catch {};
|
||||
return;
|
||||
}
|
||||
// Element is undefined and no definition exists yet — nothing to queue.
|
||||
|
||||
if (!custom._upgrade_candidate) {
|
||||
custom._upgrade_candidate = true;
|
||||
try frame._undefined_custom_elements.append(frame.arena, custom);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -126,7 +126,16 @@ pub const JsApi = struct {
|
||||
pub const srcdoc = bridge.accessor(IFrame.getSrcdoc, IFrame.setSrcdoc, .{ .ce_reactions = true });
|
||||
pub const name = reflect.string("name");
|
||||
pub const contentWindow = bridge.accessor(IFrame.getContentWindow, null, .{});
|
||||
pub const contentDocument = bridge.accessor(IFrame.getContentDocument, null, .{});
|
||||
pub const contentDocument = bridge.accessor(struct {
|
||||
fn wrap(self: *const IFrame, frame: *Frame) ?*Document {
|
||||
// specific JS implementation which is origin-aware.
|
||||
const window = self._window orelse return null;
|
||||
if (window._frame.js.origin != frame.js.origin) {
|
||||
return null;
|
||||
}
|
||||
return window._document;
|
||||
}
|
||||
}.wrap, null, .{});
|
||||
pub const sandbox = bridge.accessor(IFrame.getSandbox, null, .{ .null_as_undefined = true });
|
||||
};
|
||||
|
||||
|
||||
@@ -39,6 +39,26 @@ _value: ?[]const u8 = null,
|
||||
_selected: bool = false,
|
||||
_default_selected: bool = false,
|
||||
|
||||
pub fn constructor(text_: ?js.NullableString, value_: ?js.NullableString, default_selected_: ?bool, selected_: ?bool, frame: *Frame) !*Option {
|
||||
const node = try Frame.node_factory.createElementNS(frame.document, .html, "option", null);
|
||||
const el = node.as(Element);
|
||||
|
||||
const text = if (text_) |t| t.value else "";
|
||||
if (text.len > 0) {
|
||||
_ = try node.appendChild(try frame.document.createTextNode(text), frame);
|
||||
}
|
||||
if (value_) |v| {
|
||||
try el.setAttributeSafe(comptime .wrap("value"), .wrap(v.value), frame);
|
||||
}
|
||||
if (default_selected_ orelse false) {
|
||||
try el.setAttributeSafe(comptime .wrap("selected"), comptime .wrap(""), frame);
|
||||
}
|
||||
|
||||
const self = el.as(Option);
|
||||
self._selected = selected_ orelse false;
|
||||
return self;
|
||||
}
|
||||
|
||||
pub fn asElement(self: *Option) *Element {
|
||||
return Factory.protoOf(self).asElement();
|
||||
}
|
||||
@@ -79,7 +99,7 @@ pub fn getSelected(self: *const Option) bool {
|
||||
return self._selected;
|
||||
}
|
||||
|
||||
fn setSelected(self: *Option, selected: bool, frame: *Frame) !void {
|
||||
pub fn setSelected(self: *Option, selected: bool, frame: *Frame) !void {
|
||||
self.setSelectedness(selected);
|
||||
frame.domChanged();
|
||||
}
|
||||
@@ -96,7 +116,7 @@ fn setSelectedness(self: *Option, selected: bool) void {
|
||||
}
|
||||
|
||||
/// The <select> this option belongs to, directly or through an <optgroup>.
|
||||
fn ownerSelect(self: *Option) ?*Select {
|
||||
pub fn ownerSelect(self: *Option) ?*Select {
|
||||
var node = self.asNode().parentNode();
|
||||
while (node) |n| : (node = n.parentNode()) {
|
||||
if (n.is(Select)) |select| return select;
|
||||
@@ -135,12 +155,14 @@ pub const JsApi = struct {
|
||||
|
||||
pub const Meta = struct {
|
||||
pub const name = "HTMLOptionElement";
|
||||
pub const constructor_alias = "Option";
|
||||
pub const prototype_chain = bridge.prototypeChain();
|
||||
pub var class_id: bridge.ClassId = undefined;
|
||||
};
|
||||
|
||||
const reflect = Element.Reflect(Option);
|
||||
|
||||
pub const constructor = bridge.constructor(Option.constructor, .{});
|
||||
pub const value = bridge.accessor(Option.getValue, Option.setValue, .{ .ce_reactions = true });
|
||||
pub const text = bridge.accessor(Option.getText, Option.setText, .{ .ce_reactions = true });
|
||||
pub const label = bridge.accessor(Option.getLabel, Option.setLabel, .{ .ce_reactions = true });
|
||||
|
||||
@@ -157,8 +157,10 @@ pub const JsApi = struct {
|
||||
pub const Build = struct {
|
||||
pub fn created(node: *Node, frame: *Frame) !void {
|
||||
const self = node.as(Template);
|
||||
// Create the template content DocumentFragment
|
||||
self._content = try DocumentFragment.init(node.getDocument(frame), frame);
|
||||
// The content DocumentFragment belongs to the inert template contents
|
||||
// owner document, not to the template's own document.
|
||||
const owner = try node.getDocument(frame).templateContentsOwner(frame);
|
||||
self._content = try DocumentFragment.init(owner, frame);
|
||||
}
|
||||
|
||||
// Per the HTML spec's cloning steps for <template>, a deep clone must
|
||||
|
||||
@@ -26,6 +26,7 @@ const Factory = @import("../../Factory.zig");
|
||||
|
||||
const Event = @import("../Event.zig");
|
||||
const EventTarget = @import("../EventTarget.zig");
|
||||
const ErrorEvent = @import("../event/ErrorEvent.zig");
|
||||
|
||||
const log = lp.log;
|
||||
|
||||
@@ -44,6 +45,8 @@ const NavigationCurrentEntryChangeEvent = @import("../event/NavigationCurrentEnt
|
||||
|
||||
_proto: *EventTarget,
|
||||
_on_currententrychange: ?js.Function.Global = null,
|
||||
_on_navigatesuccess: ?js.Function.Global = null,
|
||||
_on_navigateerror: ?js.Function.Global = null,
|
||||
|
||||
_current_navigation_kind: ?NavigationKind = null,
|
||||
|
||||
@@ -66,6 +69,12 @@ pub fn onRemoveFrame(self: *Navigation) void {
|
||||
if (self._on_currententrychange) |cb| cb.release();
|
||||
self._on_currententrychange = null;
|
||||
|
||||
if (self._on_navigatesuccess) |cb| cb.release();
|
||||
self._on_navigatesuccess = null;
|
||||
|
||||
if (self._on_navigateerror) |cb| cb.release();
|
||||
self._on_navigateerror = null;
|
||||
|
||||
for (self._entries.items) |entry| {
|
||||
if (entry._on_dispose) |cb| cb.release();
|
||||
entry._on_dispose = null;
|
||||
@@ -144,10 +153,20 @@ pub fn updateEntries(
|
||||
) !void {
|
||||
switch (kind) {
|
||||
.replace => |state| {
|
||||
_ = try self.replaceEntry(url, .{ .source = .navigation, .value = state }, frame, should_dispatch);
|
||||
_ = try self.replaceEntry(
|
||||
url,
|
||||
.{ .source = .navigation, .value = state },
|
||||
frame,
|
||||
should_dispatch,
|
||||
);
|
||||
},
|
||||
.push => |state| {
|
||||
_ = try self.pushEntry(url, .{ .source = .navigation, .value = state }, frame, should_dispatch);
|
||||
_ = try self.pushEntry(
|
||||
url,
|
||||
.{ .source = .navigation, .value = state },
|
||||
frame,
|
||||
should_dispatch,
|
||||
);
|
||||
},
|
||||
.traverse => |index| {
|
||||
self._index = index;
|
||||
@@ -241,14 +260,9 @@ pub fn pushEntry(
|
||||
try self._entries.append(arena.allocator(), entry);
|
||||
self._index = index;
|
||||
|
||||
if (previous != null and should_dispatch) {
|
||||
if (self._on_currententrychange) |cec| {
|
||||
const event = (try NavigationCurrentEntryChangeEvent.initTrusted(
|
||||
.wrap("currententrychange"),
|
||||
.{ .from = previous.?, .navigationType = @tagName(.push) },
|
||||
frame,
|
||||
)).asEvent();
|
||||
try self.dispatch(cec, event, frame);
|
||||
if (should_dispatch) {
|
||||
if (previous) |p| {
|
||||
try self.fireCurrentEntryChangeEvent(p, .{ .push = state.value }, frame);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,19 +306,73 @@ pub fn replaceEntry(
|
||||
};
|
||||
|
||||
if (should_dispatch) {
|
||||
if (self._on_currententrychange) |cec| {
|
||||
const event = (try NavigationCurrentEntryChangeEvent.initTrusted(
|
||||
.wrap("currententrychange"),
|
||||
.{ .from = previous, .navigationType = @tagName(.replace) },
|
||||
frame,
|
||||
)).asEvent();
|
||||
try self.dispatch(cec, event, frame);
|
||||
}
|
||||
try self.fireCurrentEntryChangeEvent(previous, .{ .replace = state.value }, frame);
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
fn fireNavigateSuccess(self: *Navigation, frame: *Frame) !void {
|
||||
if (!frame.hasDirectListeners(
|
||||
self.asEventTarget(),
|
||||
"navigatesuccess",
|
||||
self._on_navigatesuccess,
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const event = Event.initTrusted(
|
||||
.wrap("navigatesuccess"),
|
||||
null,
|
||||
frame.page,
|
||||
) catch |err| {
|
||||
log.warn(.event, "Navigation.navigatesuccess", .{ .err = err });
|
||||
return;
|
||||
};
|
||||
|
||||
try self.dispatch(self._on_navigatesuccess, event, frame);
|
||||
}
|
||||
|
||||
fn fireCurrentEntryChangeEvent(
|
||||
self: *Navigation,
|
||||
previous: *NavigationHistoryEntry,
|
||||
kind: ?NavigationKind,
|
||||
frame: *Frame,
|
||||
) !void {
|
||||
if (!frame.hasDirectListeners(
|
||||
self.asEventTarget(),
|
||||
"currententrychange",
|
||||
self._on_currententrychange,
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const event =
|
||||
NavigationCurrentEntryChangeEvent.initTrusted(
|
||||
.wrap("currententrychange"),
|
||||
.{
|
||||
.from = previous,
|
||||
.navigationType = if (kind) |k| @tagName(k) else null,
|
||||
},
|
||||
frame,
|
||||
) catch |err| {
|
||||
log.warn(.event, "Navigation.currententrychange", .{ .err = err });
|
||||
return;
|
||||
};
|
||||
|
||||
try self.dispatch(self._on_currententrychange, event.asEvent(), frame);
|
||||
}
|
||||
|
||||
fn resolveFinished(
|
||||
self: *Navigation,
|
||||
resolver: js.PromiseResolver,
|
||||
comptime source: []const u8,
|
||||
frame: *Frame,
|
||||
) !void {
|
||||
resolver.resolve(source, {});
|
||||
try self.fireNavigateSuccess(frame);
|
||||
}
|
||||
|
||||
const NavigateOptions = struct {
|
||||
history: ?[]const u8 = null,
|
||||
info: ?js.Value = null,
|
||||
@@ -351,9 +419,8 @@ pub fn navigateInner(
|
||||
|
||||
committed.resolve("navigation push", {});
|
||||
// todo: Fire navigate event
|
||||
finished.resolve("navigation push", {});
|
||||
|
||||
_ = try self.pushEntry(url, .{ .source = .navigation, .value = state }, frame, true);
|
||||
try self.resolveFinished(finished, "navigation push", frame);
|
||||
} else {
|
||||
try frame.scheduleNavigation(url, .{ .reason = .navigation, .kind = kind }, .{ .script = frame });
|
||||
}
|
||||
@@ -364,9 +431,8 @@ pub fn navigateInner(
|
||||
|
||||
committed.resolve("navigation replace", {});
|
||||
// todo: Fire navigate event
|
||||
finished.resolve("navigation replace", {});
|
||||
|
||||
_ = try self.replaceEntry(url, .{ .source = .navigation, .value = state }, frame, true);
|
||||
try self.resolveFinished(finished, "navigation replace", frame);
|
||||
} else {
|
||||
try frame.scheduleNavigation(url, .{ .reason = .navigation, .kind = kind }, .{ .script = frame });
|
||||
}
|
||||
@@ -379,7 +445,8 @@ pub fn navigateInner(
|
||||
|
||||
committed.resolve("navigation traverse", {});
|
||||
// todo: Fire navigate event
|
||||
finished.resolve("navigation traverse", {});
|
||||
try self.fireCurrentEntryChangeEvent(previous, kind, frame);
|
||||
try self.resolveFinished(finished, "navigation traverse", frame);
|
||||
} else {
|
||||
try frame.scheduleNavigation(url, .{ .reason = .navigation, .kind = kind }, .{ .script = frame });
|
||||
}
|
||||
@@ -393,16 +460,6 @@ pub fn navigateInner(
|
||||
try frame.queueHashChange(old_url, new_url);
|
||||
}
|
||||
|
||||
if (self._on_currententrychange) |cec| {
|
||||
// If we haven't navigated off, let us fire off an a currententrychange.
|
||||
const event = (try NavigationCurrentEntryChangeEvent.initTrusted(
|
||||
.wrap("currententrychange"),
|
||||
.{ .from = previous, .navigationType = @tagName(kind) },
|
||||
frame,
|
||||
)).asEvent();
|
||||
try self.dispatch(cec, event, frame);
|
||||
}
|
||||
|
||||
_ = try committed.persist();
|
||||
_ = try finished.persist();
|
||||
return .{
|
||||
@@ -436,14 +493,11 @@ pub fn reload(self: *Navigation, _opts: ?ReloadOptions, frame: *Frame) !Navigati
|
||||
const entry = self.getCurrentEntry();
|
||||
if (opts.state) |state| {
|
||||
const previous = entry;
|
||||
entry._state = .{ .source = .navigation, .value = state.toJson(arena) catch return error.DataClone };
|
||||
|
||||
const event = try NavigationCurrentEntryChangeEvent.initTrusted(
|
||||
.wrap("currententrychange"),
|
||||
.{ .from = previous, .navigationType = @tagName(.reload) },
|
||||
frame,
|
||||
);
|
||||
try self.dispatch(.{ .currententrychange = event }, frame);
|
||||
entry._state = .{
|
||||
.source = .navigation,
|
||||
.value = state.toJson(arena.allocator()) catch return error.DataClone,
|
||||
};
|
||||
try self.fireCurrentEntryChangeEvent(previous, .reload, frame);
|
||||
}
|
||||
|
||||
return self.navigateInner(entry._url, .reload, frame);
|
||||
@@ -480,18 +534,11 @@ fn updateCurrentEntry(self: *Navigation, options: UpdateCurrentEntryOptions, fra
|
||||
.value = options.state.toJson(arena.allocator()) catch return error.DataClone,
|
||||
};
|
||||
|
||||
if (self._on_currententrychange) |cec| {
|
||||
const event = (try NavigationCurrentEntryChangeEvent.initTrusted(
|
||||
.wrap("currententrychange"),
|
||||
.{ .from = previous, .navigationType = null },
|
||||
frame,
|
||||
)).asEvent();
|
||||
try self.dispatch(cec, event, frame);
|
||||
}
|
||||
try self.fireCurrentEntryChangeEvent(previous, null, frame);
|
||||
}
|
||||
|
||||
pub fn dispatch(self: *Navigation, func: js.Function.Global, event: *Event, frame: *Frame) !void {
|
||||
return frame._event_manager.dispatchDirect(
|
||||
pub fn dispatch(self: *Navigation, func: ?js.Function.Global, event: *Event, frame: *Frame) !void {
|
||||
return frame.dispatch(
|
||||
self.asEventTarget(),
|
||||
event,
|
||||
func,
|
||||
@@ -512,6 +559,24 @@ fn setOnCurrentEntryChange(self: *Navigation, listener: ?js.Function) !void {
|
||||
}
|
||||
}
|
||||
|
||||
fn getOnNavigateSuccess(self: *Navigation) ?js.Function.Global {
|
||||
return self._on_navigatesuccess;
|
||||
}
|
||||
|
||||
fn setOnNavigateSuccess(self: *Navigation, listener: ?js.Function) !void {
|
||||
if (self._on_navigatesuccess) |old| old.release();
|
||||
self._on_navigatesuccess = if (listener) |l| try l.persistWithThis(self) else null;
|
||||
}
|
||||
|
||||
fn getOnNavigateError(self: *Navigation) ?js.Function.Global {
|
||||
return self._on_navigateerror;
|
||||
}
|
||||
|
||||
fn setOnNavigateError(self: *Navigation, listener: ?js.Function) !void {
|
||||
if (self._on_navigateerror) |old| old.release();
|
||||
self._on_navigateerror = if (listener) |l| try l.persistWithThis(self) else null;
|
||||
}
|
||||
|
||||
pub const JsApi = struct {
|
||||
pub const bridge = js.Bridge(Navigation);
|
||||
|
||||
@@ -530,6 +595,7 @@ pub const JsApi = struct {
|
||||
pub const entries = bridge.function(Navigation.entries, .{});
|
||||
pub const forward = bridge.function(Navigation.forward, .{});
|
||||
pub const navigate = bridge.function(Navigation.navigate, .{});
|
||||
pub const reload = bridge.function(Navigation.reload, .{});
|
||||
pub const traverseTo = bridge.function(Navigation.traverseTo, .{});
|
||||
pub const updateCurrentEntry = bridge.function(Navigation.updateCurrentEntry, .{});
|
||||
|
||||
@@ -538,6 +604,16 @@ pub const JsApi = struct {
|
||||
Navigation.setOnCurrentEntryChange,
|
||||
.{},
|
||||
);
|
||||
pub const onnavigatesuccess = bridge.accessor(
|
||||
Navigation.getOnNavigateSuccess,
|
||||
Navigation.setOnNavigateSuccess,
|
||||
.{},
|
||||
);
|
||||
pub const onnavigateerror = bridge.accessor(
|
||||
Navigation.getOnNavigateError,
|
||||
Navigation.setOnNavigateError,
|
||||
.{},
|
||||
);
|
||||
};
|
||||
|
||||
const testing = @import("../../../testing.zig");
|
||||
|
||||
@@ -350,9 +350,9 @@ fn corsAllowed(self: *const EventSource, transfer: *Transfer) bool {
|
||||
var allow_credentials: ?[]const u8 = null;
|
||||
var it = transfer.responseHeaderIterator();
|
||||
while (it.next()) |hdr| {
|
||||
if (std.ascii.eqlIgnoreCase(hdr.name, "access-control-allow-origin")) {
|
||||
if (std.mem.eql(u8, hdr.name, "access-control-allow-origin")) {
|
||||
allow_origin = hdr.value;
|
||||
} else if (std.ascii.eqlIgnoreCase(hdr.name, "access-control-allow-credentials")) {
|
||||
} else if (std.mem.eql(u8, hdr.name, "access-control-allow-credentials")) {
|
||||
allow_credentials = hdr.value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
// 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/>.
|
||||
|
||||
// default configuration for Sanitizer
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
const global_event_handlers = @import("global_event_handlers.zig");
|
||||
|
||||
const Namespace = @import("Sanitizer.zig").Namespace;
|
||||
|
||||
pub const xhtml_ns = "http://www.w3.org/1999/xhtml";
|
||||
pub const svg_ns = "http://www.w3.org/2000/svg";
|
||||
pub const mathml_ns = "http://www.w3.org/1998/Math/MathML";
|
||||
pub const xlink_ns = "http://www.w3.org/1999/xlink";
|
||||
pub const xml_ns = "http://www.w3.org/XML/1998/namespace";
|
||||
pub const xmlns_ns = "http://www.w3.org/2000/xmlns/";
|
||||
|
||||
// A name as a table writes it, and as one arrives from JS: still a plain slice,
|
||||
// because a `Sanitizer.Name` holds an `lp.String`, which cannot be built at
|
||||
// comptime past 12 bytes -- and `animateTransform` and friends are longer.
|
||||
// `Sanitizer.staticName` / `ownName` turn one of these into a `Name`.
|
||||
pub const Name = struct {
|
||||
name: []const u8,
|
||||
namespace: Namespace,
|
||||
};
|
||||
|
||||
pub const Element = struct {
|
||||
name: []const u8,
|
||||
namespace: Namespace,
|
||||
attributes: []const Name = &.{},
|
||||
};
|
||||
|
||||
// https://html.spec.whatwg.org/#built-in-non-replaceable-elements-list
|
||||
pub const non_replaceable_elements: []const Name = &.{
|
||||
.{ .name = "html", .namespace = .xhtml },
|
||||
.{ .name = "svg", .namespace = .svg },
|
||||
.{ .name = "math", .namespace = .mathml },
|
||||
};
|
||||
|
||||
// https://wicg.github.io/sanitizer-api/#built-in-safe-default-configuration
|
||||
pub const default_elements: []const Element = &.{
|
||||
.{ .name = "math", .namespace = .mathml },
|
||||
.{ .name = "merror", .namespace = .mathml },
|
||||
.{ .name = "mfrac", .namespace = .mathml },
|
||||
.{ .name = "mi", .namespace = .mathml },
|
||||
.{ .name = "mmultiscripts", .namespace = .mathml },
|
||||
.{ .name = "mn", .namespace = .mathml },
|
||||
.{ .name = "mo", .namespace = .mathml, .attributes = &.{ .{ .name = "fence", .namespace = .none }, .{ .name = "form", .namespace = .none }, .{ .name = "largeop", .namespace = .none }, .{ .name = "lspace", .namespace = .none }, .{ .name = "maxsize", .namespace = .none }, .{ .name = "minsize", .namespace = .none }, .{ .name = "movablelimits", .namespace = .none }, .{ .name = "rspace", .namespace = .none }, .{ .name = "separator", .namespace = .none }, .{ .name = "stretchy", .namespace = .none }, .{ .name = "symmetric", .namespace = .none } } },
|
||||
.{ .name = "mover", .namespace = .mathml, .attributes = &.{.{ .name = "accent", .namespace = .none }} },
|
||||
.{ .name = "mpadded", .namespace = .mathml, .attributes = &.{ .{ .name = "depth", .namespace = .none }, .{ .name = "height", .namespace = .none }, .{ .name = "lspace", .namespace = .none }, .{ .name = "voffset", .namespace = .none }, .{ .name = "width", .namespace = .none } } },
|
||||
.{ .name = "mphantom", .namespace = .mathml },
|
||||
.{ .name = "mprescripts", .namespace = .mathml },
|
||||
.{ .name = "mroot", .namespace = .mathml },
|
||||
.{ .name = "mrow", .namespace = .mathml },
|
||||
.{ .name = "ms", .namespace = .mathml },
|
||||
.{ .name = "mspace", .namespace = .mathml, .attributes = &.{ .{ .name = "depth", .namespace = .none }, .{ .name = "height", .namespace = .none }, .{ .name = "width", .namespace = .none } } },
|
||||
.{ .name = "msqrt", .namespace = .mathml },
|
||||
.{ .name = "mstyle", .namespace = .mathml },
|
||||
.{ .name = "msub", .namespace = .mathml },
|
||||
.{ .name = "msubsup", .namespace = .mathml },
|
||||
.{ .name = "msup", .namespace = .mathml },
|
||||
.{ .name = "mtable", .namespace = .mathml },
|
||||
.{ .name = "mtd", .namespace = .mathml, .attributes = &.{ .{ .name = "columnspan", .namespace = .none }, .{ .name = "rowspan", .namespace = .none } } },
|
||||
.{ .name = "mtext", .namespace = .mathml },
|
||||
.{ .name = "mtr", .namespace = .mathml },
|
||||
.{ .name = "munder", .namespace = .mathml, .attributes = &.{.{ .name = "accentunder", .namespace = .none }} },
|
||||
.{ .name = "munderover", .namespace = .mathml, .attributes = &.{ .{ .name = "accent", .namespace = .none }, .{ .name = "accentunder", .namespace = .none } } },
|
||||
.{ .name = "semantics", .namespace = .mathml },
|
||||
.{ .name = "a", .namespace = .xhtml, .attributes = &.{ .{ .name = "href", .namespace = .none }, .{ .name = "hreflang", .namespace = .none }, .{ .name = "type", .namespace = .none } } },
|
||||
.{ .name = "abbr", .namespace = .xhtml },
|
||||
.{ .name = "address", .namespace = .xhtml },
|
||||
.{ .name = "article", .namespace = .xhtml },
|
||||
.{ .name = "aside", .namespace = .xhtml },
|
||||
.{ .name = "b", .namespace = .xhtml },
|
||||
.{ .name = "bdi", .namespace = .xhtml },
|
||||
.{ .name = "bdo", .namespace = .xhtml },
|
||||
.{ .name = "blockquote", .namespace = .xhtml, .attributes = &.{.{ .name = "cite", .namespace = .none }} },
|
||||
.{ .name = "body", .namespace = .xhtml },
|
||||
.{ .name = "br", .namespace = .xhtml },
|
||||
.{ .name = "caption", .namespace = .xhtml },
|
||||
.{ .name = "cite", .namespace = .xhtml },
|
||||
.{ .name = "code", .namespace = .xhtml },
|
||||
.{ .name = "col", .namespace = .xhtml, .attributes = &.{.{ .name = "span", .namespace = .none }} },
|
||||
.{ .name = "colgroup", .namespace = .xhtml, .attributes = &.{.{ .name = "span", .namespace = .none }} },
|
||||
.{ .name = "data", .namespace = .xhtml, .attributes = &.{.{ .name = "value", .namespace = .none }} },
|
||||
.{ .name = "dd", .namespace = .xhtml },
|
||||
.{ .name = "del", .namespace = .xhtml, .attributes = &.{ .{ .name = "cite", .namespace = .none }, .{ .name = "datetime", .namespace = .none } } },
|
||||
.{ .name = "dfn", .namespace = .xhtml },
|
||||
.{ .name = "div", .namespace = .xhtml },
|
||||
.{ .name = "dl", .namespace = .xhtml },
|
||||
.{ .name = "dt", .namespace = .xhtml },
|
||||
.{ .name = "em", .namespace = .xhtml },
|
||||
.{ .name = "figcaption", .namespace = .xhtml },
|
||||
.{ .name = "figure", .namespace = .xhtml },
|
||||
.{ .name = "footer", .namespace = .xhtml },
|
||||
.{ .name = "h1", .namespace = .xhtml },
|
||||
.{ .name = "h2", .namespace = .xhtml },
|
||||
.{ .name = "h3", .namespace = .xhtml },
|
||||
.{ .name = "h4", .namespace = .xhtml },
|
||||
.{ .name = "h5", .namespace = .xhtml },
|
||||
.{ .name = "h6", .namespace = .xhtml },
|
||||
.{ .name = "head", .namespace = .xhtml },
|
||||
.{ .name = "header", .namespace = .xhtml },
|
||||
.{ .name = "hgroup", .namespace = .xhtml },
|
||||
.{ .name = "hr", .namespace = .xhtml },
|
||||
.{ .name = "html", .namespace = .xhtml },
|
||||
.{ .name = "i", .namespace = .xhtml },
|
||||
.{ .name = "ins", .namespace = .xhtml, .attributes = &.{ .{ .name = "cite", .namespace = .none }, .{ .name = "datetime", .namespace = .none } } },
|
||||
.{ .name = "kbd", .namespace = .xhtml },
|
||||
.{ .name = "li", .namespace = .xhtml, .attributes = &.{.{ .name = "value", .namespace = .none }} },
|
||||
.{ .name = "main", .namespace = .xhtml },
|
||||
.{ .name = "mark", .namespace = .xhtml },
|
||||
.{ .name = "menu", .namespace = .xhtml },
|
||||
.{ .name = "nav", .namespace = .xhtml },
|
||||
.{ .name = "ol", .namespace = .xhtml, .attributes = &.{ .{ .name = "reversed", .namespace = .none }, .{ .name = "start", .namespace = .none }, .{ .name = "type", .namespace = .none } } },
|
||||
.{ .name = "p", .namespace = .xhtml },
|
||||
.{ .name = "pre", .namespace = .xhtml },
|
||||
.{ .name = "q", .namespace = .xhtml },
|
||||
.{ .name = "rp", .namespace = .xhtml },
|
||||
.{ .name = "rt", .namespace = .xhtml },
|
||||
.{ .name = "ruby", .namespace = .xhtml },
|
||||
.{ .name = "s", .namespace = .xhtml },
|
||||
.{ .name = "samp", .namespace = .xhtml },
|
||||
.{ .name = "search", .namespace = .xhtml },
|
||||
.{ .name = "section", .namespace = .xhtml },
|
||||
.{ .name = "small", .namespace = .xhtml },
|
||||
.{ .name = "span", .namespace = .xhtml },
|
||||
.{ .name = "strong", .namespace = .xhtml },
|
||||
.{ .name = "sub", .namespace = .xhtml },
|
||||
.{ .name = "sup", .namespace = .xhtml },
|
||||
.{ .name = "table", .namespace = .xhtml },
|
||||
.{ .name = "tbody", .namespace = .xhtml },
|
||||
.{ .name = "td", .namespace = .xhtml, .attributes = &.{ .{ .name = "colspan", .namespace = .none }, .{ .name = "headers", .namespace = .none }, .{ .name = "rowspan", .namespace = .none } } },
|
||||
.{ .name = "tfoot", .namespace = .xhtml },
|
||||
.{ .name = "th", .namespace = .xhtml, .attributes = &.{ .{ .name = "abbr", .namespace = .none }, .{ .name = "colspan", .namespace = .none }, .{ .name = "headers", .namespace = .none }, .{ .name = "rowspan", .namespace = .none }, .{ .name = "scope", .namespace = .none } } },
|
||||
.{ .name = "thead", .namespace = .xhtml },
|
||||
.{ .name = "time", .namespace = .xhtml, .attributes = &.{.{ .name = "datetime", .namespace = .none }} },
|
||||
.{ .name = "title", .namespace = .xhtml },
|
||||
.{ .name = "tr", .namespace = .xhtml },
|
||||
.{ .name = "u", .namespace = .xhtml },
|
||||
.{ .name = "ul", .namespace = .xhtml },
|
||||
.{ .name = "var", .namespace = .xhtml },
|
||||
.{ .name = "wbr", .namespace = .xhtml },
|
||||
.{ .name = "a", .namespace = .svg, .attributes = &.{ .{ .name = "href", .namespace = .none }, .{ .name = "hreflang", .namespace = .none }, .{ .name = "type", .namespace = .none } } },
|
||||
.{ .name = "circle", .namespace = .svg, .attributes = &.{ .{ .name = "cx", .namespace = .none }, .{ .name = "cy", .namespace = .none }, .{ .name = "pathLength", .namespace = .none }, .{ .name = "r", .namespace = .none } } },
|
||||
.{ .name = "defs", .namespace = .svg },
|
||||
.{ .name = "desc", .namespace = .svg },
|
||||
.{ .name = "ellipse", .namespace = .svg, .attributes = &.{ .{ .name = "cx", .namespace = .none }, .{ .name = "cy", .namespace = .none }, .{ .name = "pathLength", .namespace = .none }, .{ .name = "rx", .namespace = .none }, .{ .name = "ry", .namespace = .none } } },
|
||||
.{ .name = "foreignObject", .namespace = .svg, .attributes = &.{ .{ .name = "height", .namespace = .none }, .{ .name = "width", .namespace = .none }, .{ .name = "x", .namespace = .none }, .{ .name = "y", .namespace = .none } } },
|
||||
.{ .name = "g", .namespace = .svg },
|
||||
.{ .name = "line", .namespace = .svg, .attributes = &.{ .{ .name = "pathLength", .namespace = .none }, .{ .name = "x1", .namespace = .none }, .{ .name = "x2", .namespace = .none }, .{ .name = "y1", .namespace = .none }, .{ .name = "y2", .namespace = .none } } },
|
||||
.{ .name = "marker", .namespace = .svg, .attributes = &.{ .{ .name = "markerHeight", .namespace = .none }, .{ .name = "markerUnits", .namespace = .none }, .{ .name = "markerWidth", .namespace = .none }, .{ .name = "orient", .namespace = .none }, .{ .name = "preserveAspectRatio", .namespace = .none }, .{ .name = "refX", .namespace = .none }, .{ .name = "refY", .namespace = .none }, .{ .name = "viewBox", .namespace = .none } } },
|
||||
.{ .name = "metadata", .namespace = .svg },
|
||||
.{ .name = "path", .namespace = .svg, .attributes = &.{ .{ .name = "d", .namespace = .none }, .{ .name = "pathLength", .namespace = .none } } },
|
||||
.{ .name = "polygon", .namespace = .svg, .attributes = &.{ .{ .name = "pathLength", .namespace = .none }, .{ .name = "points", .namespace = .none } } },
|
||||
.{ .name = "polyline", .namespace = .svg, .attributes = &.{ .{ .name = "pathLength", .namespace = .none }, .{ .name = "points", .namespace = .none } } },
|
||||
.{ .name = "rect", .namespace = .svg, .attributes = &.{ .{ .name = "height", .namespace = .none }, .{ .name = "pathLength", .namespace = .none }, .{ .name = "rx", .namespace = .none }, .{ .name = "ry", .namespace = .none }, .{ .name = "width", .namespace = .none }, .{ .name = "x", .namespace = .none }, .{ .name = "y", .namespace = .none } } },
|
||||
.{ .name = "svg", .namespace = .svg, .attributes = &.{ .{ .name = "height", .namespace = .none }, .{ .name = "preserveAspectRatio", .namespace = .none }, .{ .name = "viewBox", .namespace = .none }, .{ .name = "width", .namespace = .none }, .{ .name = "x", .namespace = .none }, .{ .name = "y", .namespace = .none } } },
|
||||
.{ .name = "text", .namespace = .svg, .attributes = &.{ .{ .name = "dx", .namespace = .none }, .{ .name = "dy", .namespace = .none }, .{ .name = "lengthAdjust", .namespace = .none }, .{ .name = "rotate", .namespace = .none }, .{ .name = "textLength", .namespace = .none }, .{ .name = "x", .namespace = .none }, .{ .name = "y", .namespace = .none } } },
|
||||
.{ .name = "textPath", .namespace = .svg, .attributes = &.{ .{ .name = "lengthAdjust", .namespace = .none }, .{ .name = "method", .namespace = .none }, .{ .name = "path", .namespace = .none }, .{ .name = "side", .namespace = .none }, .{ .name = "spacing", .namespace = .none }, .{ .name = "startOffset", .namespace = .none }, .{ .name = "textLength", .namespace = .none } } },
|
||||
.{ .name = "title", .namespace = .svg },
|
||||
.{ .name = "tspan", .namespace = .svg, .attributes = &.{ .{ .name = "dx", .namespace = .none }, .{ .name = "dy", .namespace = .none }, .{ .name = "lengthAdjust", .namespace = .none }, .{ .name = "rotate", .namespace = .none }, .{ .name = "textLength", .namespace = .none }, .{ .name = "x", .namespace = .none }, .{ .name = "y", .namespace = .none } } },
|
||||
};
|
||||
|
||||
pub const default_attributes: []const Name = &.{
|
||||
.{ .name = "alignment-baseline", .namespace = .none },
|
||||
.{ .name = "baseline-shift", .namespace = .none },
|
||||
.{ .name = "clip-path", .namespace = .none },
|
||||
.{ .name = "clip-rule", .namespace = .none },
|
||||
.{ .name = "color", .namespace = .none },
|
||||
.{ .name = "color-interpolation", .namespace = .none },
|
||||
.{ .name = "cursor", .namespace = .none },
|
||||
.{ .name = "dir", .namespace = .none },
|
||||
.{ .name = "direction", .namespace = .none },
|
||||
.{ .name = "display", .namespace = .none },
|
||||
.{ .name = "displaystyle", .namespace = .none },
|
||||
.{ .name = "dominant-baseline", .namespace = .none },
|
||||
.{ .name = "fill", .namespace = .none },
|
||||
.{ .name = "fill-opacity", .namespace = .none },
|
||||
.{ .name = "fill-rule", .namespace = .none },
|
||||
.{ .name = "font-family", .namespace = .none },
|
||||
.{ .name = "font-size", .namespace = .none },
|
||||
.{ .name = "font-size-adjust", .namespace = .none },
|
||||
.{ .name = "font-stretch", .namespace = .none },
|
||||
.{ .name = "font-style", .namespace = .none },
|
||||
.{ .name = "font-variant", .namespace = .none },
|
||||
.{ .name = "font-weight", .namespace = .none },
|
||||
.{ .name = "lang", .namespace = .none },
|
||||
.{ .name = "letter-spacing", .namespace = .none },
|
||||
.{ .name = "marker-end", .namespace = .none },
|
||||
.{ .name = "marker-mid", .namespace = .none },
|
||||
.{ .name = "marker-start", .namespace = .none },
|
||||
.{ .name = "mathbackground", .namespace = .none },
|
||||
.{ .name = "mathcolor", .namespace = .none },
|
||||
.{ .name = "mathsize", .namespace = .none },
|
||||
.{ .name = "opacity", .namespace = .none },
|
||||
.{ .name = "paint-order", .namespace = .none },
|
||||
.{ .name = "pointer-events", .namespace = .none },
|
||||
.{ .name = "scriptlevel", .namespace = .none },
|
||||
.{ .name = "shape-rendering", .namespace = .none },
|
||||
.{ .name = "stop-color", .namespace = .none },
|
||||
.{ .name = "stop-opacity", .namespace = .none },
|
||||
.{ .name = "stroke", .namespace = .none },
|
||||
.{ .name = "stroke-dasharray", .namespace = .none },
|
||||
.{ .name = "stroke-dashoffset", .namespace = .none },
|
||||
.{ .name = "stroke-linecap", .namespace = .none },
|
||||
.{ .name = "stroke-linejoin", .namespace = .none },
|
||||
.{ .name = "stroke-miterlimit", .namespace = .none },
|
||||
.{ .name = "stroke-opacity", .namespace = .none },
|
||||
.{ .name = "stroke-width", .namespace = .none },
|
||||
.{ .name = "text-anchor", .namespace = .none },
|
||||
.{ .name = "text-decoration", .namespace = .none },
|
||||
.{ .name = "text-overflow", .namespace = .none },
|
||||
.{ .name = "text-rendering", .namespace = .none },
|
||||
.{ .name = "title", .namespace = .none },
|
||||
.{ .name = "transform", .namespace = .none },
|
||||
.{ .name = "transform-origin", .namespace = .none },
|
||||
.{ .name = "unicode-bidi", .namespace = .none },
|
||||
.{ .name = "vector-effect", .namespace = .none },
|
||||
.{ .name = "visibility", .namespace = .none },
|
||||
.{ .name = "white-space", .namespace = .none },
|
||||
.{ .name = "word-spacing", .namespace = .none },
|
||||
.{ .name = "writing-mode", .namespace = .none },
|
||||
};
|
||||
|
||||
// https://html.spec.whatwg.org/#built-in-safe-baseline-configuration
|
||||
// Every HTML element the spec marks "Sanitization: Unsafe" (base, embed,
|
||||
// iframe, object, script), plus the obsolete frame and SVG's script and use.
|
||||
pub const baseline_remove_elements: []const Name = &.{
|
||||
.{ .name = "base", .namespace = .xhtml },
|
||||
.{ .name = "embed", .namespace = .xhtml },
|
||||
.{ .name = "frame", .namespace = .xhtml },
|
||||
.{ .name = "iframe", .namespace = .xhtml },
|
||||
.{ .name = "object", .namespace = .xhtml },
|
||||
.{ .name = "script", .namespace = .xhtml },
|
||||
.{ .name = "script", .namespace = .svg },
|
||||
.{ .name = "use", .namespace = .svg },
|
||||
};
|
||||
|
||||
// The baseline's own removeAttributes list is empty; `remove unsafe` instead
|
||||
// walks every "event handler content attribute". We fold lightpanda's own
|
||||
// handler set into HTML's list so that a handler added to `Handler` -- which is
|
||||
// what an `on*` content attribute is compiled against -- can never be left
|
||||
// behind by removeUnsafe().
|
||||
pub const event_handler_attributes: []const []const u8 = blk: {
|
||||
@setEvalBranchQuota(200_000);
|
||||
const handlers = std.meta.fieldNames(global_event_handlers.Handler);
|
||||
var all: [html_event_handler_attributes.len + handlers.len][]const u8 = undefined;
|
||||
for (html_event_handler_attributes, 0..) |name, i| {
|
||||
all[i] = name;
|
||||
}
|
||||
for (handlers, 0..) |name, i| {
|
||||
all[html_event_handler_attributes.len + i] = name;
|
||||
}
|
||||
std.mem.sort([]const u8, &all, {}, struct {
|
||||
fn lessThan(_: void, a: []const u8, b: []const u8) bool {
|
||||
return std.mem.lessThan(u8, a, b);
|
||||
}
|
||||
}.lessThan);
|
||||
|
||||
var unique: [all.len][]const u8 = undefined;
|
||||
var len: usize = 0;
|
||||
for (all) |name| {
|
||||
if (len == 0 or std.mem.eql(u8, unique[len - 1], name) == false) {
|
||||
unique[len] = name;
|
||||
len += 1;
|
||||
}
|
||||
}
|
||||
const final = unique[0..len].*;
|
||||
break :blk &final;
|
||||
};
|
||||
|
||||
const html_event_handler_attributes: []const []const u8 = &.{
|
||||
"onabort",
|
||||
"onactivate",
|
||||
"onafterprint",
|
||||
"onanimationcancel",
|
||||
"onanimationend",
|
||||
"onanimationiteration",
|
||||
"onanimationstart",
|
||||
"onautofill",
|
||||
"onauxclick",
|
||||
"onbeforecopy",
|
||||
"onbeforecut",
|
||||
"onbeforefilter",
|
||||
"onbeforeinput",
|
||||
"onbeforepaste",
|
||||
"onbeforeprint",
|
||||
"onbeforetoggle",
|
||||
"onbeforeunload",
|
||||
"onbegin",
|
||||
"onblur",
|
||||
"oncancel",
|
||||
"oncanplay",
|
||||
"oncanplaythrough",
|
||||
"onchange",
|
||||
"onclick",
|
||||
"onclose",
|
||||
"oncommand",
|
||||
"oncontentvisibilityautostatechange",
|
||||
"oncontextlost",
|
||||
"oncontextmenu",
|
||||
"oncontextrestored",
|
||||
"oncopy",
|
||||
"oncuechange",
|
||||
"oncut",
|
||||
"ondblclick",
|
||||
"ondrag",
|
||||
"ondragend",
|
||||
"ondragenter",
|
||||
"ondragleave",
|
||||
"ondragover",
|
||||
"ondragstart",
|
||||
"ondrop",
|
||||
"ondurationchange",
|
||||
"onemptied",
|
||||
"onend",
|
||||
"onended",
|
||||
"onerror",
|
||||
"onfocus",
|
||||
"onfocusin",
|
||||
"onfocusout",
|
||||
"onformdata",
|
||||
"ongotpointercapture",
|
||||
"onhashchange",
|
||||
"oninput",
|
||||
"oninstallresult",
|
||||
"oninvalid",
|
||||
"onkeydown",
|
||||
"onkeypress",
|
||||
"onkeyup",
|
||||
"onlanguagechange",
|
||||
"onload",
|
||||
"onloadeddata",
|
||||
"onloadedmetadata",
|
||||
"onloadstart",
|
||||
"onlocation",
|
||||
"onlostpointercapture",
|
||||
"onmessage",
|
||||
"onmessageerror",
|
||||
"onmousedown",
|
||||
"onmouseenter",
|
||||
"onmouseleave",
|
||||
"onmousemove",
|
||||
"onmouseout",
|
||||
"onmouseover",
|
||||
"onmouseup",
|
||||
"onmousewheel",
|
||||
"onmove",
|
||||
"onoffline",
|
||||
"ononline",
|
||||
"onorientationchange",
|
||||
"onpagehide",
|
||||
"onpageshow",
|
||||
"onpaste",
|
||||
"onpause",
|
||||
"onplay",
|
||||
"onplaying",
|
||||
"onpointercancel",
|
||||
"onpointerdown",
|
||||
"onpointerenter",
|
||||
"onpointerleave",
|
||||
"onpointermove",
|
||||
"onpointerout",
|
||||
"onpointerover",
|
||||
"onpointerrawupdate",
|
||||
"onpointerup",
|
||||
"onpopstate",
|
||||
"onprogress",
|
||||
"onpromptaction",
|
||||
"onpromptdismiss",
|
||||
"onratechange",
|
||||
"onrepeat",
|
||||
"onreset",
|
||||
"onresize",
|
||||
"onscroll",
|
||||
"onscrollend",
|
||||
"onscrollsnapchange",
|
||||
"onscrollsnapchanging",
|
||||
"onsearch",
|
||||
"onsecuritypolicyviolation",
|
||||
"onseeked",
|
||||
"onseeking",
|
||||
"onselect",
|
||||
"onselectionchange",
|
||||
"onselectstart",
|
||||
"onshow",
|
||||
"onslotchange",
|
||||
"onstalled",
|
||||
"onstream",
|
||||
"onstorage",
|
||||
"onsubmit",
|
||||
"onsuspend",
|
||||
"ontimeupdate",
|
||||
"ontimezonechange",
|
||||
"ontoggle",
|
||||
"ontouchcancel",
|
||||
"ontouchend",
|
||||
"ontouchmove",
|
||||
"ontouchstart",
|
||||
"ontransitionend",
|
||||
"onunload",
|
||||
"onvalidationstatuschange",
|
||||
"onvolumechange",
|
||||
"onwaiting",
|
||||
"onwebkitanimationend",
|
||||
"onwebkitanimationiteration",
|
||||
"onwebkitanimationstart",
|
||||
"onwebkitfullscreenchange",
|
||||
"onwebkitfullscreenerror",
|
||||
"onwebkittransitionend",
|
||||
"onwheel",
|
||||
};
|
||||
@@ -406,6 +406,9 @@
|
||||
\\ --cookie-jar <PATH>
|
||||
\\ Path to a JSON file to save cookies to on exit (write-only).
|
||||
\\ Defaults to no cookie saving.
|
||||
\\ --cors-store-entry-limit <INT>
|
||||
\\ Maximum number of entries kept in the CorsStore. 0 means no limit.
|
||||
\\ Defaults to 1000.
|
||||
\\ --experimental-features <FEATURE>
|
||||
\\ Enable an experimental, unstable feature. Can be passed multiple times.
|
||||
\\ Behavior may change or be removed without notice.
|
||||
@@ -515,6 +518,9 @@
|
||||
\\ --proxy-bearer-token <TOKEN>
|
||||
\\ Token sent for bearer authentication with the proxy:
|
||||
\\ Proxy-Authorization: Bearer <token>.
|
||||
\\ --robot-store-entry-limit <INT>
|
||||
\\ Maximum number of entries kept in the RobotStore. 0 means no limit.
|
||||
\\ Defaults to 1000.
|
||||
\\ --timezone <IANA>
|
||||
\\ Time zone used by Date and Intl, e.g. Europe/Paris or UTC.
|
||||
\\ Defaults to the host time zone.
|
||||
|
||||
+34
-7
@@ -140,27 +140,54 @@ pub const Format = enum {
|
||||
pretty,
|
||||
};
|
||||
|
||||
pub fn debug(scope: Scope, msg: []const u8, data: anytype) void {
|
||||
// A message is a short, plain-text key; the detail belongs in the kvs.
|
||||
const max_msg_len = 30;
|
||||
|
||||
/// The comptime form of the checks in logToErased. Those only fire in a debug
|
||||
/// build *and* only once the line actually runs, so a message on a rare path
|
||||
/// can ship and then panic on whoever first hits it. Every message below is a
|
||||
/// literal, so the same rules can be a build error instead.
|
||||
fn validateMsg(comptime msg: []const u8) void {
|
||||
comptime {
|
||||
if (msg.len > max_msg_len) {
|
||||
@compileError("log msg cannot be more than 30 characters: " ++ msg);
|
||||
}
|
||||
for (msg) |b| {
|
||||
switch (b) {
|
||||
'A'...'Z', 'a'...'z', ' ', '0'...'9', '_', '-', '.', '{', '}' => {},
|
||||
else => @compileError("log msg contains an invalid character: " ++ msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn debug(scope: Scope, comptime msg: []const u8, data: anytype) void {
|
||||
comptime validateMsg(msg);
|
||||
log(scope, .debug, msg, data);
|
||||
}
|
||||
|
||||
pub fn info(scope: Scope, msg: []const u8, data: anytype) void {
|
||||
pub fn info(scope: Scope, comptime msg: []const u8, data: anytype) void {
|
||||
comptime validateMsg(msg);
|
||||
log(scope, .info, msg, data);
|
||||
}
|
||||
|
||||
pub fn warn(scope: Scope, msg: []const u8, data: anytype) void {
|
||||
pub fn warn(scope: Scope, comptime msg: []const u8, data: anytype) void {
|
||||
comptime validateMsg(msg);
|
||||
log(scope, .warn, msg, data);
|
||||
}
|
||||
|
||||
pub fn err(scope: Scope, msg: []const u8, data: anytype) void {
|
||||
pub fn err(scope: Scope, comptime msg: []const u8, data: anytype) void {
|
||||
comptime validateMsg(msg);
|
||||
log(scope, .err, msg, data);
|
||||
}
|
||||
|
||||
pub fn fatal(scope: Scope, msg: []const u8, data: anytype) void {
|
||||
pub fn fatal(scope: Scope, comptime msg: []const u8, data: anytype) void {
|
||||
comptime validateMsg(msg);
|
||||
log(scope, .fatal, msg, data);
|
||||
}
|
||||
|
||||
pub fn note(scope: Scope, msg: []const u8, data: anytype) void {
|
||||
pub fn note(scope: Scope, comptime msg: []const u8, data: anytype) void {
|
||||
comptime validateMsg(msg);
|
||||
if (comptime lp.IS_TEST == false) {
|
||||
log(scope, .note, msg, data);
|
||||
}
|
||||
@@ -236,7 +263,7 @@ fn logTo(scope: Scope, level: Level, msg: []const u8, data: anytype, out: *std.I
|
||||
|
||||
fn logToErased(scope: Scope, level: Level, msg: []const u8, kvs: []const KV, out: *std.Io.Writer) !void {
|
||||
if (lp.IS_DEBUG) {
|
||||
if (msg.len > 30) {
|
||||
if (msg.len > max_msg_len) {
|
||||
std.debug.print("debug-only-panic: log msg cannot be more than 30 characters: {s}", .{msg});
|
||||
@panic("invalid log msg");
|
||||
}
|
||||
|
||||
@@ -102,14 +102,14 @@ fn storeFromSystemCA(allocator: Allocator) !*crypto.X509_STORE {
|
||||
// advances `ptr` past it, so we just hand it the rest of the buffer.
|
||||
var ptr: [*]const u8 = bytes.ptr + index.*;
|
||||
const x509 = crypto.d2i_X509(null, &ptr, @intCast(bytes.len - index.*)) orelse {
|
||||
log.warn(.app, "Skipping unparseable system cert", .{});
|
||||
log.warn(.app, "Skipping unparseable cert", .{});
|
||||
continue;
|
||||
};
|
||||
defer crypto.X509_free(x509); // add_cert takes its own ref; drop ours.
|
||||
|
||||
const result = crypto.X509_STORE_add_cert(store, x509);
|
||||
if (result != 1) {
|
||||
log.warn(.app, "Failed to add X509 cert to store", .{});
|
||||
log.warn(.app, "Failed to add X509 cert", .{});
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
pub fn Entry(comptime V: type) type {
|
||||
return struct {
|
||||
value: V,
|
||||
referenced: bool,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn ClockCache(comptime V: type) type {
|
||||
return struct {
|
||||
const Self = @This();
|
||||
|
||||
const Map = std.array_hash_map.String(Entry(V));
|
||||
|
||||
pub const InsertResult = union(enum) {
|
||||
exists,
|
||||
inserted: ?V,
|
||||
};
|
||||
|
||||
allocator: Allocator,
|
||||
capacity: ?usize,
|
||||
map: Map = .empty,
|
||||
hand: usize = 0,
|
||||
|
||||
pub fn init(allocator: Allocator, capacity: usize) Self {
|
||||
const true_capacity = if (capacity == 0) null else capacity;
|
||||
return .{ .allocator = allocator, .capacity = true_capacity };
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Self) void {
|
||||
for (self.map.keys()) |key| {
|
||||
self.allocator.free(key);
|
||||
}
|
||||
self.map.deinit(self.allocator);
|
||||
}
|
||||
|
||||
pub fn entries(self: *Self) []Entry(V) {
|
||||
return self.map.values();
|
||||
}
|
||||
|
||||
pub fn get(self: *Self, key: []const u8) ?*V {
|
||||
const entry = self.map.getPtr(key) orelse return null;
|
||||
entry.referenced = true;
|
||||
return &entry.value;
|
||||
}
|
||||
|
||||
pub fn remove(self: *Self, key: []const u8) ?V {
|
||||
const index = self.map.getIndex(key) orelse return null;
|
||||
const owned_key = self.map.keys()[index];
|
||||
const value = self.map.values()[index].value;
|
||||
self.map.swapRemoveAt(index);
|
||||
self.allocator.free(owned_key);
|
||||
return value;
|
||||
}
|
||||
|
||||
pub fn insert(self: *Self, key: []const u8, value: V) !InsertResult {
|
||||
const gop = try self.map.getOrPut(self.allocator, key);
|
||||
if (gop.found_existing) return .exists;
|
||||
|
||||
errdefer self.map.swapRemoveAt(gop.index);
|
||||
gop.key_ptr.* = try self.allocator.dupe(u8, key);
|
||||
gop.value_ptr.* = .{ .value = value, .referenced = true };
|
||||
|
||||
if (self.capacity) |cap| {
|
||||
if (self.map.count() > cap) {
|
||||
return .{ .inserted = self.evictOne() };
|
||||
}
|
||||
}
|
||||
|
||||
return .{ .inserted = null };
|
||||
}
|
||||
|
||||
fn evictOne(self: *Self) V {
|
||||
const items = self.map.values();
|
||||
while (true) {
|
||||
if (self.hand >= items.len) self.hand = 0;
|
||||
|
||||
if (items[self.hand].referenced) {
|
||||
items[self.hand].referenced = false;
|
||||
self.hand += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = self.map.keys()[self.hand];
|
||||
const value = items[self.hand].value;
|
||||
self.map.swapRemoveAt(self.hand);
|
||||
self.allocator.free(key);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const testing = @import("../testing.zig");
|
||||
|
||||
fn evictedOf(comptime V: type, r: ClockCache(V).InsertResult) ?V {
|
||||
return switch (r) {
|
||||
.exists => unreachable,
|
||||
.inserted => |v| v,
|
||||
};
|
||||
}
|
||||
|
||||
test "ClockCache: no eviction under capacity" {
|
||||
var q = ClockCache(u32).init(testing.allocator, 3);
|
||||
defer q.deinit();
|
||||
|
||||
try testing.expectEqual(null, evictedOf(u32, try q.insert("a", 1)));
|
||||
try testing.expectEqual(null, evictedOf(u32, try q.insert("b", 2)));
|
||||
try testing.expectEqual(null, evictedOf(u32, try q.insert("c", 3)));
|
||||
try testing.expectEqual(3, q.map.count());
|
||||
}
|
||||
|
||||
test "ClockCache: evicts once over capacity" {
|
||||
var q = ClockCache(u32).init(testing.allocator, 2);
|
||||
defer q.deinit();
|
||||
|
||||
try testing.expectEqual(null, evictedOf(u32, try q.insert("a", 1)));
|
||||
try testing.expectEqual(null, evictedOf(u32, try q.insert("b", 2)));
|
||||
|
||||
const evicted = evictedOf(u32, try q.insert("c", 3));
|
||||
try testing.expectEqual(1, evicted.?);
|
||||
try testing.expect(q.get("a") == null);
|
||||
try testing.expectEqual(2, q.map.count());
|
||||
}
|
||||
|
||||
test "ClockCache: touch protects a key from eviction" {
|
||||
var q = ClockCache(u32).init(testing.allocator, 2);
|
||||
defer q.deinit();
|
||||
|
||||
_ = try q.insert("a", 1);
|
||||
_ = try q.insert("b", 2);
|
||||
|
||||
const evicted1 = evictedOf(u32, try q.insert("c", 3));
|
||||
try testing.expectEqual(1, evicted1.?);
|
||||
try testing.expect(q.get("b") != null);
|
||||
try testing.expect(q.get("c") != null);
|
||||
|
||||
_ = q.get("b");
|
||||
const evicted2 = evictedOf(u32, try q.insert("d", 4));
|
||||
try testing.expectEqual(3, evicted2.?);
|
||||
try testing.expect(q.get("b") != null);
|
||||
try testing.expectEqual(2, q.map.count());
|
||||
}
|
||||
|
||||
test "ClockCache: insert does not overwrite" {
|
||||
var q = ClockCache(u32).init(testing.allocator, 2);
|
||||
defer q.deinit();
|
||||
|
||||
_ = try q.insert("a", 1);
|
||||
try testing.expect((try q.insert("a", 99)) == .exists);
|
||||
try testing.expectEqual(1, q.get("a").?.*);
|
||||
}
|
||||
+123
-33
@@ -25,11 +25,15 @@ const http = @import("http.zig");
|
||||
const Transfer = @import("HttpClient.zig").Transfer;
|
||||
const SingleFlight = @import("SingleFlight.zig");
|
||||
const HttpClient = @import("HttpClient.zig");
|
||||
const Network = @import("Network.zig");
|
||||
|
||||
const CorsStore = @import("CorsStore.zig");
|
||||
|
||||
const log = lp.log;
|
||||
|
||||
const CorsGate = @This();
|
||||
|
||||
network: *Network,
|
||||
single_flight: SingleFlight,
|
||||
|
||||
// CORS Request Headers
|
||||
@@ -42,6 +46,7 @@ const ACCESS_CONTROL_ALLOW_ORIGIN = "access-control-allow-origin";
|
||||
const ACCESS_CONTROL_ALLOW_METHODS = "access-control-allow-methods";
|
||||
const ACCESS_CONTROL_ALLOW_HEADERS = "access-control-allow-headers";
|
||||
const ACCESS_CONTROL_ALLOW_CREDENTIALS = "access-control-allow-credentials";
|
||||
const ACCESS_CONTROL_MAX_AGE = "access-control-max-age";
|
||||
|
||||
pub fn deinit(self: *CorsGate) void {
|
||||
self.single_flight.deinit();
|
||||
@@ -72,7 +77,7 @@ fn flushPending(self: *CorsGate, key: []const u8, allowed: bool) void {
|
||||
}
|
||||
}
|
||||
|
||||
fn isSafelistedMethod(value: http.Method) bool {
|
||||
pub fn isSafelistedMethod(value: http.Method) bool {
|
||||
return switch (value) {
|
||||
.GET, .HEAD, .POST => true,
|
||||
else => false,
|
||||
@@ -212,6 +217,28 @@ pub fn check(self: *CorsGate, transfer: *Transfer) !Result {
|
||||
return .allowed;
|
||||
}
|
||||
|
||||
const wants_credentials = req.credentials_mode == .include;
|
||||
|
||||
const authored = try collectAuthoredHeaders(transfer, transfer.arena.allocator());
|
||||
|
||||
const covered = try self.network.cors_store.coversRequest(
|
||||
transfer.arena.allocator(),
|
||||
.{ .origin = origin, .target = req.url, .credentials = wants_credentials },
|
||||
req.method,
|
||||
authored.items,
|
||||
);
|
||||
|
||||
if (covered) {
|
||||
log.debug(.cors, "cross origin", .{
|
||||
.url = req.url,
|
||||
.origin = origin,
|
||||
.preflight = false,
|
||||
.cached = true,
|
||||
});
|
||||
lp.metrics.cors_check.incr(.cached);
|
||||
return .allowed;
|
||||
}
|
||||
|
||||
log.debug(.cors, "cross origin", .{
|
||||
.url = req.url,
|
||||
.origin = origin,
|
||||
@@ -219,10 +246,25 @@ pub fn check(self: *CorsGate, transfer: *Transfer) !Result {
|
||||
});
|
||||
lp.metrics.cors_check.incr(.preflight);
|
||||
|
||||
try self.fetchThenResume(transfer);
|
||||
try self.fetchThenResume(transfer, authored.items);
|
||||
return .pending;
|
||||
}
|
||||
|
||||
fn collectAuthoredHeaders(transfer: *Transfer, allocator: std.mem.Allocator) !std.ArrayList([]const u8) {
|
||||
var header_names: std.ArrayList([]const u8) = .empty;
|
||||
for (transfer.req_headers.items) |hdr| {
|
||||
if (hdr.source != .author) continue;
|
||||
if (isSafelistedHeader(hdr.name, hdr.value)) continue;
|
||||
try header_names.append(allocator, try std.ascii.allocLowerString(allocator, hdr.name));
|
||||
}
|
||||
std.mem.sort([]const u8, header_names.items, {}, struct {
|
||||
fn lessThan(_: void, a: []const u8, b: []const u8) bool {
|
||||
return std.mem.lessThan(u8, a, b);
|
||||
}
|
||||
}.lessThan);
|
||||
return header_names;
|
||||
}
|
||||
|
||||
const CorsKey = struct {
|
||||
url: []const u8,
|
||||
origin: []const u8,
|
||||
@@ -264,6 +306,9 @@ const CorsPreflightContext = struct {
|
||||
wants_credentials: bool,
|
||||
|
||||
allowed: bool = false,
|
||||
acam: ?[]const u8 = null,
|
||||
acah: ?[]const u8 = null,
|
||||
acma: ?[]const u8 = null,
|
||||
|
||||
fn validateHeaders(
|
||||
self: *CorsPreflightContext,
|
||||
@@ -355,6 +400,57 @@ const CorsPreflightContext = struct {
|
||||
return true;
|
||||
}
|
||||
|
||||
fn cacheGrant(self: *CorsPreflightContext, acam: ?[]const u8, acah: ?[]const u8, acma: ?[]const u8) !void {
|
||||
if (self.url.len == 0) return;
|
||||
|
||||
const max_age_s: u64 = blk: {
|
||||
const v = acma orelse break :blk 5;
|
||||
if (v.len == 0) break :blk 5;
|
||||
break :blk std.fmt.parseUnsigned(u64, v, 10) catch return;
|
||||
};
|
||||
if (max_age_s == 0) return;
|
||||
|
||||
const capped_s: u64 = @min(max_age_s, 7200);
|
||||
const capped_ms = capped_s * 1000;
|
||||
|
||||
const methods_wildcard = acam != null and std.mem.eql(u8, acam.?, "*") and !self.wants_credentials;
|
||||
var methods = std.EnumSet(http.Method).initEmpty();
|
||||
if (!methods_wildcard) {
|
||||
if (acam) |list| {
|
||||
var it = std.mem.splitScalar(u8, list, ',');
|
||||
while (it.next()) |raw| {
|
||||
const token = std.mem.trim(u8, raw, &std.ascii.whitespace);
|
||||
if (std.meta.stringToEnum(http.Method, token)) |m| methods.insert(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const headers_wildcard = acah != null and std.mem.eql(u8, acah.?, "*") and !self.wants_credentials;
|
||||
|
||||
var allowed_headers: std.ArrayList([]const u8) = .empty;
|
||||
if (!headers_wildcard) {
|
||||
if (acah) |list| {
|
||||
var it = std.mem.splitScalar(u8, list, ',');
|
||||
while (it.next()) |raw| {
|
||||
const token = std.mem.trim(u8, raw, &std.ascii.whitespace);
|
||||
if (token.len == 0) continue;
|
||||
try allowed_headers.append(self.arena.allocator(), token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try self.gate.network.cors_store.put(
|
||||
.{ .origin = self.origin, .target = self.url, .credentials = self.wants_credentials },
|
||||
.{
|
||||
.methods_wildcard = methods_wildcard,
|
||||
.methods = methods,
|
||||
.headers_wildcard = headers_wildcard,
|
||||
.headers = allowed_headers.items,
|
||||
.expires_at = lp.datetime.milliTimestamp(.real) + capped_ms,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn methodAllowed(list: []const u8, method: http.Method) bool {
|
||||
const method_name = @tagName(method);
|
||||
var it = std.mem.splitScalar(u8, list, ',');
|
||||
@@ -393,26 +489,39 @@ const CorsPreflightContext = struct {
|
||||
var acam: ?[]const u8 = null;
|
||||
var acah: ?[]const u8 = null;
|
||||
var acac: ?[]const u8 = null;
|
||||
var acma: ?[]const u8 = null;
|
||||
|
||||
var iter = transfer.responseHeaderIterator();
|
||||
while (iter.next()) |hdr| {
|
||||
if (std.ascii.eqlIgnoreCase(ACCESS_CONTROL_ALLOW_ORIGIN, hdr.name)) {
|
||||
if (std.mem.eql(u8, hdr.name, ACCESS_CONTROL_ALLOW_ORIGIN)) {
|
||||
acao = hdr.value;
|
||||
} else if (std.ascii.eqlIgnoreCase(ACCESS_CONTROL_ALLOW_METHODS, hdr.name)) {
|
||||
} else if (std.mem.eql(u8, hdr.name, ACCESS_CONTROL_ALLOW_METHODS)) {
|
||||
acam = hdr.value;
|
||||
} else if (std.ascii.eqlIgnoreCase(ACCESS_CONTROL_ALLOW_HEADERS, hdr.name)) {
|
||||
} else if (std.mem.eql(u8, hdr.name, ACCESS_CONTROL_ALLOW_HEADERS)) {
|
||||
acah = hdr.value;
|
||||
} else if (std.ascii.eqlIgnoreCase(ACCESS_CONTROL_ALLOW_CREDENTIALS, hdr.name)) {
|
||||
} else if (std.mem.eql(u8, hdr.name, ACCESS_CONTROL_ALLOW_CREDENTIALS)) {
|
||||
acac = hdr.value;
|
||||
} else if (std.ascii.eqlIgnoreCase(ACCESS_CONTROL_MAX_AGE, hdr.name)) {
|
||||
acma = hdr.value;
|
||||
}
|
||||
}
|
||||
|
||||
self.allowed = self.validateHeaders(acao, acam, acah, acac);
|
||||
if (self.allowed) {
|
||||
self.acam = acam;
|
||||
self.acah = acah;
|
||||
self.acma = acma;
|
||||
}
|
||||
return .proceed;
|
||||
}
|
||||
|
||||
fn doneCallback(ctx_ptr: *anyopaque) anyerror!void {
|
||||
const self: *CorsPreflightContext = @ptrCast(@alignCast(ctx_ptr));
|
||||
if (self.allowed) {
|
||||
self.cacheGrant(self.acam, self.acah, self.acma) catch |err| {
|
||||
log.warn(.cors, "preflight cache store failed", .{ .url = self.url, .err = err });
|
||||
};
|
||||
}
|
||||
self.resolve(self.allowed);
|
||||
}
|
||||
|
||||
@@ -441,31 +550,16 @@ const CorsPreflightContext = struct {
|
||||
}
|
||||
};
|
||||
|
||||
fn fetchThenResume(self: *CorsGate, transfer: *Transfer) !void {
|
||||
fn fetchThenResume(self: *CorsGate, transfer: *Transfer, authored_headers: []const []const u8) !void {
|
||||
const url = transfer.req.url;
|
||||
const origin = transfer.req.origin orelse "null";
|
||||
|
||||
var header_names: std.ArrayList([]const u8) = .empty;
|
||||
for (transfer.req_headers.items) |hdr| {
|
||||
if (hdr.source != .author) continue;
|
||||
if (isSafelistedHeader(hdr.name, hdr.value)) continue;
|
||||
try header_names.append(
|
||||
transfer.arena.allocator(),
|
||||
try std.ascii.allocLowerString(transfer.arena.allocator(), hdr.name),
|
||||
);
|
||||
}
|
||||
std.mem.sort([]const u8, header_names.items, {}, struct {
|
||||
fn lessThan(_: void, a: []const u8, b: []const u8) bool {
|
||||
return std.mem.lessThan(u8, a, b);
|
||||
}
|
||||
}.lessThan);
|
||||
const origin = transfer.effectiveOrigin();
|
||||
|
||||
const cors_key = CorsKey{
|
||||
.url = url,
|
||||
.origin = origin,
|
||||
.method = transfer.req.method,
|
||||
.wants_credentials = transfer.req.credentials_mode == .include,
|
||||
.authored_headers = header_names.items,
|
||||
.authored_headers = authored_headers,
|
||||
};
|
||||
const key = try cors_key.build(transfer.arena.allocator());
|
||||
|
||||
@@ -488,8 +582,8 @@ fn fetchThenResume(self: *CorsGate, transfer: *Transfer) !void {
|
||||
|
||||
const referer: ?[]const u8 = transfer.findRequestHeader("referer");
|
||||
|
||||
const owned_header_names = try arena.alloc([]const u8, header_names.items.len);
|
||||
for (header_names.items, 0..) |name, i| {
|
||||
const owned_header_names = try arena.alloc([]const u8, authored_headers.len);
|
||||
for (authored_headers, 0..) |name, i| {
|
||||
owned_header_names[i] = try arena.dupe(u8, name);
|
||||
}
|
||||
|
||||
@@ -527,11 +621,7 @@ fn fetchThenResume(self: *CorsGate, transfer: *Transfer) !void {
|
||||
errdefer fetch_transfer.deinit();
|
||||
|
||||
// Origin
|
||||
try fetch_transfer.setHeader(
|
||||
ORIGIN,
|
||||
transfer.req.origin orelse "null",
|
||||
.{},
|
||||
);
|
||||
try fetch_transfer.setHeader(ORIGIN, transfer.effectiveOrigin(), .{});
|
||||
|
||||
if (referer) |r| {
|
||||
try fetch_transfer.setHeader("Referer", r, .{});
|
||||
@@ -549,8 +639,8 @@ fn fetchThenResume(self: *CorsGate, transfer: *Transfer) !void {
|
||||
);
|
||||
|
||||
// Access-Control-Allow-Headers
|
||||
if (header_names.items.len > 0) {
|
||||
const request_headers_value = try std.mem.join(arena.allocator(), ",", header_names.items);
|
||||
if (authored_headers.len > 0) {
|
||||
const request_headers_value = try std.mem.join(arena.allocator(), ",", authored_headers);
|
||||
try fetch_transfer.setHeader(
|
||||
ACCESS_CONTROL_REQUEST_HEADERS,
|
||||
request_headers_value,
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
// 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 lp = @import("lightpanda");
|
||||
|
||||
const http = @import("http.zig");
|
||||
const isSafelistedMethod = @import("CorsGate.zig").isSafelistedMethod;
|
||||
const ClockCache = @import("ClockCache.zig").ClockCache;
|
||||
|
||||
const CorsStore = @This();
|
||||
|
||||
pub const Key = struct {
|
||||
origin: []const u8,
|
||||
target: []const u8,
|
||||
credentials: bool,
|
||||
|
||||
/// Serializes into a single string suitable as a ClockCache key.
|
||||
fn build(self: Key, allocator: std.mem.Allocator) ![]const u8 {
|
||||
var buf: std.ArrayList(u8) = .empty;
|
||||
errdefer buf.deinit(allocator);
|
||||
|
||||
try buf.appendSlice(allocator, self.origin);
|
||||
try buf.append(allocator, 0);
|
||||
try buf.appendSlice(allocator, self.target);
|
||||
try buf.append(allocator, 0);
|
||||
try buf.append(allocator, @intFromBool(self.credentials));
|
||||
|
||||
return buf.toOwnedSlice(allocator);
|
||||
}
|
||||
};
|
||||
|
||||
pub const Entry = struct {
|
||||
methods_wildcard: bool,
|
||||
methods: std.EnumSet(http.Method),
|
||||
|
||||
headers_wildcard: bool,
|
||||
headers: []const []const u8,
|
||||
|
||||
expires_at: u64,
|
||||
|
||||
fn unionHeaders(
|
||||
allocator: std.mem.Allocator,
|
||||
a: []const []const u8,
|
||||
b: []const []const u8,
|
||||
) ![]const []const u8 {
|
||||
var list: std.ArrayList([]const u8) = .empty;
|
||||
errdefer {
|
||||
for (list.items) |s| allocator.free(s);
|
||||
list.deinit(allocator);
|
||||
}
|
||||
|
||||
outerA: for (a) |s| {
|
||||
for (list.items) |existing| {
|
||||
if (std.ascii.eqlIgnoreCase(existing, s)) continue :outerA;
|
||||
}
|
||||
try list.append(allocator, try allocator.dupe(u8, s));
|
||||
}
|
||||
outerB: for (b) |s| {
|
||||
for (list.items) |existing| {
|
||||
if (std.ascii.eqlIgnoreCase(existing, s)) continue :outerB;
|
||||
}
|
||||
try list.append(allocator, try allocator.dupe(u8, s));
|
||||
}
|
||||
|
||||
return list.toOwnedSlice(allocator);
|
||||
}
|
||||
|
||||
fn merge(self: Entry, allocator: std.mem.Allocator, new: Entry) !Entry {
|
||||
return .{
|
||||
.methods_wildcard = self.methods_wildcard or new.methods_wildcard,
|
||||
.methods = self.methods.unionWith(new.methods),
|
||||
.headers_wildcard = self.headers_wildcard or new.headers_wildcard,
|
||||
.headers = try unionHeaders(allocator, self.headers, new.headers),
|
||||
.expires_at = @min(self.expires_at, new.expires_at),
|
||||
};
|
||||
}
|
||||
|
||||
fn dupe(self: Entry, allocator: std.mem.Allocator) !Entry {
|
||||
var new_headers: std.ArrayList([]const u8) = try .initCapacity(allocator, self.headers.len);
|
||||
errdefer {
|
||||
for (new_headers.items) |hdr| allocator.free(hdr);
|
||||
new_headers.deinit(allocator);
|
||||
}
|
||||
|
||||
for (self.headers) |hdr| {
|
||||
new_headers.appendAssumeCapacity(try allocator.dupe(u8, hdr));
|
||||
}
|
||||
|
||||
return .{
|
||||
.methods_wildcard = self.methods_wildcard,
|
||||
.methods = self.methods,
|
||||
.headers_wildcard = self.headers_wildcard,
|
||||
.headers = new_headers.items,
|
||||
.expires_at = self.expires_at,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: Entry, allocator: std.mem.Allocator) void {
|
||||
for (self.headers) |h| allocator.free(h);
|
||||
allocator.free(self.headers);
|
||||
}
|
||||
};
|
||||
|
||||
allocator: std.mem.Allocator,
|
||||
map: ClockCache(Entry),
|
||||
mutex: std.Io.Mutex = .init,
|
||||
|
||||
pub fn init(allocator: std.mem.Allocator, capacity: usize) CorsStore {
|
||||
return .{ .allocator = allocator, .map = .init(allocator, capacity) };
|
||||
}
|
||||
|
||||
pub fn deinit(self: *CorsStore) void {
|
||||
self.mutex.lockUncancelable(lp.io);
|
||||
defer self.mutex.unlock(lp.io);
|
||||
|
||||
for (self.map.entries()) |*entry| {
|
||||
entry.value.deinit(self.allocator);
|
||||
}
|
||||
self.map.deinit();
|
||||
}
|
||||
|
||||
// Caller is expected to be holding mutex.
|
||||
fn getWithExpiration(self: *CorsStore, cache_key: []const u8) ?*Entry {
|
||||
const entry = self.map.get(cache_key) orelse return null;
|
||||
|
||||
if (entry.expires_at <= lp.datetime.milliTimestamp(.real)) {
|
||||
if (self.map.remove(cache_key)) |e| {
|
||||
e.deinit(self.allocator);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
fn matches(entry: Entry, method: http.Method, authored_headers: []const []const u8) bool {
|
||||
if (!isSafelistedMethod(method) and !entry.methods_wildcard and !entry.methods.contains(method)) {
|
||||
return false;
|
||||
}
|
||||
for (authored_headers) |name| {
|
||||
const is_authorization = std.ascii.eqlIgnoreCase(name, "authorization");
|
||||
if (entry.headers_wildcard and !is_authorization) continue;
|
||||
var found = false;
|
||||
for (entry.headers) |allowed| {
|
||||
if (std.ascii.eqlIgnoreCase(allowed, name)) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
pub fn coversRequest(
|
||||
self: *CorsStore,
|
||||
allocator: std.mem.Allocator,
|
||||
key: Key,
|
||||
method: http.Method,
|
||||
authored_headers: []const []const u8,
|
||||
) !bool {
|
||||
const primary_key = try key.build(allocator);
|
||||
defer allocator.free(primary_key);
|
||||
|
||||
self.mutex.lockUncancelable(lp.io);
|
||||
defer self.mutex.unlock(lp.io);
|
||||
|
||||
if (self.getWithExpiration(primary_key)) |entry| {
|
||||
if (matches(entry.*, method, authored_headers)) return true;
|
||||
}
|
||||
|
||||
if (key.credentials) return false;
|
||||
|
||||
const cred_key = try (Key{ .origin = key.origin, .target = key.target, .credentials = true }).build(allocator);
|
||||
defer allocator.free(cred_key);
|
||||
|
||||
const entry = self.getWithExpiration(cred_key) orelse return false;
|
||||
return matches(entry.*, method, authored_headers);
|
||||
}
|
||||
|
||||
/// Insert or merge a CORS grant for (origin, target). `entry` is not
|
||||
/// consumed: `put` copies whatever it needs (via `dupe`/`merge`, which
|
||||
/// always allocate their own copies) and never takes ownership of
|
||||
/// `entry.headers` or its contents.
|
||||
///
|
||||
/// Callers remain responsible for
|
||||
/// freeing `entry.headers` after this call, on both the insert and
|
||||
/// the merge path.
|
||||
pub fn put(self: *CorsStore, key: Key, entry: Entry) !void {
|
||||
const cache_key = try key.build(self.allocator);
|
||||
defer self.allocator.free(cache_key);
|
||||
|
||||
self.mutex.lockUncancelable(lp.io);
|
||||
defer self.mutex.unlock(lp.io);
|
||||
|
||||
if (self.getWithExpiration(cache_key)) |existing| {
|
||||
const merged = try existing.merge(self.allocator, entry);
|
||||
existing.deinit(self.allocator);
|
||||
existing.* = merged;
|
||||
return;
|
||||
}
|
||||
|
||||
const owned_entry = try entry.dupe(self.allocator);
|
||||
errdefer owned_entry.deinit(self.allocator);
|
||||
|
||||
switch (try self.map.insert(cache_key, owned_entry)) {
|
||||
.exists => unreachable,
|
||||
.inserted => |evicted| {
|
||||
if (evicted) |v| {
|
||||
var e = v;
|
||||
e.deinit(self.allocator);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const testing = @import("../testing.zig");
|
||||
|
||||
fn freeHeaders(allocator: std.mem.Allocator, headers: []const []const u8) void {
|
||||
for (headers) |h| allocator.free(h);
|
||||
allocator.free(headers);
|
||||
}
|
||||
|
||||
test "CorsStore: put then covers, miss on different origin/target/credentials" {
|
||||
const allocator = testing.allocator;
|
||||
var store = CorsStore.init(allocator, 10);
|
||||
defer store.deinit();
|
||||
|
||||
const headers = try allocator.alloc([]const u8, 1);
|
||||
headers[0] = try allocator.dupe(u8, "x-custom");
|
||||
defer freeHeaders(allocator, headers);
|
||||
|
||||
try store.put(.{ .origin = "https://a.example", .target = "https://api.example", .credentials = false }, .{
|
||||
.methods_wildcard = false,
|
||||
.methods = std.EnumSet(http.Method).initOne(.POST),
|
||||
.headers_wildcard = false,
|
||||
.headers = headers,
|
||||
.expires_at = lp.datetime.milliTimestamp(.real) + 60_000,
|
||||
});
|
||||
|
||||
try testing.expect(try store.coversRequest(
|
||||
allocator,
|
||||
.{ .origin = "https://a.example", .target = "https://api.example", .credentials = false },
|
||||
.POST,
|
||||
&.{},
|
||||
));
|
||||
try testing.expect(!try store.coversRequest(
|
||||
allocator,
|
||||
.{ .origin = "https://a.example", .target = "https://api.example", .credentials = false },
|
||||
.PUT,
|
||||
&.{},
|
||||
));
|
||||
|
||||
try testing.expect(!try store.coversRequest(
|
||||
allocator,
|
||||
.{ .origin = "https://b.example", .target = "https://api.example", .credentials = false },
|
||||
.POST,
|
||||
&.{},
|
||||
));
|
||||
try testing.expect(!try store.coversRequest(
|
||||
allocator,
|
||||
.{ .origin = "https://a.example", .target = "https://other.example", .credentials = false },
|
||||
.POST,
|
||||
&.{},
|
||||
));
|
||||
|
||||
// Same origin/target but different credentials mode: separate entry, must miss.
|
||||
try testing.expect(!try store.coversRequest(
|
||||
allocator,
|
||||
.{ .origin = "https://a.example", .target = "https://api.example", .credentials = true },
|
||||
.POST,
|
||||
&.{},
|
||||
));
|
||||
}
|
||||
|
||||
test "CorsStore: expired entries are treated as a miss on covers" {
|
||||
const allocator = testing.allocator;
|
||||
var store = CorsStore.init(allocator, 10);
|
||||
defer store.deinit();
|
||||
|
||||
try store.put(.{ .origin = "https://a.example", .target = "https://api.example", .credentials = false }, .{
|
||||
.methods_wildcard = true,
|
||||
.methods = .initEmpty(),
|
||||
.headers_wildcard = true,
|
||||
.headers = &.{}, // empty slice, nothing to free
|
||||
.expires_at = lp.datetime.milliTimestamp(.real) - 1,
|
||||
});
|
||||
|
||||
try testing.expect(!try store.coversRequest(
|
||||
allocator,
|
||||
.{ .origin = "https://a.example", .target = "https://api.example", .credentials = false },
|
||||
.GET,
|
||||
&.{},
|
||||
));
|
||||
}
|
||||
|
||||
test "CorsStore: put merges into existing entry rather than clobbering" {
|
||||
const allocator = testing.allocator;
|
||||
var store = CorsStore.init(allocator, 10);
|
||||
defer store.deinit();
|
||||
|
||||
const key = Key{ .origin = "https://a.example", .target = "https://api.example", .credentials = false };
|
||||
|
||||
const h1 = try allocator.alloc([]const u8, 1);
|
||||
h1[0] = try allocator.dupe(u8, "x-one");
|
||||
try store.put(key, .{
|
||||
.methods_wildcard = false,
|
||||
.methods = std.EnumSet(http.Method).initOne(.POST),
|
||||
.headers_wildcard = false,
|
||||
.headers = h1,
|
||||
.expires_at = lp.datetime.milliTimestamp(.real) + 60_000,
|
||||
});
|
||||
freeHeaders(allocator, h1);
|
||||
|
||||
const h2 = try allocator.alloc([]const u8, 1);
|
||||
h2[0] = try allocator.dupe(u8, "x-two");
|
||||
try store.put(key, .{
|
||||
.methods_wildcard = false,
|
||||
.methods = std.EnumSet(http.Method).initOne(.PUT),
|
||||
.headers_wildcard = false,
|
||||
.headers = h2,
|
||||
.expires_at = lp.datetime.milliTimestamp(.real) + 60_000,
|
||||
});
|
||||
freeHeaders(allocator, h2);
|
||||
|
||||
try testing.expect(try store.coversRequest(allocator, key, .POST, &.{"x-one"}));
|
||||
try testing.expect(try store.coversRequest(allocator, key, .PUT, &.{"x-two"}));
|
||||
try testing.expect(!try store.coversRequest(allocator, key, .DELETE, &.{}));
|
||||
}
|
||||
|
||||
test "CorsStore: credentialed and non-credentialed grants for same origin/target stay separate" {
|
||||
const allocator = testing.allocator;
|
||||
var store = CorsStore.init(allocator, 10);
|
||||
defer store.deinit();
|
||||
|
||||
const origin = "https://a.example";
|
||||
const target = "https://api.example";
|
||||
|
||||
// Non-credentialed grant: wildcard headers allowed (valid per spec for non-cred requests).
|
||||
try store.put(.{ .origin = origin, .target = target, .credentials = false }, .{
|
||||
.methods_wildcard = true,
|
||||
.methods = .initEmpty(),
|
||||
.headers_wildcard = true,
|
||||
.headers = &.{},
|
||||
.expires_at = lp.datetime.milliTimestamp(.real) + 60_000,
|
||||
});
|
||||
|
||||
// Credentialed grant: explicit methods/headers only, no wildcard.
|
||||
const h = try allocator.alloc([]const u8, 1);
|
||||
h[0] = try allocator.dupe(u8, "x-custom");
|
||||
try store.put(.{ .origin = origin, .target = target, .credentials = true }, .{
|
||||
.methods_wildcard = false,
|
||||
.methods = std.EnumSet(http.Method).initOne(.GET),
|
||||
.headers_wildcard = false,
|
||||
.headers = h,
|
||||
.expires_at = lp.datetime.milliTimestamp(.real) + 60_000,
|
||||
});
|
||||
freeHeaders(allocator, h);
|
||||
|
||||
// A credentialed request asking for an arbitrary header must be rejected
|
||||
// against the credentialed entry, even though the non-cred entry has a wildcard.
|
||||
try testing.expect(!try store.coversRequest(
|
||||
allocator,
|
||||
.{ .origin = origin, .target = target, .credentials = true },
|
||||
.GET,
|
||||
&.{"x-anything"},
|
||||
));
|
||||
try testing.expect(try store.coversRequest(
|
||||
allocator,
|
||||
.{ .origin = origin, .target = target, .credentials = true },
|
||||
.GET,
|
||||
&.{"x-custom"},
|
||||
));
|
||||
try testing.expect(!try store.coversRequest(
|
||||
allocator,
|
||||
.{ .origin = origin, .target = target, .credentials = true },
|
||||
.PUT,
|
||||
&.{},
|
||||
));
|
||||
|
||||
// The non-credentialed entry's wildcard still works for non-cred requests.
|
||||
try testing.expect(try store.coversRequest(allocator, .{ .origin = origin, .target = target, .credentials = false }, .GET, &.{"x-anything"}));
|
||||
}
|
||||
|
||||
test "CorsStore: covers never lets a wildcard cover Authorization" {
|
||||
const allocator = testing.allocator;
|
||||
var store = CorsStore.init(allocator, 10);
|
||||
defer store.deinit();
|
||||
|
||||
const key = Key{ .origin = "https://a.example", .target = "https://api.example", .credentials = false };
|
||||
try store.put(key, .{
|
||||
.methods_wildcard = true,
|
||||
.methods = .initEmpty(),
|
||||
.headers_wildcard = true,
|
||||
.headers = &.{},
|
||||
.expires_at = std.math.maxInt(u64),
|
||||
});
|
||||
|
||||
try testing.expect(!try store.coversRequest(allocator, key, .GET, &.{"authorization"}));
|
||||
try testing.expect(try store.coversRequest(allocator, key, .GET, &.{"x-anything"}));
|
||||
}
|
||||
+59
-25
@@ -243,7 +243,10 @@ pub fn init(self: *Client, app: *lp.App) !void {
|
||||
.network = network,
|
||||
.single_flight = .init(allocator),
|
||||
},
|
||||
.cors = .{ .single_flight = .init(allocator) },
|
||||
.cors = .{
|
||||
.network = network,
|
||||
.single_flight = .init(allocator),
|
||||
},
|
||||
.url_blocklist = url_blocklist,
|
||||
.arena_pool = &app.arena_pool,
|
||||
};
|
||||
@@ -1207,9 +1210,18 @@ pub fn resumeAfterCors(self: *Client, transfer: *Transfer) !void {
|
||||
return self.pipeline(transfer, .after_cors);
|
||||
}
|
||||
|
||||
// `headers` are response headers (lowercased names); `name` must be lowercase.
|
||||
pub fn findHeader(headers: []const http.Header, name: []const u8) ?[]const u8 {
|
||||
if (comptime lp.IS_DEBUG) {
|
||||
for (name) |c| {
|
||||
if (std.ascii.isUpper(c)) {
|
||||
log.fatal(.bug, "non-lowercase header", .{ .name = name });
|
||||
@panic("non-lowercase-header");
|
||||
}
|
||||
}
|
||||
}
|
||||
for (headers) |hdr| {
|
||||
if (std.ascii.eqlIgnoreCase(hdr.name, name)) {
|
||||
if (std.mem.eql(u8, hdr.name, name)) {
|
||||
return hdr.value;
|
||||
}
|
||||
}
|
||||
@@ -2118,12 +2130,19 @@ pub fn fulfillIntercepted(
|
||||
|
||||
transfer.unpark();
|
||||
|
||||
// Copied into the transfer arena with lowercased names, like headers
|
||||
// materialized from curl.
|
||||
const owned = transfer.dupeHeadersLower(headers) catch |err| {
|
||||
transfer.abortPipelineError(err);
|
||||
return err;
|
||||
};
|
||||
|
||||
const followed = blk: {
|
||||
if (isRedirectStatus(status) == false) {
|
||||
break :blk false;
|
||||
}
|
||||
const location = findHeader(headers, "location") orelse break :blk false;
|
||||
try self.fulfillRedirect(transfer, status, headers, location);
|
||||
const location = findHeader(owned, "location") orelse break :blk false;
|
||||
try self.fulfillRedirect(transfer, status, owned, location);
|
||||
break :blk true;
|
||||
};
|
||||
|
||||
@@ -2131,7 +2150,7 @@ pub fn fulfillIntercepted(
|
||||
return;
|
||||
}
|
||||
|
||||
transfer.bufferFulfilled(status, headers, body) catch |err| {
|
||||
transfer.bufferFulfilled(status, owned, body) catch |err| {
|
||||
transfer.abortPipelineError(err);
|
||||
return err;
|
||||
};
|
||||
@@ -2150,7 +2169,7 @@ fn fulfillRedirect(
|
||||
if (transfer.req.credentialsAllowed()) {
|
||||
if (transfer.cookie_jar) |jar| {
|
||||
for (headers) |hdr| {
|
||||
if (std.ascii.eqlIgnoreCase(hdr.name, "set-cookie")) {
|
||||
if (std.mem.eql(u8, hdr.name, "set-cookie")) {
|
||||
try jar.populateFromResponse(transfer.req.url, hdr.value);
|
||||
}
|
||||
}
|
||||
@@ -3064,7 +3083,7 @@ pub const Transfer = struct {
|
||||
return true;
|
||||
}
|
||||
for (headers) |hdr| {
|
||||
if (!std.ascii.eqlIgnoreCase(hdr.name, "timing-allow-origin")) {
|
||||
if (std.mem.eql(u8, hdr.name, "timing-allow-origin") == false) {
|
||||
continue;
|
||||
}
|
||||
var it = std.mem.splitScalar(u8, hdr.value, ',');
|
||||
@@ -3192,6 +3211,18 @@ pub const Transfer = struct {
|
||||
self.scheduleDispatch();
|
||||
}
|
||||
|
||||
// Response header names are lowercase, so lookups use std.mem.eql.
|
||||
fn setResponseHeaders(self: *Transfer, headers: []const http.Header) void {
|
||||
if (comptime lp.IS_DEBUG) {
|
||||
for (headers) |hdr| {
|
||||
for (hdr.name) |c| {
|
||||
std.debug.assert(std.ascii.isUpper(c) == false);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.res.headers = headers;
|
||||
}
|
||||
|
||||
fn setResponseHead(self: *Transfer, status: u16, content_type: ?[]const u8) void {
|
||||
self.res.header = .{
|
||||
.url = self.req.url.ptr,
|
||||
@@ -3214,7 +3245,7 @@ pub const Transfer = struct {
|
||||
};
|
||||
|
||||
self.setResponseHead(cached.status, cached.content_type);
|
||||
self.res.headers = cached.headers;
|
||||
self.setResponseHeaders(cached.headers);
|
||||
self._from_cache = true;
|
||||
self._timing.cache = cache_state;
|
||||
self._content_length = body.len;
|
||||
@@ -3222,27 +3253,29 @@ pub const Transfer = struct {
|
||||
}
|
||||
|
||||
// Materialize an interceptor-supplied response (CDP fulfillRequest).
|
||||
// `headers` and `body` are caller-owned; copy everything that must
|
||||
// survive until dispatch.
|
||||
fn dupeHeadersLower(self: *Transfer, headers: []const http.Header) ![]const http.Header {
|
||||
const allocator = self.arena.allocator();
|
||||
const owned = try allocator.alloc(http.Header, headers.len);
|
||||
for (headers, owned) |hdr, *o| {
|
||||
o.* = try hdr.normalize(allocator);
|
||||
}
|
||||
return owned;
|
||||
}
|
||||
|
||||
// our arena already owns `header` but not `body`. Why so complicated?
|
||||
fn bufferFulfilled(self: *Transfer, status: u16, headers: []const http.Header, body: ?[]const u8) !void {
|
||||
const arena = self.arena;
|
||||
|
||||
const owned = try arena.alloc(http.Header, headers.len);
|
||||
var content_type: ?[]const u8 = null;
|
||||
for (headers, 0..) |hdr, i| {
|
||||
owned[i] = .{
|
||||
.name = try arena.dupe(u8, hdr.name),
|
||||
.value = try arena.dupe(u8, hdr.value),
|
||||
};
|
||||
if (std.ascii.eqlIgnoreCase(hdr.name, "content-type")) {
|
||||
content_type = owned[i].value;
|
||||
for (headers) |hdr| {
|
||||
if (std.mem.eql(u8, hdr.name, "content-type")) {
|
||||
content_type = hdr.value;
|
||||
}
|
||||
}
|
||||
|
||||
const owned_body: []const u8 = if (body) |b| try arena.dupe(u8, b) else "";
|
||||
|
||||
self.setResponseHead(status, content_type);
|
||||
self.res.headers = owned;
|
||||
self.setResponseHeaders(headers);
|
||||
self._content_length = owned_body.len;
|
||||
try self.bufferEvents(owned_body);
|
||||
}
|
||||
@@ -3278,12 +3311,12 @@ pub const Transfer = struct {
|
||||
|
||||
var it = HeaderIterator{ .curl = .{ .conn = conn } };
|
||||
const headers = try it.collect(arena.allocator());
|
||||
self.res.headers = headers.items;
|
||||
self.setResponseHeaders(headers.items);
|
||||
|
||||
if (self.req.credentialsAllowed()) {
|
||||
if (self.cookie_jar) |jar| {
|
||||
for (self.res.headers) |hdr| {
|
||||
if (std.ascii.eqlIgnoreCase(hdr.name, "set-cookie")) {
|
||||
if (std.mem.eql(u8, hdr.name, "set-cookie")) {
|
||||
jar.populateFromResponse(self.req.url, hdr.value) catch |err| {
|
||||
log.err(.http, "set cookie", .{ .err = err, .req = self });
|
||||
return err;
|
||||
@@ -3973,7 +4006,7 @@ pub const Transfer = struct {
|
||||
fn getContentLengthRawValue(self: *const Transfer) ?[]const u8 {
|
||||
// Materialized headers (dispatch time, any source).
|
||||
for (self.res.headers) |hdr| {
|
||||
if (std.ascii.eqlIgnoreCase(hdr.name, "content-length")) {
|
||||
if (std.mem.eql(u8, hdr.name, "content-length")) {
|
||||
return hdr.value;
|
||||
}
|
||||
}
|
||||
@@ -4178,7 +4211,8 @@ const Response = struct {
|
||||
header: ?http.ResponseHead = null,
|
||||
|
||||
// Full response headers, materialized into the transfer arena at
|
||||
// completion (or set directly by cache / synthetic / fulfill).
|
||||
// completion (or set directly by cache / synthetic / fulfill). Names are
|
||||
// lowercased.
|
||||
headers: []const http.Header = &.{},
|
||||
|
||||
// total bytes received in the response, including the response status
|
||||
@@ -4285,7 +4319,7 @@ const Synthetic = struct {
|
||||
if (content_type.len > 0) {
|
||||
const h = try arena.alloc(http.Header, 1);
|
||||
h[0] = .{ .name = "content-type", .value = content_type };
|
||||
transfer.res.headers = h;
|
||||
transfer.setResponseHeaders(h);
|
||||
}
|
||||
transfer._content_length = body.len;
|
||||
try transfer.bufferEvents(body);
|
||||
|
||||
@@ -27,6 +27,7 @@ const libcurl = @import("../sys/libcurl.zig");
|
||||
const http = @import("http.zig");
|
||||
const IpFilter = @import("IpFilter.zig");
|
||||
const RobotStore = @import("Robots.zig").RobotStore;
|
||||
const CorsStore = @import("CorsStore.zig");
|
||||
const WebBotAuth = @import("WebBotAuth.zig");
|
||||
const RateLimiter = @import("RateLimiter.zig");
|
||||
const Certificates = @import("Certificates.zig");
|
||||
@@ -43,6 +44,7 @@ cache: Cache,
|
||||
allocator: Allocator,
|
||||
config: *const Config,
|
||||
robot_store: RobotStore,
|
||||
cors_store: CorsStore,
|
||||
web_bot_auth: ?WebBotAuth,
|
||||
rate_limiter: ?RateLimiter,
|
||||
certificates: Certificates,
|
||||
@@ -121,7 +123,8 @@ pub fn init(app: *App) !Network {
|
||||
.connections = connections,
|
||||
|
||||
.cache = cache,
|
||||
.robot_store = RobotStore.init(allocator),
|
||||
.robot_store = RobotStore.init(allocator, config.robotStoreEntryLimit()),
|
||||
.cors_store = CorsStore.init(allocator, config.corsStoreEntryLimit()),
|
||||
.web_bot_auth = web_bot_auth,
|
||||
.rate_limiter = if (config.httpNavDelay()) |ms| RateLimiter.init(allocator, ms, config.httpNavBurst()) else null,
|
||||
.adblocker = adblocker,
|
||||
@@ -144,6 +147,8 @@ pub fn deinit(self: *Network) void {
|
||||
self.ws_pool.deinit(self.allocator);
|
||||
|
||||
self.robot_store.deinit();
|
||||
self.cors_store.deinit();
|
||||
|
||||
if (self.rate_limiter) |*rl| {
|
||||
rl.deinit();
|
||||
}
|
||||
|
||||
+67
-61
@@ -19,6 +19,8 @@
|
||||
const std = @import("std");
|
||||
const lp = @import("lightpanda");
|
||||
|
||||
const ClockCache = @import("ClockCache.zig").ClockCache;
|
||||
|
||||
const log = lp.log;
|
||||
|
||||
const CompiledPattern = struct {
|
||||
@@ -86,9 +88,6 @@ pub const ContentSignal = struct {
|
||||
pub const Robots = @This();
|
||||
pub const empty: Robots = .{ .rules = &.{}, .content_signals = &.{} };
|
||||
|
||||
// Think twice before deleting/freeing any entries from the map. Readers, e.g.
|
||||
// get and getContentSignals, receive values from the map, and if another thread
|
||||
// was to delete / free those values while in use, UAF.
|
||||
pub const RobotStore = struct {
|
||||
const RobotsEntry = union(enum) {
|
||||
present: Robots,
|
||||
@@ -96,39 +95,46 @@ pub const RobotStore = struct {
|
||||
disallowed,
|
||||
};
|
||||
|
||||
const RobotsMap = @import("Network.zig").HostHashMap(RobotsEntry);
|
||||
|
||||
allocator: std.mem.Allocator,
|
||||
map: RobotsMap,
|
||||
map: ClockCache(RobotsEntry),
|
||||
mutex: std.Io.Mutex = .init,
|
||||
|
||||
pub fn init(allocator: std.mem.Allocator) RobotStore {
|
||||
return .{ .allocator = allocator, .map = .empty };
|
||||
pub fn init(allocator: std.mem.Allocator, capacity: u32) RobotStore {
|
||||
return .{
|
||||
.allocator = allocator,
|
||||
.map = .init(allocator, capacity),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *RobotStore) void {
|
||||
self.mutex.lockUncancelable(lp.io);
|
||||
defer self.mutex.unlock(lp.io);
|
||||
|
||||
var iter = self.map.iterator();
|
||||
|
||||
while (iter.next()) |entry| {
|
||||
self.allocator.free(entry.key_ptr.*);
|
||||
|
||||
switch (entry.value_ptr.*) {
|
||||
.present => |*robots| robots.deinit(self.allocator),
|
||||
.allowed, .disallowed => {},
|
||||
}
|
||||
for (self.map.entries()) |*entry| {
|
||||
self.freeEntry(&entry.value);
|
||||
}
|
||||
|
||||
self.map.deinit(self.allocator);
|
||||
self.map.deinit();
|
||||
}
|
||||
|
||||
pub fn get(self: *RobotStore, url: []const u8) ?RobotsEntry {
|
||||
fn freeEntry(self: *RobotStore, entry: *RobotsEntry) void {
|
||||
switch (entry.*) {
|
||||
.present => |*robots| robots.deinit(self.allocator),
|
||||
.allowed, .disallowed => {},
|
||||
}
|
||||
}
|
||||
|
||||
pub const Decision = enum { allowed, blocked };
|
||||
|
||||
pub fn checkPath(self: *RobotStore, url: []const u8, path: []const u8) ?Decision {
|
||||
self.mutex.lockUncancelable(lp.io);
|
||||
defer self.mutex.unlock(lp.io);
|
||||
|
||||
return self.map.get(url);
|
||||
const entry = self.map.get(url) orelse return null;
|
||||
return switch (entry.*) {
|
||||
.allowed => .allowed,
|
||||
.disallowed => .blocked,
|
||||
.present => |robots| if (robots.isAllowed(path)) .allowed else .blocked,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn robotsFromBytes(self: *RobotStore, user_agent: []const u8, bytes: []const u8) !Robots {
|
||||
@@ -139,69 +145,62 @@ pub const RobotStore = struct {
|
||||
self.mutex.lockUncancelable(lp.io);
|
||||
defer self.mutex.unlock(lp.io);
|
||||
|
||||
if (try self.putKey(url)) |value_ptr| {
|
||||
// first time seeing this url, store the value
|
||||
value_ptr.* = .{ .present = robots };
|
||||
return;
|
||||
}
|
||||
|
||||
// cannot overwrite an existing value, if it was `present`, we'd have
|
||||
// to free the value but it might be being used.
|
||||
if (try self.insert(url, .{ .present = robots })) return;
|
||||
|
||||
var discarded = robots;
|
||||
discarded.deinit(self.allocator);
|
||||
}
|
||||
|
||||
// The returned slice is owned by the store
|
||||
pub fn getContentSignals(self: *RobotStore, url: []const u8) ?[]const ContentSignal {
|
||||
pub fn getContentSignals(
|
||||
self: *RobotStore,
|
||||
allocator: std.mem.Allocator,
|
||||
url: []const u8,
|
||||
) !?[]const ContentSignal {
|
||||
self.mutex.lockUncancelable(lp.io);
|
||||
defer self.mutex.unlock(lp.io);
|
||||
|
||||
const entry = self.map.get(url) orelse return null;
|
||||
return switch (entry) {
|
||||
const signals = switch (entry.*) {
|
||||
.present => |robots| robots.content_signals,
|
||||
.allowed, .disallowed => null,
|
||||
.allowed, .disallowed => return null,
|
||||
};
|
||||
|
||||
const out = try allocator.alloc(ContentSignal, signals.len);
|
||||
for (signals, 0..) |signal, i| {
|
||||
out[i] = .{
|
||||
.name = try allocator.dupe(u8, signal.name),
|
||||
.value = try allocator.dupe(u8, signal.value),
|
||||
};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// This URL has no restrictions on crawling.
|
||||
pub fn putAllowed(self: *RobotStore, url: []const u8) !void {
|
||||
self.mutex.lockUncancelable(lp.io);
|
||||
defer self.mutex.unlock(lp.io);
|
||||
|
||||
if (try self.putKey(url)) |value_ptr| {
|
||||
// first time seeing this url, store the value
|
||||
value_ptr.* = .allowed;
|
||||
}
|
||||
// cannot overwrite an existing value, if it was `present`, we'd have
|
||||
// to free the value but it might be being used.
|
||||
_ = try self.insert(url, .allowed);
|
||||
}
|
||||
|
||||
/// This URL is fully restricted from crawling.
|
||||
pub fn putDisallowed(self: *RobotStore, url: []const u8) !void {
|
||||
self.mutex.lockUncancelable(lp.io);
|
||||
defer self.mutex.unlock(lp.io);
|
||||
|
||||
if (try self.putKey(url)) |value_ptr| {
|
||||
// first time seeing this url, store the value
|
||||
value_ptr.* = .disallowed;
|
||||
}
|
||||
// cannot overwrite an existing value, if it was `present`, we'd have
|
||||
// to free the value but it might be being used.
|
||||
_ = try self.insert(url, .disallowed);
|
||||
}
|
||||
|
||||
// The RobotStore is shared across Browsers. Two rowsers can request the
|
||||
// same robots URL at the same time, and they'll race here. First one wins.
|
||||
// Caller holds the mutex.
|
||||
fn putKey(self: *RobotStore, url: []const u8) !?*RobotsEntry {
|
||||
const gop = try self.map.getOrPut(self.allocator, url);
|
||||
if (gop.found_existing) {
|
||||
// already have a value, caller should not overwrite
|
||||
return null;
|
||||
fn insert(self: *RobotStore, url: []const u8, entry: RobotsEntry) !bool {
|
||||
switch (try self.map.insert(url, entry)) {
|
||||
.exists => return false,
|
||||
.inserted => |evicted| {
|
||||
if (evicted) |value| {
|
||||
lp.metrics.robots_evictions.incr();
|
||||
var e = value;
|
||||
self.freeEntry(&e);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
}
|
||||
errdefer _ = self.map.remove(url);
|
||||
gop.key_ptr.* = try self.allocator.dupe(u8, url);
|
||||
return gop.value_ptr;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1247,7 +1246,7 @@ test "Robots: content-signal prefers specific user-agent over wildcard" {
|
||||
test "Robots: RobotStore.getContentSignals round-trips" {
|
||||
const allocator = std.testing.allocator;
|
||||
|
||||
var store = RobotStore.init(allocator);
|
||||
var store = RobotStore.init(allocator, 1000);
|
||||
defer store.deinit();
|
||||
|
||||
const robots = try store.robotsFromBytes("MyBot",
|
||||
@@ -1257,13 +1256,20 @@ test "Robots: RobotStore.getContentSignals round-trips" {
|
||||
);
|
||||
try store.put("https://example.com/robots.txt", robots);
|
||||
|
||||
const signals = store.getContentSignals("https://example.com/robots.txt").?;
|
||||
const signals = (try store.getContentSignals(allocator, "https://example.com/robots.txt")).?;
|
||||
defer {
|
||||
for (signals) |signal| {
|
||||
allocator.free(signal.name);
|
||||
allocator.free(signal.value);
|
||||
}
|
||||
allocator.free(signals);
|
||||
}
|
||||
try std.testing.expectEqual(1, signals.len);
|
||||
try std.testing.expectEqualStrings("ai-train", signals[0].name);
|
||||
try std.testing.expectEqualStrings("no", signals[0].value);
|
||||
|
||||
// Unknown host has no stored robots.
|
||||
try std.testing.expectEqual(null, store.getContentSignals("https://other.com/robots.txt"));
|
||||
try std.testing.expectEqual(null, try store.getContentSignals(allocator, "https://other.com/robots.txt"));
|
||||
}
|
||||
|
||||
fn testMatch(pattern: []const u8, path: []const u8) bool {
|
||||
|
||||
+68
-51
@@ -50,17 +50,10 @@ pub fn check(self: *RobotsGate, transfer: *Transfer) !Result {
|
||||
const url = transfer.req.url;
|
||||
const robots_url = try URL.getRobotsUrl(transfer.arena.allocator(), url);
|
||||
|
||||
if (self.network.robot_store.get(robots_url)) |robot_entry| {
|
||||
switch (robot_entry) {
|
||||
if (self.network.robot_store.checkPath(robots_url, URL.getPathname(url))) |decision| {
|
||||
switch (decision) {
|
||||
.allowed => return .allowed,
|
||||
.disallowed => {
|
||||
log.warn(.http, "blocked by robots", .{ .url = url });
|
||||
return .blocked;
|
||||
},
|
||||
.present => |robots| {
|
||||
if (robots.isAllowed(URL.getPathname(url))) {
|
||||
return .allowed;
|
||||
}
|
||||
.blocked => {
|
||||
log.warn(.http, "blocked by robots", .{ .url = url });
|
||||
return .blocked;
|
||||
},
|
||||
@@ -98,7 +91,6 @@ fn fetchThenResume(self: *RobotsGate, robots_url: [:0]const u8, transfer: *Trans
|
||||
.gate = self,
|
||||
.buffer = .empty,
|
||||
.arena = arena,
|
||||
.arena_pool = client.arena_pool,
|
||||
.robots_url = owned_url,
|
||||
};
|
||||
|
||||
@@ -134,24 +126,27 @@ fn fetchThenResume(self: *RobotsGate, robots_url: [:0]const u8, transfer: *Trans
|
||||
fetch_transfer.submit() catch {};
|
||||
}
|
||||
|
||||
const Outcome = union(enum) {
|
||||
decision: Robots.RobotStore.Decision,
|
||||
robots: Robots.Robots,
|
||||
};
|
||||
|
||||
// The robots.txt fetch resolved: hand every waiter back to the pipeline,
|
||||
// each judged against its own path. No store entry (fetch failed, or a 200
|
||||
// whose body never got parsed) fails open.
|
||||
fn flushPending(self: *RobotsGate, robots_url: []const u8) void {
|
||||
fn flushPending(self: *RobotsGate, robots_url: []const u8, outcome: Outcome) void {
|
||||
var queued = self.single_flight.take(robots_url) orelse return;
|
||||
defer queued.deinit(self.single_flight.allocator);
|
||||
|
||||
const robot_entry = self.network.robot_store.get(robots_url);
|
||||
for (queued.items) |transfer| {
|
||||
transfer.unpark();
|
||||
|
||||
const allowed = if (robot_entry) |entry| switch (entry) {
|
||||
.allowed => true,
|
||||
.disallowed => false,
|
||||
.present => |robots| robots.isAllowed(URL.getPathname(transfer.req.url)),
|
||||
} else true;
|
||||
const decision: Robots.RobotStore.Decision = switch (outcome) {
|
||||
.decision => |d| d,
|
||||
.robots => |r| if (r.isAllowed(URL.getPathname(transfer.req.url))) .allowed else .blocked,
|
||||
};
|
||||
|
||||
if (!allowed) {
|
||||
if (decision == .blocked) {
|
||||
lp.metrics.robots_access.incr(.deny);
|
||||
log.warn(.http, "blocked by robots", .{ .url = transfer.req.url });
|
||||
transfer.failAsync(error.RobotsBlocked);
|
||||
@@ -170,7 +165,6 @@ fn flushPending(self: *RobotsGate, robots_url: []const u8) void {
|
||||
const RobotsContext = struct {
|
||||
gate: *RobotsGate,
|
||||
arena: *lp.Arena,
|
||||
arena_pool: *ArenaPool,
|
||||
robots_url: [:0]const u8,
|
||||
buffer: std.ArrayList(u8),
|
||||
status: u16 = 0,
|
||||
@@ -200,26 +194,26 @@ const RobotsContext = struct {
|
||||
|
||||
switch (self.status) {
|
||||
200 => {
|
||||
if (self.buffer.items.len > 0) {
|
||||
const robots: ?Robots = network.robot_store.robotsFromBytes(
|
||||
network.config.http_headers.user_agent,
|
||||
self.buffer.items,
|
||||
) catch |err| blk: {
|
||||
// We only return an error if an allocation or something fails.
|
||||
// Our parser does already leniently handle malformed input and takes whichever rules it can parse.
|
||||
// On this case of an allocation failure, it is our fault so we put it as disallowed.
|
||||
log.warn(.browser, "error while parsing robots.txt", .{ .robots_url = robots_url, .err = err });
|
||||
try network.robot_store.putDisallowed(robots_url);
|
||||
break :blk null;
|
||||
};
|
||||
if (robots) |r| {
|
||||
try network.robot_store.put(robots_url, r);
|
||||
// BE CAREFUL: robots can be invalidated after this call
|
||||
}
|
||||
} else {
|
||||
if (self.buffer.items.len == 0) {
|
||||
// Empty robots.txt means we can short-circuit the allowed path.
|
||||
try network.robot_store.putAllowed(robots_url);
|
||||
self.settle(.{ .outcome = .{ .decision = .allowed } });
|
||||
return;
|
||||
}
|
||||
|
||||
const robots = network.robot_store.robotsFromBytes(
|
||||
network.config.http_headers.user_agent,
|
||||
self.buffer.items,
|
||||
) catch |err| {
|
||||
// We only return an error if an allocation or something fails.
|
||||
// Our parser does already leniently handle malformed input and takes whichever rules it can parse.
|
||||
// On this case of an allocation failure, it is our fault so we put it as disallowed.
|
||||
log.warn(.browser, "error while parsing robots.txt", .{ .robots_url = robots_url, .err = err });
|
||||
self.settle(.{ .outcome = .{ .decision = .blocked } });
|
||||
return;
|
||||
};
|
||||
|
||||
// BE CAREFUL: robots can be invalidated after this call
|
||||
self.settle(.{ .outcome = .{ .robots = robots } });
|
||||
},
|
||||
// Unauthorized/Forbidden: treat as fully disallowed since we can't verify permissions.
|
||||
401, 403 => {
|
||||
@@ -227,12 +221,12 @@ const RobotsContext = struct {
|
||||
.url = robots_url,
|
||||
.status = self.status,
|
||||
});
|
||||
try network.robot_store.putDisallowed(robots_url);
|
||||
self.settle(.{ .outcome = .{ .decision = .blocked } });
|
||||
},
|
||||
// RFC9309: Unavailable (400-499) means that we may access any resources on the server.
|
||||
400, 402, 404...499 => {
|
||||
log.debug(.http, "robots.txt unavailable", .{ .url = robots_url });
|
||||
try network.robot_store.putAllowed(robots_url);
|
||||
self.settle(.{ .outcome = .{ .decision = .allowed } });
|
||||
},
|
||||
// RFC9309: Unreachable (500-599) means that we are completely disallowed.
|
||||
500...599 => {
|
||||
@@ -240,27 +234,26 @@ const RobotsContext = struct {
|
||||
.url = robots_url,
|
||||
.status = self.status,
|
||||
});
|
||||
try network.robot_store.putDisallowed(robots_url);
|
||||
self.settle(.{ .outcome = .{ .decision = .blocked } });
|
||||
},
|
||||
else => {
|
||||
log.debug(.http, "unexpected status on robots", .{
|
||||
.url = robots_url,
|
||||
.status = self.status,
|
||||
});
|
||||
try network.robot_store.putDisallowed(robots_url);
|
||||
self.settle(.{ .outcome = .{ .decision = .blocked } });
|
||||
},
|
||||
}
|
||||
|
||||
// If anything above threw, error_callback fires next and resolves
|
||||
// instead — resolve() must run exactly once.
|
||||
self.resolve();
|
||||
}
|
||||
|
||||
fn errorCallback(ctx_ptr: *anyopaque, err: anyerror) void {
|
||||
const self: *RobotsContext = @ptrCast(@alignCast(ctx_ptr));
|
||||
|
||||
log.warn(.http, "robots fetch failed", .{ .err = err });
|
||||
self.resolve();
|
||||
self.settle(.{
|
||||
.outcome = .{ .decision = .allowed },
|
||||
.cache = false,
|
||||
});
|
||||
}
|
||||
|
||||
fn shutdownCallback(ctx_ptr: *anyopaque) void {
|
||||
@@ -273,10 +266,34 @@ const RobotsContext = struct {
|
||||
arena.release();
|
||||
}
|
||||
|
||||
fn resolve(self: *RobotsContext) void {
|
||||
const gate = self.gate;
|
||||
const SettleOptions = struct {
|
||||
outcome: RobotsGate.Outcome,
|
||||
cache: bool = true,
|
||||
};
|
||||
|
||||
fn settle(self: *RobotsContext, options: SettleOptions) void {
|
||||
const arena = self.arena;
|
||||
gate.flushPending(self.robots_url);
|
||||
arena.release();
|
||||
defer arena.release();
|
||||
|
||||
const gate = self.gate;
|
||||
const network = gate.network;
|
||||
|
||||
gate.flushPending(self.robots_url, options.outcome);
|
||||
|
||||
if (options.cache) {
|
||||
switch (options.outcome) {
|
||||
.decision => |d| switch (d) {
|
||||
.allowed => network.robot_store.putAllowed(self.robots_url) catch |err| {
|
||||
log.warn(.browser, "cache robots decision", .{ .url = self.robots_url, .err = err });
|
||||
},
|
||||
.blocked => network.robot_store.putDisallowed(self.robots_url) catch |err| {
|
||||
log.warn(.browser, "cache robots decision", .{ .url = self.robots_url, .err = err });
|
||||
},
|
||||
},
|
||||
.robots => |r| network.robot_store.put(self.robots_url, r) catch |err| {
|
||||
log.warn(.browser, "cache robots rules", .{ .url = self.robots_url, .err = err });
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
Vendored
+19
-19
@@ -320,33 +320,33 @@ const ResponseHeaders = struct {
|
||||
|
||||
for (headers) |h| {
|
||||
switch (h.name.len) {
|
||||
3 => if (std.ascii.eqlIgnoreCase(h.name, "Age")) {
|
||||
3 => if (std.mem.eql(u8, h.name, "age")) {
|
||||
self.age = h.value;
|
||||
},
|
||||
4 => {
|
||||
if (std.ascii.eqlIgnoreCase(h.name, "Date")) {
|
||||
if (std.mem.eql(u8, h.name, "date")) {
|
||||
self.date = h.value;
|
||||
} else if (std.ascii.eqlIgnoreCase(h.name, "ETag")) {
|
||||
} else if (std.mem.eql(u8, h.name, "etag")) {
|
||||
self.etag = h.value;
|
||||
} else if (std.ascii.eqlIgnoreCase(h.name, "Vary")) {
|
||||
} else if (std.mem.eql(u8, h.name, "vary")) {
|
||||
self.vary = h.value;
|
||||
}
|
||||
},
|
||||
7 => if (std.ascii.eqlIgnoreCase(h.name, "Expires")) {
|
||||
7 => if (std.mem.eql(u8, h.name, "expires")) {
|
||||
self.expires = h.value;
|
||||
},
|
||||
10 => if (std.ascii.eqlIgnoreCase(h.name, "Set-Cookie")) {
|
||||
10 => if (std.mem.eql(u8, h.name, "set-cookie")) {
|
||||
self.has_set_cookie = true;
|
||||
},
|
||||
12 => if (std.ascii.eqlIgnoreCase(h.name, "Content-Type")) {
|
||||
12 => if (std.mem.eql(u8, h.name, "content-type")) {
|
||||
self.content_type = h.value;
|
||||
},
|
||||
13 => {
|
||||
if (std.ascii.eqlIgnoreCase(h.name, "Cache-Control")) {
|
||||
if (std.mem.eql(u8, h.name, "cache-control")) {
|
||||
self.directives = .parse(h.value);
|
||||
} else if (std.ascii.eqlIgnoreCase(h.name, "Last-Modified")) {
|
||||
} else if (std.mem.eql(u8, h.name, "last-modified")) {
|
||||
self.last_modified = h.value;
|
||||
} else if (std.ascii.eqlIgnoreCase(h.name, "Authorization")) {
|
||||
} else if (std.mem.eql(u8, h.name, "authorization")) {
|
||||
self.has_authorization = true;
|
||||
}
|
||||
},
|
||||
@@ -576,11 +576,11 @@ const TestResponse = struct {
|
||||
fn run(self: TestResponse, arena: std.mem.Allocator) !?CachePutRequest {
|
||||
var headers: std.ArrayList(Http.Header) = .empty;
|
||||
inline for (.{
|
||||
.{ "Cache-Control", self.cache_control },
|
||||
.{ "Expires", self.expires },
|
||||
.{ "Date", self.date },
|
||||
.{ "ETag", self.etag },
|
||||
.{ "Last-Modified", self.last_modified },
|
||||
.{ "cache-control", self.cache_control },
|
||||
.{ "expires", self.expires },
|
||||
.{ "date", self.date },
|
||||
.{ "etag", self.etag },
|
||||
.{ "last-modified", self.last_modified },
|
||||
}) |field| {
|
||||
if (field[1]) |value| {
|
||||
try headers.append(arena, .{ .name = field[0], .value = value });
|
||||
@@ -721,8 +721,8 @@ test "Cache: tryCache vary headers" {
|
||||
.status = 200,
|
||||
.content_type = "text/html",
|
||||
.headers = &.{
|
||||
.{ .name = "Cache-Control", .value = "max-age=300" },
|
||||
.{ .name = "Vary", .value = "accept-encoding, accept-language" },
|
||||
.{ .name = "cache-control", .value = "max-age=300" },
|
||||
.{ .name = "vary", .value = "accept-encoding, accept-language" },
|
||||
},
|
||||
.request_headers = &request_headers,
|
||||
});
|
||||
@@ -738,8 +738,8 @@ test "Cache: tryCache vary headers" {
|
||||
.status = 200,
|
||||
.content_type = "text/html",
|
||||
.headers = &.{
|
||||
.{ .name = "Cache-Control", .value = "max-age=300" },
|
||||
.{ .name = "Vary", .value = "*" },
|
||||
.{ .name = "cache-control", .value = "max-age=300" },
|
||||
.{ .name = "vary", .value = "*" },
|
||||
},
|
||||
.request_headers = &request_headers,
|
||||
});
|
||||
|
||||
Vendored
-998
@@ -1,998 +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 lp = @import("lightpanda");
|
||||
|
||||
const Cache = @import("Cache.zig");
|
||||
|
||||
const log = lp.log;
|
||||
const CacheRequest = Cache.CacheRequest;
|
||||
const RenewResponse = Cache.RenewResponse;
|
||||
const CachedMetadata = Cache.CachedMetadata;
|
||||
const CachedResponse = Cache.CachedResponse;
|
||||
|
||||
const CACHE_VERSION: usize = 1;
|
||||
const LOCK_STRIPES = 16;
|
||||
comptime {
|
||||
std.debug.assert(std.math.isPowerOfTwo(LOCK_STRIPES));
|
||||
}
|
||||
|
||||
pub const FsCache = @This();
|
||||
|
||||
dir: std.Io.Dir,
|
||||
locks: [LOCK_STRIPES]std.Io.Mutex = .{std.Io.Mutex.init} ** LOCK_STRIPES,
|
||||
|
||||
const CacheMetadataJson = struct {
|
||||
version: usize,
|
||||
metadata: CachedMetadata,
|
||||
};
|
||||
|
||||
fn getLockPtr(self: *FsCache, key: *const [HASHED_KEY_LEN]u8) *std.Io.Mutex {
|
||||
const lock_idx = std.hash.Wyhash.hash(0, key[0..]) & (LOCK_STRIPES - 1);
|
||||
return &self.locks[lock_idx];
|
||||
}
|
||||
|
||||
const BODY_LEN_HEADER_LEN = 8;
|
||||
const HASHED_KEY_LEN = 64;
|
||||
const HASHED_PATH_LEN = HASHED_KEY_LEN + 6;
|
||||
const HASHED_TMP_PATH_LEN = HASHED_PATH_LEN + 4;
|
||||
|
||||
fn hashKey(key: []const u8) [HASHED_KEY_LEN]u8 {
|
||||
var digest: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined;
|
||||
std.crypto.hash.sha2.Sha256.hash(key, &digest, .{});
|
||||
var hex: [HASHED_KEY_LEN]u8 = undefined;
|
||||
_ = std.fmt.bufPrint(&hex, "{s}", .{std.fmt.bytesToHex(&digest, .lower)}) catch unreachable;
|
||||
return hex;
|
||||
}
|
||||
|
||||
fn cachePath(hashed_key: *const [HASHED_KEY_LEN]u8) [HASHED_PATH_LEN]u8 {
|
||||
var path: [HASHED_PATH_LEN]u8 = undefined;
|
||||
_ = std.fmt.bufPrint(&path, "{s}.cache", .{hashed_key}) catch unreachable;
|
||||
return path;
|
||||
}
|
||||
|
||||
fn cacheTmpPath(hashed_key: *const [HASHED_KEY_LEN]u8) [HASHED_TMP_PATH_LEN]u8 {
|
||||
var path: [HASHED_TMP_PATH_LEN]u8 = undefined;
|
||||
_ = std.fmt.bufPrint(&path, "{s}.cache.tmp", .{hashed_key}) catch unreachable;
|
||||
return path;
|
||||
}
|
||||
|
||||
fn writeCacheFile(
|
||||
self: *FsCache,
|
||||
hashed_key: *const [HASHED_KEY_LEN]u8,
|
||||
body_reader: *std.Io.Reader,
|
||||
body_len: u64,
|
||||
meta: CachedMetadata,
|
||||
) !void {
|
||||
const cache_p = cachePath(hashed_key);
|
||||
const cache_tmp_p = cacheTmpPath(hashed_key);
|
||||
|
||||
const file = self.dir.createFile(lp.io, &cache_tmp_p, .{ .truncate = true }) catch |e| {
|
||||
log.err(.cache, "create file", .{ .url = meta.url, .file = &cache_tmp_p, .err = e });
|
||||
return e;
|
||||
};
|
||||
errdefer self.dir.deleteFile(lp.io, &cache_tmp_p) catch {};
|
||||
defer file.close(lp.io);
|
||||
|
||||
var writer_buf: [1024]u8 = undefined;
|
||||
var file_writer = file.writer(lp.io, &writer_buf);
|
||||
const w = &file_writer.interface;
|
||||
|
||||
var len_buf: [BODY_LEN_HEADER_LEN]u8 = undefined;
|
||||
std.mem.writeInt(u64, &len_buf, body_len, .little);
|
||||
try w.writeAll(&len_buf);
|
||||
|
||||
var copy_buf: [4096]u8 = undefined;
|
||||
var remaining = body_len;
|
||||
while (remaining > 0) {
|
||||
const to_read = @min(copy_buf.len, remaining);
|
||||
const n = try body_reader.readSliceShort(copy_buf[0..to_read]);
|
||||
if (n == 0) break;
|
||||
try w.writeAll(copy_buf[0..n]);
|
||||
remaining -= n;
|
||||
}
|
||||
|
||||
try std.json.Stringify.value(
|
||||
CacheMetadataJson{ .version = CACHE_VERSION, .metadata = meta },
|
||||
.{ .whitespace = .minified },
|
||||
w,
|
||||
);
|
||||
try w.flush();
|
||||
|
||||
self.dir.rename(&cache_tmp_p, self.dir, &cache_p, lp.io) catch |e| {
|
||||
log.err(.cache, "rename", .{ .url = meta.url, .from = &cache_tmp_p, .to = &cache_p, .err = e });
|
||||
return e;
|
||||
};
|
||||
}
|
||||
|
||||
pub fn init(path: []const u8) !FsCache {
|
||||
const cwd = std.Io.Dir.cwd();
|
||||
|
||||
try cwd.createDirPath(lp.io, path);
|
||||
|
||||
const dir = try cwd.openDir(lp.io, path, .{ .iterate = true });
|
||||
return .{ .dir = dir };
|
||||
}
|
||||
|
||||
pub fn deinit(self: *FsCache) void {
|
||||
self.dir.close(lp.io);
|
||||
}
|
||||
|
||||
pub fn get(self: *FsCache, arena: std.mem.Allocator, req: CacheRequest) ?CachedResponse {
|
||||
const hashed_key = hashKey(req.url);
|
||||
const cache_p = cachePath(&hashed_key);
|
||||
|
||||
const lock = self.getLockPtr(&hashed_key);
|
||||
lock.lockUncancelable(lp.io);
|
||||
defer lock.unlock(lp.io);
|
||||
|
||||
const file = self.dir.openFile(lp.io, &cache_p, .{ .mode = .read_only }) catch |e| {
|
||||
switch (e) {
|
||||
std.Io.File.OpenError.FileNotFound => {
|
||||
log.debug(.cache, "miss", .{ .url = req.url, .hash = &hashed_key, .reason = "missing" });
|
||||
},
|
||||
else => |err| {
|
||||
log.warn(.cache, "open file err", .{ .url = req.url, .err = err });
|
||||
},
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
var cleanup = false;
|
||||
defer if (cleanup) {
|
||||
file.close(lp.io);
|
||||
self.dir.deleteFile(lp.io, &cache_p) catch |e| {
|
||||
log.err(.cache, "clean fail", .{ .url = req.url, .file = &cache_p, .err = e });
|
||||
};
|
||||
};
|
||||
|
||||
var file_buf: [1024]u8 = undefined;
|
||||
var len_buf: [BODY_LEN_HEADER_LEN]u8 = undefined;
|
||||
|
||||
var file_reader = file.reader(lp.io, &file_buf);
|
||||
const file_reader_iface = &file_reader.interface;
|
||||
|
||||
file_reader_iface.readSliceAll(&len_buf) catch |e| {
|
||||
log.warn(.cache, "read header", .{ .url = req.url, .err = e });
|
||||
cleanup = true;
|
||||
return null;
|
||||
};
|
||||
const body_len = std.mem.readInt(u64, &len_buf, .little);
|
||||
|
||||
// Now we read metadata.
|
||||
file_reader.seekTo(body_len + BODY_LEN_HEADER_LEN) catch |e| {
|
||||
log.warn(.cache, "seek metadata", .{ .url = req.url, .err = e });
|
||||
cleanup = true;
|
||||
return null;
|
||||
};
|
||||
|
||||
var json_reader = std.json.Reader.init(arena, file_reader_iface);
|
||||
const cache_file: CacheMetadataJson = std.json.parseFromTokenSourceLeaky(
|
||||
CacheMetadataJson,
|
||||
arena,
|
||||
&json_reader,
|
||||
.{ .allocate = .alloc_always },
|
||||
) catch |e| {
|
||||
// Warn because malformed metadata can be a deeper symptom.
|
||||
log.warn(.cache, "miss", .{ .url = req.url, .err = e, .reason = "malformed metadata" });
|
||||
cleanup = true;
|
||||
return null;
|
||||
};
|
||||
|
||||
if (cache_file.version != CACHE_VERSION) {
|
||||
log.debug(.cache, "miss", .{
|
||||
.url = req.url,
|
||||
.reason = "version mismatch",
|
||||
.expected = CACHE_VERSION,
|
||||
.got = cache_file.version,
|
||||
});
|
||||
cleanup = true;
|
||||
return null;
|
||||
}
|
||||
|
||||
const metadata = cache_file.metadata;
|
||||
|
||||
// If we have Vary headers, ensure they are present & matching.
|
||||
for (metadata.vary_headers) |vary_hdr| {
|
||||
const name = vary_hdr.name;
|
||||
const value = vary_hdr.value;
|
||||
|
||||
const incoming = for (req.request_headers) |h| {
|
||||
if (std.ascii.eqlIgnoreCase(h.name, name)) break h.value;
|
||||
} else "";
|
||||
|
||||
if (!std.ascii.eqlIgnoreCase(value, incoming)) {
|
||||
log.debug(.cache, "miss", .{
|
||||
.url = req.url,
|
||||
.reason = "vary mismatch",
|
||||
.header = name,
|
||||
.expected = value,
|
||||
.got = incoming,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// On the case of a hash collision.
|
||||
if (!std.ascii.eqlIgnoreCase(metadata.url, req.url)) {
|
||||
log.warn(.cache, "collision", .{ .url = req.url, .expected = metadata.url, .got = req.url });
|
||||
cleanup = true;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check entry expiration.
|
||||
const expired = metadata.isStale(req.timestamp);
|
||||
log.debug(.cache, "hit", .{ .url = req.url, .hash = &hashed_key, .expired = expired });
|
||||
|
||||
return .{
|
||||
.metadata = metadata,
|
||||
.data = .{
|
||||
.file = .{
|
||||
.file = file,
|
||||
.offset = BODY_LEN_HEADER_LEN,
|
||||
.len = body_len,
|
||||
},
|
||||
},
|
||||
.expired = expired,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn put(self: *FsCache, meta: CachedMetadata, body: []const u8) !void {
|
||||
const hashed_key = hashKey(meta.url);
|
||||
|
||||
const lock = self.getLockPtr(&hashed_key);
|
||||
lock.lockUncancelable(lp.io);
|
||||
defer lock.unlock(lp.io);
|
||||
|
||||
var body_reader = std.Io.Reader.fixed(body);
|
||||
try self.writeCacheFile(&hashed_key, &body_reader, body.len, meta);
|
||||
|
||||
log.debug(.cache, "put", .{ .url = meta.url, .hash = &hashed_key, .body_len = body.len });
|
||||
}
|
||||
|
||||
pub fn clear(self: *FsCache) !void {
|
||||
for (&self.locks) |*lock| lock.lockUncancelable(lp.io);
|
||||
defer for (&self.locks) |*lock| lock.unlock(lp.io);
|
||||
|
||||
var iter = self.dir.iterate();
|
||||
while (try iter.next(lp.io)) |entry| {
|
||||
if (entry.kind != .file) continue;
|
||||
if (!std.mem.endsWith(u8, entry.name, ".cache") and
|
||||
!std.mem.endsWith(u8, entry.name, ".cache.tmp")) continue;
|
||||
|
||||
self.dir.deleteFile(lp.io, entry.name) catch |e| {
|
||||
log.err(.cache, "clear delete fail", .{ .file = entry.name, .err = e });
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub fn evict(self: *FsCache, url: []const u8) void {
|
||||
const hashed_key = hashKey(url);
|
||||
const cache_p = cachePath(&hashed_key);
|
||||
|
||||
const lock = self.getLockPtr(&hashed_key);
|
||||
lock.lockUncancelable(lp.io);
|
||||
defer lock.unlock(lp.io);
|
||||
|
||||
self.dir.deleteFile(lp.io, &cache_p) catch |e| switch (e) {
|
||||
error.FileNotFound => {},
|
||||
else => log.warn(.cache, "evict failed", .{ .url = url, .err = e }),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn renew(self: *FsCache, arena: std.mem.Allocator, req: RenewResponse) !void {
|
||||
const hashed_key = hashKey(req.url);
|
||||
const cache_p = cachePath(&hashed_key);
|
||||
|
||||
const lock = self.getLockPtr(&hashed_key);
|
||||
lock.lockUncancelable(lp.io);
|
||||
defer lock.unlock(lp.io);
|
||||
|
||||
const file = self.dir.openFile(lp.io, &cache_p, .{ .mode = .read_only }) catch |e| {
|
||||
log.warn(.cache, "renew open failed", .{ .url = req.url, .err = e });
|
||||
return e;
|
||||
};
|
||||
defer file.close(lp.io);
|
||||
|
||||
var file_buf: [1024]u8 = undefined;
|
||||
var file_reader = file.reader(lp.io, &file_buf);
|
||||
const r = &file_reader.interface;
|
||||
|
||||
var len_buf: [BODY_LEN_HEADER_LEN]u8 = undefined;
|
||||
r.readSliceAll(&len_buf) catch |e| {
|
||||
log.warn(.cache, "renew read len", .{ .url = req.url, .err = e });
|
||||
return e;
|
||||
};
|
||||
const body_len = std.mem.readInt(u64, &len_buf, .little);
|
||||
|
||||
try file_reader.seekTo(BODY_LEN_HEADER_LEN + body_len);
|
||||
|
||||
var json_reader = std.json.Reader.init(arena, r);
|
||||
var parsed: CacheMetadataJson = std.json.parseFromTokenSourceLeaky(
|
||||
CacheMetadataJson,
|
||||
arena,
|
||||
&json_reader,
|
||||
.{ .allocate = .alloc_always },
|
||||
) catch |e| {
|
||||
log.warn(.cache, "renew parse", .{ .url = req.url, .err = e });
|
||||
return e;
|
||||
};
|
||||
|
||||
parsed.metadata.renew(req);
|
||||
try file_reader.seekTo(BODY_LEN_HEADER_LEN);
|
||||
|
||||
self.writeCacheFile(&hashed_key, r, body_len, parsed.metadata) catch |e| {
|
||||
log.warn(.cache, "renew write", .{ .url = req.url, .err = e });
|
||||
return e;
|
||||
};
|
||||
|
||||
log.debug(.cache, "renewed", .{ .url = req.url });
|
||||
}
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
fn setupCache() !struct { tmp: testing.TmpDir, cache: Cache } {
|
||||
var tmp = testing.tmpDir(.{});
|
||||
errdefer tmp.cleanup();
|
||||
|
||||
const path = try tmp.dir.realPathFileAlloc(lp.io, ".", testing.allocator);
|
||||
defer testing.allocator.free(path);
|
||||
|
||||
return .{
|
||||
.tmp = tmp,
|
||||
.cache = Cache{ .kind = .{ .fs = try FsCache.init(path) } },
|
||||
};
|
||||
}
|
||||
|
||||
test "FsCache: basic put and get" {
|
||||
var setup = try setupCache();
|
||||
defer {
|
||||
setup.cache.deinit();
|
||||
setup.tmp.cleanup();
|
||||
}
|
||||
|
||||
const cache = &setup.cache;
|
||||
|
||||
var arena = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
|
||||
const now = lp.datetime.timestamp(.real);
|
||||
const meta = CachedMetadata{
|
||||
.url = "https://example.com",
|
||||
.content_type = "text/html",
|
||||
.status = 200,
|
||||
.stored_at = now,
|
||||
.age_at_store = 0,
|
||||
.cache_control = .{ .max_age = 600 },
|
||||
.headers = &.{},
|
||||
.vary_headers = &.{},
|
||||
};
|
||||
|
||||
const body = "hello world";
|
||||
try cache.put(meta, body);
|
||||
|
||||
const result = cache.get(
|
||||
arena.allocator(),
|
||||
.{
|
||||
.url = "https://example.com",
|
||||
.timestamp = now,
|
||||
.request_headers = &.{},
|
||||
},
|
||||
) orelse return error.CacheMiss;
|
||||
const f = result.data.file;
|
||||
const file = f.file;
|
||||
defer file.close(lp.io);
|
||||
|
||||
var buf: [64]u8 = undefined;
|
||||
var file_reader = file.reader(lp.io, &buf);
|
||||
try file_reader.seekTo(f.offset);
|
||||
|
||||
const read_buf = try file_reader.interface.readAlloc(testing.allocator, f.len);
|
||||
defer testing.allocator.free(read_buf);
|
||||
try testing.expectEqualStrings(body, read_buf);
|
||||
}
|
||||
|
||||
test "FsCache: get expiration" {
|
||||
var setup = try setupCache();
|
||||
defer {
|
||||
setup.cache.deinit();
|
||||
setup.tmp.cleanup();
|
||||
}
|
||||
|
||||
const cache = &setup.cache;
|
||||
|
||||
var arena = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
|
||||
const now = 5000;
|
||||
const max_age = 1000;
|
||||
|
||||
const meta = CachedMetadata{
|
||||
.url = "https://example.com",
|
||||
.content_type = "text/html",
|
||||
.status = 200,
|
||||
.stored_at = now,
|
||||
.age_at_store = 900,
|
||||
.cache_control = .{ .max_age = max_age },
|
||||
.headers = &.{},
|
||||
.vary_headers = &.{},
|
||||
};
|
||||
|
||||
const body = "hello world";
|
||||
try cache.put(meta, body);
|
||||
|
||||
const result = cache.get(
|
||||
arena.allocator(),
|
||||
.{
|
||||
.url = "https://example.com",
|
||||
.timestamp = now + 50,
|
||||
.request_headers = &.{},
|
||||
},
|
||||
) orelse return error.CacheMiss;
|
||||
result.data.file.file.close(lp.io);
|
||||
|
||||
// Expired: age = 200 + 900 = 1100 >= 1000
|
||||
const stale = cache.get(
|
||||
arena.allocator(),
|
||||
.{
|
||||
.url = "https://example.com",
|
||||
.timestamp = now + 200,
|
||||
.request_headers = &.{},
|
||||
},
|
||||
) orelse return error.CacheMiss;
|
||||
defer stale.data.file.file.close(lp.io);
|
||||
try testing.expectEqual(true, stale.expired);
|
||||
}
|
||||
|
||||
test "FsCache: put override" {
|
||||
var setup = try setupCache();
|
||||
defer {
|
||||
setup.cache.deinit();
|
||||
setup.tmp.cleanup();
|
||||
}
|
||||
|
||||
const cache = &setup.cache;
|
||||
|
||||
var arena = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
|
||||
{
|
||||
const now = 5000;
|
||||
const max_age = 1000;
|
||||
|
||||
const meta = CachedMetadata{
|
||||
.url = "https://example.com",
|
||||
.content_type = "text/html",
|
||||
.status = 200,
|
||||
.stored_at = now,
|
||||
.age_at_store = 900,
|
||||
.cache_control = .{ .max_age = max_age },
|
||||
.headers = &.{},
|
||||
.vary_headers = &.{},
|
||||
};
|
||||
|
||||
const body = "hello world";
|
||||
try cache.put(meta, body);
|
||||
|
||||
const result = cache.get(
|
||||
arena.allocator(),
|
||||
.{
|
||||
.url = "https://example.com",
|
||||
.timestamp = now,
|
||||
.request_headers = &.{},
|
||||
},
|
||||
) orelse return error.CacheMiss;
|
||||
const f = result.data.file;
|
||||
const file = f.file;
|
||||
defer file.close(lp.io);
|
||||
|
||||
var buf: [64]u8 = undefined;
|
||||
var file_reader = file.reader(lp.io, &buf);
|
||||
try file_reader.seekTo(f.offset);
|
||||
|
||||
const read_buf = try file_reader.interface.readAlloc(testing.allocator, f.len);
|
||||
defer testing.allocator.free(read_buf);
|
||||
|
||||
try testing.expectEqualStrings(body, read_buf);
|
||||
}
|
||||
|
||||
{
|
||||
const now = 10000;
|
||||
const max_age = 2000;
|
||||
|
||||
const meta = CachedMetadata{
|
||||
.url = "https://example.com",
|
||||
.content_type = "text/html",
|
||||
.status = 200,
|
||||
.stored_at = now,
|
||||
.age_at_store = 0,
|
||||
.cache_control = .{ .max_age = max_age },
|
||||
.headers = &.{},
|
||||
.vary_headers = &.{},
|
||||
};
|
||||
|
||||
const body = "goodbye world";
|
||||
try cache.put(meta, body);
|
||||
|
||||
const result = cache.get(
|
||||
arena.allocator(),
|
||||
.{
|
||||
.url = "https://example.com",
|
||||
.timestamp = now,
|
||||
.request_headers = &.{},
|
||||
},
|
||||
) orelse return error.CacheMiss;
|
||||
const f = result.data.file;
|
||||
const file = f.file;
|
||||
defer file.close(lp.io);
|
||||
|
||||
var buf: [64]u8 = undefined;
|
||||
var file_reader = file.reader(lp.io, &buf);
|
||||
try file_reader.seekTo(f.offset);
|
||||
|
||||
const read_buf = try file_reader.interface.readAlloc(testing.allocator, f.len);
|
||||
defer testing.allocator.free(read_buf);
|
||||
|
||||
try testing.expectEqualStrings(body, read_buf);
|
||||
}
|
||||
}
|
||||
|
||||
test "FsCache: garbage file" {
|
||||
@import("../../testing.zig").silenceLog(&.{.cache});
|
||||
|
||||
var setup = try setupCache();
|
||||
defer {
|
||||
setup.cache.deinit();
|
||||
setup.tmp.cleanup();
|
||||
}
|
||||
|
||||
const hashed_key = hashKey("https://example.com");
|
||||
const cache_p = cachePath(&hashed_key);
|
||||
const file = try setup.cache.kind.fs.dir.createFile(lp.io, &cache_p, .{});
|
||||
try file.writeStreamingAll(lp.io, "this is not a valid cache file !@#$%");
|
||||
file.close(lp.io);
|
||||
|
||||
var arena = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
|
||||
try testing.expectEqual(
|
||||
null,
|
||||
setup.cache.get(arena.allocator(), .{
|
||||
.url = "https://example.com",
|
||||
.timestamp = 5000,
|
||||
.request_headers = &.{},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
test "FsCache: vary hit and miss" {
|
||||
var setup = try setupCache();
|
||||
defer {
|
||||
setup.cache.deinit();
|
||||
setup.tmp.cleanup();
|
||||
}
|
||||
|
||||
const cache = &setup.cache;
|
||||
|
||||
var arena = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
|
||||
const now = lp.datetime.timestamp(.real);
|
||||
const meta = CachedMetadata{
|
||||
.url = "https://example.com",
|
||||
.content_type = "text/html",
|
||||
.status = 200,
|
||||
.stored_at = now,
|
||||
.age_at_store = 0,
|
||||
.cache_control = .{ .max_age = 600 },
|
||||
.headers = &.{},
|
||||
.vary_headers = &.{
|
||||
.{ .name = "Accept-Encoding", .value = "gzip" },
|
||||
},
|
||||
};
|
||||
|
||||
try cache.put(meta, "hello world");
|
||||
|
||||
const result = cache.get(arena.allocator(), .{
|
||||
.url = "https://example.com",
|
||||
.timestamp = now,
|
||||
.request_headers = &.{
|
||||
.{ .name = "Accept-Encoding", .value = "gzip" },
|
||||
},
|
||||
}) orelse return error.CacheMiss;
|
||||
result.data.file.file.close(lp.io);
|
||||
|
||||
try testing.expectEqual(null, cache.get(arena.allocator(), .{
|
||||
.url = "https://example.com",
|
||||
.timestamp = now,
|
||||
.request_headers = &.{
|
||||
.{ .name = "Accept-Encoding", .value = "br" },
|
||||
},
|
||||
}));
|
||||
|
||||
try testing.expectEqual(null, cache.get(arena.allocator(), .{
|
||||
.url = "https://example.com",
|
||||
.timestamp = now,
|
||||
.request_headers = &.{},
|
||||
}));
|
||||
|
||||
const result2 = cache.get(arena.allocator(), .{
|
||||
.url = "https://example.com",
|
||||
.timestamp = now,
|
||||
.request_headers = &.{
|
||||
.{ .name = "Accept-Encoding", .value = "gzip" },
|
||||
},
|
||||
}) orelse return error.CacheMiss;
|
||||
result2.data.file.file.close(lp.io);
|
||||
}
|
||||
|
||||
test "FsCache: vary multiple headers" {
|
||||
var setup = try setupCache();
|
||||
defer {
|
||||
setup.cache.deinit();
|
||||
setup.tmp.cleanup();
|
||||
}
|
||||
|
||||
const cache = &setup.cache;
|
||||
|
||||
var arena = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
|
||||
const now = lp.datetime.timestamp(.real);
|
||||
const meta = CachedMetadata{
|
||||
.url = "https://example.com",
|
||||
.content_type = "text/html",
|
||||
.status = 200,
|
||||
.stored_at = now,
|
||||
.age_at_store = 0,
|
||||
.cache_control = .{ .max_age = 600 },
|
||||
.headers = &.{},
|
||||
.vary_headers = &.{
|
||||
.{ .name = "Accept-Encoding", .value = "gzip" },
|
||||
.{ .name = "Accept-Language", .value = "en" },
|
||||
},
|
||||
};
|
||||
|
||||
try cache.put(meta, "hello world");
|
||||
|
||||
const result = cache.get(arena.allocator(), .{
|
||||
.url = "https://example.com",
|
||||
.timestamp = now,
|
||||
.request_headers = &.{
|
||||
.{ .name = "Accept-Encoding", .value = "gzip" },
|
||||
.{ .name = "Accept-Language", .value = "en" },
|
||||
},
|
||||
}) orelse return error.CacheMiss;
|
||||
result.data.file.file.close(lp.io);
|
||||
|
||||
try testing.expectEqual(null, cache.get(arena.allocator(), .{
|
||||
.url = "https://example.com",
|
||||
.timestamp = now,
|
||||
.request_headers = &.{
|
||||
.{ .name = "Accept-Encoding", .value = "gzip" },
|
||||
.{ .name = "Accept-Language", .value = "fr" },
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
test "FsCache: clear removes all entries" {
|
||||
var setup = try setupCache();
|
||||
defer {
|
||||
setup.cache.deinit();
|
||||
setup.tmp.cleanup();
|
||||
}
|
||||
|
||||
const cache = &setup.cache;
|
||||
|
||||
var arena = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
|
||||
const now = lp.datetime.timestamp(.real);
|
||||
const base_meta_a = CachedMetadata{
|
||||
.url = "https://example.com/a",
|
||||
.status = 200,
|
||||
.stored_at = now,
|
||||
.age_at_store = 0,
|
||||
.cache_control = .{ .max_age = 600 },
|
||||
.headers = &.{},
|
||||
.vary_headers = &.{},
|
||||
.content_type = "text/html",
|
||||
};
|
||||
|
||||
const base_meta_b = CachedMetadata{
|
||||
.url = "https://example.com/b",
|
||||
.status = 200,
|
||||
.stored_at = now,
|
||||
.age_at_store = 0,
|
||||
.cache_control = .{ .max_age = 600 },
|
||||
.headers = &.{},
|
||||
.vary_headers = &.{},
|
||||
.content_type = "text/html",
|
||||
};
|
||||
|
||||
try cache.put(base_meta_a, "body a");
|
||||
try cache.put(base_meta_b, "body b");
|
||||
|
||||
// Sanity check: both are cached
|
||||
const r1 = cache.get(
|
||||
arena.allocator(),
|
||||
.{
|
||||
.url = "https://example.com/a",
|
||||
.timestamp = now,
|
||||
.request_headers = &.{},
|
||||
},
|
||||
);
|
||||
try testing.expect(r1 != null);
|
||||
r1.?.data.file.file.close(lp.io);
|
||||
|
||||
const r2 = cache.get(
|
||||
arena.allocator(),
|
||||
.{
|
||||
.url = "https://example.com/b",
|
||||
.timestamp = now,
|
||||
.request_headers = &.{},
|
||||
},
|
||||
);
|
||||
try testing.expect(r2 != null);
|
||||
r2.?.data.file.file.close(lp.io);
|
||||
|
||||
try cache.clear();
|
||||
|
||||
try testing.expectEqual(null, cache.get(
|
||||
arena.allocator(),
|
||||
.{
|
||||
.url = "https://example.com/a",
|
||||
.timestamp = now,
|
||||
.request_headers = &.{},
|
||||
},
|
||||
));
|
||||
try testing.expectEqual(null, cache.get(
|
||||
arena.allocator(),
|
||||
.{
|
||||
.url = "https://example.com/b",
|
||||
.timestamp = now,
|
||||
.request_headers = &.{},
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
test "FsCache: put after clear works" {
|
||||
var setup = try setupCache();
|
||||
defer {
|
||||
setup.cache.deinit();
|
||||
setup.tmp.cleanup();
|
||||
}
|
||||
|
||||
const cache = &setup.cache;
|
||||
|
||||
var arena = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
|
||||
const now = lp.datetime.timestamp(.real);
|
||||
const meta = CachedMetadata{
|
||||
.url = "https://example.com",
|
||||
.content_type = "text/html",
|
||||
.status = 200,
|
||||
.stored_at = now,
|
||||
.age_at_store = 0,
|
||||
.cache_control = .{ .max_age = 600 },
|
||||
.headers = &.{},
|
||||
.vary_headers = &.{},
|
||||
};
|
||||
|
||||
try cache.put(meta, "before clear");
|
||||
try cache.clear();
|
||||
|
||||
// Should be a miss after clear
|
||||
try testing.expectEqual(
|
||||
null,
|
||||
cache.get(
|
||||
arena.allocator(),
|
||||
.{
|
||||
.url = "https://example.com",
|
||||
.timestamp = now,
|
||||
.request_headers = &.{},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Put again after clear — should work normally
|
||||
try cache.put(meta, "after clear");
|
||||
const result = cache.get(
|
||||
arena.allocator(),
|
||||
.{
|
||||
.url = "https://example.com",
|
||||
.timestamp = now,
|
||||
.request_headers = &.{},
|
||||
},
|
||||
) orelse return error.CacheMiss;
|
||||
const f = result.data.file;
|
||||
defer f.file.close(lp.io);
|
||||
|
||||
var buf: [64]u8 = undefined;
|
||||
var file_reader = f.file.reader(lp.io, &buf);
|
||||
try file_reader.seekTo(f.offset);
|
||||
const read_buf = try file_reader.interface.readAlloc(testing.allocator, f.len);
|
||||
defer testing.allocator.free(read_buf);
|
||||
try testing.expectEqualStrings("after clear", read_buf);
|
||||
}
|
||||
|
||||
test "FsCache: evict removes entry" {
|
||||
var setup = try setupCache();
|
||||
defer {
|
||||
setup.cache.deinit();
|
||||
setup.tmp.cleanup();
|
||||
}
|
||||
|
||||
const cache = &setup.cache;
|
||||
|
||||
var arena = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
|
||||
const now = lp.datetime.timestamp(.real);
|
||||
const meta = CachedMetadata{
|
||||
.url = "https://example.com",
|
||||
.content_type = "text/html",
|
||||
.status = 200,
|
||||
.stored_at = now,
|
||||
.age_at_store = 0,
|
||||
.cache_control = .{ .max_age = 600 },
|
||||
.headers = &.{},
|
||||
.vary_headers = &.{},
|
||||
};
|
||||
|
||||
try cache.put(meta, "hello world");
|
||||
|
||||
const result = cache.get(
|
||||
arena.allocator(),
|
||||
.{
|
||||
.url = "https://example.com",
|
||||
.timestamp = now,
|
||||
.request_headers = &.{},
|
||||
},
|
||||
) orelse return error.CacheMiss;
|
||||
result.data.file.file.close(lp.io);
|
||||
|
||||
cache.evict("https://example.com");
|
||||
|
||||
try testing.expectEqual(null, cache.get(
|
||||
arena.allocator(),
|
||||
.{
|
||||
.url = "https://example.com",
|
||||
.timestamp = now,
|
||||
.request_headers = &.{},
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
test "FsCache: renew refreshes expiry" {
|
||||
var setup = try setupCache();
|
||||
defer {
|
||||
setup.cache.deinit();
|
||||
setup.tmp.cleanup();
|
||||
}
|
||||
|
||||
const cache = &setup.cache;
|
||||
|
||||
var arena = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
|
||||
const now: u64 = 5000;
|
||||
const max_age: u64 = 1000;
|
||||
|
||||
const meta = CachedMetadata{
|
||||
.url = "https://example.com",
|
||||
.content_type = "text/html",
|
||||
.status = 200,
|
||||
.stored_at = now,
|
||||
.age_at_store = 0,
|
||||
.cache_control = .{ .max_age = max_age },
|
||||
.headers = &.{},
|
||||
.vary_headers = &.{},
|
||||
};
|
||||
|
||||
try cache.put(meta, "hello world");
|
||||
|
||||
// renew while still fresh at now+500
|
||||
try cache.renew(
|
||||
arena.allocator(),
|
||||
.{
|
||||
.url = "https://example.com",
|
||||
.timestamp = now + 500,
|
||||
.headers = &.{},
|
||||
},
|
||||
);
|
||||
|
||||
// Without revalidation would expire at now+1000, but clock reset to now+500
|
||||
// so still fresh at now+1200
|
||||
const r1 = cache.get(
|
||||
arena.allocator(),
|
||||
.{
|
||||
.url = "https://example.com",
|
||||
.timestamp = now + 1200,
|
||||
.request_headers = &.{},
|
||||
},
|
||||
) orelse return error.CacheMiss;
|
||||
r1.data.file.file.close(lp.io);
|
||||
|
||||
// Expires at now+500+1000 = now+1500
|
||||
const stale1 = cache.get(
|
||||
arena.allocator(),
|
||||
.{
|
||||
.url = "https://example.com",
|
||||
.timestamp = now + 1500,
|
||||
.request_headers = &.{},
|
||||
},
|
||||
) orelse return error.CacheMiss;
|
||||
stale1.data.file.file.close(lp.io);
|
||||
try testing.expectEqual(true, stale1.expired);
|
||||
}
|
||||
|
||||
test "FsCache: renew preserves body" {
|
||||
var setup = try setupCache();
|
||||
defer {
|
||||
setup.cache.deinit();
|
||||
setup.tmp.cleanup();
|
||||
}
|
||||
|
||||
const cache = &setup.cache;
|
||||
|
||||
var arena = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
|
||||
const now = lp.datetime.timestamp(.real);
|
||||
const meta = CachedMetadata{
|
||||
.url = "https://example.com",
|
||||
.content_type = "text/html",
|
||||
.status = 200,
|
||||
.stored_at = now,
|
||||
.age_at_store = 0,
|
||||
.cache_control = .{ .max_age = 600 },
|
||||
.headers = &.{},
|
||||
.vary_headers = &.{},
|
||||
};
|
||||
|
||||
const body = "original body";
|
||||
try cache.put(meta, body);
|
||||
|
||||
try cache.renew(
|
||||
arena.allocator(),
|
||||
.{
|
||||
.url = "https://example.com",
|
||||
.timestamp = now + 100,
|
||||
.headers = &.{},
|
||||
},
|
||||
);
|
||||
|
||||
const result = cache.get(arena.allocator(), .{
|
||||
.url = "https://example.com",
|
||||
.timestamp = now + 100,
|
||||
.request_headers = &.{},
|
||||
}) orelse return error.CacheMiss;
|
||||
|
||||
const f = result.data.file;
|
||||
defer f.file.close(lp.io);
|
||||
|
||||
var buf: [64]u8 = undefined;
|
||||
var file_reader = f.file.reader(lp.io, &buf);
|
||||
try file_reader.seekTo(f.offset);
|
||||
const read_buf = try file_reader.interface.readAlloc(testing.allocator, f.len);
|
||||
defer testing.allocator.free(read_buf);
|
||||
try testing.expectEqualStrings(body, read_buf);
|
||||
}
|
||||
@@ -71,6 +71,13 @@ pub const Header = struct {
|
||||
value: []const u8,
|
||||
};
|
||||
|
||||
pub fn normalize(self: Header, allocator: std.mem.Allocator) !Header {
|
||||
return .{
|
||||
.name = try std.ascii.allocLowerString(allocator, self.name),
|
||||
.value = try allocator.dupe(u8, self.value),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn parse(header_str: []const u8) ?Header {
|
||||
const colon_pos = std.mem.indexOfScalar(u8, header_str, ':') orelse return null;
|
||||
|
||||
@@ -147,10 +154,7 @@ pub const HeaderIterator = union(enum) {
|
||||
var list: std.ArrayList(Header) = .empty;
|
||||
|
||||
while (self.next()) |hdr| {
|
||||
try list.append(allocator, .{
|
||||
.name = try allocator.dupe(u8, hdr.name),
|
||||
.value = try allocator.dupe(u8, hdr.value),
|
||||
});
|
||||
try list.append(allocator, try hdr.normalize(allocator));
|
||||
}
|
||||
|
||||
return list;
|
||||
|
||||
@@ -874,6 +874,17 @@ impl<'arena> xml5ever::tokenizer::TokenSink for UnclosedTagSink<'arena> {
|
||||
.parse_error(std::borrow::Cow::Borrowed("Unclosed element at EOF"));
|
||||
}
|
||||
}
|
||||
// The XML declaration isn't a processing instruction, so no node
|
||||
// is created for it. xml5ever doesn't validate it.
|
||||
Token::ProcessingInstruction(pi) if &*pi.target == "xml" => {
|
||||
if !is_valid_xml_declaration(&pi.data) {
|
||||
use xml5ever::tree_builder::TreeSink;
|
||||
self.tb
|
||||
.sink
|
||||
.parse_error(std::borrow::Cow::Borrowed("Invalid XML declaration"));
|
||||
}
|
||||
return xml5ever::tokenizer::ProcessResult::Continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.tb.process_token(token)
|
||||
@@ -884,6 +895,30 @@ impl<'arena> xml5ever::tokenizer::TokenSink for UnclosedTagSink<'arena> {
|
||||
}
|
||||
}
|
||||
|
||||
// The declaration must start with `version="1.x"`: browsers accept any 1.x
|
||||
// (XML 1.0 5th edition's VersionNum is `1.[0-9]+`) and reject everything else.
|
||||
fn is_valid_xml_declaration(data: &str) -> bool {
|
||||
fn trim(s: &str) -> &str {
|
||||
s.trim_start_matches([' ', '\t', '\r', '\n'])
|
||||
}
|
||||
let Some(rest) = trim(data).strip_prefix("version").map(trim) else {
|
||||
return false;
|
||||
};
|
||||
let Some(rest) = rest.strip_prefix('=').map(trim) else {
|
||||
return false;
|
||||
};
|
||||
let Some(quote) = rest.chars().next().filter(|c| *c == '"' || *c == '\'') else {
|
||||
return false;
|
||||
};
|
||||
let Some((version, _)) = rest[1..].split_once(quote) else {
|
||||
return false;
|
||||
};
|
||||
match version.strip_prefix("1.") {
|
||||
Some(minor) => !minor.is_empty() && minor.bytes().all(|b| b.is_ascii_digit()),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
// xml5ever::driver::XmlParser, minus the tree-builder-typed tokenizer so the
|
||||
// UnclosedTagSink can sit in between.
|
||||
struct XmlDocumentParser<'arena> {
|
||||
|
||||
@@ -975,7 +975,8 @@ fn stringToOwned(
|
||||
self.env.isolate.handle,
|
||||
buf.ptr,
|
||||
buf.len,
|
||||
v8.NO_NULL_TERMINATION | v8.REPLACE_INVALID_UTF8,
|
||||
v8.WRITE_REPLACE_INVALID_UTF8,
|
||||
null,
|
||||
);
|
||||
return buf[0..written];
|
||||
}
|
||||
|
||||
@@ -2221,6 +2221,352 @@ test "server: HTTP element commands" {
|
||||
}
|
||||
}
|
||||
|
||||
test "server: HTTP element input" {
|
||||
const session_id = try createHTTPSession("{\"capabilities\":{}}", false);
|
||||
defer deleteHTTPSession(&session_id, true) catch |err| @panic(@errorName(err));
|
||||
|
||||
var c = try createTestClient();
|
||||
defer c.deinit();
|
||||
|
||||
const url = "http://127.0.0.1:9582/src/browser/tests/webdriver/input.html";
|
||||
try testing.expectEqual("{\"value\":null}", responseBody(try sessionCommand(&c, "POST", &session_id, "/url", "{\"url\":\"" ++ url ++ "\"}")));
|
||||
|
||||
const take_events = "var e = window.events.join(' '); window.events = []; return e;";
|
||||
|
||||
const btn = try findElement(&c, &session_id, "css selector", "#btn");
|
||||
try testing.expectEqual("{\"value\":null}", responseBody(try elementPost(&c, &session_id, btn, "/click", "{}")));
|
||||
try testing.expectEqual("{\"value\":\"focus@btn click@btn\"}", try executeSync(&c, &session_id, take_events, "[]"));
|
||||
|
||||
{
|
||||
const gone = try findElement(&c, &session_id, "css selector", "#gone");
|
||||
const res = try elementPost(&c, &session_id, gone, "/click", "{}");
|
||||
try testing.expect(std.mem.startsWith(u8, res, "HTTP/1.1 400 Bad Request\r\n"));
|
||||
try testing.expect(std.mem.indexOf(u8, res, "\"error\":\"element not interactable\"") != null);
|
||||
}
|
||||
|
||||
// clear: focus, the edit's input and change, blur
|
||||
const name = try findElement(&c, &session_id, "css selector", "#name");
|
||||
try testing.expectEqual("{\"value\":null}", responseBody(try elementPost(&c, &session_id, name, "/clear", "{}")));
|
||||
try testing.expectEqual("{\"value\":\"\"}", try elementCommand(&c, &session_id, name, "/property/value"));
|
||||
try testing.expectEqual("{\"value\":\"blur@btn focus@name input@name change@name blur@name\"}", try executeSync(&c, &session_id, take_events, "[]"));
|
||||
|
||||
// already empty: nothing happens at all
|
||||
try testing.expectEqual("{\"value\":null}", responseBody(try elementPost(&c, &session_id, name, "/clear", "{}")));
|
||||
try testing.expectEqual("{\"value\":\"\"}", try executeSync(&c, &session_id, take_events, "[]"));
|
||||
|
||||
for ([_][]const u8{ "#ro", "#check" }) |selector| {
|
||||
const element = try findElement(&c, &session_id, "css selector", selector);
|
||||
const res = try elementPost(&c, &session_id, element, "/clear", "{}");
|
||||
try testing.expect(std.mem.startsWith(u8, res, "HTTP/1.1 400 Bad Request\r\n"));
|
||||
try testing.expect(std.mem.indexOf(u8, res, "\"error\":\"invalid element state\"") != null);
|
||||
}
|
||||
|
||||
// a modifier stays down until it's typed again
|
||||
try testing.expectEqual("{\"value\":null}", responseBody(try elementPost(&c, &session_id, name, "/value", "{\"text\":\"\\uE008a\\uE008b\"}")));
|
||||
try testing.expectEqual("{\"value\":\"Ab\"}", try elementCommand(&c, &session_id, name, "/property/value"));
|
||||
try testing.expectEqual(
|
||||
"{\"value\":\"focus@name keydown:S-Shift@name keydown:S-A@name input@name keydown:b@name input@name\"}",
|
||||
try executeSync(&c, &session_id, take_events, "[]"),
|
||||
);
|
||||
|
||||
// focusing puts the caret at the end
|
||||
{
|
||||
const area = try findElement(&c, &session_id, "css selector", "#area");
|
||||
try testing.expectEqual("{\"value\":null}", responseBody(try elementPost(&c, &session_id, area, "/value", "{\"text\":\"!\"}")));
|
||||
try testing.expectEqual("{\"value\":\"text!\"}", try elementCommand(&c, &session_id, area, "/property/value"));
|
||||
}
|
||||
|
||||
{
|
||||
const plain = try findElement(&c, &session_id, "css selector", "#plain");
|
||||
const res = try elementPost(&c, &session_id, plain, "/value", "{\"text\":\"x\"}");
|
||||
try testing.expect(std.mem.startsWith(u8, res, "HTTP/1.1 400 Bad Request\r\n"));
|
||||
try testing.expect(std.mem.indexOf(u8, res, "\"error\":\"element not interactable\"") != null);
|
||||
}
|
||||
|
||||
// an option is clicked by selecting it
|
||||
{
|
||||
_ = try executeSync(&c, &session_id, take_events, "[]");
|
||||
const opt_b = try findElement(&c, &session_id, "css selector", "#opt_b");
|
||||
try testing.expectEqual("{\"value\":null}", responseBody(try elementPost(&c, &session_id, opt_b, "/click", "{}")));
|
||||
try testing.expectEqual("{\"value\":true}", try elementCommand(&c, &session_id, opt_b, "/selected"));
|
||||
const opt_a = try findElement(&c, &session_id, "css selector", "#opt_a");
|
||||
try testing.expectEqual("{\"value\":false}", try elementCommand(&c, &session_id, opt_a, "/selected"));
|
||||
try testing.expectEqual("{\"value\":\"blur@area focus@pick click@opt_b input@pick change@pick\"}", try executeSync(&c, &session_id, take_events, "[]"));
|
||||
}
|
||||
|
||||
// a click that navigates is answered once the new page has loaded
|
||||
{
|
||||
const next = try findElement(&c, &session_id, "css selector", "#next");
|
||||
try testing.expectEqual("{\"value\":null}", responseBody(try elementPost(&c, &session_id, next, "/click", "{}")));
|
||||
try testing.expectEqual("{\"value\":\"webdriver elements\"}", responseBody(try sessionCommand(&c, "GET", &session_id, "/title", "")));
|
||||
}
|
||||
|
||||
// and so is Enter in a form
|
||||
{
|
||||
try testing.expectEqual("{\"value\":null}", responseBody(try sessionCommand(&c, "POST", &session_id, "/url", "{\"url\":\"" ++ url ++ "\"}")));
|
||||
const q = try findElement(&c, &session_id, "css selector", "#q");
|
||||
try testing.expectEqual("{\"value\":null}", responseBody(try elementPost(&c, &session_id, q, "/value", "{\"text\":\"hi\\n\"}")));
|
||||
try testing.expectEqual(
|
||||
"{\"value\":\"http://127.0.0.1:9582/src/browser/tests/webdriver/elements.html?q=hi\"}",
|
||||
responseBody(try sessionCommand(&c, "GET", &session_id, "/url", "")),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn elementPost(c: *TestClient, session_id: *const [36]u8, id: []const u8, suffix: []const u8, body: []const u8) ![]const u8 {
|
||||
const path = try std.fmt.allocPrint(testing.arena_allocator, "/element/{s}{s}", .{ id, suffix });
|
||||
return sessionCommand(c, "POST", session_id, path, body);
|
||||
}
|
||||
|
||||
test "server: HTTP execute script" {
|
||||
const session_id = try createHTTPSession("{\"capabilities\":{}}", false);
|
||||
defer deleteHTTPSession(&session_id, true) catch |err| @panic(@errorName(err));
|
||||
|
||||
var c = try createTestClient();
|
||||
defer c.deinit();
|
||||
|
||||
const url = "http://127.0.0.1:9582/src/browser/tests/webdriver/elements.html";
|
||||
try testing.expectEqual("{\"value\":null}", responseBody(try sessionCommand(&c, "POST", &session_id, "/url", "{\"url\":\"" ++ url ++ "\"}")));
|
||||
|
||||
try testing.expectEqual(
|
||||
"{\"value\":{\"script\":30000,\"pageLoad\":300000,\"implicit\":0}}",
|
||||
responseBody(try sessionCommand(&c, "GET", &session_id, "/timeouts", "")),
|
||||
);
|
||||
|
||||
// the script is a function body, so `arguments` is bound and `return` works
|
||||
try testing.expectEqual("{\"value\":5}", try executeSync(&c, &session_id, "return arguments[0] + arguments[1];", "[2,3]"));
|
||||
try testing.expectEqual("{\"value\":\"hi\"}", try executeSync(&c, &session_id, "return 'hi';", "[]"));
|
||||
try testing.expectEqual("{\"value\":true}", try executeSync(&c, &session_id, "return 1 < 2;", "[]"));
|
||||
|
||||
// a whole number isn't 2e0, and what JSON can't hold is null
|
||||
try testing.expectEqual("{\"value\":2}", try executeSync(&c, &session_id, "return 2.0;", "[]"));
|
||||
try testing.expectEqual("{\"value\":1.5}", try executeSync(&c, &session_id, "return 1.5;", "[]"));
|
||||
try testing.expectEqual("{\"value\":null}", try executeSync(&c, &session_id, "return 0/0;", "[]"));
|
||||
|
||||
// undefined, and a body that doesn't return at all
|
||||
try testing.expectEqual("{\"value\":null}", try executeSync(&c, &session_id, "return undefined;", "[]"));
|
||||
try testing.expectEqual("{\"value\":null}", try executeSync(&c, &session_id, "var x = 1;", "[]"));
|
||||
|
||||
try testing.expectEqual(
|
||||
"{\"value\":{\"a\":1,\"b\":[true,null,\"x\"]}}",
|
||||
try executeSync(&c, &session_id, "return {a: 1, b: [true, null, 'x']};", "[]"),
|
||||
);
|
||||
|
||||
// a function has no own enumerable properties, so it clones to {}
|
||||
try testing.expectEqual("{\"value\":{}}", try executeSync(&c, &session_id, "return function() {};", "[]"));
|
||||
|
||||
// toJSON wins over the property walk
|
||||
try testing.expectEqual(
|
||||
"{\"value\":\"1970-01-01T00:00:00.000Z\"}",
|
||||
try executeSync(&c, &session_id, "return new Date(0);", "[]"),
|
||||
);
|
||||
|
||||
// an element comes back as a reference, and goes back in as the node
|
||||
{
|
||||
const body = try executeSync(&c, &session_id, "return document.getElementById('msg');", "[]");
|
||||
const parsed = try std.json.parseFromSliceLeaky(std.json.Value, testing.arena_allocator, body, .{});
|
||||
const reference = parsed.object.get("value").?.object.get(http_command.element_key).?.string;
|
||||
|
||||
// the same node the find endpoints hand out
|
||||
try testing.expectEqual(reference, try findElement(&c, &session_id, "css selector", "#msg"));
|
||||
try testing.expectEqual("{\"value\":\"hello\"}", try elementCommand(&c, &session_id, reference, "/text"));
|
||||
|
||||
const args = try std.fmt.allocPrint(testing.arena_allocator, "[{{\"" ++ http_command.element_key ++ "\":\"{s}\"}}]", .{reference});
|
||||
try testing.expectEqual("{\"value\":\"msg\"}", try executeSync(&c, &session_id, "return arguments[0].id;", args));
|
||||
}
|
||||
|
||||
// a collection is an array of references, a non-element node is a bare {}
|
||||
{
|
||||
const body = try executeSync(&c, &session_id, "return document.querySelectorAll('.item');", "[]");
|
||||
try testing.expectEqual(2, (try elementReferences(body)).len);
|
||||
try testing.expectEqual("{\"value\":[{}]}", try executeSync(&c, &session_id, "return [document.getElementById('msg').firstChild];", "[]"));
|
||||
}
|
||||
|
||||
// a reference nothing handed out
|
||||
{
|
||||
const res = try executeRaw(&c, &session_id, "sync", "return 1;", "[{\"" ++ http_command.element_key ++ "\":\"99\"}]");
|
||||
try testing.expect(std.mem.startsWith(u8, res, "HTTP/1.1 404 Not Found\r\n"));
|
||||
try testing.expect(std.mem.indexOf(u8, res, "\"error\":\"no such element\"") != null);
|
||||
}
|
||||
|
||||
// a node in another document is stale, connected or not
|
||||
{
|
||||
const body = try executeSync(&c, &session_id, "return document.getElementById('child').contentDocument.getElementById('inner');", "[]");
|
||||
const parsed = try std.json.parseFromSliceLeaky(std.json.Value, testing.arena_allocator, body, .{});
|
||||
const reference = parsed.object.get("value").?.object.get(http_command.element_key).?.string;
|
||||
|
||||
const path = try std.fmt.allocPrint(testing.arena_allocator, "/element/{s}/text", .{reference});
|
||||
const res = try sessionCommand(&c, "GET", &session_id, path, "");
|
||||
try testing.expect(std.mem.startsWith(u8, res, "HTTP/1.1 404 Not Found\r\n"));
|
||||
try testing.expect(std.mem.indexOf(u8, res, "\"error\":\"stale element reference\"") != null);
|
||||
|
||||
const args = try std.fmt.allocPrint(testing.arena_allocator, "[{{\"" ++ http_command.element_key ++ "\":\"{s}\"}}]", .{reference});
|
||||
const arg_res = try executeRaw(&c, &session_id, "sync", "return 1;", args);
|
||||
try testing.expect(std.mem.indexOf(u8, arg_res, "\"error\":\"stale element reference\"") != null);
|
||||
}
|
||||
|
||||
// a throw fails the command; it isn't reported inside a successful result
|
||||
{
|
||||
const res = try executeRaw(&c, &session_id, "sync", "throw new Error('nope');", "[]");
|
||||
try testing.expect(std.mem.startsWith(u8, res, "HTTP/1.1 500 Internal Server Error\r\n"));
|
||||
try testing.expect(std.mem.indexOf(u8, res, "\"error\":\"javascript error\"") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, res, "Error: nope") != null);
|
||||
}
|
||||
|
||||
{
|
||||
const res = try executeRaw(&c, &session_id, "sync", "return (", "[]");
|
||||
try testing.expect(std.mem.indexOf(u8, res, "\"error\":\"javascript error\"") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, res, "SyntaxError") != null);
|
||||
}
|
||||
|
||||
// a cycle is an error, not a collapsed value like a RemoteValue's
|
||||
{
|
||||
const res = try executeRaw(&c, &session_id, "sync", "var a = {}; a.self = a; return a;", "[]");
|
||||
try testing.expect(std.mem.indexOf(u8, res, "\"error\":\"javascript error\"") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, res, "circular reference") != null);
|
||||
}
|
||||
|
||||
// a returned promise is resolved before we answer
|
||||
try testing.expectEqual("{\"value\":7}", try executeSync(&c, &session_id, "return Promise.resolve(7);", "[]"));
|
||||
try testing.expectEqual(
|
||||
"{\"value\":8}",
|
||||
try executeSync(&c, &session_id, "return new Promise(function(r) { setTimeout(function() { r(8); }, 5); });", "[]"),
|
||||
);
|
||||
{
|
||||
const res = try executeRaw(&c, &session_id, "sync", "return Promise.reject(new Error('late'));", "[]");
|
||||
try testing.expect(std.mem.indexOf(u8, res, "\"error\":\"javascript error\"") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, res, "Error: late") != null);
|
||||
}
|
||||
|
||||
// async: the callback is the last argument, and only its first call counts
|
||||
try testing.expectEqual("{\"value\":42}", try executeAsync(&c, &session_id, "arguments[0](42);", "[]"));
|
||||
try testing.expectEqual(
|
||||
"{\"value\":42}",
|
||||
try executeAsync(&c, &session_id, "var cb = arguments[arguments.length - 1]; cb(arguments[0] * 2);", "[21]"),
|
||||
);
|
||||
try testing.expectEqual(
|
||||
"{\"value\":\"late\"}",
|
||||
try executeAsync(&c, &session_id, "var cb = arguments[0]; setTimeout(function() { cb('late'); cb('again'); }, 5);", "[]"),
|
||||
);
|
||||
// what an async body returns is ignored
|
||||
try testing.expectEqual("{\"value\":null}", try executeAsync(&c, &session_id, "arguments[0](); return 9;", "[]"));
|
||||
|
||||
// only the first call counts; the rest are a no-op on a settled promise
|
||||
try testing.expectEqual("{\"value\":1}", try executeAsync(&c, &session_id, "arguments[0](1); arguments[0](2);", "[]"));
|
||||
|
||||
// The body is promise-called, so throwing rejects it and fails the
|
||||
// command even though the callback already ran -- and that failure must
|
||||
// not be a *second* answer on a connection we already handed back.
|
||||
{
|
||||
const res = try executeRaw(&c, &session_id, "async", "arguments[0](1); throw new Error('too late');", "[]");
|
||||
try testing.expect(std.mem.indexOf(u8, res, "\"error\":\"javascript error\"") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, res, "Error: too late") != null);
|
||||
}
|
||||
try testing.expectEqual("{\"value\":2}", try executeSync(&c, &session_id, "return 2;", "[]"));
|
||||
|
||||
// a throw before the callback still fails the command
|
||||
{
|
||||
const res = try executeRaw(&c, &session_id, "async", "throw new Error('early');", "[]");
|
||||
try testing.expect(std.mem.indexOf(u8, res, "\"error\":\"javascript error\"") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, res, "Error: early") != null);
|
||||
}
|
||||
|
||||
// a script that never completes is answered by the script timeout
|
||||
{
|
||||
try testing.expectEqual("{\"value\":null}", responseBody(try sessionCommand(&c, "POST", &session_id, "/timeouts", "{\"script\":50}")));
|
||||
try testing.expectEqual(
|
||||
"{\"value\":{\"script\":50,\"pageLoad\":300000,\"implicit\":0}}",
|
||||
responseBody(try sessionCommand(&c, "GET", &session_id, "/timeouts", "")),
|
||||
);
|
||||
|
||||
const res = try executeRaw(&c, &session_id, "async", "// never calls back", "[]");
|
||||
try testing.expect(std.mem.startsWith(u8, res, "HTTP/1.1 500 Internal Server Error\r\n"));
|
||||
try testing.expect(std.mem.indexOf(u8, res, "\"error\":\"script timeout\"") != null);
|
||||
|
||||
// a sync script whose promise never settles times out the same way
|
||||
const promise = try executeRaw(&c, &session_id, "sync", "return new Promise(function() {});", "[]");
|
||||
try testing.expect(std.mem.indexOf(u8, promise, "\"error\":\"script timeout\"") != null);
|
||||
|
||||
// A script that resolves AFTER it timed out: answering is not the
|
||||
// promise settling, so the Pending has to outlive its own answer.
|
||||
// Freeing it on the timeout leaves V8 holding our callbacks on a
|
||||
// live promise and the late resolve lands in freed memory -- which a
|
||||
// release build segfaults on, but the debug allocator here does not
|
||||
// trap, so this covers the path rather than proving the invariant.
|
||||
// selenium/http/demo.js in ../demo is what actually catches it.
|
||||
const late = try executeRaw(&c, &session_id, "async", "var cb = arguments[0]; setTimeout(function() { window.__late = true; cb('way late'); }, 150);", "[]");
|
||||
try testing.expect(std.mem.indexOf(u8, late, "\"error\":\"script timeout\"") != null);
|
||||
// the assertion only means anything if the stale resolve actually ran
|
||||
var ran = false;
|
||||
for (0..200) |_| {
|
||||
if (std.mem.eql(u8, "{\"value\":true}", try executeSync(&c, &session_id, "return window.__late === true;", "[]"))) {
|
||||
ran = true;
|
||||
break;
|
||||
}
|
||||
lp.io.sleep(.fromMilliseconds(10), .awake) catch {};
|
||||
}
|
||||
try testing.expect(ran);
|
||||
try testing.expectEqual("{\"value\":\"alive\"}", try executeSync(&c, &session_id, "return 'alive';", "[]"));
|
||||
}
|
||||
|
||||
// null turns the script timeout off
|
||||
{
|
||||
try testing.expectEqual("{\"value\":null}", responseBody(try sessionCommand(&c, "POST", &session_id, "/timeouts", "{\"script\":null}")));
|
||||
try testing.expectEqual(
|
||||
"{\"value\":{\"script\":null,\"pageLoad\":300000,\"implicit\":0}}",
|
||||
responseBody(try sessionCommand(&c, "GET", &session_id, "/timeouts", "")),
|
||||
);
|
||||
try testing.expectEqual("{\"value\":1}", try executeSync(&c, &session_id, "return 1;", "[]"));
|
||||
}
|
||||
|
||||
// Navigating out from under a running script answers it. The Pending
|
||||
// stays alive past that answer -- V8 still holds its callback -- until
|
||||
// the frame, and with it the context, is destroyed.
|
||||
{
|
||||
const handle = blk: {
|
||||
const body = responseBody(try sessionCommand(&c, "GET", &session_id, "/window", ""));
|
||||
break :blk try testing.arena_allocator.dupe(u8, body[10..46]);
|
||||
};
|
||||
|
||||
var ws = try createTestClient();
|
||||
defer ws.deinit();
|
||||
var path_buf: [64]u8 = undefined;
|
||||
try ws.handshake(try std.fmt.bufPrint(&path_buf, "/session/{s}", .{&session_id}));
|
||||
|
||||
// the script never calls back, so its connection parks
|
||||
var parked = try createTestClient();
|
||||
defer parked.deinit();
|
||||
try writeSessionCommand(&parked, "POST", &session_id, "/execute/async", "{\"script\":\"// never calls back\",\"args\":[]}");
|
||||
lp.io.sleep(.fromMilliseconds(50), .awake) catch {};
|
||||
|
||||
try ws.bidiCommand(try std.fmt.allocPrint(testing.arena_allocator,
|
||||
\\{{"id":1,"method":"browsingContext.navigate","params":{{"context":"{s}","url":"about:blank","wait":"complete"}}}}
|
||||
, .{handle}));
|
||||
|
||||
const res = try parked.httpRequest("");
|
||||
try testing.expect(std.mem.indexOf(u8, res, "\"error\":\"javascript error\"") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, res, "document was unloaded") != null);
|
||||
}
|
||||
}
|
||||
|
||||
// POST /execute/{sync,async}: the raw response, so a test can assert on an
|
||||
// error too.
|
||||
fn executeRaw(c: *TestClient, session_id: *const [36]u8, kind: []const u8, script: []const u8, args: []const u8) ![]const u8 {
|
||||
const arena = testing.arena_allocator;
|
||||
const quoted = try std.json.Stringify.valueAlloc(arena, script, .{});
|
||||
const body = try std.fmt.allocPrint(arena, "{{\"script\":{s},\"args\":{s}}}", .{ quoted, args });
|
||||
const path = try std.fmt.allocPrint(arena, "/execute/{s}", .{kind});
|
||||
return sessionCommand(c, "POST", session_id, path, body);
|
||||
}
|
||||
|
||||
fn executeSync(c: *TestClient, session_id: *const [36]u8, script: []const u8, args: []const u8) ![]const u8 {
|
||||
return responseBody(try executeRaw(c, session_id, "sync", script, args));
|
||||
}
|
||||
|
||||
fn executeAsync(c: *TestClient, session_id: *const [36]u8, script: []const u8, args: []const u8) ![]const u8 {
|
||||
return responseBody(try executeRaw(c, session_id, "async", script, args));
|
||||
}
|
||||
|
||||
fn findElement(c: *TestClient, session_id: *const [36]u8, using: []const u8, value: []const u8) ![]const u8 {
|
||||
const body = try std.fmt.allocPrint(testing.arena_allocator, "{{\"using\":\"{s}\",\"value\":\"{s}\"}}", .{ using, value });
|
||||
const res = responseBody(try sessionCommand(c, "POST", session_id, "/element", body));
|
||||
@@ -2773,6 +3119,9 @@ fn createTestClient() !TestClient {
|
||||
});
|
||||
try posix.setsockopt(socket, posix.SOL.SOCKET, posix.SO.RCVTIMEO, &timeout);
|
||||
try posix.setsockopt(socket, posix.SOL.SOCKET, posix.SO.SNDTIMEO, &timeout);
|
||||
if (@hasDecl(posix.TCP, "NODELAY")) {
|
||||
try posix.setsockopt(socket, posix.IPPROTO.TCP, posix.TCP.NODELAY, &std.mem.toBytes(@as(c_int, 1)));
|
||||
}
|
||||
return .{
|
||||
.socket = socket,
|
||||
.reader = .{
|
||||
|
||||
@@ -36,6 +36,7 @@ const Server = @import("../Server.zig");
|
||||
|
||||
const script = @import("script.zig");
|
||||
const http_command = @import("http_command.zig");
|
||||
const execute = @import("execute.zig");
|
||||
const remote_value = @import("remote_value.zig");
|
||||
|
||||
const posix = std.posix;
|
||||
@@ -84,6 +85,13 @@ handles: remote_value.Handles,
|
||||
// Commands awaiting promise resolution
|
||||
pending: std.ArrayList(*script.Pending) = .empty,
|
||||
|
||||
// The HTTP session's execute/sync and execute/async, awaiting a promise or
|
||||
// the callback an async script was handed.
|
||||
execute_pending: std.ArrayList(*execute.Pending) = .empty,
|
||||
|
||||
// The HTTP session's timeouts. BiDi has no equivalent.
|
||||
timeouts: Timeouts = .{},
|
||||
|
||||
input_state: @import("input.zig").State = .{},
|
||||
|
||||
subscriptions: std.ArrayList(Subscription) = .empty,
|
||||
@@ -121,6 +129,12 @@ const InputMessage = struct {
|
||||
method: ?[]const u8 = null,
|
||||
};
|
||||
|
||||
pub const Timeouts = struct {
|
||||
script: ?u32 = 30_000,
|
||||
pageLoad: u32 = 300_000,
|
||||
implicit: u32 = 0,
|
||||
};
|
||||
|
||||
pub fn init(self: *BiDi, app: *App, inbox: *Inbox, origin: Origin) !void {
|
||||
const allocator = app.allocator;
|
||||
{
|
||||
@@ -176,11 +190,14 @@ pub fn deinit(self: *BiDi) void {
|
||||
|
||||
// Cancel first, so that any completions during session teardown are still valid
|
||||
script.Pending.cancelAll(self);
|
||||
execute.Pending.cancelAll(self);
|
||||
self.handles.deinit();
|
||||
self.browser.closeSession();
|
||||
// Now we can destroy
|
||||
script.Pending.destroyAll(self);
|
||||
execute.Pending.destroyAll(self);
|
||||
self.pending.deinit(allocator);
|
||||
self.execute_pending.deinit(allocator);
|
||||
self.input_state.deinit(allocator);
|
||||
|
||||
self.node_registry.deinit();
|
||||
@@ -262,6 +279,7 @@ const UserContext = struct {
|
||||
|
||||
pub fn resetRealm(self: *BiDi) void {
|
||||
script.Pending.realmReset(self);
|
||||
execute.Pending.realmReset(self);
|
||||
self.handles.releaseAll();
|
||||
self.node_registry.reset();
|
||||
if (self.browsing_context) |*ctx| {
|
||||
|
||||
@@ -32,6 +32,7 @@ const Notification = @import("../../Notification.zig");
|
||||
|
||||
const BiDi = @import("BiDi.zig");
|
||||
const script = @import("script.zig");
|
||||
const execute = @import("execute.zig");
|
||||
const remote_value = @import("remote_value.zig");
|
||||
|
||||
const log = lp.log;
|
||||
@@ -267,6 +268,16 @@ fn startNavigation(cmd: *BiDi.Command, ctx: *Context, frame: *Frame, url: [:0]co
|
||||
}
|
||||
}
|
||||
|
||||
// For commands that start a navigation, e.g. clicking a link.
|
||||
pub fn answerAfterNavigation(cmd: *BiDi.Command, ctx: *Context, frame: *const Frame) !void {
|
||||
if (frame._queued_navigation == null) {
|
||||
return cmd.sendDone();
|
||||
}
|
||||
// There was an already queued navigation, reject the previous pending command
|
||||
try rejectPending(cmd.bidi, ctx, "navigation superseded");
|
||||
ctx.pending_navigate = .{ .reply = cmd.takeReply(), .until = .complete };
|
||||
}
|
||||
|
||||
fn close(cmd: *BiDi.Command) !void {
|
||||
const p = try cmd.params(struct {
|
||||
context: []const u8,
|
||||
@@ -547,6 +558,7 @@ fn onFrameCreated(ptr: *anyopaque, frame: *Frame) !void {
|
||||
fn onFrameDestroyed(ptr: *anyopaque, frame: *const Frame) !void {
|
||||
const bidi: *BiDi = @ptrCast(@alignCast(ptr));
|
||||
script.Pending.contextDestroyed(bidi, frame.js.id);
|
||||
execute.Pending.contextDestroyed(bidi, frame.js.id);
|
||||
}
|
||||
|
||||
fn onFrameNavigate(ptr: *anyopaque, msg: *const Notification.FrameNavigate) !void {
|
||||
|
||||
@@ -0,0 +1,581 @@
|
||||
// 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/>.
|
||||
|
||||
// Unlike most WebDriver endpoints, we can't re-use the BiDi flow here. The
|
||||
// flow is too different. WebDriver treats throws as command failures and
|
||||
// serializs over JSON. BiDi treats throws as success with the error reported
|
||||
// inside and serializes via RemoteValue.
|
||||
|
||||
const std = @import("std");
|
||||
const lp = @import("lightpanda");
|
||||
|
||||
const NodeRegistry = @import("../../NodeRegistry.zig");
|
||||
|
||||
const js = @import("../../browser/js/js.zig");
|
||||
const Frame = @import("../../browser/Frame.zig");
|
||||
const Node = @import("../../browser/webapi/Node.zig");
|
||||
const NodeList = @import("../../browser/webapi/collections/NodeList.zig");
|
||||
const HTMLCollection = @import("../../browser/webapi/collections/HTMLCollection.zig");
|
||||
|
||||
const BiDi = @import("BiDi.zig");
|
||||
const http_command = @import("http_command.zig");
|
||||
|
||||
const log = lp.log;
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
pub const Script = struct {
|
||||
script: []const u8,
|
||||
args: []const std.json.Value = &.{},
|
||||
};
|
||||
|
||||
pub const Mode = enum {
|
||||
sync,
|
||||
async,
|
||||
};
|
||||
|
||||
// POST /session/{id}/execute/sync, POST /session/{id}/execute/async
|
||||
pub fn run(cmd: *BiDi.Command, p: Script, mode: Mode) !void {
|
||||
const bidi = cmd.bidi;
|
||||
const frame = bidi.user_context.session.currentFrame() orelse {
|
||||
return cmd.sendError("no such window", "no frame");
|
||||
};
|
||||
|
||||
var ls: js.Local.Scope = undefined;
|
||||
frame.js.localScope(&ls);
|
||||
defer ls.deinit();
|
||||
const local = &ls.local;
|
||||
|
||||
var try_catch: js.TryCatch = undefined;
|
||||
try_catch.init(local);
|
||||
defer try_catch.deinit();
|
||||
|
||||
// `script` is a function *body*, not an expression
|
||||
const function = local.compileFunction(p.script, &.{}, &.{}) catch |err| {
|
||||
if (err == error.ExecutionTerminated or err == error.OutOfMemory) {
|
||||
return err;
|
||||
}
|
||||
return cmd.sendError("javascript error", exceptionText(cmd.arena, &try_catch, err));
|
||||
};
|
||||
|
||||
const extra = @intFromBool(mode == .async);
|
||||
const arguments = try cmd.arena.alloc(js.Value, p.args.len + extra);
|
||||
for (p.args, arguments[0..p.args.len]) |argument, *js_argument| {
|
||||
js_argument.* = fromJson(local, &bidi.node_registry, argument, frame) catch |err| switch (err) {
|
||||
error.NoSuchElement => return cmd.sendError("no such element", "unknown element reference"),
|
||||
error.StaleElement => return cmd.sendError("stale element reference", "element is not in the current document"),
|
||||
error.InvalidArgument => return cmd.sendError("invalid argument", "cannot deserialize an argument"),
|
||||
else => return err,
|
||||
};
|
||||
}
|
||||
|
||||
var deferred: ?js.Promise = null;
|
||||
if (mode == .async) {
|
||||
// It looks like we're supposed to give it a plain callback (which we
|
||||
// could, via `local.newCallback`), but a promise makes managing the
|
||||
// lifetime easier (because it can only be settled once).
|
||||
const pair = (try local.exec(async_bridge, "webdriver.executeAsync")).toArray();
|
||||
deferred = (try pair.get(0)).toPromise();
|
||||
arguments[p.args.len] = try pair.get(1);
|
||||
}
|
||||
|
||||
const undef = try local.zigValueToJs({}, .{});
|
||||
const returned = function.callWithThisRethrow(js.Value, undef, arguments) catch |err| {
|
||||
if (err == error.ExecutionTerminated or err == error.OutOfMemory) {
|
||||
return err;
|
||||
}
|
||||
return cmd.sendError("javascript error", exceptionText(cmd.arena, &try_catch, err));
|
||||
};
|
||||
|
||||
// An async script's return value is ignored, the resolved value is what answers.
|
||||
const value = if (deferred) |promise| promise.toValue() else returned;
|
||||
if (value.isPromise() == false) {
|
||||
return sendResult(cmd, frame, local, value);
|
||||
}
|
||||
|
||||
// this isn't jus the async path, we're also here if the sync script returned
|
||||
// a promise. This is another advantage of using a profile as our async
|
||||
// paramater: it gives us a single thing to handle here (a promise) rather
|
||||
// than a promise (from a sync return) and a callback (if we used local.newCallback)
|
||||
|
||||
const promise = value.toPromise();
|
||||
promise.markAsHandled();
|
||||
local.runMicrotasks();
|
||||
|
||||
switch (promise.state()) {
|
||||
.fulfilled => return sendResult(cmd, frame, local, promise.result()),
|
||||
.rejected => return sendRejection(cmd, promise.result()),
|
||||
.pending => {
|
||||
const pnd = try Pending.create(cmd, frame);
|
||||
_ = try promise.thenAndCatch(
|
||||
local.newCallback(Pending.onFulfilled, pnd),
|
||||
local.newCallback(Pending.onRejected, pnd),
|
||||
);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Returns [promise, resolve]. The script gets `resolve`; we wait on the promise.
|
||||
const async_bridge = "(function(){var r; var p = new Promise(function(res){r = res;}); return [p, r];})()";
|
||||
|
||||
fn sendResult(cmd: *BiDi.Command, frame: *Frame, local: *const js.Local, value: js.Value) !void {
|
||||
const cloned = clone(cmd.arena, &cmd.bidi.node_registry, frame, local, value) catch |err| switch (err) {
|
||||
error.OutOfMemory, error.ExecutionTerminated => return err,
|
||||
else => return cmd.sendError("javascript error", cloneErrorMessage(err)),
|
||||
};
|
||||
return cmd.sendResult(cloned);
|
||||
}
|
||||
|
||||
fn sendRejection(cmd: *BiDi.Command, reason: js.Value) !void {
|
||||
return cmd.sendError("javascript error", rejectionText(cmd.arena, reason));
|
||||
}
|
||||
|
||||
// The exception stringified ("Error: nope"), not just its message, which is
|
||||
// what a driver's users expect to read.
|
||||
fn exceptionText(arena: Allocator, try_catch: *const js.TryCatch, err: anyerror) []const u8 {
|
||||
const caught = try_catch.caughtOrError(arena, err);
|
||||
const fallback = caught.exception orelse @errorName(err);
|
||||
const thrown = try_catch.exceptionValue() orelse return fallback;
|
||||
return thrown.toStringSliceWithAlloc(arena) catch fallback;
|
||||
}
|
||||
|
||||
fn rejectionText(arena: Allocator, reason: js.Value) []const u8 {
|
||||
return reason.toStringSliceWithAlloc(arena) catch "promise rejected";
|
||||
}
|
||||
|
||||
// Script with a value that'll come later, i.e. a sync script that returned
|
||||
// a promise (thus, when the promise is resolved/rejected) or for an async
|
||||
// script that will resolve the promise.
|
||||
pub const Pending = struct {
|
||||
bidi: *BiDi,
|
||||
to: BiDi.Reply,
|
||||
js_context_id: usize,
|
||||
deadline: ?u64, // null if there isn't one
|
||||
answered: bool = false,
|
||||
|
||||
fn create(cmd: *BiDi.Command, frame: *Frame) !*Pending {
|
||||
const bidi = cmd.bidi;
|
||||
const allocator = bidi.app.allocator;
|
||||
const timeout = bidi.timeouts.script;
|
||||
|
||||
const self = try allocator.create(Pending);
|
||||
errdefer allocator.destroy(self);
|
||||
|
||||
self.* = .{
|
||||
.bidi = bidi,
|
||||
.to = cmd.reply(),
|
||||
.js_context_id = frame.js.id,
|
||||
.deadline = if (timeout) |ms| lp.datetime.milliTimestamp(.boot) + ms else null,
|
||||
};
|
||||
try bidi.execute_pending.append(allocator, self);
|
||||
errdefer _ = bidi.execute_pending.pop();
|
||||
|
||||
if (timeout) |ms| {
|
||||
// timeout defaults to 30 seconds and is likely not going to be
|
||||
// needed, never block done for this.
|
||||
try frame.js.scheduler.add(bidi, onTimeout, ms, .{
|
||||
.name = "webdriver.scriptTimeout",
|
||||
.blocks_done = false,
|
||||
});
|
||||
}
|
||||
|
||||
// from this point on, we own the reply
|
||||
_ = cmd.takeReply();
|
||||
return self;
|
||||
}
|
||||
|
||||
fn unregister(self: *Pending) void {
|
||||
const bidi = self.bidi;
|
||||
for (bidi.execute_pending.items, 0..) |pending, i| {
|
||||
if (pending == self) {
|
||||
_ = bidi.execute_pending.swapRemove(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
bidi.app.allocator.destroy(self);
|
||||
}
|
||||
|
||||
fn onFulfilled(self: *Pending, value: js.Value, exec: *const js.Execution) void {
|
||||
defer self.unregister();
|
||||
self.answer(exec.js.local.?, value);
|
||||
}
|
||||
|
||||
fn onRejected(self: *Pending, reason: js.Value, _: *const js.Execution) void {
|
||||
defer self.unregister();
|
||||
if (self.answered) {
|
||||
return;
|
||||
}
|
||||
const arena = self.scratch() orelse return;
|
||||
defer arena.release();
|
||||
self.fail("javascript error", rejectionText(arena.allocator(), reason));
|
||||
}
|
||||
|
||||
// The ctx for this is *BiDi, not *Pending, because the *Pending will clean
|
||||
// itself up once the promise is resolved/rejected, but the scheduled timeout
|
||||
// will live on. To make this work with a *Pending, the Scheduler would need
|
||||
// to be able to remove a task. Don't think we've needed that before, and
|
||||
// hard to justify just for this case. So, what we can do it just scan
|
||||
// the bidi's list of pending's to see if any have timed out.
|
||||
fn onTimeout(ctx: *anyopaque) !?u32 {
|
||||
// Ab
|
||||
const bidi: *BiDi = @ptrCast(@alignCast(ctx));
|
||||
const now = lp.datetime.milliTimestamp(.boot);
|
||||
|
||||
var soonest: ?u64 = null;
|
||||
var i = bidi.execute_pending.items.len;
|
||||
while (i > 0) {
|
||||
i -= 1;
|
||||
const pending = bidi.execute_pending.items[i];
|
||||
const deadline = pending.deadline orelse continue;
|
||||
if (deadline > now) {
|
||||
soonest = if (soonest) |s| @min(s, deadline) else deadline;
|
||||
continue;
|
||||
}
|
||||
// Most important thing is here: we answer but don't free. The
|
||||
// promise could still be resolved at some point in the future!
|
||||
pending.fail("script timeout", "the script did not complete within the script timeout");
|
||||
pending.deadline = null;
|
||||
}
|
||||
|
||||
if (soonest) |deadline| {
|
||||
return @intCast(deadline - now);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
fn answer(self: *Pending, local: *const js.Local, value: js.Value) void {
|
||||
if (self.answered) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bidi = self.bidi;
|
||||
const frame = bidi.user_context.session.currentFrame() orelse {
|
||||
return self.fail("no such window", "no frame");
|
||||
};
|
||||
|
||||
const arena = self.scratch() orelse return;
|
||||
defer arena.release();
|
||||
|
||||
const cloned = clone(arena.allocator(), &bidi.node_registry, frame, local, value) catch |err| {
|
||||
return self.fail("javascript error", cloneErrorMessage(err));
|
||||
};
|
||||
|
||||
self.answered = true;
|
||||
bidi.replyResult(self.to, cloned) catch |err| {
|
||||
log.err(.bidi, "execute result", .{ .err = err, .reply = self.to });
|
||||
};
|
||||
}
|
||||
|
||||
fn fail(self: *Pending, code: []const u8, message: []const u8) void {
|
||||
if (self.answered) {
|
||||
return;
|
||||
}
|
||||
self.answered = true;
|
||||
self.bidi.replyError(self.to, code, message) catch |err| {
|
||||
log.err(.bidi, "execute error", .{ .err = err, .reply = self.to });
|
||||
};
|
||||
}
|
||||
|
||||
fn scratch(self: *Pending) ?*lp.Arena {
|
||||
return self.bidi.app.arena_pool.acquire(.small, "webdriver execute") catch |err| {
|
||||
self.fail("unknown error", @errorName(err));
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
pub fn realmReset(bidi: *BiDi) void {
|
||||
for (bidi.execute_pending.items) |pending| {
|
||||
pending.fail("javascript error", "the document was unloaded while the script was running");
|
||||
}
|
||||
}
|
||||
|
||||
// A frame, and with it the JS context holding our callbacks, is gone.
|
||||
pub fn contextDestroyed(bidi: *BiDi, js_context_id: usize) void {
|
||||
var i = bidi.execute_pending.items.len;
|
||||
while (i > 0) {
|
||||
i -= 1;
|
||||
const pending = bidi.execute_pending.items[i];
|
||||
if (pending.js_context_id == js_context_id) {
|
||||
_ = bidi.execute_pending.swapRemove(i);
|
||||
bidi.app.allocator.destroy(pending);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Teardown: completions are still reachable, but their reply isn't.
|
||||
pub fn cancelAll(bidi: *BiDi) void {
|
||||
for (bidi.execute_pending.items) |pending| {
|
||||
pending.answered = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn destroyAll(bidi: *BiDi) void {
|
||||
while (bidi.execute_pending.pop()) |pending| {
|
||||
bidi.app.allocator.destroy(pending);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const CloneError = error{
|
||||
CyclicReference,
|
||||
TooDeep,
|
||||
OutOfMemory,
|
||||
ExecutionTerminated,
|
||||
TypeError,
|
||||
JsException,
|
||||
MethodNotFound,
|
||||
DeadFunctionHandle,
|
||||
InvalidArgument,
|
||||
};
|
||||
|
||||
fn cloneErrorMessage(err: anyerror) []const u8 {
|
||||
return switch (err) {
|
||||
error.CyclicReference => "cannot serialize a circular reference",
|
||||
error.TooDeep => "the result is nested too deeply to serialize",
|
||||
else => "cannot serialize the script's result",
|
||||
};
|
||||
}
|
||||
|
||||
// W3C's "JSON clone" of a script's result. Not a RemoteValue: a client reads
|
||||
// it as plain JSON, with an element the one exception.
|
||||
const Value = union(enum) {
|
||||
null,
|
||||
boolean: bool,
|
||||
number: f64,
|
||||
string: []const u8,
|
||||
element: http_command.Reference,
|
||||
array: []const Value,
|
||||
object: []const Property,
|
||||
|
||||
pub const Property = struct {
|
||||
name: []const u8,
|
||||
value: Value,
|
||||
};
|
||||
|
||||
pub fn jsonStringify(self: *const Value, w: anytype) !void {
|
||||
switch (self.*) {
|
||||
.null => try w.write(null),
|
||||
.boolean => |v| try w.write(v),
|
||||
.string => |v| try w.write(v),
|
||||
.element => |v| try w.write(v),
|
||||
.number => |v| {
|
||||
// JSON has no NaN or Infinity, and a whole number must not go
|
||||
// out as 3e0 -- a client that rejects non-conforming JSON is
|
||||
// within its rights.
|
||||
if (std.math.isFinite(v) == false) {
|
||||
return w.write(null);
|
||||
}
|
||||
const max_safe_integer = 9007199254740991;
|
||||
if (@trunc(v) == v and @abs(v) <= max_safe_integer) {
|
||||
return w.write(@as(i64, @intFromFloat(v)));
|
||||
}
|
||||
try w.write(v);
|
||||
},
|
||||
.array => |values| {
|
||||
try w.beginArray();
|
||||
for (values) |*value| {
|
||||
try w.write(value);
|
||||
}
|
||||
try w.endArray();
|
||||
},
|
||||
.object => |properties| {
|
||||
try w.beginObject();
|
||||
for (properties) |*property| {
|
||||
try w.objectField(property.name);
|
||||
try w.write(&property.value);
|
||||
}
|
||||
try w.endObject();
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fn clone(
|
||||
arena: Allocator,
|
||||
registry: *NodeRegistry,
|
||||
frame: *Frame,
|
||||
local: *const js.Local,
|
||||
value: js.Value,
|
||||
) CloneError!Value {
|
||||
var cloner: Cloner = .{ .arena = arena, .registry = registry, .frame = frame, .local = local };
|
||||
return cloner.run(value);
|
||||
}
|
||||
|
||||
const Cloner = struct {
|
||||
arena: Allocator,
|
||||
registry: *NodeRegistry,
|
||||
frame: *Frame,
|
||||
local: *const js.Local,
|
||||
// cyclical dependencies are an error (vs RemoteValue which collapses it)
|
||||
seen: std.ArrayList(js.Object) = .empty, //cyclicli
|
||||
|
||||
const max_depth = 64;
|
||||
|
||||
fn run(self: *Cloner, value: js.Value) CloneError!Value {
|
||||
if (value.isNullOrUndefined()) {
|
||||
return .null;
|
||||
}
|
||||
if (value.isBoolean()) {
|
||||
return .{ .boolean = value.toBool() };
|
||||
}
|
||||
if (value.isNumber()) {
|
||||
return .{ .number = try value.toF64() };
|
||||
}
|
||||
if (value.isString() != null) {
|
||||
return .{ .string = try value.toStringSliceWithAlloc(self.arena) };
|
||||
}
|
||||
if (value.isObject() == false) {
|
||||
// a symbol or a bigint
|
||||
return .null;
|
||||
}
|
||||
|
||||
const object = value.toObject();
|
||||
if (self.isSeen(object)) {
|
||||
return error.CyclicReference;
|
||||
}
|
||||
if (self.seen.items.len == max_depth) {
|
||||
return error.TooDeep;
|
||||
}
|
||||
try self.seen.append(self.arena, object);
|
||||
defer _ = self.seen.pop();
|
||||
|
||||
if (value.taggedOpaque()) |tao| {
|
||||
if (try self.platform(tao)) |cloned| {
|
||||
return cloned;
|
||||
}
|
||||
// self.platform() only handles a few select types. Everything else
|
||||
// goes through a more generic path , e.g. self.properties()
|
||||
}
|
||||
|
||||
if (value.isArray()) {
|
||||
return .{ .array = try self.items(value.toArray()) };
|
||||
}
|
||||
|
||||
if (try object.getFunction("toJSON") != null) {
|
||||
return self.run(try object.callMethod(js.Value, "toJSON", .{}));
|
||||
}
|
||||
|
||||
return .{ .object = try self.properties(object) };
|
||||
}
|
||||
|
||||
fn platform(self: *Cloner, tao: *const js.TaggedOpaque) !?Value {
|
||||
if (tao.as(Node)) |node| {
|
||||
// Non-elements will be serialized via properties()
|
||||
const element = node.is(Node.Element) orelse return null;
|
||||
return .{ .element = try self.reference(element.asNode()) };
|
||||
}
|
||||
|
||||
if (tao.as(NodeList)) |list| {
|
||||
const values = try self.arena.alloc(Value, try list.length(self.frame));
|
||||
for (values, 0..) |*item, i| {
|
||||
const node = (try list.getAtIndex(i, self.frame)) orelse unreachable;
|
||||
item.* = try self.run(try self.local.zigValueToJs(node, .{}));
|
||||
}
|
||||
return .{ .array = values };
|
||||
}
|
||||
|
||||
if (tao.as(HTMLCollection)) |collection| {
|
||||
const values = try self.arena.alloc(Value, collection.length(self.frame));
|
||||
for (values, 0..) |*item, i| {
|
||||
const element = collection.getAtIndex(i, self.frame) orelse unreachable;
|
||||
item.* = .{ .element = try self.reference(element.asNode()) };
|
||||
}
|
||||
return .{ .array = values };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
fn reference(self: *Cloner, node: *Node) !http_command.Reference {
|
||||
return .init(self.arena, self.registry, node);
|
||||
}
|
||||
|
||||
fn items(self: *Cloner, array: js.Array) CloneError![]const Value {
|
||||
const values = try self.arena.alloc(Value, array.len());
|
||||
for (values, 0..) |*value, i| {
|
||||
value.* = try self.run(try array.get(@intCast(i)));
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
fn properties(self: *Cloner, object: js.Object) CloneError![]const Value.Property {
|
||||
var it = try object.iterator();
|
||||
var list: std.ArrayList(Value.Property) = try .initCapacity(self.arena, it.count);
|
||||
while (try it.next()) |entry| {
|
||||
list.appendAssumeCapacity(.{
|
||||
.name = try self.arena.dupe(u8, entry.name),
|
||||
.value = try self.run(entry.value),
|
||||
});
|
||||
}
|
||||
return list.items;
|
||||
}
|
||||
|
||||
fn isSeen(self: *const Cloner, object: js.Object) bool {
|
||||
const candidate = object.toValue();
|
||||
for (self.seen.items) |ancestor| {
|
||||
if (ancestor.toValue().strictEquals(candidate)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
fn fromJson(
|
||||
local: *const js.Local,
|
||||
registry: *const NodeRegistry,
|
||||
value: std.json.Value,
|
||||
frame: *const Frame,
|
||||
) !js.Value {
|
||||
switch (value) {
|
||||
.null => return local.zigValueToJs(null, .{}),
|
||||
.bool => |v| return local.zigValueToJs(v, .{}),
|
||||
.integer => |v| return local.newNumber(@floatFromInt(v)),
|
||||
.float => |v| return local.newNumber(v),
|
||||
.number_string => |v| return local.newNumber(std.fmt.parseFloat(f64, v) catch return error.InvalidArgument),
|
||||
.string => |v| return local.zigValueToJs(v, .{}),
|
||||
.array => |v| {
|
||||
var array = local.newArray(@intCast(v.items.len));
|
||||
for (v.items, 0..) |item, i| {
|
||||
if (try array.set(@intCast(i), try fromJson(local, registry, item, frame), .{}) == false) {
|
||||
return error.InvalidArgument;
|
||||
}
|
||||
}
|
||||
return array.toValue();
|
||||
},
|
||||
.object => |fields| {
|
||||
if (fields.get(http_command.element_key)) |id| {
|
||||
const shared_id = switch (id) {
|
||||
.string => |s| s,
|
||||
else => return error.NoSuchElement,
|
||||
};
|
||||
const element = try http_command.elementFromReference(registry, shared_id, frame);
|
||||
return local.zigValueToJs(element.asNode(), .{});
|
||||
}
|
||||
|
||||
const object = local.newObject();
|
||||
var it = fields.iterator();
|
||||
while (it.next()) |entry| {
|
||||
const item = try fromJson(local, registry, entry.value_ptr.*, frame);
|
||||
if (try object.set(entry.key_ptr.*, item, .{}) == false) {
|
||||
return error.InvalidArgument;
|
||||
}
|
||||
}
|
||||
return object.toValue();
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -26,14 +26,17 @@ const lp = @import("lightpanda");
|
||||
const js = @import("../../browser/js/js.zig");
|
||||
const Frame = @import("../../browser/Frame.zig");
|
||||
const Node = @import("../../browser/webapi/Node.zig");
|
||||
const NodeRegistry = @import("../../NodeRegistry.zig");
|
||||
|
||||
const Method = @import("../http.zig").Connection.Method;
|
||||
|
||||
const BiDi = @import("BiDi.zig");
|
||||
const input = @import("input.zig");
|
||||
const execute = @import("execute.zig");
|
||||
const remote_value = @import("remote_value.zig");
|
||||
const browsing_context = @import("browsing_context.zig");
|
||||
|
||||
const Html = Node.Element.Html;
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
// The key of a WebDriver element reference: {"element-6066-…": "<sharedId>"}
|
||||
@@ -63,14 +66,13 @@ pub const Command = union(enum) {
|
||||
get_element_rect: ElementId,
|
||||
is_element_enabled: ElementId,
|
||||
is_element_selected: ElementId,
|
||||
};
|
||||
|
||||
pub const NavigateTo = struct {
|
||||
url: [:0]const u8,
|
||||
};
|
||||
|
||||
pub const PerformActions = struct {
|
||||
actions: []const std.json.Value,
|
||||
element_click: ElementId,
|
||||
element_clear: ElementId,
|
||||
element_send_keys: SendKeys,
|
||||
execute_script: execute.Script,
|
||||
execute_async_script: execute.Script,
|
||||
get_timeouts,
|
||||
set_timeouts: SetTimeouts,
|
||||
};
|
||||
|
||||
// A command's path parameters are its leading fields (see `parse`); the rest
|
||||
@@ -225,9 +227,16 @@ const routes = [_]Route{
|
||||
.init(.GET, "/element/{id}/rect", .get_element_rect),
|
||||
.init(.GET, "/element/{id}/enabled", .is_element_enabled),
|
||||
.init(.GET, "/element/{id}/selected", .is_element_selected),
|
||||
.init(.POST, "/element/{id}/click", .element_click),
|
||||
.init(.POST, "/element/{id}/clear", .element_clear),
|
||||
.init(.POST, "/element/{id}/value", .element_send_keys),
|
||||
.init(.GET, "/element/{id}/attribute/{name}", .get_element_attribute),
|
||||
.init(.GET, "/element/{id}/property/{name}", .get_element_property),
|
||||
.init(.GET, "/element/{id}/css/{name}", .get_element_css_value),
|
||||
.init(.POST, "/execute/sync", .execute_script),
|
||||
.init(.POST, "/execute/async", .execute_async_script),
|
||||
.init(.GET, "/timeouts", .get_timeouts),
|
||||
.init(.POST, "/timeouts", .set_timeouts),
|
||||
};
|
||||
|
||||
pub const ParseError = error{
|
||||
@@ -323,10 +332,20 @@ pub fn process(cmd: *BiDi.Command) !void {
|
||||
.get_element_rect => |p| return getElementRect(cmd, p),
|
||||
.is_element_enabled => |p| return isElementEnabled(cmd, p),
|
||||
.is_element_selected => |p| return isElementSelected(cmd, p),
|
||||
.element_click => |p| return elementClick(cmd, p),
|
||||
.element_clear => |p| return elementClear(cmd, p),
|
||||
.element_send_keys => |p| return elementSendKeys(cmd, p),
|
||||
.execute_script => |p| return executeScript(cmd, p, .sync),
|
||||
.execute_async_script => |p| return executeScript(cmd, p, .async),
|
||||
.get_timeouts => return getTimeouts(cmd),
|
||||
.set_timeouts => |p| return setTimeouts(cmd, p),
|
||||
}
|
||||
}
|
||||
|
||||
// POST /session/{id}/url.
|
||||
pub const NavigateTo = struct {
|
||||
url: [:0]const u8,
|
||||
};
|
||||
fn navigateTo(cmd: *BiDi.Command, p: NavigateTo) !void {
|
||||
const ctx = (try currentContext(cmd)) orelse return;
|
||||
return browsing_context.navigate(cmd, ctx, .{ .url = p.url, .wait = .complete });
|
||||
@@ -379,6 +398,9 @@ fn takeScreenshot(cmd: *BiDi.Command) !void {
|
||||
}
|
||||
|
||||
// POST /session/{id}/actions.
|
||||
pub const PerformActions = struct {
|
||||
actions: []const std.json.Value,
|
||||
};
|
||||
fn performActions(cmd: *BiDi.Command, p: PerformActions) !void {
|
||||
_ = (try currentContext(cmd)) orelse return;
|
||||
return input.perform(cmd, p.actions);
|
||||
@@ -398,7 +420,7 @@ fn findElement(cmd: *BiDi.Command, using: Using, value: []const u8, from: ?[]con
|
||||
if (nodes.len == 0) {
|
||||
return cmd.sendError("no such element", "no matching element");
|
||||
}
|
||||
return cmd.sendResult(try reference(cmd, nodes[0]));
|
||||
return cmd.sendResult(try Reference.initFromCommand(cmd, nodes[0]));
|
||||
}
|
||||
|
||||
// POST /session/{id}/elements, POST /session/{id}/element/{id}/elements
|
||||
@@ -409,7 +431,7 @@ fn findElements(cmd: *BiDi.Command, using: Using, value: []const u8, from: ?[]co
|
||||
|
||||
const references = try cmd.arena.alloc(Reference, nodes.len);
|
||||
for (nodes, references) |node, *ref| {
|
||||
ref.* = try reference(cmd, node);
|
||||
ref.* = try Reference.initFromCommand(cmd, node);
|
||||
}
|
||||
return cmd.sendResult(references);
|
||||
}
|
||||
@@ -417,7 +439,7 @@ fn findElements(cmd: *BiDi.Command, using: Using, value: []const u8, from: ?[]co
|
||||
// The document, or the element a "from element" search starts at.
|
||||
fn findRoot(cmd: *BiDi.Command, frame: *Frame, from: ?[]const u8) !?*Node {
|
||||
const id = from orelse return frame.window._document.asNode();
|
||||
const element = (try requireElement(cmd, id)) orelse return null;
|
||||
const element = (try requireElement(cmd, id, frame)) orelse return null;
|
||||
return element.asNode();
|
||||
}
|
||||
|
||||
@@ -441,13 +463,13 @@ fn getActiveElement(cmd: *BiDi.Command) !void {
|
||||
const element = frame.window._document.getActiveElement() orelse {
|
||||
return cmd.sendError("no such element", "no active element");
|
||||
};
|
||||
return cmd.sendResult(try reference(cmd, element.asNode()));
|
||||
return cmd.sendResult(try Reference.initFromCommand(cmd, element.asNode()));
|
||||
}
|
||||
|
||||
// GET /session/{id}/element/{id}/text.
|
||||
fn getElementText(cmd: *BiDi.Command, p: ElementId) !void {
|
||||
const frame = (try currentFrame(cmd)) orelse return;
|
||||
const element = (try requireElement(cmd, p.id)) orelse return;
|
||||
const element = (try requireElement(cmd, p.id, frame)) orelse return;
|
||||
|
||||
var aw: std.Io.Writer.Allocating = .init(cmd.arena);
|
||||
element.getInnerText(&aw.writer, frame) catch |err| switch (err) {
|
||||
@@ -459,14 +481,15 @@ fn getElementText(cmd: *BiDi.Command, p: ElementId) !void {
|
||||
|
||||
// GET /session/{id}/element/{id}/name. Lowercase, like every other driver.
|
||||
fn getElementTagName(cmd: *BiDi.Command, p: ElementId) !void {
|
||||
const element = (try requireElement(cmd, p.id)) orelse return;
|
||||
const frame = (try currentFrame(cmd)) orelse return;
|
||||
const element = (try requireElement(cmd, p.id, frame)) orelse return;
|
||||
return cmd.sendResult(element.getTagNameLower());
|
||||
}
|
||||
|
||||
// GET /session/{id}/element/{id}/attribute/{name}
|
||||
fn getElementAttribute(cmd: *BiDi.Command, p: ElementName) !void {
|
||||
const frame = (try currentFrame(cmd)) orelse return;
|
||||
const element = (try requireElement(cmd, p.id)) orelse return;
|
||||
const element = (try requireElement(cmd, p.id, frame)) orelse return;
|
||||
|
||||
if (isBooleanAttribute(p.name)) {
|
||||
// a boolean attribute is "true" or nothing at all, never its value
|
||||
@@ -484,7 +507,7 @@ fn getElementAttribute(cmd: *BiDi.Command, p: ElementName) !void {
|
||||
// GET /session/{id}/element/{id}/property/{name}
|
||||
fn getElementProperty(cmd: *BiDi.Command, p: ElementName) !void {
|
||||
const frame = (try currentFrame(cmd)) orelse return;
|
||||
const element = (try requireElement(cmd, p.id)) orelse return;
|
||||
const element = (try requireElement(cmd, p.id, frame)) orelse return;
|
||||
|
||||
var scope: js.Local.Scope = undefined;
|
||||
frame.js.localScope(&scope);
|
||||
@@ -500,7 +523,7 @@ fn getElementProperty(cmd: *BiDi.Command, p: ElementName) !void {
|
||||
if (value.isObject()) {
|
||||
if (value.taggedOpaque()) |tagged| {
|
||||
if (tagged.as(Node)) |node| {
|
||||
return cmd.sendResult(try reference(cmd, node));
|
||||
return cmd.sendResult(try Reference.initFromCommand(cmd, node));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -510,7 +533,7 @@ fn getElementProperty(cmd: *BiDi.Command, p: ElementName) !void {
|
||||
// GET /session/{id}/element/{id}/css/{name}
|
||||
fn getElementCssValue(cmd: *BiDi.Command, p: ElementName) !void {
|
||||
const frame = (try currentFrame(cmd)) orelse return;
|
||||
const element = (try requireElement(cmd, p.id)) orelse return;
|
||||
const element = (try requireElement(cmd, p.id, frame)) orelse return;
|
||||
const style = try frame.window.getComputedStyle(element, null, frame);
|
||||
return cmd.sendResult(style.asCSSStyleDeclaration().getPropertyValue(p.name, frame));
|
||||
}
|
||||
@@ -519,7 +542,7 @@ fn getElementCssValue(cmd: *BiDi.Command, p: ElementName) !void {
|
||||
// however far the page is scrolled.
|
||||
fn getElementRect(cmd: *BiDi.Command, p: ElementId) !void {
|
||||
const frame = (try currentFrame(cmd)) orelse return;
|
||||
const element = (try requireElement(cmd, p.id)) orelse return;
|
||||
const element = (try requireElement(cmd, p.id, frame)) orelse return;
|
||||
const rect = try element.getBoundingClientRect(frame);
|
||||
const window = frame.window;
|
||||
return cmd.sendResult(.{
|
||||
@@ -532,13 +555,15 @@ fn getElementRect(cmd: *BiDi.Command, p: ElementId) !void {
|
||||
|
||||
// GET /session/{id}/element/{id}/enabled
|
||||
fn isElementEnabled(cmd: *BiDi.Command, p: ElementId) !void {
|
||||
const element = (try requireElement(cmd, p.id)) orelse return;
|
||||
const frame = (try currentFrame(cmd)) orelse return;
|
||||
const element = (try requireElement(cmd, p.id, frame)) orelse return;
|
||||
return cmd.sendResult(element.isDisabled() == false);
|
||||
}
|
||||
|
||||
// GET /session/{id}/element/{id}/selected
|
||||
fn isElementSelected(cmd: *BiDi.Command, p: ElementId) !void {
|
||||
const element = (try requireElement(cmd, p.id)) orelse return;
|
||||
const frame = (try currentFrame(cmd)) orelse return;
|
||||
const element = (try requireElement(cmd, p.id, frame)) orelse return;
|
||||
|
||||
if (element.is(Node.Element.Html.Input)) |input_element| {
|
||||
return cmd.sendResult(switch (input_element._input_type) {
|
||||
@@ -552,11 +577,207 @@ fn isElementSelected(cmd: *BiDi.Command, p: ElementId) !void {
|
||||
return cmd.sendResult(false);
|
||||
}
|
||||
|
||||
// POST /session/{id}/element/{id}/click.
|
||||
fn elementClick(cmd: *BiDi.Command, p: ElementId) !void {
|
||||
const ctx = (try currentContext(cmd)) orelse return;
|
||||
const frame = (try currentFrame(cmd)) orelse return;
|
||||
const element = (try requireElement(cmd, p.id, frame)) orelse return;
|
||||
|
||||
if (element.is(Html.Input)) |input_element| {
|
||||
if (input_element._input_type == .file) {
|
||||
return cmd.sendError("invalid argument", "a file input can't be clicked");
|
||||
}
|
||||
}
|
||||
if ((try requireInteractable(cmd, element, frame)) == false) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (element.is(Html.Option)) |option| blk: {
|
||||
const select = option.ownerSelect() orelse break :blk;
|
||||
const select_element = select.asElement();
|
||||
if (select_element.isDisabled() or element.isDisabled()) {
|
||||
break :blk;
|
||||
}
|
||||
|
||||
Frame.user_input.updateHoverTarget(frame, element, .{ .with_pointer = true });
|
||||
try Frame.user_input.triggerClick(frame, element, .{});
|
||||
|
||||
// a multiple <select> toggles the option, any other selects it
|
||||
const selected = if (select.getMultiple()) option.getSelected() == false else true;
|
||||
if (option.getSelected() == selected) {
|
||||
break :blk;
|
||||
}
|
||||
try option.setSelected(selected, frame);
|
||||
try lp.actions.dispatchInputAndChangeEvents(select_element, frame);
|
||||
} else if (element.isDisabled() == false) {
|
||||
Frame.user_input.updateHoverTarget(frame, element, .{ .with_pointer = true });
|
||||
try Frame.user_input.triggerClick(frame, element, .{});
|
||||
}
|
||||
return browsing_context.answerAfterNavigation(cmd, ctx, frame);
|
||||
}
|
||||
|
||||
// POST /session/{id}/element/{id}/clear
|
||||
fn elementClear(cmd: *BiDi.Command, p: ElementId) !void {
|
||||
const frame = (try currentFrame(cmd)) orelse return;
|
||||
const element = (try requireElement(cmd, p.id, frame)) orelse return;
|
||||
|
||||
if (isEditable(element) == false) {
|
||||
return cmd.sendError("invalid element state", "element is not editable");
|
||||
}
|
||||
if ((try requireInteractable(cmd, element, frame)) == false) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (element.is(Html.Input)) |input_element| {
|
||||
try clearControl(input_element, frame);
|
||||
} else if (element.is(Html.TextArea)) |textarea| {
|
||||
try clearControl(textarea, frame);
|
||||
} else {
|
||||
try element.asNode().setTextContent("", frame);
|
||||
}
|
||||
return cmd.sendDone();
|
||||
}
|
||||
|
||||
fn clearControl(ctl: anytype, frame: *Frame) !void {
|
||||
if (ctl.getValue().len == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const element = ctl.asElement();
|
||||
// give it focus
|
||||
try element.focus(frame);
|
||||
// clear it
|
||||
try ctl.setValue("", frame);
|
||||
try lp.actions.dispatchInputAndChangeEvents(element, frame);
|
||||
// remove focus
|
||||
try element.blur(frame);
|
||||
}
|
||||
|
||||
fn isEditable(element: *Node.Element) bool {
|
||||
if (element.is(Html.Input)) |input_element| {
|
||||
switch (input_element._input_type) {
|
||||
.hidden, .checkbox, .radio, .submit, .reset, .button, .image => return false,
|
||||
else => {},
|
||||
}
|
||||
} else if (element.is(Html.TextArea) == null) {
|
||||
return element.isEditingHost();
|
||||
}
|
||||
return element.isDisabled() == false and element.getAttributeSafe(comptime .wrap("readonly")) == null;
|
||||
}
|
||||
|
||||
// POST /session/{id}/element/{id}/value.
|
||||
pub const SendKeys = struct {
|
||||
id: []const u8,
|
||||
text: []const u8,
|
||||
};
|
||||
fn elementSendKeys(cmd: *BiDi.Command, p: SendKeys) !void {
|
||||
const ctx = (try currentContext(cmd)) orelse return;
|
||||
const frame = (try currentFrame(cmd)) orelse return;
|
||||
const element = (try requireElement(cmd, p.id, frame)) orelse return;
|
||||
|
||||
if (element.is(Html.Input)) |input_element| {
|
||||
if (input_element._input_type == .file) {
|
||||
return cmd.sendError("unsupported operation", "file upload is not supported");
|
||||
}
|
||||
}
|
||||
element.scrollIntoView(null, frame);
|
||||
|
||||
const document = frame.window._document;
|
||||
if (document.getActiveElement() != element) {
|
||||
try element.focus(frame);
|
||||
if (document.getActiveElement() != element) {
|
||||
return cmd.sendError("element not interactable", "element can't be focused");
|
||||
}
|
||||
if (element.is(Html.Input)) |input_element| {
|
||||
try caretToEnd(input_element, frame);
|
||||
} else if (element.is(Html.TextArea)) |textarea| {
|
||||
try caretToEnd(textarea, frame);
|
||||
}
|
||||
}
|
||||
|
||||
input.typeText(frame, p.text) catch |err| switch (err) {
|
||||
error.InvalidUtf8 => return cmd.sendError("invalid argument", "text is not valid UTF-8"),
|
||||
else => return err,
|
||||
};
|
||||
return browsing_context.answerAfterNavigation(cmd, ctx, frame);
|
||||
}
|
||||
|
||||
fn caretToEnd(ctl: anytype, frame: *Frame) !void {
|
||||
if (ctl.selectionAvailable() == false) {
|
||||
return;
|
||||
}
|
||||
const len: u32 = @intCast(ctl.getValue().len);
|
||||
try ctl.setSelectionRange(len, len, null, frame);
|
||||
}
|
||||
|
||||
fn requireInteractable(cmd: *BiDi.Command, element: *Node.Element, frame: *Frame) !bool {
|
||||
element.scrollIntoView(null, frame);
|
||||
if (element.checkVisibility(.{}, frame)) {
|
||||
return true;
|
||||
}
|
||||
try cmd.sendError("element not interactable", "element is not displayed");
|
||||
return false;
|
||||
}
|
||||
|
||||
// POST /session/{id}/execute/sync, POST /session/{id}/execute/async
|
||||
fn executeScript(cmd: *BiDi.Command, p: execute.Script, mode: execute.Mode) !void {
|
||||
_ = (try currentContext(cmd)) orelse return;
|
||||
return execute.run(cmd, p, mode);
|
||||
}
|
||||
|
||||
// GET /session/{id}/timeouts
|
||||
fn getTimeouts(cmd: *BiDi.Command) !void {
|
||||
return cmd.sendResult(cmd.bidi.timeouts);
|
||||
}
|
||||
|
||||
// POST /session/{id}/timeouts
|
||||
pub const SetTimeouts = struct {
|
||||
script: ScriptTimeout = .absent,
|
||||
pageLoad: ?u32 = null,
|
||||
implicit: ?u32 = null,
|
||||
|
||||
pub const ScriptTimeout = union(enum) {
|
||||
absent, // not sent, keep whatever we have
|
||||
disabled, // explicit null == no timeout
|
||||
ms: u32,
|
||||
|
||||
pub fn jsonParse(arena: Allocator, source: anytype, opts: std.json.ParseOptions) !ScriptTimeout {
|
||||
const value = try std.json.innerParse(?u32, arena, source, opts);
|
||||
return if (value) |ms| .{ .ms = ms } else .disabled;
|
||||
}
|
||||
};
|
||||
};
|
||||
fn setTimeouts(cmd: *BiDi.Command, p: SetTimeouts) !void {
|
||||
const timeouts = &cmd.bidi.timeouts;
|
||||
|
||||
switch (p.script) {
|
||||
.absent => {},
|
||||
.disabled => timeouts.script = null,
|
||||
.ms => |ms| timeouts.script = ms,
|
||||
}
|
||||
if (p.pageLoad) |ms| {
|
||||
timeouts.pageLoad = ms;
|
||||
}
|
||||
if (p.implicit) |ms| {
|
||||
timeouts.implicit = ms;
|
||||
}
|
||||
return cmd.sendDone();
|
||||
}
|
||||
|
||||
// {"element-6066-…": "<sharedId>"}: a WebDriver element reference is the
|
||||
// node registry's id, the same one BiDi hands out.
|
||||
const Reference = struct {
|
||||
pub const Reference = struct {
|
||||
shared_id: []const u8,
|
||||
|
||||
pub fn init(arena: Allocator, registry: *NodeRegistry, node: *Node) !Reference {
|
||||
const registered = try registry.register(node);
|
||||
return .{ .shared_id = try std.fmt.allocPrint(arena, "{d}", .{registered.id}) };
|
||||
}
|
||||
|
||||
fn initFromCommand(cmd: *BiDi.Command, node: *Node) !Reference {
|
||||
return .init(cmd.arena, &cmd.bidi.node_registry, node);
|
||||
}
|
||||
|
||||
pub fn jsonStringify(self: Reference, jws: anytype) !void {
|
||||
try jws.beginObject();
|
||||
try jws.objectField(element_key);
|
||||
@@ -565,29 +786,36 @@ const Reference = struct {
|
||||
}
|
||||
};
|
||||
|
||||
fn reference(cmd: *BiDi.Command, node: *Node) !Reference {
|
||||
const registered = try cmd.bidi.node_registry.register(node);
|
||||
return .{ .shared_id = try std.fmt.allocPrint(cmd.arena, "{d}", .{registered.id}) };
|
||||
pub const ReferenceError = error{
|
||||
// the id is unknown, or names something that isn't an element
|
||||
NoSuchElement,
|
||||
// the element is no longer in a document
|
||||
StaleElement,
|
||||
};
|
||||
|
||||
pub fn elementFromReference(registry: *const NodeRegistry, id: []const u8, frame: *const Frame) ReferenceError!*Node.Element {
|
||||
// ids are dropped on navigation
|
||||
const node = remote_value.nodeFromSharedId(registry, .{ .string = id }) catch return error.NoSuchElement;
|
||||
const element = node.is(Node.Element) orelse return error.NoSuchElement;
|
||||
if (node.isConnected() == false or node.getDocument(frame) != frame.window._document) {
|
||||
// disconnected or one from a document different than the current one
|
||||
return error.StaleElement;
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
// Answers the command and returns null when the reference doesn't resolve.
|
||||
fn requireElement(cmd: *BiDi.Command, id: []const u8) !?*Node.Element {
|
||||
const node = remote_value.nodeFromSharedId(&cmd.bidi.node_registry, .{ .string = id }) catch {
|
||||
// ids are dropped on navigation, so a stale one is unknown by then
|
||||
try cmd.sendError("no such element", "unknown element reference");
|
||||
return null;
|
||||
fn requireElement(cmd: *BiDi.Command, id: []const u8, frame: *const Frame) !?*Node.Element {
|
||||
return elementFromReference(&cmd.bidi.node_registry, id, frame) catch |err| switch (err) {
|
||||
error.NoSuchElement => {
|
||||
try cmd.sendError("no such element", "unknown element reference");
|
||||
return null;
|
||||
},
|
||||
error.StaleElement => {
|
||||
try cmd.sendError("stale element reference", "element is not in the current document");
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
const element = node.is(Node.Element) orelse {
|
||||
try cmd.sendError("no such element", "not an element");
|
||||
return null;
|
||||
};
|
||||
|
||||
if (node.isConnected() == false) {
|
||||
try cmd.sendError("stale element reference", "element is no longer attached to the document");
|
||||
return null;
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
// HTML's boolean attributes: present means "true", absent means null, and
|
||||
@@ -672,6 +900,45 @@ test "bidi.http_command: parse" {
|
||||
try testing.expectEqual("data-x", command.get_element_attribute.name);
|
||||
}
|
||||
|
||||
{
|
||||
const command = try parse(arena, .POST, "/execute/sync", "{\"script\":\"return 1\",\"args\":[1,\"a\"]}");
|
||||
try testing.expectEqual("return 1", command.execute_script.script);
|
||||
try testing.expectEqual(2, command.execute_script.args.len);
|
||||
}
|
||||
|
||||
{
|
||||
// args defaults to empty
|
||||
const command = try parse(arena, .POST, "/execute/async", "{\"script\":\"\"}");
|
||||
try testing.expectEqual(0, command.execute_async_script.args.len);
|
||||
}
|
||||
|
||||
{
|
||||
// a partial update leaves the fields it doesn't name alone
|
||||
const command = try parse(arena, .POST, "/timeouts", "{\"implicit\":5}");
|
||||
try testing.expectEqual(5, command.set_timeouts.implicit.?);
|
||||
try testing.expect(command.set_timeouts.script == .absent);
|
||||
try testing.expect(command.set_timeouts.pageLoad == null);
|
||||
}
|
||||
|
||||
{
|
||||
// null is a value for script, not its absence
|
||||
try testing.expect((try parse(arena, .POST, "/timeouts", "{\"script\":null}")).set_timeouts.script == .disabled);
|
||||
try testing.expectEqual(50, (try parse(arena, .POST, "/timeouts", "{\"script\":50}")).set_timeouts.script.ms);
|
||||
}
|
||||
|
||||
{
|
||||
// Selenium sends the legacy `value` array next to `text`
|
||||
const command = try parse(arena, .POST, "/element/7/value", "{\"text\":\"ab\",\"value\":[\"a\",\"b\"]}");
|
||||
try testing.expectEqual("7", command.element_send_keys.id);
|
||||
try testing.expectEqual("ab", command.element_send_keys.text);
|
||||
}
|
||||
try testing.expectEqual("7", (try parse(arena, .POST, "/element/7/click", "{}")).element_click.id);
|
||||
try testing.expectEqual("7", (try parse(arena, .POST, "/element/7/clear", "{}")).element_clear.id);
|
||||
try testing.expectError(error.InvalidArgument, parse(arena, .POST, "/element/7/value", "{}"));
|
||||
|
||||
try testing.expect(try parse(arena, .GET, "/timeouts", "") == .get_timeouts);
|
||||
try testing.expectError(error.InvalidArgument, parse(arena, .POST, "/execute/sync", "{}"));
|
||||
|
||||
// a literal segment wins over the parameter that would also match it
|
||||
try testing.expect(try parse(arena, .GET, "/element/active", "") == .get_active_element);
|
||||
try testing.expect(try parse(arena, .GET, "/element/7/text", "") == .get_element_text);
|
||||
|
||||
Loaded 100 of 111 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user