diff --git a/README.md b/README.md
index 9a880cba3..1f7c3a2f2 100644
--- a/README.md
+++ b/README.md
@@ -164,7 +164,7 @@ The output of an agent session is a
[PandaScript](https://lightpanda.io/docs/usage/pandascript): vanilla JavaScript
with a small set of native browser primitives built directly into Lightpanda.
Run `/save` to export one from your current session, then replay it with
-`lightpanda agent
diff --git a/src/browser/tests/custom_elements/create_element_post_conditions.html b/src/browser/tests/custom_elements/create_element_post_conditions.html
new file mode 100644
index 000000000..c4f1477ad
--- /dev/null
+++ b/src/browser/tests/custom_elements/create_element_post_conditions.html
@@ -0,0 +1,136 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/tests/custom_elements/error_reporting.html b/src/browser/tests/custom_elements/error_reporting.html
new file mode 100644
index 000000000..0b608c671
--- /dev/null
+++ b/src/browser/tests/custom_elements/error_reporting.html
@@ -0,0 +1,286 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/tests/element/matches.html b/src/browser/tests/element/matches.html
index f28d7a716..a1a3743e5 100644
--- a/src/browser/tests/element/matches.html
+++ b/src/browser/tests/element/matches.html
@@ -62,6 +62,26 @@
}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/tests/frames/target.html b/src/browser/tests/frames/target.html
index 924e0e590..a44a47cb3 100644
--- a/src/browser/tests/frames/target.html
+++ b/src/browser/tests/frames/target.html
@@ -126,3 +126,44 @@
});
}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/tests/net/fetch.html b/src/browser/tests/net/fetch.html
index fdd13dbb2..ef676af35 100644
--- a/src/browser/tests/net/fetch.html
+++ b/src/browser/tests/net/fetch.html
@@ -451,3 +451,20 @@
});
}
+
+
+
diff --git a/src/browser/tests/shadowroot/events.html b/src/browser/tests/shadowroot/events.html
index 7dbf1fea8..25c305b04 100644
--- a/src/browser/tests/shadowroot/events.html
+++ b/src/browser/tests/shadowroot/events.html
@@ -326,3 +326,141 @@
host.remove();
}
+
+
+
+
+
+
+
+
diff --git a/src/browser/tools.zig b/src/browser/tools.zig
index 73f2ee8bb..a3b989796 100644
--- a/src/browser/tools.zig
+++ b/src/browser/tools.zig
@@ -276,6 +276,16 @@ pub const Tool = enum {
};
}
+ /// Waits for page readiness on its own, letting the recorder downgrade a
+ /// preceding `goto` to `domcontentloaded`. Exhaustive like the sibling
+ /// predicates so a new wait tool makes an explicit choice here.
+ pub fn waitsForReadiness(self: Tool) bool {
+ return switch (self) {
+ .waitForSelector, .waitForScript, .waitForState => true,
+ .goto, .evaluate, .extract, .click, .fill, .scroll, .hover, .press, .selectOption, .setChecked, .search, .markdown, .html, .links, .tree, .nodeDetails, .interactiveElements, .structuredData, .detectForms, .findElement, .consoleLogs, .getUrl, .getCookies, .getEnv => false,
+ };
+ }
+
/// A read tool that navigates when handed a `url`. The read isn't recorded,
/// but the navigation is, so the recorder captures it as a `goto`. Excludes
/// `evaluate` (carries its own `url`), `search` (derived engine URL), and
@@ -329,7 +339,10 @@ pub const Tool = enum {
\\ "type": "object",
\\ "properties": {
\\ "url": { "type": "string", "description": "The URL to navigate to, must be a valid URL." },
- \\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 10000." }
+ \\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 10000." },
+ \\ "waitUntil": { "type": "string", "enum":
+ ++ lp.Config.tagJsonArray(lp.Config.WaitUntil) ++
+ \\, "description": "Event that completes the navigation. Defaults to 'load'. Prefer 'domcontentloaded' followed by waitForSelector on pages whose late scripts (ads) hold 'load' back. Avoid 'done' (full quiescence): on pages with constant background activity it is the slowest choice and can run to the timeout." }
\\ },
\\ "required": ["url"]
\\}
@@ -357,7 +370,7 @@ pub const Tool = enum {
\\ "type": "object",
\\ "properties": {
\\ "selector": { "type": "string", "description": "Optional CSS selector. Render markdown for just that element's subtree." },
- \\ "backendNodeId": { "type": "integer", "description": "Optional backend node ID. Render markdown for just that node's subtree." },
+ \\ "backendNodeId": { "type": "integer", "description": "Optional backend node ID. Render markdown for just that node's subtree. 0 is treated as omitted." },
\\ "maxBytes": { "type": "integer", "description": "Optional soft cap on output size in bytes. Content is truncated at a UTF-8 boundary and a short '[truncated]' marker is appended past the cap." },
\\ "url": { "type": "string", "description": "Optional URL to navigate to before rendering." },
\\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 10000." }
@@ -373,7 +386,7 @@ pub const Tool = enum {
\\ "type": "object",
\\ "properties": {
\\ "selector": { "type": "string", "description": "Optional CSS selector. When set, dump only that element's outerHTML." },
- \\ "backendNodeId": { "type": "integer", "description": "Optional backend node ID. When set, dump only that node's outerHTML." },
+ \\ "backendNodeId": { "type": "integer", "description": "Optional backend node ID. When set, dump only that node's outerHTML. 0 is treated as omitted." },
\\ "url": { "type": "string", "description": "Optional URL to navigate to before dumping." },
\\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 10000." }
\\ }
@@ -441,7 +454,7 @@ pub const Tool = enum {
\\ "properties": {
\\ "url": { "type": "string", "description": "Optional URL to navigate to before fetching the semantic tree." },
\\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 10000." },
- \\ "backendNodeId": { "type": "integer", "description": "Optional backend node ID to get the tree for a specific element instead of the document root." },
+ \\ "backendNodeId": { "type": "integer", "description": "Optional backend node ID to get the tree for a specific element instead of the document root. 0 is treated as omitted." },
\\ "maxDepth": { "type": "integer", "description": "Optional maximum depth of the tree to return. Useful for exploring high-level structure first." }
\\ }
\\}
@@ -510,7 +523,7 @@ pub const Tool = enum {
\\{
\\ "type": "object",
\\ "properties": {
- \\ "backendNodeId": { "type": "integer", "description": "Optional: The backend node ID of the element to scroll. If omitted, scrolls the window." },
+ \\ "backendNodeId": { "type": "integer", "description": "Optional: The backend node ID of the element to scroll. If omitted (or 0), scrolls the window." },
\\ "x": { "type": "integer", "description": "Optional: The horizontal scroll offset." },
\\ "y": { "type": "integer", "description": "Optional: The vertical scroll offset." }
\\ }
@@ -525,7 +538,7 @@ pub const Tool = enum {
\\ "type": "object",
\\ "properties": {
\\ "selector": { "type": "string", "description": "The CSS selector to wait for." },
- \\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 5000." }
+ \\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 5000, or 15000 when the page has not reached 'load' yet." }
\\ },
\\ "required": ["selector"]
\\}
@@ -539,7 +552,7 @@ pub const Tool = enum {
\\ "type": "object",
\\ "properties": {
\\ "script": { "type": "string", "description": "JS expression evaluated each tick until truthy. Must be an expression (not a statement)." },
- \\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 5000." }
+ \\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 5000, or 15000 when the page has not reached 'load' yet." }
\\ },
\\ "required": ["script"]
\\}
@@ -583,7 +596,7 @@ pub const Tool = enum {
\\ "properties": {
\\ "key": { "type": "string", "description": "The key to press (e.g. 'Enter', 'Tab', 'a')." },
\\ "selector": { "type": "string", "description": "Optional CSS selector of the element to target. Preferred over backendNodeId." },
- \\ "backendNodeId": { "type": "integer", "description": "Optional backend node ID of the element to target. Defaults to the document when neither selector nor backendNodeId is provided." }
+ \\ "backendNodeId": { "type": "integer", "description": "Optional backend node ID of the element to target. Defaults to the document when neither selector nor backendNodeId is provided; 0 is treated as omitted." }
\\ },
\\ "required": ["key"]
\\}
@@ -744,6 +757,16 @@ pub const ToolError = error{
OutOfMemory,
};
+/// LLM-facing message for a tool failure. Bare error names leave the model
+/// retrying blind; spell out the recovery for errors it can act on.
+pub fn errorMessage(err: ToolError) []const u8 {
+ return switch (err) {
+ error.NodeNotFound => "NodeNotFound: the selector or backendNodeId matched nothing on the current page. Re-inspect the page (tree/interactiveElements) for fresh node ids, or omit backendNodeId to target the document root.",
+ error.FrameNotLoaded => "FrameNotLoaded: no page is loaded — call goto (or pass a url) first.",
+ else => @errorName(err),
+ };
+}
+
/// Outcome of running a tool against the page. Operational failures (OOM,
/// missing page, invalid params) come out as Zig errors on the enclosing
/// `!ToolResult`; `is_error = true` is the in-band signal for a JS-level
@@ -758,6 +781,7 @@ pub const ToolResult = struct {
pub const GotoParams = struct {
url: [:0]const u8,
timeout: ?u32 = null,
+ waitUntil: lp.Config.WaitUntil = default_nav_wait,
};
pub const UrlParams = struct {
@@ -786,7 +810,13 @@ pub fn call(
tool_name: []const u8,
arguments: ?std.json.Value,
) ToolError!ToolResult {
- const tool = std.meta.stringToEnum(Tool, tool_name) orelse return ToolError.InvalidParams;
+ // In-band so an LLM that invented a tool name (e.g. OpenAI's internal
+ // `multi_tool_use.parallel` wrapper) learns the name is wrong instead of
+ // retrying it with different arguments.
+ const tool = std.meta.stringToEnum(Tool, tool_name) orelse return .{
+ .text = try std.fmt.allocPrint(arena, "Unknown tool: {s}", .{tool_name}),
+ .is_error = true,
+ };
if (diagnoseArgs(arena, arguments)) |msg|
return .{ .text = msg, .is_error = true };
// Must run before substituteStringArgs so the `key=="value"` secret-
@@ -936,7 +966,7 @@ const schema_walker_suffix = ")";
fn execGoto(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode.Registry, arguments: ?std.json.Value) ToolError![]const u8 {
const args = try parseArgs(GotoParams, arena, arguments);
- return switch (try performGoto(session, registry, args.url, args.timeout)) {
+ return switch (try performGoto(session, registry, args.url, .{ .timeout = args.timeout, .wait_until = args.waitUntil })) {
.completed => "Navigated successfully.",
.timeout => "Navigation started but the page did not finish loading before the timeout.",
};
@@ -988,7 +1018,7 @@ fn execSearch(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode
.{encoded},
0,
) catch return ToolError.OutOfMemory;
- _ = try performGoto(session, registry, ddg_url, args.timeout);
+ _ = try performGoto(session, registry, ddg_url, .{ .timeout = args.timeout });
const ddg_frame = try requireFrame(session);
return .{ .text = try renderFrameMarkdown(arena, ddg_frame) };
}
@@ -1619,6 +1649,14 @@ fn execScroll(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode
/// already-loaded page rather than a full navigation (which uses 10000).
const default_wait_timeout_ms: u32 = 5000;
+/// A wait entered before `load` also inherits the nav budget: a post-load
+/// selector keeps the wall time it had when `goto` waited for `load` itself.
+/// Shared with CDP's `LP.waitForSelector`, which fronts the same action.
+pub fn defaultWaitTimeout(frame: *const lp.Frame) u32 {
+ if (frame._load_state == .complete) return default_wait_timeout_ms;
+ return default_nav_timeout_ms + default_wait_timeout_ms;
+}
+
fn execWaitForSelector(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode.Registry, arguments: ?std.json.Value) ToolError![]const u8 {
const Params = struct {
selector: [:0]const u8,
@@ -1628,7 +1666,7 @@ fn execWaitForSelector(arena: std.mem.Allocator, session: *lp.Session, registry:
const frame = try requireFrame(session);
- const timeout_ms = args.timeout orelse default_wait_timeout_ms;
+ const timeout_ms = args.timeout orelse defaultWaitTimeout(frame);
const node = lp.actions.waitForSelector(args.selector, timeout_ms, frame._frame_id, session) catch |err| switch (err) {
error.InvalidSelector => return ToolError.InvalidParams,
@@ -1655,7 +1693,7 @@ fn execWaitForScript(arena: std.mem.Allocator, session: *lp.Session, arguments:
const frame = try requireFrame(session);
- const timeout_ms = args.timeout orelse default_wait_timeout_ms;
+ const timeout_ms = args.timeout orelse defaultWaitTimeout(frame);
lp.actions.waitForScript(args.script, timeout_ms, frame._frame_id, session) catch |err| switch (err) {
error.Cancelled => return ToolError.Cancelled,
@@ -1921,7 +1959,7 @@ fn ensurePage(session: *lp.Session, registry: *CDPNode.Registry, url: ?[:0]const
if (session.currentFrame()) |frame| {
if (std.mem.eql(u8, frame.url, u)) return frame;
}
- _ = try performGoto(session, registry, u, timeout);
+ _ = try performGoto(session, registry, u, .{ .timeout = timeout });
}
return session.currentFrame() orelse ToolError.FrameNotLoaded;
}
@@ -1937,6 +1975,7 @@ const default_nav_timeout_ms: u32 = 10000;
pub const StartedGoto = struct {
frame_id: u32,
timeout_ms: u32,
+ until: lp.Config.WaitUntil,
};
/// Open a fresh top-level page and start its navigation. The frame is non-null
@@ -1971,10 +2010,19 @@ pub fn startGoto(
}
}
const page = try openPage(session, args.url);
- return .{ .frame_id = page.frame_id, .timeout_ms = args.timeout orelse default_nav_timeout_ms };
+ return .{
+ .frame_id = page.frame_id,
+ .timeout_ms = args.timeout orelse default_nav_timeout_ms,
+ .until = args.waitUntil,
+ };
}
-fn performGoto(session: *lp.Session, registry: *CDPNode.Registry, url: [:0]const u8, timeout: ?u32) ToolError!lp.Session.Runner.WaitResult {
+const PerformGotoOpts = struct {
+ timeout: ?u32 = null,
+ wait_until: lp.Config.WaitUntil = default_nav_wait,
+};
+
+fn performGoto(session: *lp.Session, registry: *CDPNode.Registry, url: [:0]const u8, opts: PerformGotoOpts) ToolError!lp.Session.Runner.WaitResult {
if (session.primaryPage()) |old_page| {
registry.reset();
old_page.close();
@@ -1982,9 +2030,9 @@ fn performGoto(session: *lp.Session, registry: *CDPNode.Registry, url: [:0]const
const page = try openPage(session, url);
var runner = session.runner(.{});
- const condition = lp.Session.Runner.WaitCondition{ .frame_id = page.frame_id, .until = default_nav_wait };
+ const condition = lp.Session.Runner.WaitCondition{ .frame_id = page.frame_id, .until = opts.wait_until };
var conditions = [_]lp.Session.Runner.WaitCondition{condition};
- const result = runner.waitResult(timeout orelse default_nav_timeout_ms, &conditions) catch |err| {
+ const result = runner.waitResult(opts.timeout orelse default_nav_timeout_ms, &conditions) catch |err| {
return if (err == error.Cancelled) ToolError.Cancelled else ToolError.NavigationFailed;
};
@@ -2038,13 +2086,23 @@ fn formatEnumError(arena: std.mem.Allocator, field: []const u8, got: []const u8,
}
pub fn parseValue(comptime T: type, arena: std.mem.Allocator, value: std.json.Value) ParseArgsError!T {
- return std.json.parseFromValueLeaky(T, arena, value, .{ .ignore_unknown_fields = true }) catch |err| switch (err) {
- error.OutOfMemory => error.OutOfMemory,
+ var parsed = std.json.parseFromValueLeaky(T, arena, value, .{ .ignore_unknown_fields = true }) catch |err| switch (err) {
+ error.OutOfMemory => return error.OutOfMemory,
else => {
log.debug(.browser, "parseValue rejected", .{ .err = @errorName(err), .type = @typeName(T) });
return error.InvalidParams;
},
};
+ // Schema contract: backendNodeId 0 means omitted — registry ids start at 1,
+ // and zero-filling models (gpt-5.x) send 0 for "unset".
+ if (comptime @typeInfo(T) == .@"struct" and @hasField(T, "backendNodeId") and
+ @typeInfo(@FieldType(T, "backendNodeId")) == .optional)
+ {
+ if (parsed.backendNodeId) |nid| {
+ if (nid == 0) parsed.backendNodeId = null;
+ }
+ }
+ return parsed;
}
/// For tools where every field is optional. Missing args → default `T`;
@@ -2170,7 +2228,7 @@ pub fn reverseSubstituteEnvVars(arena: std.mem.Allocator, input: []const u8) err
// before its full match is found, leaking a suffix into the recording.
const Pair = struct { name: []const u8, value: []const u8 };
var pairs: std.ArrayList(Pair) = .empty;
- try pairs.ensureTotalCapacity(arena, env_names.len);
+ try pairs.ensureTotalCapacityPrecise(arena, env_names.len);
for (env_names) |name| {
const value = lookupLpEnv(name) orelse continue;
if (value.len < 4) continue;
@@ -2193,6 +2251,46 @@ pub fn reverseSubstituteEnvVars(arena: std.mem.Allocator, input: []const u8) err
return if (changed) current else input;
}
+test "call: unknown tool name surfaces in-band" {
+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena.deinit();
+
+ // Session/registry are never touched on this branch; the name check is
+ // the first thing `call` does.
+ const r = try call(arena.allocator(), undefined, undefined, "multi_tool_use.parallel", null);
+ try std.testing.expect(r.is_error);
+ try std.testing.expectEqualStrings("Unknown tool: multi_tool_use.parallel", r.text);
+}
+
+test "parseValue: zero-filled optional backendNodeId treated as omitted" {
+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena.deinit();
+ const aa = arena.allocator();
+
+ const Params = struct {
+ backendNodeId: ?CDPNode.Id = null,
+ maxDepth: ?u32 = null,
+ };
+ const zeroed = try std.json.parseFromSliceLeaky(std.json.Value, aa,
+ \\{"backendNodeId":0,"maxDepth":2}
+ , .{});
+ const args = try parseValue(Params, aa, zeroed);
+ try std.testing.expectEqual(@as(?CDPNode.Id, null), args.backendNodeId);
+ try std.testing.expectEqual(@as(?u32, 2), args.maxDepth);
+
+ const real = try std.json.parseFromSliceLeaky(std.json.Value, aa,
+ \\{"backendNodeId":7}
+ , .{});
+ try std.testing.expectEqual(@as(?CDPNode.Id, 7), (try parseValue(Params, aa, real)).backendNodeId);
+
+ // Non-optional ids (nodeDetails) pass through untouched.
+ const Required = struct { backendNodeId: CDPNode.Id };
+ const zero_required = try std.json.parseFromSliceLeaky(std.json.Value, aa,
+ \\{"backendNodeId":0}
+ , .{});
+ try std.testing.expectEqual(@as(CDPNode.Id, 0), (try parseValue(Required, aa, zero_required)).backendNodeId);
+}
+
test "substituteEnvVars resolves LP_* vars" {
var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer arena.deinit();
diff --git a/src/browser/webapi/CData.zig b/src/browser/webapi/CData.zig
index 674472142..4eeab1337 100644
--- a/src/browser/webapi/CData.zig
+++ b/src/browser/webapi/CData.zig
@@ -77,9 +77,9 @@ pub fn subtype(self: *const CData, comptime T: type) *T {
// the arithmetic rides on factory-chain contiguity; the stored
// back-pointer doubles as its canary
if (comptime T == CDATASection) {
- std.debug.assert(sub._proto._proto == self);
+ std.debug.assert(sub._proto_canary._proto_canary == self);
} else {
- std.debug.assert(sub._proto == self);
+ std.debug.assert(sub._proto_canary == self);
}
}
return sub;
diff --git a/src/browser/webapi/CustomElementRegistry.zig b/src/browser/webapi/CustomElementRegistry.zig
index a6f19830f..36ab863e0 100644
--- a/src/browser/webapi/CustomElementRegistry.zig
+++ b/src/browser/webapi/CustomElementRegistry.zig
@@ -181,7 +181,9 @@ fn upgradeElement(self: *CustomElementRegistry, element: *Element, frame: *Frame
return Custom.checkAndAttachBuiltIn(element, frame);
};
- if (custom._definition != null) return;
+ if (custom._definition != null or custom._upgrade_failed) {
+ return;
+ }
const name = custom._tag_name.str();
const definition = self._definitions.get(name) orelse return;
@@ -198,19 +200,49 @@ pub fn upgradeCustomElement(custom: *Custom, definition: *CustomElementDefinitio
const node = custom.asNode();
const prev_upgrading = frame._upgrading_element;
+ const prev_consumed = frame._upgrading_consumed;
frame._upgrading_element = node;
- defer frame._upgrading_element = prev_upgrading;
+ frame._upgrading_consumed = false;
+ defer {
+ frame._upgrading_element = prev_upgrading;
+ frame._upgrading_consumed = prev_consumed;
+ }
var ls: js.Local.Scope = undefined;
frame.js.localScope(&ls);
defer ls.deinit();
- var caught: js.TryCatch.Caught = undefined;
- _ = ls.toLocal(definition.constructor).newInstance(&caught) catch |err| {
- log.warn(.js, "custom element upgrade", .{ .name = definition.name, .err = err, .caught = caught });
+ const local = &ls.local;
+ var try_catch: js.TryCatch = undefined;
+ try_catch.init(local);
+ defer try_catch.deinit();
+
+ const object = ls.toLocal(definition.constructor).newInstanceThrow() catch |err| {
+ if (err == error.ExecutionTerminated) {
+ custom._definition = null;
+ return err;
+ }
+ log.warn(.js, "custom element upgrade", .{ .name = definition.name, .err = err });
+ upgradeFailed(custom);
+ if (try_catch.exceptionValue()) |exc| {
+ frame.window.reportError(exc, frame) catch {};
+ }
return error.CustomElementUpgradeFailed;
};
+ const same = if (object.toZig(*Node)) |result| result == node else |_| false;
+ if (!same) {
+ // the construction result must be the element being upgraded.
+ log.warn(.js, "custom element upgrade", .{ .name = definition.name, .reason = "constructor returned another value" });
+ upgradeFailed(custom);
+ const exc: js.Value = .{
+ .local = local,
+ .handle = local.isolate.createTypeError("custom element constructor must return the upgraded element"),
+ };
+ frame.window.reportError(exc, frame) catch {};
+ return error.CustomElementUpgradeFailed;
+ }
+
// Enqueue attributeChangedCallback for existing observed attributes
const element = custom.asElement();
for (element.attributeEntries()) |*attr| {
@@ -227,6 +259,11 @@ pub fn upgradeCustomElement(custom: *Custom, definition: *CustomElementDefinitio
}
}
+fn upgradeFailed(custom: *Custom) void {
+ custom._definition = null;
+ custom._upgrade_failed = true;
+}
+
fn validateName(name: []const u8) !void {
if (name.len == 0) {
return error.SyntaxError;
@@ -288,5 +325,6 @@ pub const JsApi = struct {
const testing = @import("../../testing.zig");
test "WebApi: CustomElementRegistry" {
+ testing.expectLog(&.{ .js, .js, .js, .js, .js, .js, .js, .js, .js, .js, .js, .js, .js, .js, .js, .js, .js, .js, .js, .js, .js, .js, .js });
try testing.htmlRunner("custom_elements", .{});
}
diff --git a/src/browser/webapi/DataTransferItem.zig b/src/browser/webapi/DataTransferItem.zig
index d62ef7b16..796e3c139 100644
--- a/src/browser/webapi/DataTransferItem.zig
+++ b/src/browser/webapi/DataTransferItem.zig
@@ -81,7 +81,7 @@ pub fn getAsString(self: *const DataTransferItem, cb_: ?js.Function) !void {
.string => |str| str,
.file => return,
};
- var caught: js.TryCatch.Caught = undefined;
+ var caught: js.TryCatch.Caught = .{};
cb.tryCall(void, .{s}, &caught) catch {
log.debug(.js, "getAsString callback", .{ .caught = caught, .source = "DataTransferItem" });
};
diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig
index 78e2a3f83..f3b2e75f3 100644
--- a/src/browser/webapi/Document.zig
+++ b/src/browser/webapi/Document.zig
@@ -212,6 +212,11 @@ pub fn getLastModified(self: *const Document, frame: *Frame) ![]const u8 {
});
}
+pub fn getReferrer(self: *const Document) []const u8 {
+ const frame = self._frame orelse return "";
+ return frame._referrer orelse "";
+}
+
pub fn getCharset(self: *const Document) []const u8 {
if (self._charset) |charset| {
return charset;
@@ -723,7 +728,15 @@ pub fn getReadyState(self: *const Document) []const u8 {
pub fn getActiveElement(self: *Document) ?*Element {
if (self._active_element) |el| {
- return el;
+ // A focused element inside a shadow tree is exposed as its outermost
+ // host; one in a detached tree isn't exposed at all.
+ var candidate = el;
+ while (candidate.asNode().containingShadowRoot()) |shadow| {
+ candidate = shadow._host;
+ }
+ if (candidate.asNode().getRootNode(.{}) == self.asNode()) {
+ return candidate;
+ }
}
// Default to body if it exists
@@ -760,6 +773,9 @@ pub fn adoptNode(self: *Document, node: *Node, frame: *Frame) !*Node {
if (node._type == .document) {
return error.NotSupported;
}
+ if (node.is(Node.ShadowRoot) != null) {
+ return error.HierarchyError;
+ }
const old_owner = node.ownerDocument(frame) orelse frame.document;
@@ -1588,15 +1604,12 @@ pub const JsApi = struct {
pub const hasFocus = bridge.function(Document.hasFocus, .{});
pub const prerendering = bridge.property(false, .{ .template = false });
- pub const characterSet = bridge.accessor(getCharacterSet, null, .{});
- pub const charset = bridge.accessor(getCharacterSet, null, .{});
- pub const inputEncoding = bridge.accessor(getCharacterSet, null, .{});
+ pub const characterSet = bridge.accessor(Document.getCharset, null, .{});
+ pub const charset = bridge.accessor(Document.getCharset, null, .{});
+ pub const inputEncoding = bridge.accessor(Document.getCharset, null, .{});
pub const compatMode = bridge.accessor(Document.getCompatMode, null, .{});
pub const lastModified = bridge.accessor(Document.getLastModified, null, .{});
- fn getCharacterSet(self: *const Document) []const u8 {
- return self.getCharset();
- }
- pub const referrer = bridge.property("", .{ .template = false });
+ pub const referrer = bridge.accessor(Document.getReferrer, null, .{});
// Generates a getter/setter pair backed by the frame's attribute-listener
// map, like onclick above, for other document event handler properties.
diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig
index 180addb36..497ea81c4 100644
--- a/src/browser/webapi/Element.zig
+++ b/src/browser/webapi/Element.zig
@@ -51,6 +51,30 @@ pub const Proto = Node;
pub const DatasetLookup = std.AutoHashMapUnmanaged(*Element, *DOMStringMap);
pub const StyleLookup = std.AutoHashMapUnmanaged(*Element, *CSSStyleProperties);
+pub const ComputedStyleLookup = std.AutoHashMapUnmanaged(ComputedStyleKey, *CSSStyleProperties);
+
+pub const ComputedStyleKey = struct {
+ element: *Element,
+ pseudo: PseudoElement,
+};
+
+pub const PseudoElement = enum {
+ none,
+ before,
+ after,
+ other,
+
+ pub fn parse(pseudo: []const u8) PseudoElement {
+ if (pseudo.len == 0 or pseudo[0] != ':') {
+ return .none;
+ }
+ const name = if (std.mem.startsWith(u8, pseudo, "::")) pseudo[2..] else pseudo[1..];
+ if (std.ascii.eqlIgnoreCase(name, "before")) return .before;
+ if (std.ascii.eqlIgnoreCase(name, "after")) return .after;
+ return .other;
+ }
+};
+
pub const ClassListLookup = std.AutoHashMapUnmanaged(*Element, *collections.DOMTokenList);
pub const RelListLookup = std.AutoHashMapUnmanaged(*Element, *collections.DOMTokenList);
pub const ShadowRootLookup = std.AutoHashMapUnmanaged(*Element, *ShadowRoot);
@@ -115,15 +139,35 @@ _attributes: Attribute.List = .{},
// work to resolve the proto).
_proto_canary: if (lp.IS_DEBUG) *Node else void = undefined,
-pub const Type = union(enum) {
- html: *Html,
- svg: *Svg,
+pub const Type = enum(u8) {
+ html,
+ svg,
};
+pub fn Subtype(comptime tag: Type) type {
+ return switch (tag) {
+ .html => Html,
+ .svg => Svg,
+ };
+}
+
+pub fn subtype(self: *const Element, comptime T: type) *T {
+ const offset = comptime Factory.chainOffsetOf(T, T) - Factory.chainOffsetOf(T, Element);
+ const sub: *T = @ptrFromInt(@intFromPtr(self) + offset);
+ if (comptime lp.IS_DEBUG) {
+ // This pointer dance only works because the factory allocates the chain
+ // in a contiguous block of memory. In debug, we assert this holds via
+ // the _proto_canary back pointer.
+ std.debug.assert(Factory.protoOf(sub) == self);
+ }
+ return sub;
+}
+
pub fn is(self: *Element, comptime T: type) ?*T {
const type_name = @typeName(T);
switch (self._type) {
- .html => |el| {
+ .html => {
+ const el = self.subtype(Html);
if (T == Html) {
return el;
}
@@ -131,7 +175,8 @@ pub fn is(self: *Element, comptime T: type) ?*T {
return el.is(T);
}
},
- .svg => |svg| {
+ .svg => {
+ const svg = self.subtype(Svg);
if (T == Svg) {
return svg;
}
@@ -178,177 +223,183 @@ pub fn isEqualNode(self: *Element, other: *Element) bool {
pub fn getTagNameLower(self: *const Element) []const u8 {
switch (self._type) {
- .html => |he| switch (he._type) {
- .custom => |ce| {
- @branchHint(.unlikely);
- return ce._tag_name.str();
- },
- else => return switch (he._type) {
- .anchor => "a",
- .area => "area",
- .base => "base",
- .body => "body",
- .br => "br",
- .button => "button",
- .canvas => "canvas",
- .custom => |e| e._tag_name.str(),
- .data => "data",
- .datalist => "datalist",
- .details => "details",
- .dialog => "dialog",
- .directory => "dir",
- .div => "div",
- .dl => "dl",
- .embed => "embed",
- .fieldset => "fieldset",
- .font => "font",
- .frameset => "frameset",
- .form => "form",
- .generic => |e| e._tag_name.str(),
- .heading => |e| e._tag_name.str(),
- .head => "head",
- .html => "html",
- .hr => "hr",
- .iframe => "iframe",
- .img => "img",
- .input => "input",
- .label => "label",
- .legend => "legend",
- .li => "li",
- .link => "link",
- .map => "map",
- .marquee => "marquee",
- .media => |m| switch (m._type) {
- .audio => "audio",
- .video => "video",
- .generic => "media",
+ .html => {
+ const he = self.subtype(Html);
+ switch (he._type) {
+ .custom => {
+ @branchHint(.unlikely);
+ return he.subtype(Html.Custom)._tag_name.str();
},
- .meta => "meta",
- .meter => "meter",
- .mod => |e| e._tag_name.str(),
- .object => "object",
- .ol => "ol",
- .optgroup => "optgroup",
- .option => "option",
- .output => "output",
- .p => "p",
- .picture => "picture",
- .param => "param",
- .pre => "pre",
- .progress => "progress",
- .quote => |e| e._tag_name.str(),
- .script => "script",
- .select => "select",
- .slot => "slot",
- .source => "source",
- .span => "span",
- .style => "style",
- .table => "table",
- .table_caption => "caption",
- .table_cell => |e| e._tag_name.str(),
- .table_col => |e| e._tag_name.str(),
- .table_row => "tr",
- .table_section => |e| e._tag_name.str(),
- .template => "template",
- .textarea => "textarea",
- .time => "time",
- .title => "title",
- .track => "track",
- .ul => "ul",
- .unknown => |e| e._tag_name.str(),
- },
+ else => return switch (he._type) {
+ .anchor => "a",
+ .area => "area",
+ .base => "base",
+ .body => "body",
+ .br => "br",
+ .button => "button",
+ .canvas => "canvas",
+ .custom => he.subtype(Html.Custom)._tag_name.str(),
+ .data => "data",
+ .datalist => "datalist",
+ .details => "details",
+ .dialog => "dialog",
+ .directory => "dir",
+ .div => "div",
+ .dl => "dl",
+ .embed => "embed",
+ .fieldset => "fieldset",
+ .font => "font",
+ .frameset => "frameset",
+ .form => "form",
+ .generic => he.subtype(Html.Generic)._tag_name.str(),
+ .heading => he.subtype(Html.Heading)._tag_name.str(),
+ .head => "head",
+ .html => "html",
+ .hr => "hr",
+ .iframe => "iframe",
+ .img => "img",
+ .input => "input",
+ .label => "label",
+ .legend => "legend",
+ .li => "li",
+ .link => "link",
+ .map => "map",
+ .marquee => "marquee",
+ .media => switch (he.subtype(Html.Media)._type) {
+ .audio => "audio",
+ .video => "video",
+ .generic => "media",
+ },
+ .meta => "meta",
+ .meter => "meter",
+ .mod => he.subtype(Html.Mod)._tag_name.str(),
+ .object => "object",
+ .ol => "ol",
+ .optgroup => "optgroup",
+ .option => "option",
+ .output => "output",
+ .p => "p",
+ .picture => "picture",
+ .param => "param",
+ .pre => "pre",
+ .progress => "progress",
+ .quote => he.subtype(Html.Quote)._tag_name.str(),
+ .script => "script",
+ .select => "select",
+ .slot => "slot",
+ .source => "source",
+ .span => "span",
+ .style => "style",
+ .table => "table",
+ .table_caption => "caption",
+ .table_cell => he.subtype(Html.TableCell)._tag_name.str(),
+ .table_col => he.subtype(Html.TableCol)._tag_name.str(),
+ .table_row => "tr",
+ .table_section => he.subtype(Html.TableSection)._tag_name.str(),
+ .template => "template",
+ .textarea => "textarea",
+ .time => "time",
+ .title => "title",
+ .track => "track",
+ .ul => "ul",
+ .unknown => he.subtype(Html.Unknown)._tag_name.str(),
+ },
+ }
},
- .svg => |svg| return svg._tag_name.str(),
+ .svg => return self.subtype(Svg)._tag_name.str(),
}
}
pub fn getTagNameSpec(self: *const Element, buf: []u8) []const u8 {
return switch (self._type) {
- .html => |he| switch (he._type) {
- .anchor => "A",
- .area => "AREA",
- .base => "BASE",
- .body => "BODY",
- .br => "BR",
- .button => "BUTTON",
- .canvas => "CANVAS",
- .custom => |e| upperTagName(&e._tag_name, buf),
- .data => "DATA",
- .datalist => "DATALIST",
- .details => "DETAILS",
- .dialog => "DIALOG",
- .directory => "DIR",
- .div => "DIV",
- .dl => "DL",
- .embed => "EMBED",
- .fieldset => "FIELDSET",
- .font => "FONT",
- .frameset => "FRAMESET",
- .form => "FORM",
- .generic => |e| upperTagName(&e._tag_name, buf),
- .heading => |e| upperTagName(&e._tag_name, buf),
- .head => "HEAD",
- .html => "HTML",
- .hr => "HR",
- .iframe => "IFRAME",
- .img => "IMG",
- .input => "INPUT",
- .label => "LABEL",
- .legend => "LEGEND",
- .li => "LI",
- .link => "LINK",
- .map => "MAP",
- .marquee => "MARQUEE",
- .meta => "META",
- .media => |m| switch (m._type) {
- .audio => "AUDIO",
- .video => "VIDEO",
- .generic => "MEDIA",
- },
- .meter => "METER",
- .mod => |e| upperTagName(&e._tag_name, buf),
- .object => "OBJECT",
- .ol => "OL",
- .optgroup => "OPTGROUP",
- .option => "OPTION",
- .output => "OUTPUT",
- .p => "P",
- .picture => "PICTURE",
- .param => "PARAM",
- .pre => "PRE",
- .progress => "PROGRESS",
- .quote => |e| upperTagName(&e._tag_name, buf),
- .script => "SCRIPT",
- .select => "SELECT",
- .slot => "SLOT",
- .source => "SOURCE",
- .span => "SPAN",
- .style => "STYLE",
- .table => "TABLE",
- .table_caption => "CAPTION",
- .table_cell => |e| upperTagName(&e._tag_name, buf),
- .table_col => |e| upperTagName(&e._tag_name, buf),
- .table_row => "TR",
- .table_section => |e| upperTagName(&e._tag_name, buf),
- .template => "TEMPLATE",
- .textarea => "TEXTAREA",
- .time => "TIME",
- .title => "TITLE",
- .track => "TRACK",
- .ul => "UL",
- .unknown => |e| switch (self._namespace) {
- .html => upperTagName(&e._tag_name, buf),
- .svg, .xml, .mathml, .unknown, .null => e._tag_name.str(),
- },
+ .html => blk: {
+ const he = self.subtype(Html);
+ break :blk switch (he._type) {
+ .anchor => "A",
+ .area => "AREA",
+ .base => "BASE",
+ .body => "BODY",
+ .br => "BR",
+ .button => "BUTTON",
+ .canvas => "CANVAS",
+ .custom => upperTagName(&he.subtype(Html.Custom)._tag_name, buf),
+ .data => "DATA",
+ .datalist => "DATALIST",
+ .details => "DETAILS",
+ .dialog => "DIALOG",
+ .directory => "DIR",
+ .div => "DIV",
+ .dl => "DL",
+ .embed => "EMBED",
+ .fieldset => "FIELDSET",
+ .font => "FONT",
+ .frameset => "FRAMESET",
+ .form => "FORM",
+ .generic => upperTagName(&he.subtype(Html.Generic)._tag_name, buf),
+ .heading => upperTagName(&he.subtype(Html.Heading)._tag_name, buf),
+ .head => "HEAD",
+ .html => "HTML",
+ .hr => "HR",
+ .iframe => "IFRAME",
+ .img => "IMG",
+ .input => "INPUT",
+ .label => "LABEL",
+ .legend => "LEGEND",
+ .li => "LI",
+ .link => "LINK",
+ .map => "MAP",
+ .marquee => "MARQUEE",
+ .meta => "META",
+ .media => switch (he.subtype(Html.Media)._type) {
+ .audio => "AUDIO",
+ .video => "VIDEO",
+ .generic => "MEDIA",
+ },
+ .meter => "METER",
+ .mod => upperTagName(&he.subtype(Html.Mod)._tag_name, buf),
+ .object => "OBJECT",
+ .ol => "OL",
+ .optgroup => "OPTGROUP",
+ .option => "OPTION",
+ .output => "OUTPUT",
+ .p => "P",
+ .picture => "PICTURE",
+ .param => "PARAM",
+ .pre => "PRE",
+ .progress => "PROGRESS",
+ .quote => upperTagName(&he.subtype(Html.Quote)._tag_name, buf),
+ .script => "SCRIPT",
+ .select => "SELECT",
+ .slot => "SLOT",
+ .source => "SOURCE",
+ .span => "SPAN",
+ .style => "STYLE",
+ .table => "TABLE",
+ .table_caption => "CAPTION",
+ .table_cell => upperTagName(&he.subtype(Html.TableCell)._tag_name, buf),
+ .table_col => upperTagName(&he.subtype(Html.TableCol)._tag_name, buf),
+ .table_row => "TR",
+ .table_section => upperTagName(&he.subtype(Html.TableSection)._tag_name, buf),
+ .template => "TEMPLATE",
+ .textarea => "TEXTAREA",
+ .time => "TIME",
+ .title => "TITLE",
+ .track => "TRACK",
+ .ul => "UL",
+ .unknown => switch (self._namespace) {
+ .html => upperTagName(&he.subtype(Html.Unknown)._tag_name, buf),
+ .svg, .xml, .mathml, .unknown, .null => he.subtype(Html.Unknown)._tag_name.str(),
+ },
+ };
},
- .svg => |svg| svg._tag_name.str(),
+ .svg => self.subtype(Svg)._tag_name.str(),
};
}
pub fn getTagNameDump(self: *const Element) []const u8 {
switch (self._type) {
.html => return self.getTagNameLower(),
- .svg => |svg| return svg._tag_name.str(),
+ .svg => return self.subtype(Svg)._tag_name.str(),
}
}
@@ -1255,17 +1306,30 @@ pub fn checkVisibility(self: *Element, opts_: ?CheckVisibilityOpts, frame: *Fram
});
}
-pub fn getElementDimensions(self: *Element, frame: *Frame) struct { width: f64, height: f64 } {
- var width: f64 = 5.0;
- var height: f64 = 5.0;
+pub const Dimensions = struct {
+ width: f64,
+ height: f64,
+ // if the value is explicit (e.g. inline style, width attribute, ...) or defaulted
+ explicit_width: bool = false,
+ explicit_height: bool = false,
+};
+
+pub fn getElementDimensions(self: *Element, frame: *Frame) Dimensions {
+ var dims: Dimensions = .{ .width = 5.0, .height = 5.0 };
if (self.getStyle(frame)) |style| {
const decl = style.asCSSStyleDeclaration();
- width = CSS.parseDimensionViewport(decl.getPropertyValue("width", frame), frame) orelse 5.0;
- height = CSS.parseDimensionViewport(decl.getPropertyValue("height", frame), frame) orelse 5.0;
+ if (CSS.parseDimensionViewport(decl.getPropertyValue("width", frame), frame)) |w| {
+ dims.width = w;
+ dims.explicit_width = true;
+ }
+ if (CSS.parseDimensionViewport(decl.getPropertyValue("height", frame), frame)) |h| {
+ dims.height = h;
+ dims.explicit_height = true;
+ }
}
- if (width == 5.0 or height == 5.0) {
+ if (dims.width == 5.0 or dims.height == 5.0) {
const tag = self.getTag();
// Root containers get large default size to contain descendant positions.
@@ -1273,35 +1337,69 @@ pub fn getElementDimensions(self: *Element, frame: *Frame) struct { width: f64,
// even very deep trees (100 levels) stay within 10,000px.
// 100M pixels is plausible for very long documents.
if (tag == .html or tag == .body) {
- if (width == 5.0) width = 1920.0;
- if (height == 5.0) height = 100_000_000.0;
+ if (dims.width == 5.0) dims.width = 1920.0;
+ if (dims.height == 5.0) dims.height = 100_000_000.0;
} else if (tag == .img or tag == .iframe) {
if (self.getAttributeSafe(comptime .wrap("width"))) |w| {
- width = std.fmt.parseFloat(f64, w) catch width;
+ if (std.fmt.parseFloat(f64, w)) |parsed| {
+ dims.width = parsed;
+ dims.explicit_width = true;
+ } else |_| {}
}
if (self.getAttributeSafe(comptime .wrap("height"))) |h| {
- height = std.fmt.parseFloat(f64, h) catch height;
+ if (std.fmt.parseFloat(f64, h)) |parsed| {
+ dims.height = parsed;
+ dims.explicit_height = true;
+ } else |_| {}
}
}
}
- return .{ .width = width, .height = height };
+ return dims;
}
+// We can't do this correctly without full styles and more rendering. We also
+// can't just ignore the children since some sites append nodes until a certain
+// width / height treshold is reached. If the size isn't explicit, we fallback
+// to contentWidth/contentHeight
pub fn getClientWidth(self: *Element, frame: *Frame) f64 {
- if (!self.checkVisibilityCached(null, frame)) {
+ var visibility_cache: VisibilityCache = .{};
+ return self.getClientWidthWithCache(frame, &visibility_cache);
+}
+
+pub fn getClientWidthWithCache(self: *Element, frame: *Frame, visibility_cache: *VisibilityCache) f64 {
+ if (!self.checkVisibilityCached(visibility_cache, frame)) {
return 0.0;
}
+
const dims = self.getElementDimensions(frame);
- return dims.width;
+
+ const tag = self.getTag();
+ if (tag == .html or tag == .body or dims.explicit_width) {
+ return dims.width;
+ }
+
+ return @max(dims.width, self.contentWidth(frame, visibility_cache));
}
pub fn getClientHeight(self: *Element, frame: *Frame) f64 {
- if (!self.checkVisibilityCached(null, frame)) {
+ var visibility_cache: VisibilityCache = .{};
+ return self.getClientHeightWithCache(frame, &visibility_cache);
+}
+
+pub fn getClientHeightWithCache(self: *Element, frame: *Frame, visibility_cache: *VisibilityCache) f64 {
+ if (!self.checkVisibilityCached(visibility_cache, frame)) {
return 0.0;
}
+
const dims = self.getElementDimensions(frame);
- return dims.height;
+
+ const tag = self.getTag();
+ if (tag == .html or tag == .body or dims.explicit_height) {
+ return dims.height;
+ }
+
+ return @max(dims.height, self.contentHeight(frame, visibility_cache));
}
pub fn getBoundingClientRect(self: *Element, frame: *Frame) !*DOMRect {
@@ -1916,81 +2014,84 @@ fn upperTagName(tag_name: *String, buf: []u8) []const u8 {
pub fn getTag(self: *const Element) Tag {
return switch (self._type) {
- .html => |he| switch (he._type) {
- .anchor => .anchor,
- .area => .area,
- .base => .base,
- .div => .div,
- .dl => .dl,
- .embed => .embed,
- .form => .form,
- .p => .p,
- .custom => .custom,
- .data => .data,
- .datalist => .datalist,
- .details => .details,
- .dialog => .dialog,
- .directory => .directory,
- .iframe => .iframe,
- .img => .img,
- .br => .br,
- .button => .button,
- .canvas => .canvas,
- .fieldset => .fieldset,
- .font => .font,
- .frameset => .frameset,
- .heading => |h| h._tag,
- .label => .label,
- .legend => .legend,
- .li => .li,
- .map => .map,
- .marquee => .marquee,
- .ul => .ul,
- .ol => .ol,
- .object => .object,
- .optgroup => .optgroup,
- .output => .output,
- .picture => .picture,
- .param => .param,
- .pre => .pre,
- .generic => |g| g._tag,
- .media => |m| switch (m._type) {
- .audio => .audio,
- .video => .video,
- .generic => .media,
- },
- .meter => .meter,
- .mod => |m| m._tag,
- .progress => .progress,
- .quote => |q| q._tag,
- .script => .script,
- .select => .select,
- .slot => .slot,
- .source => .source,
- .span => .span,
- .option => .option,
- .table => .table,
- .table_caption => .caption,
- .table_cell => |tc| tc._tag,
- .table_col => |tc| tc._tag,
- .table_row => .tr,
- .table_section => |ts| ts._tag,
- .template => .template,
- .textarea => .textarea,
- .time => .time,
- .track => .track,
- .input => .input,
- .link => .link,
- .meta => .meta,
- .hr => .hr,
- .style => .style,
- .title => .title,
- .body => .body,
- .html => .html,
- .head => .head,
- .unknown => .unknown,
+ .html => blk: {
+ const he = self.subtype(Html);
+ break :blk switch (he._type) {
+ .anchor => .anchor,
+ .area => .area,
+ .base => .base,
+ .div => .div,
+ .dl => .dl,
+ .embed => .embed,
+ .form => .form,
+ .p => .p,
+ .custom => .custom,
+ .data => .data,
+ .datalist => .datalist,
+ .details => .details,
+ .dialog => .dialog,
+ .directory => .directory,
+ .iframe => .iframe,
+ .img => .img,
+ .br => .br,
+ .button => .button,
+ .canvas => .canvas,
+ .fieldset => .fieldset,
+ .font => .font,
+ .frameset => .frameset,
+ .heading => he.subtype(Html.Heading)._tag,
+ .label => .label,
+ .legend => .legend,
+ .li => .li,
+ .map => .map,
+ .marquee => .marquee,
+ .ul => .ul,
+ .ol => .ol,
+ .object => .object,
+ .optgroup => .optgroup,
+ .output => .output,
+ .picture => .picture,
+ .param => .param,
+ .pre => .pre,
+ .generic => he.subtype(Html.Generic)._tag,
+ .media => switch (he.subtype(Html.Media)._type) {
+ .audio => .audio,
+ .video => .video,
+ .generic => .media,
+ },
+ .meter => .meter,
+ .mod => he.subtype(Html.Mod)._tag,
+ .progress => .progress,
+ .quote => he.subtype(Html.Quote)._tag,
+ .script => .script,
+ .select => .select,
+ .slot => .slot,
+ .source => .source,
+ .span => .span,
+ .option => .option,
+ .table => .table,
+ .table_caption => .caption,
+ .table_cell => he.subtype(Html.TableCell)._tag,
+ .table_col => he.subtype(Html.TableCol)._tag,
+ .table_row => .tr,
+ .table_section => he.subtype(Html.TableSection)._tag,
+ .template => .template,
+ .textarea => .textarea,
+ .time => .time,
+ .track => .track,
+ .input => .input,
+ .link => .link,
+ .meta => .meta,
+ .hr => .hr,
+ .style => .style,
+ .title => .title,
+ .body => .body,
+ .html => .html,
+ .head => .head,
+ .unknown => .unknown,
+ };
},
- .svg => |se| se.getTag(),
+ .svg => self.subtype(Svg).getTag(),
};
}
@@ -2315,6 +2416,7 @@ pub const JsApi = struct {
pub const previousElementSibling = bridge.accessor(Element.previousElementSibling, null, .{});
pub const childElementCount = bridge.accessor(Element.getChildElementCount, null, .{});
pub const matches = bridge.function(Element.matches, .{});
+ pub const webkitMatchesSelector = bridge.function(Element.matches, .{});
pub const querySelector = bridge.function(Element.querySelector, .{});
pub const querySelectorAll = bridge.function(Element.querySelectorAll, .{});
pub const closest = bridge.function(Element.closest, .{});
@@ -2353,22 +2455,21 @@ pub const Build = struct {
// Calls `func_name` with `args` on the most specific type where it is
// implement. This could be on the Element itself.
pub fn call(self: *const Element, comptime func_name: []const u8, args: anytype) !bool {
- inline for (@typeInfo(Element.Type).@"union".fields) |f| {
- if (@field(Element.Type, f.name) == self._type) {
- // The inner type implements this function. Call it and we're done.
- const S = reflect.Struct(f.type);
+ switch (self._type) {
+ inline else => |tag| {
+ const S = Subtype(tag);
if (@hasDecl(S, "Build")) {
+ // The inner type has its own "call" method. Defer to it.
if (@hasDecl(S.Build, "call")) {
- const sub = @field(self._type, f.name);
- return S.Build.call(sub, func_name, args);
+ return S.Build.call(self.subtype(S), func_name, args);
}
// The inner type implements this function. Call it and we're done.
- if (@hasDecl(f.type, func_name)) {
- return @call(.auto, @field(f.type, func_name), args);
+ if (@hasDecl(S, func_name)) {
+ return @call(.auto, @field(S, func_name), args);
}
}
- }
+ },
}
if (@hasDecl(Element.Build, func_name)) {
diff --git a/src/browser/webapi/Event.zig b/src/browser/webapi/Event.zig
index 6076e91c8..95c427598 100644
--- a/src/browser/webapi/Event.zig
+++ b/src/browser/webapi/Event.zig
@@ -21,6 +21,7 @@ const lp = @import("lightpanda");
const js = @import("../js/js.zig");
const Page = @import("../Page.zig");
+const EventManager = @import("../EventManager.zig");
const Node = @import("Node.zig");
const EventTarget = @import("EventTarget.zig");
@@ -40,6 +41,7 @@ _type_string: String,
_target: ?*EventTarget = null,
_current_target: ?*EventTarget = null,
_dispatch_target: ?*EventTarget = null, // Original target for composedPath()
+_dispatch_related_target: ?*EventTarget = null,
_prevent_default: bool = false,
_stop_propagation: bool = false,
_stop_immediate_propagation: bool = false,
@@ -325,113 +327,61 @@ pub fn composedPath(self: *Event, exec: *Execution) ![]const *EventTarget {
else => return &.{},
};
- // Build the path by walking up from target
- var path_len: usize = 0;
- var path_buffer: [128]*EventTarget = undefined;
- var stopped_at_shadow_boundary = false;
-
- // Track closed shadow boundaries (position in path and host position)
- var closed_shadow_boundary: ?struct { shadow_end: usize, host_start: usize } = null;
-
const frame_ = switch (exec.js.global) {
.frame => |frame| frame,
else => null,
};
- const target_root = target_node.getRootNode(.{});
- var node: ?*Node = target_node;
- while (node) |n| {
- if (path_len >= path_buffer.len) {
- break;
- }
- path_buffer[path_len] = n.asEventTarget();
- path_len += 1;
-
- // Check if this node is a shadow root
- if (n._type == .document_fragment) {
- const df = n.subtype(Node.DocumentFragment);
- if (df._type == .shadow_root) {
- const shadow = df._type.shadow_root;
-
- if (!self._composed and n == target_root) {
- stopped_at_shadow_boundary = true;
- break;
- }
-
- // Track the first closed shadow boundary we encounter
- if (shadow._mode == .closed and closed_shadow_boundary == null) {
- // Mark where the shadow root is in the path
- // The next element will be the host
- closed_shadow_boundary = .{
- .shadow_end = path_len - 1, // index of shadow root
- .host_start = path_len, // index where host will be
- };
- }
-
- // Jump to the shadow host and continue
- node = shadow._host.asNode();
- continue;
- }
- }
-
- // an assigned slottable's event-path parent is its assigned slot,
- // routing the event into the slot's shadow tree
- if (frame_) |frame| {
- if (frame._assigned_slots.get(n)) |slot| {
- node = slot.asNode();
- continue;
- }
- }
-
- node = n._parent;
+ var path_buffer: [128]*EventTarget = undefined;
+ var path_len = EventManager.buildEventPath(target_node, self, frame_, &path_buffer).len;
+ if (path_len == 0) {
+ return &.{};
}
- // Add window at the end. It only participates when propagation did not stop
- // at a shadow boundary...
- if (stopped_at_shadow_boundary == false) {
- // ... AND when the tree's root is a document
- const root_is_document = path_len > 0 and switch (path_buffer[path_len - 1]._type) {
- .node => |n| n._type == .document,
- else => false,
+ // Window follows the document at the end of the path. A path that stopped
+ // early — at a shadow boundary, or at the relatedTarget — doesn't end on
+ // the document and so doesn't reach it.
+ const root_is_document = switch (path_buffer[path_len - 1]._type) {
+ .node => |n| n._type == .document,
+ else => false,
+ };
+ if (root_is_document and path_len < path_buffer.len) {
+ if (frame_) |frame| {
+ path_buffer[path_len] = frame.window.asEventTarget();
+ path_len += 1;
+ }
+ }
+
+ // The host of the first closed shadow root on the path. Everything before
+ // it is inside that root and hidden from a currentTarget outside it.
+ var closed_host_index: ?usize = null;
+ for (path_buffer[0..path_len], 0..) |entry, i| {
+ const node = switch (entry._type) {
+ .node => |n| n,
+ else => continue,
};
- if (root_is_document) {
- if (path_len < path_buffer.len) {
- switch (exec.js.global) {
- .worker => {},
- .frame => |frame| {
- path_buffer[path_len] = frame.window.asEventTarget();
- path_len += 1;
- },
- }
- }
+ const shadow = node.is(Node.ShadowRoot) orelse continue;
+ if (shadow._mode == .closed) {
+ closed_host_index = i + 1;
+ break;
}
}
// Determine visible path based on current_target and closed shadow boundaries
var visible_start_index: usize = 0;
- if (closed_shadow_boundary) |boundary| {
- // Check if current_target is outside the closed shadow
- // If current_target is null or is at/after the host position, hide shadow internals
- const current_target = self._current_target;
-
- if (current_target) |ct| {
- // Find current_target in the path
- var ct_index: ?usize = null;
+ if (closed_host_index) |host_index| {
+ // Find current_target in the path; if it's at or after the host, it's
+ // outside the closed shadow and must not see the nodes inside it.
+ if (self._current_target) |ct| {
for (path_buffer[0..path_len], 0..) |elem, i| {
if (elem == ct) {
- ct_index = i;
+ if (i >= host_index) {
+ visible_start_index = host_index;
+ }
break;
}
}
-
- // If current_target is at or after the host (outside the closed shadow),
- // hide everything from target up to the host
- if (ct_index) |idx| {
- if (idx >= boundary.host_start) {
- visible_start_index = boundary.host_start;
- }
- }
}
}
diff --git a/src/browser/webapi/EventCounts.zig b/src/browser/webapi/EventCounts.zig
index f53f01d59..1e74c60c6 100644
--- a/src/browser/webapi/EventCounts.zig
+++ b/src/browser/webapi/EventCounts.zig
@@ -116,7 +116,7 @@ pub fn forEach(self: *EventCounts, cb_: js.Function, js_this_: ?js.Object) !void
const cb = if (js_this_) |js_this| try cb_.withThis(js_this) else cb_;
for (tracked_event_types, self._counts) |event_type, count| {
- var caught: js.TryCatch.Caught = undefined;
+ var caught: js.TryCatch.Caught = .{};
cb.tryCall(void, .{ count, event_type, self }, &caught) catch {
log.debug(.js, "forEach callback", .{ .caught = caught, .source = "EventCounts" });
};
diff --git a/src/browser/webapi/HTMLDocument.zig b/src/browser/webapi/HTMLDocument.zig
index 1ad5814c8..304982f94 100644
--- a/src/browser/webapi/HTMLDocument.zig
+++ b/src/browser/webapi/HTMLDocument.zig
@@ -119,7 +119,7 @@ pub fn getTitle(self: *HTMLDocument, frame: *Frame) ![]const u8 {
var started = false;
var in_whitespace = false;
var result: std.ArrayList(u8) = .empty;
- try result.ensureTotalCapacity(frame.local_arena, text.len);
+ try result.ensureTotalCapacityPrecise(frame.local_arena, text.len);
for (text) |c| {
const is_ascii_ws = c == ' ' or c == '\t' or c == '\n' or c == '\r' or c == '\x0C';
diff --git a/src/browser/webapi/IntersectionObserver.zig b/src/browser/webapi/IntersectionObserver.zig
index 37fe27b22..5a9a7f848 100644
--- a/src/browser/webapi/IntersectionObserver.zig
+++ b/src/browser/webapi/IntersectionObserver.zig
@@ -302,7 +302,7 @@ pub fn deliverEntries(self: *IntersectionObserver, frame: *Frame) !void {
}
const entries = try self.takeRecords(frame);
- var caught: js.TryCatch.Caught = undefined;
+ var caught: js.TryCatch.Caught = .{};
var ls: js.Local.Scope = undefined;
frame.js.localScope(&ls);
diff --git a/src/browser/webapi/KeyValueList.zig b/src/browser/webapi/KeyValueList.zig
index 363e7330e..8956ff0e1 100644
--- a/src/browser/webapi/KeyValueList.zig
+++ b/src/browser/webapi/KeyValueList.zig
@@ -100,7 +100,7 @@ pub fn init() KeyValueList {
}
pub fn ensureTotalCapacity(self: *KeyValueList, allocator: Allocator, n: usize) !void {
- return self._entries.ensureTotalCapacity(allocator, n);
+ return self._entries.ensureTotalCapacityPrecise(allocator, n);
}
pub fn get(self: *const KeyValueList, name: []const u8) ?[]const u8 {
diff --git a/src/browser/webapi/ModelContext.zig b/src/browser/webapi/ModelContext.zig
index c8797c183..edae42ab6 100644
--- a/src/browser/webapi/ModelContext.zig
+++ b/src/browser/webapi/ModelContext.zig
@@ -195,7 +195,7 @@ pub const ModelContextClient = struct {
defer ls.deinit();
const resolver = ls.local.createPromiseResolver();
- var caught: js.TryCatch.Caught = undefined;
+ var caught: js.TryCatch.Caught = .{};
if (callback.tryCall(js.Value, .{}, &caught)) |result| {
// The callback may itself return a thenable; resolving with its
// value lets V8's promise resolution machinery unwrap it.
diff --git a/src/browser/webapi/MutationObserver.zig b/src/browser/webapi/MutationObserver.zig
index a11782c19..6ed87ba8f 100644
--- a/src/browser/webapi/MutationObserver.zig
+++ b/src/browser/webapi/MutationObserver.zig
@@ -348,7 +348,7 @@ pub fn deliverRecords(self: *MutationObserver, frame: *Frame) !void {
frame.js.localScope(&ls);
defer ls.deinit();
- var caught: js.TryCatch.Caught = undefined;
+ var caught: js.TryCatch.Caught = .{};
ls.toLocal(self._callback).tryCallWithThis(void, self, .{ records, self }, &caught) catch |err| {
log.err(.frame, "MutObserver.deliverRecords", .{ .err = err, .caught = caught });
return err;
diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig
index cb6f038d7..03b84fa8d 100644
--- a/src/browser/webapi/Node.zig
+++ b/src/browser/webapi/Node.zig
@@ -464,7 +464,7 @@ pub fn getChildTextContent(self: *Node, writer: *std.Io.Writer) error{WriteFaile
var it = self.childrenIterator();
while (it.next()) |child| {
if (child.is(CData.Text)) |text| {
- try writer.writeAll(text._proto._data.str());
+ try writer.writeAll(text.asCData()._data.str());
}
}
}
@@ -476,7 +476,7 @@ pub fn childTextContentLen(self: *Node) usize {
var it = self.childrenIterator();
while (it.next()) |child| {
if (child.is(CData.Text)) |text| {
- len += text._proto._data.str().len;
+ len += text.asCData()._data.str().len;
}
}
return len;
@@ -636,15 +636,15 @@ pub fn isEqualChildren(a: *Node, b: *Node) bool {
return a_count == b_count;
}
+// The shadow root whose tree this node belongs to, or null when it belongs to
+// a document tree or a detached one. Inclusive: a shadow root is in its own
+// tree.
+pub fn containingShadowRoot(self: *Node) ?*ShadowRoot {
+ return self.getRootNode(.{}).is(ShadowRoot);
+}
+
pub fn isInShadowTree(self: *Node) bool {
- var node = self._parent;
- while (node) |n| {
- if (n.is(ShadowRoot) != null) {
- return true;
- }
- node = n._parent;
- }
- return false;
+ return self.containingShadowRoot() != null;
}
pub fn isConnected(self: *const Node) bool {
@@ -1209,6 +1209,10 @@ const CloneError = error{
ExecutionTerminated,
};
pub fn cloneNode(self: *Node, deep_: ?bool, frame: *Frame) CloneError!*Node {
+ if (self.is(ShadowRoot) != null) {
+ return error.NotSupported;
+ }
+
const deep = deep_ orelse false;
switch (self._type) {
.cdata => {
@@ -1389,7 +1393,7 @@ fn _normalize(self: *Node, allocator: Allocator, buffer: *std.ArrayList(u8), fra
continue;
};
- if (text_node._proto.getData().len == 0) {
+ if (text_node.asCData().getData().len == 0) {
frame.removeNode(self, current_node, .{ .will_be_reconnected = false });
child = next_node;
continue;
@@ -1407,7 +1411,7 @@ fn _normalize(self: *Node, allocator: Allocator, buffer: *std.ArrayList(u8), fra
next_node = node_to_merge.nextSibling();
frame.removeNode(self, to_remove, .{ .will_be_reconnected = false });
}
- text_node._proto._data = try frame.dupeSSO(buffer.items);
+ text_node.asCData()._data = try frame.dupeSSO(buffer.items);
buffer.clearRetainingCapacity();
}
}
diff --git a/src/browser/webapi/PerformanceObserver.zig b/src/browser/webapi/PerformanceObserver.zig
index 4cc37a2d7..627cad849 100644
--- a/src/browser/webapi/PerformanceObserver.zig
+++ b/src/browser/webapi/PerformanceObserver.zig
@@ -181,7 +181,7 @@ pub fn dispatch(self: *PerformanceObserver) !void {
self._js.localScope(&ls);
defer ls.deinit();
- var caught: js.TryCatch.Caught = undefined;
+ var caught: js.TryCatch.Caught = .{};
ls.toLocal(self._callback).tryCall(void, .{ EntryList{ ._entries = records }, self }, &caught) catch |err| {
log.err(.frame, "PerfObserver.dispatch", .{ .err = err, .caught = caught });
return err;
diff --git a/src/browser/webapi/Range.zig b/src/browser/webapi/Range.zig
index 30da788d6..6b6978ded 100644
--- a/src/browser/webapi/Range.zig
+++ b/src/browser/webapi/Range.zig
@@ -364,7 +364,7 @@ pub fn insertNode(self: *Range, node: *Node, frame: *Frame) !void {
// records browsers do (one for the split-off node, one for
// the inserted node).
const second = try t.splitText(offset, frame);
- _ = try parent.insertBefore(node, second._proto.asNode(), frame);
+ _ = try parent.insertBefore(node, second.asCData().asNode(), frame);
} else {
_ = try parent.insertBefore(node, container.nextSibling(), frame);
}
diff --git a/src/browser/webapi/ResizeObserver.zig b/src/browser/webapi/ResizeObserver.zig
index f74ee3201..026564696 100644
--- a/src/browser/webapi/ResizeObserver.zig
+++ b/src/browser/webapi/ResizeObserver.zig
@@ -183,7 +183,7 @@ pub fn deliverEntries(self: *ResizeObserver, frame: *Frame) !void {
return;
}
- var caught: js.TryCatch.Caught = undefined;
+ var caught: js.TryCatch.Caught = .{};
var ls: js.Local.Scope = undefined;
frame.js.localScope(&ls);
diff --git a/src/browser/webapi/ShadowRoot.zig b/src/browser/webapi/ShadowRoot.zig
index d2ea005b3..878b4d2f2 100644
--- a/src/browser/webapi/ShadowRoot.zig
+++ b/src/browser/webapi/ShadowRoot.zig
@@ -124,6 +124,23 @@ pub fn setOnSlotChange(self: *ShadowRoot, callback: ?js.Function.Global, frame:
}
}
+pub fn getActiveElement(self: *ShadowRoot, frame: *Frame) ?*Element {
+ const root = self.asNode();
+ const document = root.ownerDocument(frame) orelse frame.document;
+
+ // This is answering two questions:
+ // 1 - is the active element contained by me (if not, return null)
+ // 2 - if it is, is there 1+ other shadowroot between us
+ // a - if there is, return the nearest (to self) shadowroot's host
+ // b - if there isn't, return the active element
+ var candidate = document._active_element orelse return null;
+ while (candidate.asNode().getRootNode(.{}) != root) {
+ const shadow = candidate.asNode().containingShadowRoot() orelse return null;
+ candidate = shadow._host;
+ }
+ return candidate;
+}
+
pub fn getElementById(self: *ShadowRoot, id: []const u8, frame: *Frame) ?*Element {
if (id.len == 0) {
return null;
@@ -177,6 +194,7 @@ pub const JsApi = struct {
pub var class_id: bridge.ClassId = undefined;
};
+ pub const activeElement = bridge.accessor(ShadowRoot.getActiveElement, null, .{});
pub const mode = bridge.accessor(ShadowRoot.getMode, null, .{});
pub const host = bridge.accessor(ShadowRoot.getHost, null, .{});
pub const delegatesFocus = bridge.accessor(ShadowRoot.getDelegatesFocus, null, .{});
diff --git a/src/browser/webapi/SharedWorkerGlobalScope.zig b/src/browser/webapi/SharedWorkerGlobalScope.zig
index afa2eec6c..65ef9f1d2 100644
--- a/src/browser/webapi/SharedWorkerGlobalScope.zig
+++ b/src/browser/webapi/SharedWorkerGlobalScope.zig
@@ -212,7 +212,7 @@ fn httpHeaderCallback(transfer: *Transfer) !Transfer.HeaderResult {
}
if (transfer.getContentLength()) |cl| {
- try self._script_buffer.ensureTotalCapacity(self._script_arena.?.allocator(), cl);
+ try self._script_buffer.ensureTotalCapacityPrecise(self._script_arena.?.allocator(), cl);
}
return .proceed;
diff --git a/src/browser/webapi/WebDriver.zig b/src/browser/webapi/WebDriver.zig
index 57ae22def..87ccfb6ab 100644
--- a/src/browser/webapi/WebDriver.zig
+++ b/src/browser/webapi/WebDriver.zig
@@ -60,8 +60,8 @@ pub fn getComputedLabel(_: *const WebDriver, element: *Element, frame: *Frame) !
pub fn click(_: *const WebDriver, element: *Element, frame: *Frame) !void {
if (element.is(Element.Html)) |html| {
switch (html._type) {
- inline .button, .input, .textarea, .select => |i| {
- if (i.getDisabled()) {
+ inline .button, .input, .textarea, .select => |tag| {
+ if (html.subtype(Element.Html.Subtype(tag)).getDisabled()) {
return;
}
},
diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig
index 4181ffbbd..14ccb86f2 100644
--- a/src/browser/webapi/Window.zig
+++ b/src/browser/webapi/Window.zig
@@ -639,6 +639,7 @@ pub fn reportError(self: *Window, err: js.Value, frame: *Frame) !void {
// We still dispatch so that addEventListener('error', ...) listeners fire.
try frame._event_manager.dispatchDirect(target, event, null, .{
.context = "window.reportError",
+ .run_microtasks = false,
});
if (comptime lp.IS_TEST == false) {
@@ -661,16 +662,15 @@ pub fn matchMedia(_: *const Window, query: []const u8, frame: *Frame) !*MediaQue
}
pub fn getComputedStyle(_: *const Window, element: *Element, pseudo_element: ?[]const u8, frame: *Frame) !*CSSStyleProperties {
- if (pseudo_element) |pe| {
- if (pe.len != 0) {
- log.warn(.not_implemented, "window.GetComputedStyle", .{ .pseudo_element = pe });
- // Chrome hands out a distinct object per pseudo-element, so these
- // can't share the per-element cache entry.
- return CSSStyleProperties.init(element, true, frame);
- }
- }
- const gop = try frame._element_computed_styles.getOrPut(frame.arena, element);
+ // :before/:after get their own cache entry and no warning: our answer
+ // (the element's own computed style) is a reasonable default for the
+ // common probes
+ const pseudo = Element.PseudoElement.parse(pseudo_element orelse "");
+ const gop = try frame._element_computed_styles.getOrPut(frame.arena, .{ .element = element, .pseudo = pseudo });
if (!gop.found_existing) {
+ if (pseudo == .other) {
+ log.warn(.not_implemented, "window.GetComputedStyle", .{ .pseudo_element = pseudo_element.? });
+ }
gop.value_ptr.* = try CSSStyleProperties.init(element, true, frame);
}
return gop.value_ptr.*;
diff --git a/src/browser/webapi/Worker.zig b/src/browser/webapi/Worker.zig
index 2a572d986..049bb8064 100644
--- a/src/browser/webapi/Worker.zig
+++ b/src/browser/webapi/Worker.zig
@@ -170,7 +170,7 @@ fn httpHeaderCallback(transfer: *Transfer) !Transfer.HeaderResult {
}
if (transfer.getContentLength()) |cl| {
- try self._script_buffer.ensureTotalCapacity(self._script_arena.?.allocator(), cl);
+ try self._script_buffer.ensureTotalCapacityPrecise(self._script_arena.?.allocator(), cl);
}
return .proceed;
diff --git a/src/browser/webapi/cdata/CDATASection.zig b/src/browser/webapi/cdata/CDATASection.zig
index ed638c172..f775b1dae 100644
--- a/src/browser/webapi/cdata/CDATASection.zig
+++ b/src/browser/webapi/cdata/CDATASection.zig
@@ -16,6 +16,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const lp = @import("lightpanda");
+
const js = @import("../../js/js.zig");
const Text = @import("Text.zig");
@@ -24,7 +26,8 @@ const CDATASection = @This();
pub const Proto = Text;
-_proto: *Text,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *Text else void = undefined,
pub const JsApi = struct {
pub const bridge = js.Bridge(CDATASection);
diff --git a/src/browser/webapi/cdata/Comment.zig b/src/browser/webapi/cdata/Comment.zig
index 0ceb91c04..37ea7d2e2 100644
--- a/src/browser/webapi/cdata/Comment.zig
+++ b/src/browser/webapi/cdata/Comment.zig
@@ -16,6 +16,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const lp = @import("lightpanda");
+
const js = @import("../../js/js.zig");
const Frame = @import("../../Frame.zig");
@@ -25,7 +27,8 @@ const Comment = @This();
pub const Proto = CData;
-_proto: *CData,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *CData else void = undefined,
pub fn init(str: ?js.NullableString, frame: *Frame) !*Comment {
const node = try Frame.node_factory.createComment(frame, if (str) |s| s.value else "");
diff --git a/src/browser/webapi/cdata/ProcessingInstruction.zig b/src/browser/webapi/cdata/ProcessingInstruction.zig
index 3c57238f6..5c5ea3ff9 100644
--- a/src/browser/webapi/cdata/ProcessingInstruction.zig
+++ b/src/browser/webapi/cdata/ProcessingInstruction.zig
@@ -16,6 +16,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const lp = @import("lightpanda");
+
const js = @import("../../js/js.zig");
const CData = @import("../CData.zig");
@@ -24,7 +26,7 @@ const ProcessingInstruction = @This();
pub const Proto = CData;
-_proto: *CData,
+_proto_canary: if (lp.IS_DEBUG) *CData else void = undefined,
_target: []const u8,
pub fn getTarget(self: *const ProcessingInstruction) []const u8 {
diff --git a/src/browser/webapi/cdata/Text.zig b/src/browser/webapi/cdata/Text.zig
index 91300f850..e27a61590 100644
--- a/src/browser/webapi/cdata/Text.zig
+++ b/src/browser/webapi/cdata/Text.zig
@@ -17,8 +17,10 @@
// along with this program. If not, see .
const std = @import("std");
+const lp = @import("lightpanda");
const js = @import("../../js/js.zig");
+const Factory = @import("../../Factory.zig");
const Frame = @import("../../Frame.zig");
const Node = @import("../Node.zig");
const CData = @import("../CData.zig");
@@ -29,7 +31,13 @@ const Text = @This();
pub const Proto = CData;
-_proto: *CData,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *CData else void = undefined,
+
+// Takes a const text but returns a mutable proto; see Node.subtype.
+pub fn asCData(self: *const Text) *CData {
+ return Factory.protoOf(self);
+}
pub fn init(str: ?js.NullableString, frame: *Frame) !*Text {
const node = try Frame.node_factory.createTextNode(frame, if (str) |s| s.value else "");
@@ -38,13 +46,13 @@ pub fn init(str: ?js.NullableString, frame: *Frame) !*Text {
// This Text node's own data (getWholeText below spans adjacent Text nodes).
pub fn ownData(self: *const Text) []const u8 {
- return self._proto._data.str();
+ return Factory.protoOf(self)._data.str();
}
// The concatenated data of the contiguous exclusive Text nodes (adjacent
// Text siblings on both sides of this one), in tree order.
pub fn getWholeText(self: *Text, frame: *Frame) ![]const u8 {
- const node = self._proto.asNode();
+ const node = Factory.protoOf(self).asNode();
var first = node;
while (first.previousSibling()) |prev| {
@@ -57,7 +65,7 @@ pub fn getWholeText(self: *Text, frame: *Frame) ![]const u8 {
// Common case: no adjacent text nodes, return our data directly.
const has_next_text = if (node.nextSibling()) |next| isExclusiveTextNode(next) else false;
if (first == node and !has_next_text) {
- return self._proto._data.str();
+ return Factory.protoOf(self)._data.str();
}
var buf: std.ArrayList(u8) = .empty;
@@ -74,11 +82,11 @@ fn isExclusiveTextNode(node: *Node) bool {
}
pub fn getAssignedSlot(self: *Text, frame: *Frame) ?*Slot {
- return slotting.findSlot(self._proto.asNode(), true, frame);
+ return slotting.findSlot(Factory.protoOf(self).asNode(), true, frame);
}
pub fn splitText(self: *Text, offset: usize, frame: *Frame) !*Text {
- const data = self._proto._data.str();
+ const data = Factory.protoOf(self)._data.str();
const byte_offset = CData.utf16OffsetToUtf8(data, offset) catch return error.IndexSizeError;
@@ -86,7 +94,7 @@ pub fn splitText(self: *Text, offset: usize, frame: *Frame) !*Text {
const new_node = try Frame.node_factory.createTextNode(frame, new_data);
const new_text = new_node.as(Text);
- const node = self._proto.asNode();
+ const node = Factory.protoOf(self).asNode();
// Per DOM spec splitText: insert first (step 7a), then update ranges (7b-7e),
// then truncate original node (step 8).
@@ -103,8 +111,8 @@ pub fn splitText(self: *Text, offset: usize, frame: *Frame) !*Text {
// Step 8: truncate original node via replaceData(offset, count, "").
// Use replaceData instead of setData so live range updates fire
// (matters for detached text nodes where steps 7b-7e were skipped).
- const length = self._proto.getLength();
- try self._proto.replaceData(offset, length - offset, "", frame);
+ const length = Factory.protoOf(self).getLength();
+ try Factory.protoOf(self).replaceData(offset, length - offset, "", frame);
return new_text;
}
diff --git a/src/browser/webapi/collections/DOMTokenList.zig b/src/browser/webapi/collections/DOMTokenList.zig
index e71ea6b73..68365df37 100644
--- a/src/browser/webapi/collections/DOMTokenList.zig
+++ b/src/browser/webapi/collections/DOMTokenList.zig
@@ -246,7 +246,7 @@ pub fn forEach(self: *DOMTokenList, cb_: js.Function, js_this_: ?js.Object, fram
if (gop.found_existing) {
continue;
}
- var caught: js.TryCatch.Caught = undefined;
+ var caught: js.TryCatch.Caught = .{};
cb.tryCall(void, .{ token, i, self }, &caught) catch |err| {
frame._page.recordJsError(err);
log.debug(.js, "forEach callback", .{ .caught = caught, .source = "DOMTokenList" });
diff --git a/src/browser/webapi/collections/NodeList.zig b/src/browser/webapi/collections/NodeList.zig
index f1451cd79..676b92e3c 100644
--- a/src/browser/webapi/collections/NodeList.zig
+++ b/src/browser/webapi/collections/NodeList.zig
@@ -97,7 +97,7 @@ pub fn forEach(self: *NodeList, cb: js.Function, frame: *Frame) !void {
while (true) : (i += 1) {
const node = try self.getAtIndex(@intCast(i), frame) orelse return;
- var caught: js.TryCatch.Caught = undefined;
+ var caught: js.TryCatch.Caught = .{};
cb.tryCall(void, .{ node, i, self }, &caught) catch |err| {
frame._page.recordJsError(err);
log.debug(.js, "forEach callback", .{ .caught = caught, .source = "nodelist" });
diff --git a/src/browser/webapi/collections/node_live.zig b/src/browser/webapi/collections/node_live.zig
index c96f9b179..884aefaf6 100644
--- a/src/browser/webapi/collections/node_live.zig
+++ b/src/browser/webapi/collections/node_live.zig
@@ -407,7 +407,7 @@ pub fn NodeLive(comptime mode: Mode) type {
fn isFormControl(el: *Element) bool {
if (el._type != .html) return false;
- const html = el._type.html;
+ const html = el.subtype(Element.Html);
return switch (html._type) {
.input, .button, .select, .textarea => true,
else => false,
diff --git a/src/browser/webapi/css/CSSStyleDeclaration.zig b/src/browser/webapi/css/CSSStyleDeclaration.zig
index 80121f58d..8a9ca1258 100644
--- a/src/browser/webapi/css/CSSStyleDeclaration.zig
+++ b/src/browser/webapi/css/CSSStyleDeclaration.zig
@@ -97,9 +97,11 @@ pub fn getPropertyValue(self: *const CSSStyleDeclaration, property_name: []const
}
// Computed width/height must agree with the synthetic layout
- // metrics (offsetWidth/getBoundingClientRect). Returning ""
- // makes measurement code see contradictory sizes — jQuery's
- // "shrink text until it fits" loops then never terminate.
+ // metrics. Returning "" makes measurement code see
+ // contradictory sizes — jQuery's "shrink text until it fits"
+ // loops then never terminate. jQuery's .width() reads this
+ // value, so it must also carry clientWidth's content fallback
+ // or append-until-wide marquee loops never terminate.
if (wrapped.eql(comptime .wrap("width"))) {
return resolvedDimension(element, .width, frame);
}
@@ -115,13 +117,13 @@ pub fn getPropertyValue(self: *const CSSStyleDeclaration, property_name: []const
}
fn resolvedDimension(element: *Element, dimension: enum { width, height }, frame: *Frame) []const u8 {
- if (!element.checkVisibilityCached(null, frame)) {
+ var visibility_cache: Element.VisibilityCache = .{};
+ if (!element.checkVisibilityCached(&visibility_cache, frame)) {
return "auto";
}
- const dims = element.getElementDimensions(frame);
const value = switch (dimension) {
- .width => dims.width,
- .height => dims.height,
+ .width => element.getClientWidthWithCache(frame, &visibility_cache),
+ .height => element.getClientHeightWithCache(frame, &visibility_cache),
};
return std.fmt.allocPrint(frame.local_arena, "{d}px", .{value}) catch "auto";
}
@@ -802,8 +804,8 @@ fn getDefaultPropertyValue(self: *const CSSStyleDeclaration, name: String) []con
fn getDefaultDisplay(element: *const Element) []const u8 {
switch (element._type) {
- .html => |html| {
- return switch (html._type) {
+ .html => {
+ return switch (element.subtype(Element.Html)._type) {
.anchor, .br, .span, .label, .time, .font, .mod, .quote => "inline",
.body, .div, .dl, .p, .heading, .form, .button, .canvas, .details, .dialog, .embed, .head, .html, .hr, .iframe, .img, .input, .li, .link, .meta, .ol, .option, .script, .select, .slot, .style, .template, .textarea, .title, .ul, .media, .area, .base, .datalist, .directory, .fieldset, .frameset, .legend, .map, .marquee, .meter, .object, .optgroup, .output, .param, .picture, .pre, .progress, .source, .table, .table_caption, .table_cell, .table_col, .table_row, .table_section, .track => "block",
.generic, .custom, .unknown, .data => blk: {
@@ -835,8 +837,8 @@ fn isInlineTag(tag_name: []const u8) bool {
fn getDefaultColor(element: *const Element) []const u8 {
switch (element._type) {
- .html => |html| {
- return switch (html._type) {
+ .html => {
+ return switch (element.subtype(Element.Html)._type) {
.anchor => "rgb(0, 0, 238)", // blue
else => "rgb(0, 0, 0)",
};
diff --git a/src/browser/webapi/element/Attribute.zig b/src/browser/webapi/element/Attribute.zig
index 12b30ce75..c5486490b 100644
--- a/src/browser/webapi/element/Attribute.zig
+++ b/src/browser/webapi/element/Attribute.zig
@@ -353,7 +353,7 @@ pub const List = struct {
pub fn getNames(self: *const List, allocator: Allocator) ![][]const u8 {
var arr: std.ArrayList([]const u8) = .empty;
- try arr.ensureTotalCapacity(allocator, self._len);
+ try arr.ensureTotalCapacityPrecise(allocator, self._len);
for (self.entries()) |*e| {
arr.appendAssumeCapacity(e.name());
}
diff --git a/src/browser/webapi/element/DOMStringMap.zig b/src/browser/webapi/element/DOMStringMap.zig
index 09066ef29..9bb125449 100644
--- a/src/browser/webapi/element/DOMStringMap.zig
+++ b/src/browser/webapi/element/DOMStringMap.zig
@@ -81,7 +81,7 @@ fn camelToKebab(arena: Allocator, camel: String) !String {
// Fallback: allocate for longer strings
var result: std.ArrayList(u8) = .empty;
- try result.ensureTotalCapacity(arena, output_len);
+ try result.ensureTotalCapacityPrecise(arena, output_len);
result.appendSliceAssumeCapacity("data-");
for (camel_str, 0..) |c, i| {
@@ -110,7 +110,7 @@ fn kebabToCamel(arena: Allocator, kebab: []const u8) !?[]const u8 {
const data_part = kebab[5..]; // Skip "data-"
var result: std.ArrayList(u8) = .empty;
- try result.ensureTotalCapacity(arena, data_part.len);
+ try result.ensureTotalCapacityPrecise(arena, data_part.len);
var i: usize = 0;
while (i < data_part.len) : (i += 1) {
diff --git a/src/browser/webapi/element/Html.zig b/src/browser/webapi/element/Html.zig
index 64e96684d..1dc08f973 100644
--- a/src/browser/webapi/element/Html.zig
+++ b/src/browser/webapi/element/Html.zig
@@ -116,6 +116,10 @@ _proto_canary: if (lp.IS_DEBUG) *Element else void = undefined,
// which custom element class was invoked; look it up in the registry.
pub fn construct(new_target: js.Function, frame: *Frame) !*Element {
if (frame._upgrading_element) |node| {
+ if (frame._upgrading_consumed) {
+ return error.TypeError;
+ }
+ frame._upgrading_consumed = true;
return node.is(Element) orelse return error.IllegalConstructor;
}
return Frame.node_factory.constructCustomElement(frame, new_target);
@@ -127,90 +131,176 @@ pub fn construct(new_target: js.Function, frame: *Frame) !*Element {
// constructors routed here.
pub fn upgradeConstruct(frame: *Frame) !*Element {
const node = frame._upgrading_element orelse return error.TypeError;
+ if (frame._upgrading_consumed) {
+ return error.TypeError;
+ }
+ frame._upgrading_consumed = true;
return node.is(Element) orelse return error.TypeError;
}
-pub const Type = union(enum) {
- anchor: *Anchor,
- area: *Area,
- base: *Base,
- body: *Body,
- br: *BR,
- button: *Button,
- canvas: *Canvas,
- custom: *Custom,
- data: *Data,
- datalist: *DataList,
- details: *Details,
- dialog: *Dialog,
- directory: *Directory,
- div: *Div,
- dl: *DList,
- embed: *Embed,
- fieldset: *FieldSet,
- font: *Font,
- form: *Form,
- frameset: *FrameSet,
- generic: *Generic,
- heading: *Heading,
- head: *Head,
- html: *Html,
- hr: *HR,
- img: *Image,
- iframe: *IFrame,
- input: *Input,
- label: *Label,
- legend: *Legend,
- li: *LI,
- link: *Link,
- map: *Map,
- marquee: *Marquee,
- media: *Media,
- meta: *Meta,
- meter: *Meter,
- mod: *Mod,
- object: *Object,
- ol: *OL,
- optgroup: *OptGroup,
- option: *Option,
- output: *Output,
- p: *Paragraph,
- picture: *Picture,
- param: *Param,
- pre: *Pre,
- progress: *Progress,
- quote: *Quote,
- script: *Script,
- select: *Select,
- slot: *Slot,
- source: *Source,
- span: *Span,
- style: *Style,
- table: *Table,
- table_caption: *TableCaption,
- table_cell: *TableCell,
- table_col: *TableCol,
- table_row: *TableRow,
- table_section: *TableSection,
- template: *Template,
- textarea: *TextArea,
- time: *Time,
- title: *Title,
- track: *Track,
- ul: *UL,
- unknown: *Unknown,
+pub const Type = enum(u8) {
+ anchor,
+ area,
+ base,
+ body,
+ br,
+ button,
+ canvas,
+ custom,
+ data,
+ datalist,
+ details,
+ dialog,
+ directory,
+ div,
+ dl,
+ embed,
+ fieldset,
+ font,
+ form,
+ frameset,
+ generic,
+ heading,
+ head,
+ html,
+ hr,
+ img,
+ iframe,
+ input,
+ label,
+ legend,
+ li,
+ link,
+ map,
+ marquee,
+ media,
+ meta,
+ meter,
+ mod,
+ object,
+ ol,
+ optgroup,
+ option,
+ output,
+ p,
+ picture,
+ param,
+ pre,
+ progress,
+ quote,
+ script,
+ select,
+ slot,
+ source,
+ span,
+ style,
+ table,
+ table_caption,
+ table_cell,
+ table_col,
+ table_row,
+ table_section,
+ template,
+ textarea,
+ time,
+ title,
+ track,
+ ul,
+ unknown,
};
+pub fn Subtype(comptime tag: Type) type {
+ return switch (tag) {
+ .anchor => Anchor,
+ .area => Area,
+ .base => Base,
+ .body => Body,
+ .br => BR,
+ .button => Button,
+ .canvas => Canvas,
+ .custom => Custom,
+ .data => Data,
+ .datalist => DataList,
+ .details => Details,
+ .dialog => Dialog,
+ .directory => Directory,
+ .div => Div,
+ .dl => DList,
+ .embed => Embed,
+ .fieldset => FieldSet,
+ .font => Font,
+ .form => Form,
+ .frameset => FrameSet,
+ .generic => Generic,
+ .heading => Heading,
+ .head => Head,
+ .html => Html,
+ .hr => HR,
+ .img => Image,
+ .iframe => IFrame,
+ .input => Input,
+ .label => Label,
+ .legend => Legend,
+ .li => LI,
+ .link => Link,
+ .map => Map,
+ .marquee => Marquee,
+ .media => Media,
+ .meta => Meta,
+ .meter => Meter,
+ .mod => Mod,
+ .object => Object,
+ .ol => OL,
+ .optgroup => OptGroup,
+ .option => Option,
+ .output => Output,
+ .p => Paragraph,
+ .picture => Picture,
+ .param => Param,
+ .pre => Pre,
+ .progress => Progress,
+ .quote => Quote,
+ .script => Script,
+ .select => Select,
+ .slot => Slot,
+ .source => Source,
+ .span => Span,
+ .style => Style,
+ .table => Table,
+ .table_caption => TableCaption,
+ .table_cell => TableCell,
+ .table_col => TableCol,
+ .table_row => TableRow,
+ .table_section => TableSection,
+ .template => Template,
+ .textarea => TextArea,
+ .time => Time,
+ .title => Title,
+ .track => Track,
+ .ul => UL,
+ .unknown => Unknown,
+ };
+}
+
+pub fn subtype(self: *const HtmlElement, comptime T: type) *T {
+ const offset = comptime Factory.chainOffsetOf(T, T) - Factory.chainOffsetOf(T, HtmlElement);
+ const sub: *T = @ptrFromInt(@intFromPtr(self) + offset);
+ if (comptime lp.IS_DEBUG) {
+ // This pointer dance only works because the factory allocates the chain
+ // in a contiguous block of memory. In debug, we assert this holds via
+ // the _proto_canary back pointer.
+ std.debug.assert(Factory.protoOf(sub) == self);
+ }
+ return sub;
+}
+
pub fn is(self: *HtmlElement, comptime T: type) ?*T {
- inline for (@typeInfo(Type).@"union".fields) |f| {
- if (@field(Type, f.name) == self._type) {
- if (f.type == T) {
- return &@field(self._type, f.name);
+ switch (self._type) {
+ inline else => |tag| {
+ if (Subtype(tag) == T) {
+ return self.subtype(T);
}
- if (f.type == *T) {
- return @field(self._type, f.name);
- }
- }
+ },
}
return null;
}
@@ -304,8 +394,8 @@ pub fn insertAdjacentHTML(
pub fn click(self: *HtmlElement, frame: *Frame) !void {
switch (self._type) {
- inline .button, .input, .textarea, .select => |i| {
- if (i.getDisabled()) {
+ inline .button, .input, .textarea, .select => |tag| {
+ if (self.subtype(Subtype(tag)).getDisabled()) {
return;
}
},
@@ -330,8 +420,8 @@ pub fn click(self: *HtmlElement, frame: *Frame) !void {
if (event._prevent_default == false) {
// toggle the popover_target
const explicit: ?*Element = switch (self._type) {
- .button => |b| b._popover_target,
- .input => |i| i._popover_target,
+ .button => self.subtype(Button)._popover_target,
+ .input => self.subtype(Input)._popover_target,
else => null,
};
try popover.runInvokerActivation(self, explicit, frame);
@@ -543,7 +633,7 @@ fn setAttributeListener(
) !void {
if (comptime lp.IS_DEBUG) {
log.debug(.event, "Html.setAttributeListener", .{
- .type = std.meta.activeTag(self._type),
+ .type = self._type,
.listener_type = listener_type,
});
}
@@ -1492,14 +1582,14 @@ fn collectInnerText(self: *HtmlElement, state: *InnerTextState) std.Io.Writer.Er
const e = child.subtype(Node.Element);
switch (e._type) {
.svg => {},
- .html => |he| {
+ .html => {
const tag = e.getTag();
switch (child_filter) {
.none => {},
.select => if (tag != .option and tag != .optgroup) continue,
.optgroup => if (tag != .option) continue,
}
- try handleChildElement(he, tag, state, &saw_cell, &saw_row);
+ try handleChildElement(e.subtype(HtmlElement), tag, state, &saw_cell, &saw_row);
},
}
},
@@ -1870,17 +1960,17 @@ pub const Build = struct {
// Calls `func_name` with `args` on the most specific type where it is
// implement. This could be on the HtmlElement itself.
pub fn call(self: *const HtmlElement, comptime func_name: []const u8, args: anytype) !bool {
- inline for (@typeInfo(HtmlElement.Type).@"union".fields) |f| {
- if (@field(HtmlElement.Type, f.name) == self._type) {
+ switch (self._type) {
+ inline else => |tag| {
+ const S = Subtype(tag);
// The inner type implements this function. Call it and we're done.
- const S = reflect.Struct(f.type);
if (@hasDecl(S, "Build")) {
if (@hasDecl(S.Build, func_name)) {
try @call(.auto, @field(S.Build, func_name), args);
return true;
}
}
- }
+ },
}
if (@hasDecl(HtmlElement.Build, func_name)) {
diff --git a/src/browser/webapi/element/Svg.zig b/src/browser/webapi/element/Svg.zig
index a7a39cfe3..39960ec0b 100644
--- a/src/browser/webapi/element/Svg.zig
+++ b/src/browser/webapi/element/Svg.zig
@@ -16,6 +16,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../../js/js.zig");
@@ -47,64 +48,99 @@ _type: Type,
_tag_name: String, // Svg elements are case-preserving
_proto_canary: if (lp.IS_DEBUG) *Element else void = undefined,
-pub const Type = union(enum) {
- graphics: *Graphics,
- view: *View,
- title: *Title,
- desc: *Desc,
- metadata: *Metadata,
- gradient: *GradientElement,
- clip_path: *ClipPath,
- marker: *Marker,
- mask: *Mask,
- pattern: *Pattern,
- stop: *Stop,
- generic: *Generic,
+pub const Type = enum(u8) {
+ graphics,
+ view,
+ title,
+ desc,
+ metadata,
+ gradient,
+ clip_path,
+ marker,
+ mask,
+ pattern,
+ stop,
+ generic,
};
+pub fn Subtype(comptime tag: Type) type {
+ return switch (tag) {
+ .graphics => Graphics,
+ .view => View,
+ .title => Title,
+ .desc => Desc,
+ .metadata => Metadata,
+ .gradient => GradientElement,
+ .clip_path => ClipPath,
+ .marker => Marker,
+ .mask => Mask,
+ .pattern => Pattern,
+ .stop => Stop,
+ .generic => Generic,
+ };
+}
+
+pub fn subtype(self: *const Svg, comptime T: type) *T {
+ const offset = comptime Factory.chainOffsetOf(T, T) - Factory.chainOffsetOf(T, Svg);
+ const sub: *T = @ptrFromInt(@intFromPtr(self) + offset);
+ if (comptime lp.IS_DEBUG) {
+ // This pointer dance only works because the factory allocates the chain
+ // in a contiguous block of memory. In debug, we assert this holds via
+ // the _proto_canary back pointer.
+ std.debug.assert(Factory.protoOf(sub) == self);
+ }
+ return sub;
+}
+
pub fn is(self: *Svg, comptime T: type) ?*T {
- inline for (@typeInfo(Type).@"union".fields) |field| {
- if (@field(Type, field.name) == self._type) {
- if (field.type == *T) {
- return @field(self._type, field.name);
+ switch (self._type) {
+ inline else => |tag| {
+ if (Subtype(tag) == T) {
+ return self.subtype(T);
}
- }
+ },
}
if (self._type == .graphics) {
- return self._type.graphics.is(T);
+ return self.subtype(Graphics).is(T);
}
if (self._type == .gradient) {
- return self._type.gradient.is(T);
+ return self.subtype(GradientElement).is(T);
}
return null;
}
pub fn getTag(self: *const Svg) Element.Tag {
return switch (self._type) {
- .graphics => |g| switch (g._type) {
- .svg => .svg,
- .g => .g,
- // No dedicated Element.Tag values; tag-name matching falls back
- // to _tag_name, like it does for generic SVG elements.
- .a, .use, .image, .defs, .symbol, .switch_element, .foreign_object => .unknown,
- .text_content => |content| switch (content._type) {
- .positioning => |positioning| switch (positioning._type) {
- .text => .text,
- .tspan => .unknown,
+ .graphics => blk: {
+ const g = self.subtype(Graphics);
+ break :blk switch (g._type) {
+ .svg => .svg,
+ .g => .g,
+ // No dedicated Element.Tag values; tag-name matching falls back
+ // to _tag_name, like it does for generic SVG elements.
+ .a, .use, .image, .defs, .symbol, .switch_element, .foreign_object => .unknown,
+ .text_content => tc: {
+ const content = g.subtype(Graphics.TextContent);
+ break :tc switch (content._type) {
+ .positioning => switch (content.subtype(Graphics.TextContent.TextPositioning)._type) {
+ .text => .text,
+ .tspan => .unknown,
+ },
+ .text_path => .unknown,
+ };
},
- .text_path => .unknown,
- },
- .geometry => |geo| switch (geo._type) {
- .rect => .rect,
- .circle => .circle,
- .ellipse => .ellipse,
- .line => .line,
- .path => .path,
- .polygon => .polygon,
- .polyline => .polyline,
- },
+ .geometry => switch (g.subtype(Graphics.Geometry)._type) {
+ .rect => .rect,
+ .circle => .circle,
+ .ellipse => .ellipse,
+ .line => .line,
+ .path => .path,
+ .polygon => .polygon,
+ .polyline => .polyline,
+ },
+ };
},
- .generic => |g| g._tag,
+ .generic => self.subtype(Generic)._tag,
.title => .title,
.view, .desc, .metadata, .gradient, .clip_path, .marker, .mask, .pattern, .stop => .unknown,
};
diff --git a/src/browser/webapi/element/html/Anchor.zig b/src/browser/webapi/element/html/Anchor.zig
index 7ee163470..54563d1fe 100644
--- a/src/browser/webapi/element/html/Anchor.zig
+++ b/src/browser/webapi/element/html/Anchor.zig
@@ -16,8 +16,10 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const lp = @import("lightpanda");
const std = @import("std");
const js = @import("../../../js/js.zig");
+const Factory = @import("../../../Factory.zig");
const Frame = @import("../../../Frame.zig");
const URL = @import("../../../URL.zig");
@@ -28,13 +30,14 @@ const HtmlElement = @import("../Html.zig");
const Anchor = @This();
pub const Proto = HtmlElement;
-_proto: *HtmlElement,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *Anchor) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asConstElement(self: *const Anchor) *const Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Anchor) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/html/Area.zig b/src/browser/webapi/element/html/Area.zig
index 81f73ea0b..ba79696af 100644
--- a/src/browser/webapi/element/html/Area.zig
+++ b/src/browser/webapi/element/html/Area.zig
@@ -13,8 +13,10 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const lp = @import("lightpanda");
const std = @import("std");
const js = @import("../../../js/js.zig");
+const Factory = @import("../../../Factory.zig");
const Frame = @import("../../../Frame.zig");
const URL = @import("../../../URL.zig");
@@ -28,13 +30,14 @@ const Area = @This();
pub const Proto = HtmlElement;
-_proto: *HtmlElement,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *Area) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asConstElement(self: *const Area) *const Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Area) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/html/Audio.zig b/src/browser/webapi/element/html/Audio.zig
index 2c9f807c3..2cf152a08 100644
--- a/src/browser/webapi/element/html/Audio.zig
+++ b/src/browser/webapi/element/html/Audio.zig
@@ -19,6 +19,7 @@
const lp = @import("lightpanda");
const js = @import("../../../js/js.zig");
+const Factory = @import("../../../Factory.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
@@ -31,7 +32,8 @@ const Audio = @This();
pub const Proto = Media;
-_proto: *Media,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *Media else void = undefined,
pub fn constructor(maybe_url: ?String, frame: *Frame) !*Media {
const node = try Frame.node_factory.createElementNS(frame, .html, "audio", null);
@@ -48,11 +50,11 @@ pub fn constructor(maybe_url: ?String, frame: *Frame) !*Media {
}
pub fn asMedia(self: *Audio) *Media {
- return self._proto;
+ return Factory.protoOf(self);
}
pub fn asElement(self: *Audio) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Audio) *Node {
diff --git a/src/browser/webapi/element/html/BR.zig b/src/browser/webapi/element/html/BR.zig
index f101d0077..2f9c67824 100644
--- a/src/browser/webapi/element/html/BR.zig
+++ b/src/browser/webapi/element/html/BR.zig
@@ -1,3 +1,5 @@
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
@@ -7,10 +9,11 @@ const BR = @This();
pub const Proto = HtmlElement;
-_proto: *HtmlElement,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *BR) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *BR) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/html/Base.zig b/src/browser/webapi/element/html/Base.zig
index e22bfd0fe..0e157c3e6 100644
--- a/src/browser/webapi/element/html/Base.zig
+++ b/src/browser/webapi/element/html/Base.zig
@@ -1,3 +1,5 @@
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const URL = @import("../../../URL.zig");
const Frame = @import("../../../Frame.zig");
@@ -10,10 +12,11 @@ const Base = @This();
pub const Proto = HtmlElement;
-_proto: *HtmlElement,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *Base) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Base) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/html/Body.zig b/src/browser/webapi/element/html/Body.zig
index d4914fc76..5602f3cc0 100644
--- a/src/browser/webapi/element/html/Body.zig
+++ b/src/browser/webapi/element/html/Body.zig
@@ -18,6 +18,7 @@
const lp = @import("lightpanda");
const js = @import("../../../js/js.zig");
+const Factory = @import("../../../Factory.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
@@ -31,10 +32,11 @@ const Body = @This();
pub const Proto = HtmlElement;
-_proto: *HtmlElement,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *Body) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Body) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/html/Button.zig b/src/browser/webapi/element/html/Button.zig
index 823cdf13b..0ee5acf83 100644
--- a/src/browser/webapi/element/html/Button.zig
+++ b/src/browser/webapi/element/html/Button.zig
@@ -16,9 +16,11 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const lp = @import("lightpanda");
const std = @import("std");
const js = @import("../../../js/js.zig");
+const Factory = @import("../../../Factory.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
@@ -34,16 +36,16 @@ const Button = @This();
pub const Proto = HtmlElement;
-_proto: *HtmlElement,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
_custom_validity: ?[]const u8 = null,
_validity: ?*ValidityState = null,
_popover_target: ?*Element = null,
pub fn asElement(self: *Button) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asConstElement(self: *const Button) *const Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Button) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/html/Canvas.zig b/src/browser/webapi/element/html/Canvas.zig
index 9c3ef96ed..e9c301a15 100644
--- a/src/browser/webapi/element/html/Canvas.zig
+++ b/src/browser/webapi/element/html/Canvas.zig
@@ -16,8 +16,10 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const lp = @import("lightpanda");
const std = @import("std");
const js = @import("../../../js/js.zig");
+const Factory = @import("../../../Factory.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
@@ -32,14 +34,14 @@ const Execution = js.Execution;
const Canvas = @This();
pub const Proto = HtmlElement;
-_proto: *HtmlElement,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
_cached: ?DrawingContext = null,
pub fn asElement(self: *Canvas) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asConstElement(self: *const Canvas) *const Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Canvas) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/html/Custom.zig b/src/browser/webapi/element/html/Custom.zig
index 3cccc27c6..153657bef 100644
--- a/src/browser/webapi/element/html/Custom.zig
+++ b/src/browser/webapi/element/html/Custom.zig
@@ -20,6 +20,7 @@ const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../../../js/js.zig");
+const Factory = @import("../../../Factory.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
@@ -35,14 +36,15 @@ const String = lp.String;
const Custom = @This();
pub const Proto = HtmlElement;
-_proto: *HtmlElement,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
_tag_name: String,
_definition: ?*CustomElementDefinition,
_connected_callback_invoked: bool = false,
_disconnected_callback_invoked: bool = false,
+_upgrade_failed: bool = false, // a failed upgrade is never retried
pub fn asElement(self: *Custom) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Custom) *Node {
return self.asElement().asNode();
@@ -61,6 +63,9 @@ pub fn enqueueConnectedCallbackOnElement(comptime from_parser: bool, element: *E
if (element.is(Custom)) |custom| {
// Upgrade if a definition exists but isn't yet attached
if (custom._definition == null) {
+ if (custom._upgrade_failed) {
+ return;
+ }
const name = custom._tag_name.str();
if (frame.window._custom_elements._definitions.get(name)) |definition| {
const CustomElementRegistry = @import("../../CustomElementRegistry.zig");
@@ -256,29 +261,35 @@ pub fn checkAndAttachBuiltIn(element: *Element, frame: *Frame) !void {
// Invoke constructor
const prev_upgrading = frame._upgrading_element;
+ const prev_consumed = frame._upgrading_consumed;
const node = element.asNode();
frame._upgrading_element = node;
- defer frame._upgrading_element = prev_upgrading;
+ frame._upgrading_consumed = false;
+ defer {
+ frame._upgrading_element = prev_upgrading;
+ frame._upgrading_consumed = prev_consumed;
+ }
// PERFORMANCE OPTIMIZATION: This pattern is discouraged in general code.
// Used here because: (1) multiple early returns before needing Local,
// (2) called from both V8 callbacks (Local exists) and parser (no Local).
// Prefer either: requiring *const js.Local parameter, OR always creating
// Local.Scope upfront.
- var ls: ?js.Local.Scope = null;
- var local = blk: {
+ var ls: js.Local.Scope = undefined;
+ var ls_open = false;
+ const local = blk: {
if (frame.js.local) |l| {
break :blk l;
}
- ls = undefined;
- frame.js.localScope(&ls.?);
- break :blk &ls.?.local;
+ frame.js.localScope(&ls);
+ ls_open = true;
+ break :blk &ls.local;
};
- defer if (ls) |*_ls| {
- _ls.deinit();
+ defer if (ls_open) {
+ ls.deinit();
};
- var caught: js.TryCatch.Caught = undefined;
+ var caught: js.TryCatch.Caught = .{};
_ = local.toLocal(definition.constructor).newInstance(&caught) catch |err| {
log.warn(.js, "custom builtin ctor", .{ .name = is_value, .err = err, .caught = caught });
return;
diff --git a/src/browser/webapi/element/html/DList.zig b/src/browser/webapi/element/html/DList.zig
index 353c0120e..0a22e0396 100644
--- a/src/browser/webapi/element/html/DList.zig
+++ b/src/browser/webapi/element/html/DList.zig
@@ -16,6 +16,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
@@ -24,10 +26,11 @@ const HtmlElement = @import("../Html.zig");
const DList = @This();
pub const Proto = HtmlElement;
-_proto: *HtmlElement,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *DList) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *DList) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/html/Data.zig b/src/browser/webapi/element/html/Data.zig
index d4bc50d54..baeb5b41c 100644
--- a/src/browser/webapi/element/html/Data.zig
+++ b/src/browser/webapi/element/html/Data.zig
@@ -16,6 +16,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Frame = @import("../../../Frame.zig");
@@ -27,10 +29,11 @@ const Data = @This();
pub const Proto = HtmlElement;
-_proto: *HtmlElement,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *Data) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Data) *Node {
diff --git a/src/browser/webapi/element/html/DataList.zig b/src/browser/webapi/element/html/DataList.zig
index 93ec1a406..ef85c9fe4 100644
--- a/src/browser/webapi/element/html/DataList.zig
+++ b/src/browser/webapi/element/html/DataList.zig
@@ -1,3 +1,5 @@
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
@@ -7,10 +9,11 @@ const DataList = @This();
pub const Proto = HtmlElement;
-_proto: *HtmlElement,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *DataList) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *DataList) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/html/Details.zig b/src/browser/webapi/element/html/Details.zig
index 30c4134be..ed077be58 100644
--- a/src/browser/webapi/element/html/Details.zig
+++ b/src/browser/webapi/element/html/Details.zig
@@ -1,3 +1,5 @@
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Frame = @import("../../../Frame.zig");
@@ -9,13 +11,14 @@ const Details = @This();
pub const Proto = HtmlElement;
-_proto: *HtmlElement,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *Details) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asConstElement(self: *const Details) *const Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Details) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/html/Dialog.zig b/src/browser/webapi/element/html/Dialog.zig
index 5765f51a4..805c37b2d 100644
--- a/src/browser/webapi/element/html/Dialog.zig
+++ b/src/browser/webapi/element/html/Dialog.zig
@@ -1,3 +1,5 @@
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Frame = @import("../../../Frame.zig");
@@ -10,13 +12,14 @@ const Dialog = @This();
pub const Proto = HtmlElement;
-_proto: *HtmlElement,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *Dialog) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asConstElement(self: *const Dialog) *const Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Dialog) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/html/Directory.zig b/src/browser/webapi/element/html/Directory.zig
index 1c6d8ff00..bf5bb248e 100644
--- a/src/browser/webapi/element/html/Directory.zig
+++ b/src/browser/webapi/element/html/Directory.zig
@@ -1,3 +1,5 @@
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
@@ -8,10 +10,11 @@ const Directory = @This();
pub const Proto = HtmlElement;
-_proto: *HtmlElement,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *Directory) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Directory) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/html/Div.zig b/src/browser/webapi/element/html/Div.zig
index 6a7edb125..88fa70476 100644
--- a/src/browser/webapi/element/html/Div.zig
+++ b/src/browser/webapi/element/html/Div.zig
@@ -16,6 +16,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
@@ -24,10 +26,11 @@ const HtmlElement = @import("../Html.zig");
const Div = @This();
pub const Proto = HtmlElement;
-_proto: *HtmlElement,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *Div) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Div) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/html/Embed.zig b/src/browser/webapi/element/html/Embed.zig
index b66e94f74..1ee55547f 100644
--- a/src/browser/webapi/element/html/Embed.zig
+++ b/src/browser/webapi/element/html/Embed.zig
@@ -16,6 +16,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
@@ -25,13 +27,14 @@ const HtmlElement = @import("../Html.zig");
const Embed = @This();
pub const Proto = HtmlElement;
-_proto: *HtmlElement,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *Embed) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asConstElement(self: *const Embed) *const Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Embed) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/html/FieldSet.zig b/src/browser/webapi/element/html/FieldSet.zig
index 717e115af..00e8e1f92 100644
--- a/src/browser/webapi/element/html/FieldSet.zig
+++ b/src/browser/webapi/element/html/FieldSet.zig
@@ -1,3 +1,5 @@
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
@@ -8,10 +10,11 @@ const FieldSet = @This();
pub const Proto = HtmlElement;
-_proto: *HtmlElement,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *FieldSet) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *FieldSet) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/html/Font.zig b/src/browser/webapi/element/html/Font.zig
index 0b198518c..9517d93a6 100644
--- a/src/browser/webapi/element/html/Font.zig
+++ b/src/browser/webapi/element/html/Font.zig
@@ -1,3 +1,5 @@
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
@@ -8,10 +10,11 @@ const Font = @This();
pub const Proto = HtmlElement;
-_proto: *HtmlElement,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *Font) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Font) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/html/Form.zig b/src/browser/webapi/element/html/Form.zig
index 65c0872a0..ee970e536 100644
--- a/src/browser/webapi/element/html/Form.zig
+++ b/src/browser/webapi/element/html/Form.zig
@@ -16,8 +16,10 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const lp = @import("lightpanda");
const std = @import("std");
const js = @import("../../../js/js.zig");
+const Factory = @import("../../../Factory.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
@@ -33,7 +35,7 @@ pub const TextArea = @import("TextArea.zig");
const Form = @This();
pub const Proto = HtmlElement;
-_proto: *HtmlElement,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
// Prevents submission of the form while we're in the process of submitting
// the form. You can imagine an onsubmit = () => form.submit() endless loop.
@@ -44,13 +46,13 @@ _firing_submission_events: bool = false,
_constructing_entry_list: bool = false,
pub fn asHtmlElement(self: *Form) *HtmlElement {
- return self._proto;
+ return Factory.protoOf(self);
}
fn asConstElement(self: *const Form) *const Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asElement(self: *Form) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Form) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/html/FrameSet.zig b/src/browser/webapi/element/html/FrameSet.zig
index f218da810..404a34e96 100644
--- a/src/browser/webapi/element/html/FrameSet.zig
+++ b/src/browser/webapi/element/html/FrameSet.zig
@@ -1,6 +1,7 @@
const lp = @import("lightpanda");
const js = @import("../../../js/js.zig");
+const Factory = @import("../../../Factory.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
@@ -13,10 +14,11 @@ const FrameSet = @This();
pub const Proto = HtmlElement;
-_proto: *HtmlElement,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *FrameSet) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *FrameSet) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/html/Generic.zig b/src/browser/webapi/element/html/Generic.zig
index a5b2ec26c..a4c9d3e6e 100644
--- a/src/browser/webapi/element/html/Generic.zig
+++ b/src/browser/webapi/element/html/Generic.zig
@@ -19,6 +19,7 @@
const lp = @import("lightpanda");
const js = @import("../../../js/js.zig");
+const Factory = @import("../../../Factory.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
@@ -31,10 +32,10 @@ const Generic = @This();
pub const Proto = HtmlElement;
_tag_name: String,
_tag: Element.Tag,
-_proto: *HtmlElement,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *Generic) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Generic) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/html/HR.zig b/src/browser/webapi/element/html/HR.zig
index ad98db6ed..fa3573c92 100644
--- a/src/browser/webapi/element/html/HR.zig
+++ b/src/browser/webapi/element/html/HR.zig
@@ -16,6 +16,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
@@ -24,10 +26,11 @@ const HtmlElement = @import("../Html.zig");
const HR = @This();
pub const Proto = HtmlElement;
-_proto: *HtmlElement,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *HR) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *HR) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/html/Head.zig b/src/browser/webapi/element/html/Head.zig
index c69e843b5..bb17950c3 100644
--- a/src/browser/webapi/element/html/Head.zig
+++ b/src/browser/webapi/element/html/Head.zig
@@ -16,6 +16,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
@@ -24,10 +26,11 @@ const HtmlElement = @import("../Html.zig");
const Head = @This();
pub const Proto = HtmlElement;
-_proto: *HtmlElement,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *Head) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Head) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/html/Heading.zig b/src/browser/webapi/element/html/Heading.zig
index 5711b2ccf..dac7d5378 100644
--- a/src/browser/webapi/element/html/Heading.zig
+++ b/src/browser/webapi/element/html/Heading.zig
@@ -19,6 +19,7 @@
const lp = @import("lightpanda");
const js = @import("../../../js/js.zig");
+const Factory = @import("../../../Factory.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
@@ -29,12 +30,12 @@ const String = lp.String;
const Heading = @This();
pub const Proto = HtmlElement;
-_proto: *HtmlElement,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
_tag_name: String,
_tag: Element.Tag,
pub fn asElement(self: *Heading) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Heading) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/html/Html.zig b/src/browser/webapi/element/html/Html.zig
index 0441baab2..f8de33c0b 100644
--- a/src/browser/webapi/element/html/Html.zig
+++ b/src/browser/webapi/element/html/Html.zig
@@ -16,6 +16,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
@@ -24,10 +26,11 @@ const HtmlElement = @import("../Html.zig");
const Html = @This();
pub const Proto = HtmlElement;
-_proto: *HtmlElement,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *Html) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Html) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/html/IFrame.zig b/src/browser/webapi/element/html/IFrame.zig
index 21d0503b4..4dc882b4d 100644
--- a/src/browser/webapi/element/html/IFrame.zig
+++ b/src/browser/webapi/element/html/IFrame.zig
@@ -16,9 +16,11 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const lp = @import("lightpanda");
const std = @import("std");
const js = @import("../../../js/js.zig");
+const Factory = @import("../../../Factory.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
@@ -29,16 +31,17 @@ const DOMTokenList = @import("../../collections.zig").DOMTokenList;
const HtmlElement = @import("../Html.zig");
+const String = lp.String;
const IFrame = @This();
pub const Proto = HtmlElement;
-_proto: *HtmlElement,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
_src: []const u8 = "",
_executed: bool = false,
_window: ?*Window = null,
pub fn asElement(self: *IFrame) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *IFrame) *Node {
return self.asElement().asNode();
@@ -77,6 +80,19 @@ pub fn setSrc(self: *IFrame, src: []const u8, frame: *Frame) !void {
}
}
+pub fn hasSrcdoc(self: *IFrame) bool {
+ return self.asElement().getAttributeSafe(comptime .wrap("srcdoc")) != null;
+}
+
+pub fn getSrcdoc(self: *IFrame) []const u8 {
+ return self.asElement().getAttributeSafe(comptime .wrap("srcdoc")) orelse "";
+}
+
+pub fn setSrcdoc(self: *IFrame, value: []const u8, frame: *Frame) !void {
+ // Build.attributeChange triggers the (re)navigation.
+ try self.asElement().setAttributeSafe(comptime .wrap("srcdoc"), .wrap(value), frame);
+}
+
pub fn getName(self: *IFrame) []const u8 {
return self.asElement().getAttributeSafe(comptime .wrap("name")) orelse "";
}
@@ -103,6 +119,7 @@ pub const JsApi = struct {
};
pub const src = bridge.accessor(IFrame.getSrc, IFrame.setSrc, .{ .ce_reactions = true });
+ pub const srcdoc = bridge.accessor(IFrame.getSrcdoc, IFrame.setSrcdoc, .{ .ce_reactions = true });
pub const name = bridge.accessor(IFrame.getName, IFrame.setName, .{ .ce_reactions = true });
pub const contentWindow = bridge.accessor(IFrame.getContentWindow, null, .{});
pub const contentDocument = bridge.accessor(IFrame.getContentDocument, null, .{});
@@ -115,4 +132,28 @@ pub const Build = struct {
const element = self.asElement();
self._src = element.getAttributeSafe(comptime .wrap("src")) orelse "";
}
+
+ pub fn attributeChange(element: *Element, name: String, _: String, frame: *Frame) !void {
+ if (!name.eql(comptime .wrap("srcdoc"))) {
+ return;
+ }
+ if (element.asNode().isConnected()) {
+ // like src, setting srcdoc reloads the frame even if the value didn't change
+ const self = element.as(IFrame);
+ self._executed = false;
+ try frame.iframeAddedCallback(self);
+ }
+ }
+
+ pub fn attributeRemove(element: *Element, name: String, frame: *Frame) !void {
+ if (!name.eql(comptime .wrap("srcdoc"))) {
+ return;
+ }
+ if (element.asNode().isConnected()) {
+ const self = element.as(IFrame);
+ // removing srcdoc falls back to src (or about:blank)
+ self._executed = false;
+ try frame.iframeAddedCallback(self);
+ }
+ }
};
diff --git a/src/browser/webapi/element/html/Image.zig b/src/browser/webapi/element/html/Image.zig
index e75e7f9df..62608385b 100644
--- a/src/browser/webapi/element/html/Image.zig
+++ b/src/browser/webapi/element/html/Image.zig
@@ -1,5 +1,7 @@
+const lp = @import("lightpanda");
const std = @import("std");
const js = @import("../../../js/js.zig");
+const Factory = @import("../../../Factory.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
@@ -8,7 +10,8 @@ const HtmlElement = @import("../Html.zig");
const Image = @This();
pub const Proto = HtmlElement;
-_proto: *HtmlElement,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn constructor(w_: ?u32, h_: ?u32, frame: *Frame) !*Image {
const node = try Frame.node_factory.createElementNS(frame, .html, "img", null);
@@ -26,10 +29,10 @@ pub fn constructor(w_: ?u32, h_: ?u32, frame: *Frame) !*Image {
}
pub fn asElement(self: *Image) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asConstElement(self: *const Image) *const Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Image) *Node {
return self.asElement().asNode();
@@ -130,7 +133,7 @@ pub fn imageAddedCallback(self: *Image, frame: *Frame) !void {
const src = element.getAttributeSafe(comptime .wrap("src")) orelse return;
if (src.len == 0) return;
- try frame.queueLoad(self._proto);
+ try frame.queueLoad(Factory.protoOf(self));
}
pub const JsApi = struct {
diff --git a/src/browser/webapi/element/html/Input.zig b/src/browser/webapi/element/html/Input.zig
index 0eb523419..c823b3843 100644
--- a/src/browser/webapi/element/html/Input.zig
+++ b/src/browser/webapi/element/html/Input.zig
@@ -20,6 +20,7 @@ const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../../../js/js.zig");
+const Factory = @import("../../../Factory.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
@@ -80,7 +81,7 @@ pub const Type = enum {
}
};
-_proto: *HtmlElement,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
_default_value: ?[]const u8 = null,
_default_checked: bool = false,
_value: ?[]const u8 = null,
@@ -122,10 +123,10 @@ fn dispatchInputEvent(self: *Input, data: ?[]const u8, input_type: []const u8, f
}
pub fn asElement(self: *Input) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asConstElement(self: *const Input) *const Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Input) *Node {
return self.asElement().asNode();
@@ -891,6 +892,20 @@ pub fn getLabels(self: *Input, frame: *Frame) !js.Array {
return @import("Label.zig").getControlLabels(self.asElement(), frame);
}
+pub fn getList(self: *Input, frame: *Frame) ?*HtmlElement.DataList {
+ switch (self._input_type) {
+ .hidden, .password, .checkbox, .radio, .file, .submit, .image, .reset, .button => return null,
+ else => {},
+ }
+
+ const element = self.asElement();
+ const list_id = element.getAttributeSafe(comptime .wrap("list")) orelse return null;
+
+ // list= resolves in the input's own tree (shadow root or document).
+ const target = frame.getElementByIdFromNode(element.asNode(), list_id) orelse return null;
+ return target.is(HtmlElement.DataList);
+}
+
pub fn getForm(self: *Input, frame: *Frame) ?*Form {
const element = self.asElement();
@@ -1426,6 +1441,7 @@ pub const JsApi = struct {
pub const size = bridge.accessor(Input.getSize, Input.setSize, .{ .ce_reactions = true });
pub const src = bridge.accessor(Input.getSrc, Input.setSrc, .{ .ce_reactions = true });
pub const form = bridge.accessor(Input.getForm, null, .{});
+ pub const list = bridge.accessor(Input.getList, null, .{});
pub const formAction = bridge.accessor(Input.getFormAction, Input.setFormAction, .{});
pub const formEnctype = bridge.accessor(Input.getFormEnctype, Input.setFormEnctype, .{});
pub const formMethod = bridge.accessor(Input.getFormMethod, Input.setFormMethod, .{});
diff --git a/src/browser/webapi/element/html/LI.zig b/src/browser/webapi/element/html/LI.zig
index 9caa1907f..a209ff33f 100644
--- a/src/browser/webapi/element/html/LI.zig
+++ b/src/browser/webapi/element/html/LI.zig
@@ -16,8 +16,10 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const lp = @import("lightpanda");
const std = @import("std");
const js = @import("../../../js/js.zig");
+const Factory = @import("../../../Factory.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
@@ -26,10 +28,11 @@ const HtmlElement = @import("../Html.zig");
const LI = @This();
pub const Proto = HtmlElement;
-_proto: *HtmlElement,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *LI) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *LI) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/html/Label.zig b/src/browser/webapi/element/html/Label.zig
index 7616c0e2c..2285e229c 100644
--- a/src/browser/webapi/element/html/Label.zig
+++ b/src/browser/webapi/element/html/Label.zig
@@ -1,5 +1,7 @@
+const lp = @import("lightpanda");
const std = @import("std");
const js = @import("../../../js/js.zig");
+const Factory = @import("../../../Factory.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
@@ -10,10 +12,11 @@ const Label = @This();
pub const Proto = HtmlElement;
-_proto: *HtmlElement,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *Label) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Label) *Node {
return self.asElement().asNode();
@@ -49,7 +52,7 @@ fn isLabelable(el: *Element) bool {
const html = el.is(HtmlElement) orelse return false;
return switch (html._type) {
.button, .meter, .output, .progress, .select, .textarea => true,
- .input => |input| input._input_type != .hidden,
+ .input => html.subtype(HtmlElement.Input)._input_type != .hidden,
else => false,
};
}
diff --git a/src/browser/webapi/element/html/Legend.zig b/src/browser/webapi/element/html/Legend.zig
index 27d8a1fa4..0c1661298 100644
--- a/src/browser/webapi/element/html/Legend.zig
+++ b/src/browser/webapi/element/html/Legend.zig
@@ -1,3 +1,5 @@
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
@@ -7,10 +9,11 @@ const Legend = @This();
pub const Proto = HtmlElement;
-_proto: *HtmlElement,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *Legend) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Legend) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/html/Link.zig b/src/browser/webapi/element/html/Link.zig
index af33fcf11..8c29f36f5 100644
--- a/src/browser/webapi/element/html/Link.zig
+++ b/src/browser/webapi/element/html/Link.zig
@@ -16,8 +16,10 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const lp = @import("lightpanda");
const std = @import("std");
const js = @import("../../../js/js.zig");
+const Factory = @import("../../../Factory.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
@@ -29,7 +31,7 @@ const HtmlElement = @import("../Html.zig");
const Link = @This();
pub const Proto = HtmlElement;
-_proto: *HtmlElement,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
// Cached CSSStyleSheet for an external `rel=stylesheet` once
// `Frame.loadExternalStylesheet` has registered it. Re-fetches (href
// mutated on a connected link) reuse this sheet via `replaceSync` so the
@@ -38,10 +40,10 @@ _proto: *HtmlElement,
_sheet: ?*@import("../../css/CSSStyleSheet.zig") = null,
pub fn asElement(self: *Link) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asConstElement(self: *const Link) *const Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Link) *Node {
return self.asElement().asNode();
@@ -232,26 +234,26 @@ pub fn linkAddedCallback(self: *Link, frame: *Frame) !void {
if (std.mem.eql(u8, rel, "preload")) {
const as = element.getAttributeSafe(comptime .wrap("as")) orelse "";
if (std.ascii.eqlIgnoreCase(as, "script")) {
- if (Frame.preload.scriptHint(frame, self._proto, href)) {
+ if (Frame.preload.scriptHint(frame, Factory.protoOf(self), href)) {
// load/error fires when the fetch settles
return;
}
}
// synthetic load, fires next tick
- return frame.queueLoad(self._proto);
+ return frame.queueLoad(Factory.protoOf(self));
}
if (std.mem.eql(u8, rel, "modulepreload")) {
// "as" defaults to script in this case
const as = element.getAttributeSafe(comptime .wrap("as")) orelse "";
if (as.len == 0 or std.ascii.eqlIgnoreCase(as, "script")) {
- if (Frame.preload.moduleHint(frame, self._proto, href)) {
+ if (Frame.preload.moduleHint(frame, Factory.protoOf(self), href)) {
// load/error fires when the fetch settles
return;
}
}
// synthetic load, fires next tick
- return frame.queueLoad(self._proto);
+ return frame.queueLoad(Factory.protoOf(self));
}
}
diff --git a/src/browser/webapi/element/html/Map.zig b/src/browser/webapi/element/html/Map.zig
index ee95dbe99..8864f892c 100644
--- a/src/browser/webapi/element/html/Map.zig
+++ b/src/browser/webapi/element/html/Map.zig
@@ -1,3 +1,5 @@
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
@@ -7,10 +9,11 @@ const Map = @This();
pub const Proto = HtmlElement;
-_proto: *HtmlElement,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *Map) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Map) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/html/Marquee.zig b/src/browser/webapi/element/html/Marquee.zig
index c9601176d..804ee34fa 100644
--- a/src/browser/webapi/element/html/Marquee.zig
+++ b/src/browser/webapi/element/html/Marquee.zig
@@ -1,6 +1,8 @@
+const lp = @import("lightpanda");
const std = @import("std");
const js = @import("../../../js/js.zig");
+const Factory = @import("../../../Factory.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
@@ -10,10 +12,11 @@ const Marquee = @This();
pub const Proto = HtmlElement;
-_proto: *HtmlElement,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *Marquee) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Marquee) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/html/Media.zig b/src/browser/webapi/element/html/Media.zig
index 8e3af790c..823dcee38 100644
--- a/src/browser/webapi/element/html/Media.zig
+++ b/src/browser/webapi/element/html/Media.zig
@@ -49,12 +49,34 @@ pub const NetworkState = enum(u16) {
NETWORK_NO_SOURCE = 3,
};
-pub const Type = union(enum) {
+pub const Type = enum(u8) {
generic,
- audio: *Audio,
- video: *Video,
+ audio,
+ video,
};
+// `.generic` maps to Media itself: a bare chain ends at Media, so the
+// tag has no chain member of its own.
+pub fn Subtype(comptime tag: Type) type {
+ return switch (tag) {
+ .generic => Media,
+ .audio => Audio,
+ .video => Video,
+ };
+}
+
+pub fn subtype(self: *const Media, comptime T: type) *T {
+ const offset = comptime Factory.chainOffsetOf(T, T) - Factory.chainOffsetOf(T, Media);
+ const sub: *T = @ptrFromInt(@intFromPtr(self) + offset);
+ if (comptime lp.IS_DEBUG) {
+ // This pointer dance only works because the factory allocates the chain
+ // in a contiguous block of memory. In debug, we assert this holds via
+ // the _proto_canary back pointer.
+ std.debug.assert(Factory.protoOf(sub) == self);
+ }
+ return sub;
+}
+
_type: Type,
_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
_paused: bool = true,
@@ -77,18 +99,10 @@ pub fn asNode(self: *Media) *Node {
}
pub fn is(self: *Media, comptime T: type) ?*T {
- const type_name = @typeName(T);
switch (self._type) {
- .audio => |a| {
- if (T == *Audio) return a;
- if (comptime std.mem.startsWith(u8, type_name, "browser.webapi.element.html.Audio")) {
- return a;
- }
- },
- .video => |v| {
- if (T == *Video) return v;
- if (comptime std.mem.startsWith(u8, type_name, "browser.webapi.element.html.Video")) {
- return v;
+ inline .audio, .video => |tag| {
+ if (Subtype(tag) == T) {
+ return self.subtype(T);
}
},
.generic => {},
diff --git a/src/browser/webapi/element/html/Meta.zig b/src/browser/webapi/element/html/Meta.zig
index ea5baee1a..5ff50eff4 100644
--- a/src/browser/webapi/element/html/Meta.zig
+++ b/src/browser/webapi/element/html/Meta.zig
@@ -16,11 +16,16 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const lp = @import("lightpanda");
+const std = @import("std");
+
const js = @import("../../../js/js.zig");
+const Factory = @import("../../../Factory.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
const HtmlElement = @import("../Html.zig");
+const referrer = @import("../../../referrer.zig");
const Meta = @This();
@@ -29,10 +34,11 @@ pub const Proto = HtmlElement;
// Create a different alias we can use when in such ambiguous cases.
const MetaElement = Meta;
-_proto: *HtmlElement,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *Meta) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Meta) *Node {
return self.asElement().asNode();
@@ -78,6 +84,21 @@ pub fn setScheme(self: *Meta, value: []const u8, frame: *Frame) !void {
try self.asElement().setAttributeSafe(comptime .wrap("scheme"), .wrap(value), frame);
}
+pub const Build = struct {
+ // sets the document's referrer policy.
+ pub fn created(node: *Node, frame: *Frame) !void {
+ const el = node.as(Element);
+ const name = el.getAttributeSafe(comptime .wrap("name")) orelse return;
+ if (std.ascii.eqlIgnoreCase(name, "referrer") == false) {
+ return;
+ }
+ const content = el.getAttributeSafe(comptime .wrap("content")) orelse return;
+ if (referrer.parseMeta(content)) |rp| {
+ frame.referrer_policy = rp;
+ }
+ }
+};
+
pub const JsApi = struct {
pub const bridge = js.Bridge(MetaElement);
diff --git a/src/browser/webapi/element/html/Meter.zig b/src/browser/webapi/element/html/Meter.zig
index 922a60502..cae055854 100644
--- a/src/browser/webapi/element/html/Meter.zig
+++ b/src/browser/webapi/element/html/Meter.zig
@@ -1,3 +1,5 @@
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
@@ -8,10 +10,11 @@ const Meter = @This();
pub const Proto = HtmlElement;
-_proto: *HtmlElement,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *Meter) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Meter) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/html/Mod.zig b/src/browser/webapi/element/html/Mod.zig
index ac6c02223..ef51d64c6 100644
--- a/src/browser/webapi/element/html/Mod.zig
+++ b/src/browser/webapi/element/html/Mod.zig
@@ -1,6 +1,7 @@
const lp = @import("lightpanda");
const js = @import("../../../js/js.zig");
+const Factory = @import("../../../Factory.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
const HtmlElement = @import("../Html.zig");
@@ -13,10 +14,10 @@ pub const Proto = HtmlElement;
_tag_name: String,
_tag: Element.Tag,
-_proto: *HtmlElement,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *Mod) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Mod) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/html/OL.zig b/src/browser/webapi/element/html/OL.zig
index d2c452157..aa44e3c42 100644
--- a/src/browser/webapi/element/html/OL.zig
+++ b/src/browser/webapi/element/html/OL.zig
@@ -16,8 +16,10 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const lp = @import("lightpanda");
const std = @import("std");
const js = @import("../../../js/js.zig");
+const Factory = @import("../../../Factory.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
@@ -26,10 +28,11 @@ const HtmlElement = @import("../Html.zig");
const OL = @This();
pub const Proto = HtmlElement;
-_proto: *HtmlElement,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *OL) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *OL) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/html/Object.zig b/src/browser/webapi/element/html/Object.zig
index b4f1a1a7e..8ca2703c6 100644
--- a/src/browser/webapi/element/html/Object.zig
+++ b/src/browser/webapi/element/html/Object.zig
@@ -1,3 +1,5 @@
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
@@ -7,10 +9,11 @@ const Object = @This();
pub const Proto = HtmlElement;
-_proto: *HtmlElement,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *Object) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Object) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/html/OptGroup.zig b/src/browser/webapi/element/html/OptGroup.zig
index 82e7e6684..23d788ac5 100644
--- a/src/browser/webapi/element/html/OptGroup.zig
+++ b/src/browser/webapi/element/html/OptGroup.zig
@@ -1,3 +1,5 @@
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
@@ -8,10 +10,11 @@ const OptGroup = @This();
pub const Proto = HtmlElement;
-_proto: *HtmlElement,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *OptGroup) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *OptGroup) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/html/Option.zig b/src/browser/webapi/element/html/Option.zig
index 46d5c4ecb..7ec0d244a 100644
--- a/src/browser/webapi/element/html/Option.zig
+++ b/src/browser/webapi/element/html/Option.zig
@@ -20,6 +20,7 @@ const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../../../js/js.zig");
+const Factory = @import("../../../Factory.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
@@ -32,17 +33,17 @@ const Option = @This();
pub const Proto = HtmlElement;
-_proto: *HtmlElement,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
_value: ?[]const u8 = null,
_selected: bool = false,
_default_selected: bool = false,
_disabled: bool = false,
pub fn asElement(self: *Option) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asConstElement(self: *const Option) *const Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Option) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/html/Output.zig b/src/browser/webapi/element/html/Output.zig
index 0fb343f41..11b2aa1e0 100644
--- a/src/browser/webapi/element/html/Output.zig
+++ b/src/browser/webapi/element/html/Output.zig
@@ -16,6 +16,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Frame = @import("../../../Frame.zig");
@@ -29,10 +31,11 @@ const Output = @This();
pub const Proto = HtmlElement;
-_proto: *HtmlElement,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *Output) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Output) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/html/Paragraph.zig b/src/browser/webapi/element/html/Paragraph.zig
index 0714e1897..abf6faf65 100644
--- a/src/browser/webapi/element/html/Paragraph.zig
+++ b/src/browser/webapi/element/html/Paragraph.zig
@@ -16,6 +16,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
@@ -24,10 +26,11 @@ const HtmlElement = @import("../Html.zig");
const Paragraph = @This();
pub const Proto = HtmlElement;
-_proto: *HtmlElement,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *Paragraph) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Paragraph) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/html/Param.zig b/src/browser/webapi/element/html/Param.zig
index 7eaa04155..8c1164091 100644
--- a/src/browser/webapi/element/html/Param.zig
+++ b/src/browser/webapi/element/html/Param.zig
@@ -1,3 +1,5 @@
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
@@ -8,10 +10,11 @@ const Param = @This();
pub const Proto = HtmlElement;
-_proto: *HtmlElement,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *Param) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Param) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/html/Picture.zig b/src/browser/webapi/element/html/Picture.zig
index 5e50bbb61..dc69571e4 100644
--- a/src/browser/webapi/element/html/Picture.zig
+++ b/src/browser/webapi/element/html/Picture.zig
@@ -1,3 +1,5 @@
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
@@ -7,10 +9,11 @@ const Picture = @This();
pub const Proto = HtmlElement;
-_proto: *HtmlElement,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *Picture) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Picture) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/html/Pre.zig b/src/browser/webapi/element/html/Pre.zig
index 3ef89d15c..eaf797a6d 100644
--- a/src/browser/webapi/element/html/Pre.zig
+++ b/src/browser/webapi/element/html/Pre.zig
@@ -1,3 +1,5 @@
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
@@ -7,10 +9,11 @@ const Pre = @This();
pub const Proto = HtmlElement;
-_proto: *HtmlElement,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *Pre) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Pre) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/html/Progress.zig b/src/browser/webapi/element/html/Progress.zig
index d8a5f9acc..e0869d28f 100644
--- a/src/browser/webapi/element/html/Progress.zig
+++ b/src/browser/webapi/element/html/Progress.zig
@@ -1,3 +1,5 @@
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
@@ -8,10 +10,11 @@ const Progress = @This();
pub const Proto = HtmlElement;
-_proto: *HtmlElement,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *Progress) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Progress) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/html/Quote.zig b/src/browser/webapi/element/html/Quote.zig
index f132096d1..ee9b4ca68 100644
--- a/src/browser/webapi/element/html/Quote.zig
+++ b/src/browser/webapi/element/html/Quote.zig
@@ -1,6 +1,7 @@
const lp = @import("lightpanda");
const js = @import("../../../js/js.zig");
+const Factory = @import("../../../Factory.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
@@ -14,10 +15,10 @@ pub const Proto = HtmlElement;
_tag_name: String,
_tag: Element.Tag,
-_proto: *HtmlElement,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *Quote) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Quote) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/html/Script.zig b/src/browser/webapi/element/html/Script.zig
index c30c09399..57db5064a 100644
--- a/src/browser/webapi/element/html/Script.zig
+++ b/src/browser/webapi/element/html/Script.zig
@@ -19,6 +19,7 @@ const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../../../js/js.zig");
+const Factory = @import("../../../Factory.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
@@ -30,18 +31,18 @@ const Script = @This();
pub const Proto = HtmlElement;
-_proto: *HtmlElement,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
_src: []const u8 = "",
_executed: bool = false,
// dynamic scripts are forced to be async by default
_force_async: bool = true,
pub fn asElement(self: *Script) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asConstElement(self: *const Script) *const Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Script) *Node {
diff --git a/src/browser/webapi/element/html/Select.zig b/src/browser/webapi/element/html/Select.zig
index 7345ae39c..48bbb7298 100644
--- a/src/browser/webapi/element/html/Select.zig
+++ b/src/browser/webapi/element/html/Select.zig
@@ -16,6 +16,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Frame = @import("../../../Frame.zig");
@@ -32,16 +34,16 @@ const Select = @This();
pub const Proto = HtmlElement;
-_proto: *HtmlElement,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
_selected_index_set: bool = false,
_custom_validity: ?[]const u8 = null,
_validity: ?*ValidityState = null,
pub fn asElement(self: *Select) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asConstElement(self: *const Select) *const Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Select) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/html/Slot.zig b/src/browser/webapi/element/html/Slot.zig
index 169ed5ccd..0bb61a579 100644
--- a/src/browser/webapi/element/html/Slot.zig
+++ b/src/browser/webapi/element/html/Slot.zig
@@ -1,17 +1,18 @@
+const lp = @import("lightpanda");
const std = @import("std");
const js = @import("../../../js/js.zig");
+const Factory = @import("../../../Factory.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
const HtmlElement = @import("../Html.zig");
-const ShadowRoot = @import("../../ShadowRoot.zig");
const slotting = @import("../slotting.zig");
const Slot = @This();
pub const Proto = HtmlElement;
-_proto: *HtmlElement,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
// DOM spec "assigned nodes". Maintained by slotting.assignSlottables; always
// empty while the slot isn't in a shadow tree.
_assigned: std.ArrayList(*Node) = .empty,
@@ -20,11 +21,11 @@ _assigned: std.ArrayList(*Node) = .empty,
_manually_assigned: std.ArrayList(*Node) = .empty,
pub fn asElement(self: *Slot) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asConstElement(self: *const Slot) *const Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Slot) *Node {
@@ -74,7 +75,7 @@ fn CollectionType(comptime elements: bool) type {
// DOM spec "find flattened slottables"
fn collectFlattened(self: *Slot, comptime elements: bool, coll: CollectionType(elements), frame: *Frame) error{OutOfMemory}!void {
- if (self.asNode().getRootNode(.{}).is(ShadowRoot) == null) {
+ if (self.asNode().containingShadowRoot() == null) {
return;
}
@@ -99,7 +100,7 @@ fn appendFlattened(comptime elements: bool, coll: CollectionType(elements), node
if (node.is(Slot)) |nested| {
// a slottable (or fallback child) that is itself a slot in a shadow
// tree flattens to its own flattened slottables
- if (nested.asNode().getRootNode(.{}).is(ShadowRoot) != null) {
+ if (nested.asNode().containingShadowRoot() != null) {
return nested.collectFlattened(elements, coll, frame);
}
}
@@ -151,9 +152,8 @@ pub fn assign(self: *Slot, values: []const js.Value, frame: *Frame) !void {
try self._manually_assigned.append(frame.arena, node);
}
- const root = self.asNode().getRootNode(.{});
- if (root.is(ShadowRoot) != null) {
- slotting.assignSlottablesForTree(root, frame);
+ if (self.asNode().containingShadowRoot()) |shadow_root| {
+ slotting.assignSlottablesForTree(shadow_root.asNode(), frame);
}
}
diff --git a/src/browser/webapi/element/html/Source.zig b/src/browser/webapi/element/html/Source.zig
index 5e45a100f..1db9d63ad 100644
--- a/src/browser/webapi/element/html/Source.zig
+++ b/src/browser/webapi/element/html/Source.zig
@@ -1,5 +1,7 @@
+const lp = @import("lightpanda");
const std = @import("std");
const js = @import("../../../js/js.zig");
+const Factory = @import("../../../Factory.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
@@ -9,13 +11,14 @@ const Source = @This();
pub const Proto = HtmlElement;
-_proto: *HtmlElement,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *Source) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asConstElement(self: *const Source) *const Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Source) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/html/Span.zig b/src/browser/webapi/element/html/Span.zig
index 9afee3d26..125252cd0 100644
--- a/src/browser/webapi/element/html/Span.zig
+++ b/src/browser/webapi/element/html/Span.zig
@@ -1,3 +1,5 @@
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
@@ -7,10 +9,11 @@ const Span = @This();
pub const Proto = HtmlElement;
-_proto: *HtmlElement,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *Span) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Span) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/html/Style.zig b/src/browser/webapi/element/html/Style.zig
index 1fc7ef552..23276545d 100644
--- a/src/browser/webapi/element/html/Style.zig
+++ b/src/browser/webapi/element/html/Style.zig
@@ -16,8 +16,10 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const lp = @import("lightpanda");
const std = @import("std");
const js = @import("../../../js/js.zig");
+const Factory = @import("../../../Factory.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
@@ -27,14 +29,14 @@ const HtmlElement = @import("../Html.zig");
const Style = @This();
pub const Proto = HtmlElement;
-_proto: *HtmlElement,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
_sheet: ?*CSSStyleSheet = null,
pub fn asElement(self: *Style) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asConstElement(self: *const Style) *const Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Style) *Node {
return self.asElement().asNode();
@@ -116,7 +118,7 @@ pub fn styleAddedCallback(self: *Style, frame: *Frame) !void {
return;
}
- try frame.queueLoad(self._proto);
+ try frame.queueLoad(Factory.protoOf(self));
}
pub const JsApi = struct {
diff --git a/src/browser/webapi/element/html/Table.zig b/src/browser/webapi/element/html/Table.zig
index 110910023..c7b56891d 100644
--- a/src/browser/webapi/element/html/Table.zig
+++ b/src/browser/webapi/element/html/Table.zig
@@ -1,3 +1,5 @@
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Node = @import("../../Node.zig");
const Frame = @import("../../../Frame.zig");
@@ -9,10 +11,11 @@ const Table = @This();
pub const Proto = HtmlElement;
-_proto: *HtmlElement,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *Table) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Table) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/html/TableCaption.zig b/src/browser/webapi/element/html/TableCaption.zig
index 0c8da33d5..fedc32cb0 100644
--- a/src/browser/webapi/element/html/TableCaption.zig
+++ b/src/browser/webapi/element/html/TableCaption.zig
@@ -1,3 +1,5 @@
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
@@ -7,10 +9,11 @@ const TableCaption = @This();
pub const Proto = HtmlElement;
-_proto: *HtmlElement,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *TableCaption) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *TableCaption) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/html/TableCell.zig b/src/browser/webapi/element/html/TableCell.zig
index 73635f8dc..7abd67205 100644
--- a/src/browser/webapi/element/html/TableCell.zig
+++ b/src/browser/webapi/element/html/TableCell.zig
@@ -2,6 +2,7 @@ const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../../../js/js.zig");
+const Factory = @import("../../../Factory.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
@@ -15,10 +16,10 @@ pub const Proto = HtmlElement;
_tag_name: String,
_tag: Element.Tag,
-_proto: *HtmlElement,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *TableCell) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *TableCell) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/html/TableCol.zig b/src/browser/webapi/element/html/TableCol.zig
index 196a26ae8..55558a97e 100644
--- a/src/browser/webapi/element/html/TableCol.zig
+++ b/src/browser/webapi/element/html/TableCol.zig
@@ -1,6 +1,7 @@
const lp = @import("lightpanda");
const js = @import("../../../js/js.zig");
+const Factory = @import("../../../Factory.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
const HtmlElement = @import("../Html.zig");
@@ -13,10 +14,10 @@ pub const Proto = HtmlElement;
_tag_name: String,
_tag: Element.Tag,
-_proto: *HtmlElement,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *TableCol) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *TableCol) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/html/TableRow.zig b/src/browser/webapi/element/html/TableRow.zig
index ff9eec942..6191ed084 100644
--- a/src/browser/webapi/element/html/TableRow.zig
+++ b/src/browser/webapi/element/html/TableRow.zig
@@ -1,3 +1,5 @@
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Node = @import("../../Node.zig");
const Frame = @import("../../../Frame.zig");
@@ -9,10 +11,11 @@ const TableRow = @This();
pub const Proto = HtmlElement;
-_proto: *HtmlElement,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *TableRow) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *TableRow) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/html/TableSection.zig b/src/browser/webapi/element/html/TableSection.zig
index e396de34d..e996ceec6 100644
--- a/src/browser/webapi/element/html/TableSection.zig
+++ b/src/browser/webapi/element/html/TableSection.zig
@@ -1,6 +1,7 @@
const lp = @import("lightpanda");
const js = @import("../../../js/js.zig");
+const Factory = @import("../../../Factory.zig");
const Node = @import("../../Node.zig");
const Frame = @import("../../../Frame.zig");
const Element = @import("../../Element.zig");
@@ -15,10 +16,10 @@ pub const Proto = HtmlElement;
_tag_name: String,
_tag: Element.Tag,
-_proto: *HtmlElement,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *TableSection) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *TableSection) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/html/Template.zig b/src/browser/webapi/element/html/Template.zig
index 68af063b0..d84550fb9 100644
--- a/src/browser/webapi/element/html/Template.zig
+++ b/src/browser/webapi/element/html/Template.zig
@@ -20,6 +20,7 @@ const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../../../js/js.zig");
+const Factory = @import("../../../Factory.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
@@ -34,15 +35,15 @@ const Template = @This();
pub const Proto = HtmlElement;
-_proto: *HtmlElement,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
_content: *DocumentFragment,
pub fn asElement(self: *Template) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asConstElement(self: *const Template) *const Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Template) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/html/TextArea.zig b/src/browser/webapi/element/html/TextArea.zig
index 3f19c0d3a..5f488b9e8 100644
--- a/src/browser/webapi/element/html/TextArea.zig
+++ b/src/browser/webapi/element/html/TextArea.zig
@@ -16,8 +16,10 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const lp = @import("lightpanda");
const std = @import("std");
const js = @import("../../../js/js.zig");
+const Factory = @import("../../../Factory.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
@@ -33,7 +35,7 @@ const TextArea = @This();
pub const Proto = HtmlElement;
-_proto: *HtmlElement,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
_value: ?[]const u8 = null,
_selection_start: u32 = 0,
@@ -67,10 +69,10 @@ fn dispatchInputEvent(self: *TextArea, data: ?[]const u8, input_type: []const u8
}
pub fn asElement(self: *TextArea) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asConstElement(self: *const TextArea) *const Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *TextArea) *Node {
return self.asElement().asNode();
@@ -102,7 +104,7 @@ pub fn setDefaultValue(self: *TextArea, value: []const u8, frame: *Frame) !void
const node = self.asNode();
if (node.firstChild()) |child| {
if (child.is(Node.CData.Text)) |txt| {
- txt._proto._data = try frame.dupeSSO(value);
+ txt.asCData()._data = try frame.dupeSSO(value);
return;
}
}
diff --git a/src/browser/webapi/element/html/Time.zig b/src/browser/webapi/element/html/Time.zig
index 9232f7489..3bfb88069 100644
--- a/src/browser/webapi/element/html/Time.zig
+++ b/src/browser/webapi/element/html/Time.zig
@@ -1,3 +1,5 @@
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
@@ -8,10 +10,11 @@ const Time = @This();
pub const Proto = HtmlElement;
-_proto: *HtmlElement,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *Time) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Time) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/html/Title.zig b/src/browser/webapi/element/html/Title.zig
index c81ae6031..bdc4f8622 100644
--- a/src/browser/webapi/element/html/Title.zig
+++ b/src/browser/webapi/element/html/Title.zig
@@ -1,3 +1,5 @@
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
@@ -7,10 +9,11 @@ const Title = @This();
pub const Proto = HtmlElement;
-_proto: *HtmlElement,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *Title) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Title) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/html/Track.zig b/src/browser/webapi/element/html/Track.zig
index 3e3e10514..4d29d3939 100644
--- a/src/browser/webapi/element/html/Track.zig
+++ b/src/browser/webapi/element/html/Track.zig
@@ -20,6 +20,7 @@ const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../../../js/js.zig");
+const Factory = @import("../../../Factory.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
@@ -31,14 +32,14 @@ const Track = @This();
pub const Proto = HtmlElement;
-_proto: *HtmlElement,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
_kind: String,
_ready_state: ReadyState,
const ReadyState = enum(u8) { none, loading, loaded, @"error" };
pub fn asElement(self: *Track) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Track) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/html/UL.zig b/src/browser/webapi/element/html/UL.zig
index 5d69779e2..372377d24 100644
--- a/src/browser/webapi/element/html/UL.zig
+++ b/src/browser/webapi/element/html/UL.zig
@@ -16,6 +16,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
@@ -24,10 +26,11 @@ const HtmlElement = @import("../Html.zig");
const UL = @This();
pub const Proto = HtmlElement;
-_proto: *HtmlElement,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *UL) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *UL) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/html/Unknown.zig b/src/browser/webapi/element/html/Unknown.zig
index 336adc6da..6561a5256 100644
--- a/src/browser/webapi/element/html/Unknown.zig
+++ b/src/browser/webapi/element/html/Unknown.zig
@@ -19,6 +19,7 @@
const lp = @import("lightpanda");
const js = @import("../../../js/js.zig");
+const Factory = @import("../../../Factory.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
@@ -29,11 +30,11 @@ const String = lp.String;
const Unknown = @This();
pub const Proto = HtmlElement;
-_proto: *HtmlElement,
+_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
_tag_name: String,
pub fn asElement(self: *Unknown) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Unknown) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/html/Video.zig b/src/browser/webapi/element/html/Video.zig
index 4daff3160..ccd3e1b4f 100644
--- a/src/browser/webapi/element/html/Video.zig
+++ b/src/browser/webapi/element/html/Video.zig
@@ -16,6 +16,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Frame = @import("../../../Frame.zig");
@@ -27,18 +29,19 @@ const Video = @This();
pub const Proto = Media;
-_proto: *Media,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *Media else void = undefined,
pub fn asMedia(self: *Video) *Media {
- return self._proto;
+ return Factory.protoOf(self);
}
pub fn asElement(self: *Video) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asConstElement(self: *const Video) *const Element {
- return self._proto.asConstElement();
+ return Factory.protoOf(self).asConstElement();
}
pub fn asNode(self: *Video) *Node {
diff --git a/src/browser/webapi/element/popover.zig b/src/browser/webapi/element/popover.zig
index 68c490bc4..4912473de 100644
--- a/src/browser/webapi/element/popover.zig
+++ b/src/browser/webapi/element/popover.zig
@@ -207,7 +207,7 @@ pub fn invokerTarget(invoker: *Node, explicit: ?*Element, frame: *Frame) ?*Eleme
pub fn runInvokerActivation(invoker: *HtmlElement, explicit: ?*Element, frame: *Frame) !void {
switch (invoker._type) {
.button => {},
- .input => |input| switch (input._input_type) {
+ .input => switch (invoker.subtype(HtmlElement.Input)._input_type) {
.button, .submit, .reset, .image => {},
else => return, // not an invoker
},
diff --git a/src/browser/webapi/element/slotting.zig b/src/browser/webapi/element/slotting.zig
index c62ff5505..1e26ca1d6 100644
--- a/src/browser/webapi/element/slotting.zig
+++ b/src/browser/webapi/element/slotting.zig
@@ -23,7 +23,6 @@ const Frame = @import("../../Frame.zig");
const Node = @import("../Node.zig");
const Element = @import("../Element.zig");
-const ShadowRoot = @import("../ShadowRoot.zig");
const TreeWalker = @import("../TreeWalker.zig");
const Text = @import("../cdata/Text.zig");
@@ -93,7 +92,7 @@ fn assignSlottables(slot: *Slot, frame: *Frame) void {
fn _assignSlottables(slot: *Slot, frame: *Frame) !void {
var slottables: std.ArrayList(*Node) = .empty;
- if (slot.asNode().getRootNode(.{}).is(ShadowRoot)) |shadow_root| {
+ if (slot.asNode().containingShadowRoot()) |shadow_root| {
const host = shadow_root.getHost();
if (shadow_root._slot_assignment == .manual) {
// manual assignment preserves the assign(...) order, not tree order
@@ -184,7 +183,7 @@ pub fn insertionSteps(parent: *Node, child: *Node, in_fragment_parse: bool, fram
// assignment they were parsed with.
if (in_fragment_parse == false) {
if (parent.is(Slot)) |parent_slot| {
- if (parent_slot._assigned.items.len == 0 and parent.getRootNode(.{}).is(ShadowRoot) != null) {
+ if (parent_slot._assigned.items.len == 0 and parent.containingShadowRoot() != null) {
frame.signalSlotChange(parent_slot);
}
}
@@ -192,9 +191,8 @@ pub fn insertionSteps(parent: *Node, child: *Node, in_fragment_parse: bool, fram
// A subtree containing slots was inserted into a shadow tree.
if (subtreeHasSlot(child)) {
- const root = child.getRootNode(.{});
- if (root.is(ShadowRoot) != null) {
- assignSlottablesForTree(root, frame);
+ if (child.containingShadowRoot()) |shadow_root| {
+ assignSlottablesForTree(shadow_root.asNode(), frame);
}
}
}
@@ -213,7 +211,7 @@ pub fn removalSteps(parent: *Node, child: *Node, frame: *Frame) void {
// Fallback content was removed from a slot that renders its fallback.
if (parent.is(Slot)) |parent_slot| {
- if (parent_slot._assigned.items.len == 0 and parent.getRootNode(.{}).is(ShadowRoot) != null) {
+ if (parent_slot._assigned.items.len == 0 and parent.containingShadowRoot() != null) {
frame.signalSlotChange(parent_slot);
}
}
@@ -221,9 +219,8 @@ pub fn removalSteps(parent: *Node, child: *Node, frame: *Frame) void {
// A subtree containing slots was removed: update assignments in the old
// tree, and clear assignments held by slots in the detached subtree.
if (subtreeHasSlot(child)) {
- const root = parent.getRootNode(.{});
- if (root.is(ShadowRoot) != null) {
- assignSlottablesForTree(root, frame);
+ if (parent.containingShadowRoot()) |shadow_root| {
+ assignSlottablesForTree(shadow_root.asNode(), frame);
}
assignSlottablesForTree(child, frame);
}
@@ -248,8 +245,7 @@ pub fn nameAttributeChanged(slot: *Slot, old_value: []const u8, value: []const u
if (std.mem.eql(u8, old_value, value)) {
return;
}
- const root = slot.asNode().getRootNode(.{});
- if (root.is(ShadowRoot) != null) {
- assignSlottablesForTree(root, frame);
+ if (slot.asNode().containingShadowRoot()) |shadow_root| {
+ assignSlottablesForTree(shadow_root.asNode(), frame);
}
}
diff --git a/src/browser/webapi/element/svg/A.zig b/src/browser/webapi/element/svg/A.zig
index 0ce9f09d6..cb7009bf4 100644
--- a/src/browser/webapi/element/svg/A.zig
+++ b/src/browser/webapi/element/svg/A.zig
@@ -16,6 +16,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Frame = @import("../../../Frame.zig");
@@ -29,10 +31,11 @@ const Graphics = @import("Graphics.zig");
const A = @This();
pub const Proto = Graphics;
-_proto: *Graphics,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *Graphics else void = undefined,
pub fn asElement(self: *A) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *A) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/svg/Circle.zig b/src/browser/webapi/element/svg/Circle.zig
index e14cf41cf..c3f73d1d2 100644
--- a/src/browser/webapi/element/svg/Circle.zig
+++ b/src/browser/webapi/element/svg/Circle.zig
@@ -16,6 +16,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Frame = @import("../../../Frame.zig");
@@ -28,10 +30,11 @@ const AnimatedLength = @import("../../svg/AnimatedLength.zig");
const Circle = @This();
pub const Proto = Geometry;
-_proto: *Geometry,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *Geometry else void = undefined,
pub fn asElement(self: *Circle) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Circle) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/svg/ClipPath.zig b/src/browser/webapi/element/svg/ClipPath.zig
index 152fb3895..76d302de9 100644
--- a/src/browser/webapi/element/svg/ClipPath.zig
+++ b/src/browser/webapi/element/svg/ClipPath.zig
@@ -16,6 +16,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Frame = @import("../../../Frame.zig");
@@ -30,10 +32,11 @@ const Svg = @import("../Svg.zig");
const ClipPath = @This();
pub const Proto = Svg;
-_proto: *Svg,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *Svg else void = undefined,
pub fn asElement(self: *ClipPath) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *ClipPath) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/svg/Defs.zig b/src/browser/webapi/element/svg/Defs.zig
index ec3a15e30..46081cf45 100644
--- a/src/browser/webapi/element/svg/Defs.zig
+++ b/src/browser/webapi/element/svg/Defs.zig
@@ -16,6 +16,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Node = @import("../../Node.zig");
@@ -26,10 +28,11 @@ const Graphics = @import("Graphics.zig");
const Defs = @This();
pub const Proto = Graphics;
-_proto: *Graphics,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *Graphics else void = undefined,
pub fn asElement(self: *Defs) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Defs) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/svg/Desc.zig b/src/browser/webapi/element/svg/Desc.zig
index 9c87fc62e..a8edcde9c 100644
--- a/src/browser/webapi/element/svg/Desc.zig
+++ b/src/browser/webapi/element/svg/Desc.zig
@@ -16,6 +16,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
@@ -24,10 +26,11 @@ const Svg = @import("../Svg.zig");
const Desc = @This();
pub const Proto = Svg;
-_proto: *Svg,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *Svg else void = undefined,
pub fn asElement(self: *Desc) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Desc) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/svg/Ellipse.zig b/src/browser/webapi/element/svg/Ellipse.zig
index 8f2266407..56adb4f0c 100644
--- a/src/browser/webapi/element/svg/Ellipse.zig
+++ b/src/browser/webapi/element/svg/Ellipse.zig
@@ -16,6 +16,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Frame = @import("../../../Frame.zig");
@@ -28,10 +30,11 @@ const AnimatedLength = @import("../../svg/AnimatedLength.zig");
const Ellipse = @This();
pub const Proto = Geometry;
-_proto: *Geometry,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *Geometry else void = undefined,
pub fn asElement(self: *Ellipse) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Ellipse) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/svg/ForeignObject.zig b/src/browser/webapi/element/svg/ForeignObject.zig
index 4004e0212..e0ab7784e 100644
--- a/src/browser/webapi/element/svg/ForeignObject.zig
+++ b/src/browser/webapi/element/svg/ForeignObject.zig
@@ -16,9 +16,11 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const lp = @import("lightpanda");
const std = @import("std");
const js = @import("../../../js/js.zig");
+const Factory = @import("../../../Factory.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
@@ -31,10 +33,11 @@ const Graphics = @import("Graphics.zig");
const ForeignObject = @This();
pub const Proto = Graphics;
-_proto: *Graphics,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *Graphics else void = undefined,
pub fn asElement(self: *ForeignObject) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *ForeignObject) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/svg/G.zig b/src/browser/webapi/element/svg/G.zig
index 88514c893..01c94b339 100644
--- a/src/browser/webapi/element/svg/G.zig
+++ b/src/browser/webapi/element/svg/G.zig
@@ -16,6 +16,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Node = @import("../../Node.zig");
@@ -26,10 +28,11 @@ const Graphics = @import("Graphics.zig");
const G = @This();
pub const Proto = Graphics;
-_proto: *Graphics,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *Graphics else void = undefined,
pub fn asElement(self: *G) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *G) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/svg/Generic.zig b/src/browser/webapi/element/svg/Generic.zig
index 5a7a91417..7d4660041 100644
--- a/src/browser/webapi/element/svg/Generic.zig
+++ b/src/browser/webapi/element/svg/Generic.zig
@@ -16,6 +16,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Node = @import("../../Node.zig");
@@ -26,11 +28,11 @@ const Svg = @import("../Svg.zig");
const Generic = @This();
pub const Proto = Svg;
-_proto: *Svg,
+_proto_canary: if (lp.IS_DEBUG) *Svg else void = undefined,
_tag: Element.Tag,
pub fn asElement(self: *Generic) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Generic) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/svg/Geometry.zig b/src/browser/webapi/element/svg/Geometry.zig
index 7df09b2ce..5f54155da 100644
--- a/src/browser/webapi/element/svg/Geometry.zig
+++ b/src/browser/webapi/element/svg/Geometry.zig
@@ -45,23 +45,47 @@ pub const Proto = Graphics;
_type: Type,
_proto_canary: if (lp.IS_DEBUG) *Graphics else void = undefined,
-pub const Type = union(enum) {
- rect: *Rect,
- circle: *Circle,
- ellipse: *Ellipse,
- line: *Line,
- path: *Path,
- polygon: *Polygon,
- polyline: *Polyline,
+pub const Type = enum(u8) {
+ rect,
+ circle,
+ ellipse,
+ line,
+ path,
+ polygon,
+ polyline,
};
+pub fn Subtype(comptime tag: Type) type {
+ return switch (tag) {
+ .rect => Rect,
+ .circle => Circle,
+ .ellipse => Ellipse,
+ .line => Line,
+ .path => Path,
+ .polygon => Polygon,
+ .polyline => Polyline,
+ };
+}
+
+pub fn subtype(self: *const Geometry, comptime T: type) *T {
+ const offset = comptime Factory.chainOffsetOf(T, T) - Factory.chainOffsetOf(T, Geometry);
+ const sub: *T = @ptrFromInt(@intFromPtr(self) + offset);
+ if (comptime lp.IS_DEBUG) {
+ // This pointer dance only works because the factory allocates the chain
+ // in a contiguous block of memory. In debug, we assert this holds via
+ // the _proto_canary back pointer.
+ std.debug.assert(Factory.protoOf(sub) == self);
+ }
+ return sub;
+}
+
pub fn is(self: *Geometry, comptime T: type) ?*T {
- inline for (@typeInfo(Type).@"union".fields) |f| {
- if (@field(Type, f.name) == self._type) {
- if (f.type == *T) {
- return @field(self._type, f.name);
+ switch (self._type) {
+ inline else => |tag| {
+ if (Subtype(tag) == T) {
+ return self.subtype(T);
}
- }
+ },
}
return null;
}
@@ -107,16 +131,16 @@ pub fn getPointAtLength(self: *Geometry, distance: f64, frame: *Frame) !*DOMPoin
pub fn buildPath(self: *Geometry, frame: *Frame) !PathData.Path {
return switch (self._type) {
- .rect => |rect| buildRect(rect, frame),
- .circle => |circle| buildCircle(circle, frame),
- .ellipse => |ellipse| buildEllipse(ellipse, frame),
- .line => |line| buildLine(line, frame),
- .path => |path| PathData.parse(
- path.asElement().getAttributeSafe(comptime .wrap("d")) orelse "",
+ .rect => buildRect(self.subtype(Rect), frame),
+ .circle => buildCircle(self.subtype(Circle), frame),
+ .ellipse => buildEllipse(self.subtype(Ellipse), frame),
+ .line => buildLine(self.subtype(Line), frame),
+ .path => PathData.parse(
+ self.subtype(Path).asElement().getAttributeSafe(comptime .wrap("d")) orelse "",
frame.local_arena,
),
- .polygon => |polygon| buildPoints(try polygon.getPoints(frame), true, frame),
- .polyline => |polyline| buildPoints(try polyline.getPoints(frame), false, frame),
+ .polygon => buildPoints(try self.subtype(Polygon).getPoints(frame), true, frame),
+ .polyline => buildPoints(try self.subtype(Polyline).getPoints(frame), false, frame),
};
}
diff --git a/src/browser/webapi/element/svg/GradientElement.zig b/src/browser/webapi/element/svg/GradientElement.zig
index c63d460df..26eabb90f 100644
--- a/src/browser/webapi/element/svg/GradientElement.zig
+++ b/src/browser/webapi/element/svg/GradientElement.zig
@@ -16,6 +16,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../../../js/js.zig");
const Frame = @import("../../../Frame.zig");
@@ -39,18 +40,37 @@ pub const Proto = Svg;
_type: Type,
_proto_canary: if (lp.IS_DEBUG) *Svg else void = undefined,
-pub const Type = union(enum) {
- linear: *LinearGradient,
- radial: *RadialGradient,
+pub const Type = enum(u8) {
+ linear,
+ radial,
};
+pub fn Subtype(comptime tag: Type) type {
+ return switch (tag) {
+ .linear => LinearGradient,
+ .radial => RadialGradient,
+ };
+}
+
+pub fn subtype(self: *const GradientElement, comptime T: type) *T {
+ const offset = comptime Factory.chainOffsetOf(T, T) - Factory.chainOffsetOf(T, GradientElement);
+ const sub: *T = @ptrFromInt(@intFromPtr(self) + offset);
+ if (comptime lp.IS_DEBUG) {
+ // This pointer dance only works because the factory allocates the chain
+ // in a contiguous block of memory. In debug, we assert this holds via
+ // the _proto_canary back pointer.
+ std.debug.assert(Factory.protoOf(sub) == self);
+ }
+ return sub;
+}
+
pub fn is(self: *GradientElement, comptime T: type) ?*T {
- inline for (@typeInfo(Type).@"union".fields) |field| {
- if (@field(Type, field.name) == self._type) {
- if (field.type == *T) {
- return @field(self._type, field.name);
+ switch (self._type) {
+ inline else => |tag| {
+ if (Subtype(tag) == T) {
+ return self.subtype(T);
}
- }
+ },
}
return null;
}
diff --git a/src/browser/webapi/element/svg/Graphics.zig b/src/browser/webapi/element/svg/Graphics.zig
index 8668ba3b1..e4b4f9d05 100644
--- a/src/browser/webapi/element/svg/Graphics.zig
+++ b/src/browser/webapi/element/svg/Graphics.zig
@@ -49,33 +49,61 @@ pub const Proto = SvgElement;
_type: Type,
_proto_canary: if (lp.IS_DEBUG) *SvgElement else void = undefined,
-pub const Type = union(enum) {
- svg: *Svg,
- g: *G,
- a: *A,
- use: *Use,
- image: *Image,
- defs: *Defs,
- symbol: *Symbol,
- switch_element: *Switch,
- foreign_object: *ForeignObject,
- text_content: *TextContent,
- geometry: *Geometry,
+pub const Type = enum(u8) {
+ svg,
+ g,
+ a,
+ use,
+ image,
+ defs,
+ symbol,
+ switch_element,
+ foreign_object,
+ text_content,
+ geometry,
};
+pub fn Subtype(comptime tag: Type) type {
+ return switch (tag) {
+ .svg => Svg,
+ .g => G,
+ .a => A,
+ .use => Use,
+ .image => Image,
+ .defs => Defs,
+ .symbol => Symbol,
+ .switch_element => Switch,
+ .foreign_object => ForeignObject,
+ .text_content => TextContent,
+ .geometry => Geometry,
+ };
+}
+
+pub fn subtype(self: *const Graphics, comptime T: type) *T {
+ const offset = comptime Factory.chainOffsetOf(T, T) - Factory.chainOffsetOf(T, Graphics);
+ const sub: *T = @ptrFromInt(@intFromPtr(self) + offset);
+ if (comptime lp.IS_DEBUG) {
+ // This pointer dance only works because the factory allocates the chain
+ // in a contiguous block of memory. In debug, we assert this holds via
+ // the _proto_canary back pointer.
+ std.debug.assert(Factory.protoOf(sub) == self);
+ }
+ return sub;
+}
+
pub fn is(self: *Graphics, comptime T: type) ?*T {
- inline for (@typeInfo(Type).@"union".fields) |f| {
- if (@field(Type, f.name) == self._type) {
- if (f.type == *T) {
- return @field(self._type, f.name);
+ switch (self._type) {
+ inline else => |tag| {
+ if (Subtype(tag) == T) {
+ return self.subtype(T);
}
- }
+ },
}
if (self._type == .geometry) {
- return self._type.geometry.is(T);
+ return self.subtype(Geometry).is(T);
}
if (self._type == .text_content) {
- return self._type.text_content.is(T);
+ return self.subtype(TextContent).is(T);
}
return null;
}
@@ -108,12 +136,12 @@ pub const JsApi = struct {
pub fn getBBox(self: *Graphics, frame: *Frame) !*DOMRect {
var bounds: PathData.Bounds = .{};
switch (self._type) {
- .geometry => |geometry| {
- var path = try geometry.buildPath(frame);
+ .geometry => {
+ var path = try self.subtype(Geometry).buildPath(frame);
defer path.deinit(frame.local_arena);
bounds = path.bounds(.{});
},
- .foreign_object => |foreign_object| bounds = try foreign_object.getBounds(frame),
+ .foreign_object => bounds = try self.subtype(ForeignObject).getBounds(frame),
.g, .a, .svg => try accumulateChildren(self, .{}, &bounds, frame),
.defs, .symbol, .switch_element, .use, .image, .text_content => {},
}
@@ -156,13 +184,13 @@ fn accumulateChildren(parent: *Graphics, matrix: PathData.Matrix, bounds: *PathD
const child_matrix = parent_matrix.multiply(transformMatrix(element));
switch (graphics._type) {
- .geometry => |geometry| {
- var path = try geometry.buildPath(frame);
+ .geometry => {
+ var path = try graphics.subtype(Geometry).buildPath(frame);
defer path.deinit(frame.local_arena);
bounds.merge(path.bounds(child_matrix));
},
- .foreign_object => |foreign_object| {
- const child_bounds = try foreign_object.getBounds(frame);
+ .foreign_object => {
+ const child_bounds = try graphics.subtype(ForeignObject).getBounds(frame);
if (!child_bounds.isEmpty()) {
var foreign_path: PathData.Path = .{};
defer foreign_path.deinit(frame.local_arena);
diff --git a/src/browser/webapi/element/svg/Image.zig b/src/browser/webapi/element/svg/Image.zig
index a01218444..6e0d058bc 100644
--- a/src/browser/webapi/element/svg/Image.zig
+++ b/src/browser/webapi/element/svg/Image.zig
@@ -16,6 +16,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Frame = @import("../../../Frame.zig");
@@ -28,10 +30,11 @@ const Graphics = @import("Graphics.zig");
const Image = @This();
pub const Proto = Graphics;
-_proto: *Graphics,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *Graphics else void = undefined,
pub fn asElement(self: *Image) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Image) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/svg/Line.zig b/src/browser/webapi/element/svg/Line.zig
index d47e72b3c..323e1b3e4 100644
--- a/src/browser/webapi/element/svg/Line.zig
+++ b/src/browser/webapi/element/svg/Line.zig
@@ -16,6 +16,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Frame = @import("../../../Frame.zig");
@@ -28,10 +30,11 @@ const AnimatedLength = @import("../../svg/AnimatedLength.zig");
const Line = @This();
pub const Proto = Geometry;
-_proto: *Geometry,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *Geometry else void = undefined,
pub fn asElement(self: *Line) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Line) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/svg/LinearGradient.zig b/src/browser/webapi/element/svg/LinearGradient.zig
index 7a92e803e..a29c4d510 100644
--- a/src/browser/webapi/element/svg/LinearGradient.zig
+++ b/src/browser/webapi/element/svg/LinearGradient.zig
@@ -16,6 +16,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Frame = @import("../../../Frame.zig");
@@ -29,10 +31,11 @@ const GradientElement = @import("GradientElement.zig");
const LinearGradient = @This();
pub const Proto = GradientElement;
-_proto: *GradientElement,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *GradientElement else void = undefined,
pub fn asElement(self: *LinearGradient) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *LinearGradient) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/svg/Marker.zig b/src/browser/webapi/element/svg/Marker.zig
index 73209cd7d..3d1200ff4 100644
--- a/src/browser/webapi/element/svg/Marker.zig
+++ b/src/browser/webapi/element/svg/Marker.zig
@@ -16,6 +16,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Frame = @import("../../../Frame.zig");
@@ -30,10 +32,11 @@ const Svg = @import("../Svg.zig");
const Marker = @This();
pub const Proto = Svg;
-_proto: *Svg,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *Svg else void = undefined,
pub fn asElement(self: *Marker) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Marker) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/svg/Mask.zig b/src/browser/webapi/element/svg/Mask.zig
index 92dde0a64..26378cde5 100644
--- a/src/browser/webapi/element/svg/Mask.zig
+++ b/src/browser/webapi/element/svg/Mask.zig
@@ -16,6 +16,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Frame = @import("../../../Frame.zig");
@@ -30,10 +32,11 @@ const Svg = @import("../Svg.zig");
const Mask = @This();
pub const Proto = Svg;
-_proto: *Svg,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *Svg else void = undefined,
pub fn asElement(self: *Mask) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Mask) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/svg/Metadata.zig b/src/browser/webapi/element/svg/Metadata.zig
index 434e0ec1f..636b8edbc 100644
--- a/src/browser/webapi/element/svg/Metadata.zig
+++ b/src/browser/webapi/element/svg/Metadata.zig
@@ -16,6 +16,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Node = @import("../../Node.zig");
@@ -26,10 +28,11 @@ const Svg = @import("../Svg.zig");
const Metadata = @This();
pub const Proto = Svg;
-_proto: *Svg,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *Svg else void = undefined,
pub fn asElement(self: *Metadata) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Metadata) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/svg/Path.zig b/src/browser/webapi/element/svg/Path.zig
index 15d4d79c0..ccdb78e42 100644
--- a/src/browser/webapi/element/svg/Path.zig
+++ b/src/browser/webapi/element/svg/Path.zig
@@ -16,6 +16,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Node = @import("../../Node.zig");
@@ -26,10 +28,11 @@ const Geometry = @import("Geometry.zig");
const Path = @This();
pub const Proto = Geometry;
-_proto: *Geometry,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *Geometry else void = undefined,
pub fn asElement(self: *Path) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Path) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/svg/Pattern.zig b/src/browser/webapi/element/svg/Pattern.zig
index 639812707..d30aa0066 100644
--- a/src/browser/webapi/element/svg/Pattern.zig
+++ b/src/browser/webapi/element/svg/Pattern.zig
@@ -16,6 +16,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Frame = @import("../../../Frame.zig");
@@ -32,10 +34,11 @@ const Svg = @import("../Svg.zig");
const Pattern = @This();
pub const Proto = Svg;
-_proto: *Svg,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *Svg else void = undefined,
pub fn asElement(self: *Pattern) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Pattern) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/svg/Polygon.zig b/src/browser/webapi/element/svg/Polygon.zig
index dac7e8741..cf7074bb2 100644
--- a/src/browser/webapi/element/svg/Polygon.zig
+++ b/src/browser/webapi/element/svg/Polygon.zig
@@ -16,6 +16,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Frame = @import("../../../Frame.zig");
@@ -28,10 +30,11 @@ const PointList = @import("../../svg/PointList.zig");
const Polygon = @This();
pub const Proto = Geometry;
-_proto: *Geometry,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *Geometry else void = undefined,
pub fn asElement(self: *Polygon) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Polygon) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/svg/Polyline.zig b/src/browser/webapi/element/svg/Polyline.zig
index a4e267af8..02e0d0346 100644
--- a/src/browser/webapi/element/svg/Polyline.zig
+++ b/src/browser/webapi/element/svg/Polyline.zig
@@ -16,6 +16,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Frame = @import("../../../Frame.zig");
@@ -28,10 +30,11 @@ const PointList = @import("../../svg/PointList.zig");
const Polyline = @This();
pub const Proto = Geometry;
-_proto: *Geometry,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *Geometry else void = undefined,
pub fn asElement(self: *Polyline) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Polyline) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/svg/RadialGradient.zig b/src/browser/webapi/element/svg/RadialGradient.zig
index 041ad36fc..981dd4c9c 100644
--- a/src/browser/webapi/element/svg/RadialGradient.zig
+++ b/src/browser/webapi/element/svg/RadialGradient.zig
@@ -16,6 +16,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Frame = @import("../../../Frame.zig");
@@ -29,10 +31,11 @@ const GradientElement = @import("GradientElement.zig");
const RadialGradient = @This();
pub const Proto = GradientElement;
-_proto: *GradientElement,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *GradientElement else void = undefined,
pub fn asElement(self: *RadialGradient) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *RadialGradient) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/svg/Rect.zig b/src/browser/webapi/element/svg/Rect.zig
index 99d0a1e8c..2cde221c1 100644
--- a/src/browser/webapi/element/svg/Rect.zig
+++ b/src/browser/webapi/element/svg/Rect.zig
@@ -16,6 +16,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
@@ -26,10 +28,11 @@ const AnimatedLength = @import("../../svg/AnimatedLength.zig");
const Rect = @This();
pub const Proto = Geometry;
-_proto: *Geometry,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *Geometry else void = undefined,
pub fn asElement(self: *Rect) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Rect) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/svg/Stop.zig b/src/browser/webapi/element/svg/Stop.zig
index 845c9f863..dfc88c7ed 100644
--- a/src/browser/webapi/element/svg/Stop.zig
+++ b/src/browser/webapi/element/svg/Stop.zig
@@ -16,6 +16,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Frame = @import("../../../Frame.zig");
@@ -29,10 +31,11 @@ const Svg = @import("../Svg.zig");
const Stop = @This();
pub const Proto = Svg;
-_proto: *Svg,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *Svg else void = undefined,
pub fn asElement(self: *Stop) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Stop) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/svg/Svg.zig b/src/browser/webapi/element/svg/Svg.zig
index b0d9d88ed..4e2f4f467 100644
--- a/src/browser/webapi/element/svg/Svg.zig
+++ b/src/browser/webapi/element/svg/Svg.zig
@@ -16,9 +16,11 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const lp = @import("lightpanda");
const std = @import("std");
const js = @import("../../../js/js.zig");
+const Factory = @import("../../../Factory.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
@@ -39,10 +41,11 @@ const Graphics = @import("Graphics.zig");
const Svg = @This();
pub const Proto = Graphics;
-_proto: *Graphics,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *Graphics else void = undefined,
pub fn asElement(self: *Svg) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Svg) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/svg/Switch.zig b/src/browser/webapi/element/svg/Switch.zig
index 2c528b32d..8d7b5e77a 100644
--- a/src/browser/webapi/element/svg/Switch.zig
+++ b/src/browser/webapi/element/svg/Switch.zig
@@ -16,6 +16,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Node = @import("../../Node.zig");
@@ -26,10 +28,11 @@ const Graphics = @import("Graphics.zig");
const Switch = @This();
pub const Proto = Graphics;
-_proto: *Graphics,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *Graphics else void = undefined,
pub fn asElement(self: *Switch) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Switch) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/svg/Symbol.zig b/src/browser/webapi/element/svg/Symbol.zig
index c0793c9c4..4718c74b3 100644
--- a/src/browser/webapi/element/svg/Symbol.zig
+++ b/src/browser/webapi/element/svg/Symbol.zig
@@ -16,6 +16,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Node = @import("../../Node.zig");
@@ -26,10 +28,11 @@ const Graphics = @import("Graphics.zig");
const Symbol = @This();
pub const Proto = Graphics;
-_proto: *Graphics,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *Graphics else void = undefined,
pub fn asElement(self: *Symbol) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Symbol) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/svg/TSpan.zig b/src/browser/webapi/element/svg/TSpan.zig
index a2021df02..c7175bf6b 100644
--- a/src/browser/webapi/element/svg/TSpan.zig
+++ b/src/browser/webapi/element/svg/TSpan.zig
@@ -16,6 +16,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Node = @import("../../Node.zig");
@@ -26,10 +28,11 @@ const TextPositioning = @import("TextPositioning.zig");
const TSpan = @This();
pub const Proto = TextPositioning;
-_proto: *TextPositioning,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *TextPositioning else void = undefined,
pub fn asElement(self: *TSpan) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *TSpan) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/svg/Text.zig b/src/browser/webapi/element/svg/Text.zig
index bc4ed94b2..a4bb67e90 100644
--- a/src/browser/webapi/element/svg/Text.zig
+++ b/src/browser/webapi/element/svg/Text.zig
@@ -16,6 +16,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Node = @import("../../Node.zig");
@@ -26,10 +28,11 @@ const TextPositioning = @import("TextPositioning.zig");
const Text = @This();
pub const Proto = TextPositioning;
-_proto: *TextPositioning,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *TextPositioning else void = undefined,
pub fn asElement(self: *Text) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Text) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/svg/TextContent.zig b/src/browser/webapi/element/svg/TextContent.zig
index 665453aca..4f8683fda 100644
--- a/src/browser/webapi/element/svg/TextContent.zig
+++ b/src/browser/webapi/element/svg/TextContent.zig
@@ -16,6 +16,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../../../js/js.zig");
const Frame = @import("../../../Frame.zig");
@@ -39,18 +40,39 @@ pub const Proto = Graphics;
_type: Type,
_proto_canary: if (lp.IS_DEBUG) *Graphics else void = undefined,
-pub const Type = union(enum) {
- positioning: *TextPositioning,
- text_path: *TextPath,
+pub const Type = enum(u8) {
+ positioning,
+ text_path,
};
-pub fn is(self: *TextContent, comptime T: type) ?*T {
- inline for (@typeInfo(Type).@"union".fields) |field| {
- if (@field(Type, field.name) == self._type) {
- if (field.type == *T) return @field(self._type, field.name);
- }
+pub fn Subtype(comptime tag: Type) type {
+ return switch (tag) {
+ .positioning => TextPositioning,
+ .text_path => TextPath,
+ };
+}
+
+pub fn subtype(self: *const TextContent, comptime T: type) *T {
+ const offset = comptime Factory.chainOffsetOf(T, T) - Factory.chainOffsetOf(T, TextContent);
+ const sub: *T = @ptrFromInt(@intFromPtr(self) + offset);
+ if (comptime lp.IS_DEBUG) {
+ // This pointer dance only works because the factory allocates the chain
+ // in a contiguous block of memory. In debug, we assert this holds via
+ // the _proto_canary back pointer.
+ std.debug.assert(Factory.protoOf(sub) == self);
}
- if (self._type == .positioning) return self._type.positioning.is(T);
+ return sub;
+}
+
+pub fn is(self: *TextContent, comptime T: type) ?*T {
+ switch (self._type) {
+ inline else => |tag| {
+ if (Subtype(tag) == T) {
+ return self.subtype(T);
+ }
+ },
+ }
+ if (self._type == .positioning) return self.subtype(TextPositioning).is(T);
return null;
}
diff --git a/src/browser/webapi/element/svg/TextPath.zig b/src/browser/webapi/element/svg/TextPath.zig
index 627bb4605..dd5213292 100644
--- a/src/browser/webapi/element/svg/TextPath.zig
+++ b/src/browser/webapi/element/svg/TextPath.zig
@@ -16,6 +16,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Frame = @import("../../../Frame.zig");
@@ -31,10 +33,11 @@ const TextContent = @import("TextContent.zig");
const TextPath = @This();
pub const Proto = TextContent;
-_proto: *TextContent,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *TextContent else void = undefined,
pub fn asElement(self: *TextPath) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *TextPath) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/svg/TextPositioning.zig b/src/browser/webapi/element/svg/TextPositioning.zig
index 91052aed8..61567a4ea 100644
--- a/src/browser/webapi/element/svg/TextPositioning.zig
+++ b/src/browser/webapi/element/svg/TextPositioning.zig
@@ -16,6 +16,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../../../js/js.zig");
@@ -34,16 +35,37 @@ pub const Proto = TextContent;
_type: Type,
_proto_canary: if (lp.IS_DEBUG) *TextContent else void = undefined,
-pub const Type = union(enum) {
- text: *Text,
- tspan: *TSpan,
+pub const Type = enum(u8) {
+ text,
+ tspan,
};
+pub fn Subtype(comptime tag: Type) type {
+ return switch (tag) {
+ .text => Text,
+ .tspan => TSpan,
+ };
+}
+
+pub fn subtype(self: *const TextPositioning, comptime T: type) *T {
+ const offset = comptime Factory.chainOffsetOf(T, T) - Factory.chainOffsetOf(T, TextPositioning);
+ const sub: *T = @ptrFromInt(@intFromPtr(self) + offset);
+ if (comptime lp.IS_DEBUG) {
+ // This pointer dance only works because the factory allocates the chain
+ // in a contiguous block of memory. In debug, we assert this holds via
+ // the _proto_canary back pointer.
+ std.debug.assert(Factory.protoOf(sub) == self);
+ }
+ return sub;
+}
+
pub fn is(self: *TextPositioning, comptime T: type) ?*T {
- inline for (@typeInfo(Type).@"union".fields) |field| {
- if (@field(Type, field.name) == self._type) {
- if (field.type == *T) return @field(self._type, field.name);
- }
+ switch (self._type) {
+ inline else => |tag| {
+ if (Subtype(tag) == T) {
+ return self.subtype(T);
+ }
+ },
}
return null;
}
diff --git a/src/browser/webapi/element/svg/Title.zig b/src/browser/webapi/element/svg/Title.zig
index 65660827e..b12707012 100644
--- a/src/browser/webapi/element/svg/Title.zig
+++ b/src/browser/webapi/element/svg/Title.zig
@@ -16,6 +16,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Node = @import("../../Node.zig");
@@ -26,10 +28,11 @@ const Svg = @import("../Svg.zig");
const Title = @This();
pub const Proto = Svg;
-_proto: *Svg,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *Svg else void = undefined,
pub fn asElement(self: *Title) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Title) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/svg/Use.zig b/src/browser/webapi/element/svg/Use.zig
index 8aa3b253f..300cfd3c3 100644
--- a/src/browser/webapi/element/svg/Use.zig
+++ b/src/browser/webapi/element/svg/Use.zig
@@ -16,6 +16,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Frame = @import("../../../Frame.zig");
@@ -28,10 +30,11 @@ const Graphics = @import("Graphics.zig");
const Use = @This();
pub const Proto = Graphics;
-_proto: *Graphics,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *Graphics else void = undefined,
pub fn asElement(self: *Use) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Use) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/element/svg/View.zig b/src/browser/webapi/element/svg/View.zig
index 5554fced4..69a849828 100644
--- a/src/browser/webapi/element/svg/View.zig
+++ b/src/browser/webapi/element/svg/View.zig
@@ -16,6 +16,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const lp = @import("lightpanda");
+const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Node = @import("../../Node.zig");
@@ -26,10 +28,11 @@ const Svg = @import("../Svg.zig");
const View = @This();
pub const Proto = Svg;
-_proto: *Svg,
+_pad: bool = false,
+_proto_canary: if (lp.IS_DEBUG) *Svg else void = undefined,
pub fn asElement(self: *View) *Element {
- return self._proto.asElement();
+ return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *View) *Node {
return self.asElement().asNode();
diff --git a/src/browser/webapi/net/Fetch.zig b/src/browser/webapi/net/Fetch.zig
index 0832c1aa8..0e58aef76 100644
--- a/src/browser/webapi/net/Fetch.zig
+++ b/src/browser/webapi/net/Fetch.zig
@@ -152,7 +152,7 @@ fn httpHeaderDoneCallback(transfer: *Transfer) !Transfer.HeaderResult {
const arena = self._response._arena;
if (transfer.getContentLength()) |cl| {
- try self._buf.ensureTotalCapacity(arena.allocator(), cl);
+ try self._buf.ensureTotalCapacityPrecise(arena.allocator(), cl);
}
const res = self._response;
diff --git a/src/browser/webapi/net/Headers.zig b/src/browser/webapi/net/Headers.zig
index 7bc7c8600..7d3915d3a 100644
--- a/src/browser/webapi/net/Headers.zig
+++ b/src/browser/webapi/net/Headers.zig
@@ -91,7 +91,7 @@ pub fn forEach(self: *Headers, cb_: js.Function, js_this_: ?js.Object) !void {
const cb = if (js_this_) |js_this| try cb_.withThis(js_this) else cb_;
for (self._list._entries.items) |entry| {
- var caught: js.TryCatch.Caught = undefined;
+ var caught: js.TryCatch.Caught = .{};
cb.tryCall(void, .{ entry.value.str(), entry.name.str(), self }, &caught) catch {
log.debug(.js, "forEach callback", .{ .caught = caught, .source = "headers" });
};
diff --git a/src/browser/webapi/net/WebSocket.zig b/src/browser/webapi/net/WebSocket.zig
index f119bc386..aa594ad6d 100644
--- a/src/browser/webapi/net/WebSocket.zig
+++ b/src/browser/webapi/net/WebSocket.zig
@@ -901,7 +901,7 @@ fn _receivedDataCallback(conn: *http.Connection, data: []const u8) !void {
if (meta.len > self._http_client.max_response_size) {
return error.MessageTooLarge;
}
- try self._recv_buffer.ensureTotalCapacity(self._arena.allocator(), meta.len);
+ try self._recv_buffer.ensureTotalCapacityPrecise(self._arena.allocator(), meta.len);
}
try self._recv_buffer.appendSlice(self._arena.allocator(), data);
diff --git a/src/browser/webapi/net/XMLHttpRequest.zig b/src/browser/webapi/net/XMLHttpRequest.zig
index 75275b429..b55e925d9 100644
--- a/src/browser/webapi/net/XMLHttpRequest.zig
+++ b/src/browser/webapi/net/XMLHttpRequest.zig
@@ -541,7 +541,7 @@ fn httpHeaderDoneCallback(transfer: *Transfer) !Transfer.HeaderResult {
self._response_status = transfer.responseStatus().?;
if (transfer.getContentLength()) |cl| {
self._response_len = cl;
- try self._response_data.ensureTotalCapacity(self._arena.allocator(), cl);
+ try self._response_data.ensureTotalCapacityPrecise(self._arena.allocator(), cl);
}
self._response_url = try self._arena.dupeZ(u8, transfer.req.url);
diff --git a/src/browser/webapi/svg/reflected_list.zig b/src/browser/webapi/svg/reflected_list.zig
index 9138ccdc4..d375d6c74 100644
--- a/src/browser/webapi/svg/reflected_list.zig
+++ b/src/browser/webapi/svg/reflected_list.zig
@@ -175,7 +175,7 @@ pub fn Mixin(comptime List: type, comptime Item: type, comptime hooks: anytype)
self._snapshot.clearRetainingCapacity();
try self._snapshot.appendSlice(frame.arena, raw);
try retireAll(self, frame);
- try self._items.ensureTotalCapacity(frame.arena, parsed.items.len);
+ try self._items.ensureTotalCapacityPrecise(frame.arena, parsed.items.len);
for (parsed.items) |item| {
self._items.appendAssumeCapacity(item);
hooks.attach(self, item);
diff --git a/src/cdp/CDP.zig b/src/cdp/CDP.zig
index 2ed72c51b..9399a7821 100644
--- a/src/cdp/CDP.zig
+++ b/src/cdp/CDP.zig
@@ -193,7 +193,7 @@ pub fn terminateFromNetwork(self: *CDP) void {
// valid for the duration of dispatch.
pub fn onMessage(self: *CDP, c: *Inbox.Message.Cdp) anyerror!void {
// Once a terminate is pending, don't dispatch
- if (self.browser.env.isExecutionTerminating()) {
+ if (self.browser.env.terminatePending()) {
return;
}
@@ -440,15 +440,28 @@ fn dispatchCommand(command: *Command, method: []const u8) !void {
return error.UnknownDomain;
}
-fn isValidSessionId(self: *const CDP, input_session_id: []const u8) bool {
+pub fn resolveSessionId(self: *const CDP, input_session_id: []const u8) ?[]const u8 {
if (self.browser_session_id) |browser_session_id| {
if (std.mem.eql(u8, browser_session_id, input_session_id)) {
- return true;
+ return browser_session_id;
}
}
- const browser_context = &(self.browser_context orelse return false);
- const session_id = browser_context.session_id orelse return false;
- return std.mem.eql(u8, session_id, input_session_id);
+ const browser_context = &(self.browser_context orelse return null);
+ if (browser_context.session_id) |session_id| {
+ if (std.mem.eql(u8, session_id, input_session_id)) {
+ return session_id;
+ }
+ }
+ for (browser_context.attached_sessions.items) |session| {
+ if (std.mem.eql(u8, session.id, input_session_id)) {
+ return session.id;
+ }
+ }
+ return null;
+}
+
+fn isValidSessionId(self: *const CDP, input_session_id: []const u8) bool {
+ return self.resolveSessionId(input_session_id) != null;
}
pub fn createBrowserContext(self: *CDP) ![]const u8 {
@@ -496,18 +509,22 @@ pub const BrowserContext = struct {
data: std.ArrayList(u8),
};
- // Key for `captured_responses`. Documents are keyed by `loader_id`,
- // everything else by `request_id` — the two id-spaces are independent
- // counters and overlap numerically (loader 1 / request 1, loader 2 /
- // request 2, ...), so the map key has to carry the namespace or
- // entries collide. The wire-format prefix (`LID-` / `REQ-`) provides
- // the same disambiguation on lookup; see `idFromRequestId` in
- // domains/network.zig.
- pub const CapturedResponseKey = struct {
+ // Key for `captured_responses` / `captured_requests`. Documents are
+ // keyed by `loader_id`, everything else by `request_id` — the two
+ // id-spaces are independent counters and overlap numerically (loader 1
+ // / request 1, loader 2 / request 2, ...), so the map key has to carry
+ // the namespace or entries collide. The wire-format prefix (`LID-` /
+ // `REQ-`) provides the same disambiguation on lookup; see
+ // `idFromRequestId` in domains/network.zig.
+ pub const CapturedKey = struct {
kind: enum { request, loader },
id: u32,
};
+ pub const AttachedSession = struct {
+ id: []const u8,
+ parent_id: ?[]const u8,
+ };
id: []const u8,
cdp: *CDP,
@@ -550,6 +567,7 @@ pub const BrowserContext = struct {
// if we get a request with a sessionId that doesn't match the current one
// we should reject it.
session_id: ?[]const u8,
+ attached_sessions: std.ArrayList(AttachedSession) = .empty,
security_origin: []const u8,
page_life_cycle_events: bool,
@@ -572,6 +590,11 @@ pub const BrowserContext = struct {
extra_headers: std.ArrayList(http.Header) = .empty,
intercept_state: InterceptState,
+ fetch_session_id: ?[]const u8 = null,
+
+ // Request bodies retained for Network.getRequestPostData, which can be
+ // called after the transfer is gone. Capped at max_post_data_size.
+ captured_requests: std.AutoHashMapUnmanaged(CapturedKey, []const u8),
// When network is enabled, we'll capture the transfer.id -> body
// This is awfully memory intensive, but our underlying http client and
@@ -580,7 +603,7 @@ pub const BrowserContext = struct {
// ever streamed. So if CDP is the only thing that needs bodies in
// memory for an arbitrary amount of time, then that's where we're going
// to store the,
- captured_responses: std.AutoHashMapUnmanaged(CapturedResponseKey, CapturedResponse),
+ captured_responses: std.AutoHashMapUnmanaged(CapturedKey, CapturedResponse),
notification: *Notification,
@@ -632,6 +655,7 @@ pub const BrowserContext = struct {
.arena = cdp.browser_context_arena.allocator(),
.notification_arena = cdp.notification_arena.allocator(),
.intercept_state = try InterceptState.init(allocator),
+ .captured_requests = .empty,
.captured_responses = .empty,
.notification = notification,
};
@@ -714,6 +738,18 @@ pub const BrowserContext = struct {
}
pub fn createIsolatedWorld(self: *BrowserContext, world_name: []const u8, grant_universal_access: bool) !*IsolatedWorld {
+ // The name is the world's identity (matching Chrome). Clients re-issue
+ // this call after every navigation; appending a duplicate each time
+ // would grow the per-page context count without bound.
+ for (self.isolated_worlds.items) |world| {
+ if (std.mem.eql(u8, world.name, world_name)) {
+ if (world.grant_universal_access != grant_universal_access) {
+ log.warn(.cdp, "isolated world mismatch", .{ .name = world_name, .gua = grant_universal_access });
+ }
+ return world;
+ }
+ }
+
const browser = &self.cdp.browser;
const arena = try browser.arena_pool.acquire(.small, "IsolatedWorld");
errdefer arena.release();
@@ -820,17 +856,26 @@ pub const BrowserContext = struct {
self.notification.unregister(.http_request_served_from_cache, self);
}
- pub fn fetchEnable(self: *BrowserContext, authRequests: bool) !void {
+ pub fn fetchEnable(self: *BrowserContext, authRequests: bool, session_id: []const u8) !void {
self.fetchDisable(); //in case of multiple calls
+ self.fetch_session_id = session_id;
try self.notification.register(.http_request_intercept, self, onHttpRequestIntercept);
if (authRequests) {
try self.notification.register(.http_request_auth_required, self, onHttpRequestAuthRequired);
}
}
+ pub fn fetchDisableForSession(self: *BrowserContext, session_id: []const u8) void {
+ const active_session_id = self.fetch_session_id orelse return;
+ if (std.mem.eql(u8, active_session_id, session_id)) {
+ self.fetchDisable();
+ }
+ }
+
pub fn fetchDisable(self: *BrowserContext) void {
self.notification.unregister(.http_request_intercept, self);
self.notification.unregister(.http_request_auth_required, self);
+ self.fetch_session_id = null;
}
pub fn lifecycleEventsEnable(self: *BrowserContext) !void {
@@ -951,12 +996,26 @@ pub const BrowserContext = struct {
pub fn onHttpRequestStart(ctx: *anyopaque, msg: *const Notification.RequestStart) !void {
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
- try @import("domains/network.zig").httpRequestStart(self, msg);
+ {
+ // capture the request
+ const transfer = msg.transfer;
+ const key = keyFromTransfer(transfer);
+ const body = transfer.req.body orelse "";
+ if (body.len == 0 or body.len > @import("domains/network.zig").max_post_data_size) {
+ _ = self.captured_requests.remove(key);
+ } else {
+ const owned_body = try self.frame_arena.dupe(u8, body);
+ try self.captured_requests.put(self.frame_arena, key, owned_body);
+ }
+ }
+ defer self.resetNotificationArena();
+ try @import("domains/network.zig").httpRequestStart(self.notification_arena, self, msg);
}
pub fn onHttpRequestIntercept(ctx: *anyopaque, msg: *const Notification.RequestIntercept) !void {
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
- try @import("domains/fetch.zig").requestIntercept(self, msg);
+ defer self.resetNotificationArena();
+ try @import("domains/fetch.zig").requestIntercept(self.notification_arena, self, msg);
}
pub fn onHttpRequestFail(ctx: *anyopaque, msg: *const Notification.RequestFail) !void {
@@ -979,7 +1038,7 @@ pub const BrowserContext = struct {
return @import("domains/page.zig").javascriptDialogOpening(self, msg);
}
- fn keyFromTransfer(transfer: *const Transfer) CDP.BrowserContext.CapturedResponseKey {
+ fn keyFromTransfer(transfer: *const Transfer) CDP.BrowserContext.CapturedKey {
return if (transfer.req.resource_type == .document)
.{ .kind = .loader, .id = transfer.req.loader_id }
else
@@ -1038,7 +1097,7 @@ pub const BrowserContext = struct {
pub fn onHttpRequestAuthRequired(ctx: *anyopaque, data: *const Notification.RequestAuthRequired) !void {
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
defer self.resetNotificationArena();
- try @import("domains/fetch.zig").requestAuthRequired(self, data);
+ try @import("domains/fetch.zig").requestAuthRequired(self.notification_arena, self, data);
}
pub fn onHttpRequestServedFromCache(ctx: *anyopaque, msg: *const Notification.RequestServedFromCache) !void {
@@ -1110,7 +1169,7 @@ pub const BrowserContext = struct {
const message_len = msg.len + session_id.len + 1 + field.len + 10;
var buf: std.ArrayList(u8) = .empty;
- buf.ensureTotalCapacity(allocator, message_len) catch |err| {
+ buf.ensureTotalCapacityPrecise(allocator, message_len) catch |err| {
log.err(.cdp, "inspector buffer", .{ .err = err });
return;
};
diff --git a/src/cdp/Connection.zig b/src/cdp/Connection.zig
index 860716d90..d328964bf 100644
--- a/src/cdp/Connection.zig
+++ b/src/cdp/Connection.zig
@@ -27,6 +27,7 @@ const ArenaPool = @import("../ArenaPool.zig");
const WS = @import("../network/WS.zig");
const sys_net = @import("../sys/net.zig");
+const header_parser = @import("../network/header_parser.zig");
const log = lp.log;
const posix = std.posix;
@@ -433,59 +434,58 @@ fn pushCdp(self: *Connection, bytes: []const u8) !bool {
}
pub fn upgrade(self: *Connection, request: []u8) !void {
- // our caller already confirmed that we have a trailing \r\n\r\n
- const request_line_end = std.mem.indexOfScalar(u8, request, '\r') orelse unreachable;
- const request_line = request[0..request_line_end];
+ // We need to extract the `Sec-WebSocket-Key` value.
+ var sec_websocket_key: []const u8 = "";
+ // We need to make sure that we got all the necessary headers + values;
+ // a bit per required header.
+ const FOUND_UPGRADE: u8 = 1 << 0; // Upgrade: websocket
+ const FOUND_VERSION: u8 = 1 << 1; // Sec-WebSocket-Version: 13
+ const FOUND_CONNECTION: u8 = 1 << 2; // Connection: upgrade
+ const FOUND_KEY: u8 = 1 << 3; // Sec-WebSocket-Key
+ const FOUND_ALL = FOUND_UPGRADE | FOUND_VERSION | FOUND_CONNECTION | FOUND_KEY;
- if (!std.ascii.endsWithIgnoreCase(request_line, "http/1.1")) {
+ // A malformed request line maps to a 400 in processHttpRequest.
+ const method, _, const version, var header_iterator = header_parser.parseRequest(request) catch {
+ return error.InvalidProtocol;
+ };
+ if (method != .get or version != .@"1.1") {
return error.InvalidProtocol;
}
- // we need to extract the sec-websocket-key value
- var key: []const u8 = "";
+ var found_headers: u8 = 0;
- // we need to make sure that we got all the necessary headers + values
- var required_headers: u8 = 0;
+ // A malformed header maps to a 400 in processHttpRequest.
+ while (header_iterator.next() catch return error.InvalidRequest) |header| {
+ const key = header.key;
+ const value = header.value;
- // can't std.mem.split because it forces the iterated value to be const
- // (we could @constCast...)
-
- var buf = request[request_line_end + 2 ..];
-
- while (buf.len > 4) {
- const index = std.mem.indexOfScalar(u8, buf, '\r') orelse unreachable;
- const separator = std.mem.indexOfScalar(u8, buf[0..index], ':') orelse return error.InvalidRequest;
-
- const name = std.mem.trim(u8, toLower(buf[0..separator]), &std.ascii.whitespace);
- const value = std.mem.trim(u8, buf[(separator + 1)..index], &std.ascii.whitespace);
-
- if (std.mem.eql(u8, name, "upgrade")) {
+ // Header names are case-insensitive; `Header.parse` keeps their
+ // original casing.
+ if (std.ascii.eqlIgnoreCase(key, "upgrade")) {
if (!std.ascii.eqlIgnoreCase("websocket", value)) {
return error.InvalidUpgradeHeader;
}
- required_headers |= 1;
- } else if (std.mem.eql(u8, name, "sec-websocket-version")) {
+ found_headers |= FOUND_UPGRADE;
+ } else if (std.ascii.eqlIgnoreCase(key, "sec-websocket-version")) {
if (value.len != 2 or value[0] != '1' or value[1] != '3') {
return error.InvalidVersionHeader;
}
- required_headers |= 2;
- } else if (std.mem.eql(u8, name, "connection")) {
+ found_headers |= FOUND_VERSION;
+ } else if (std.ascii.eqlIgnoreCase(key, "connection")) {
// find if connection header has upgrade in it, example header:
// Connection: keep-alive, Upgrade
if (std.ascii.indexOfIgnoreCase(value, "upgrade") == null) {
return error.InvalidConnectionHeader;
}
- required_headers |= 4;
- } else if (std.mem.eql(u8, name, "sec-websocket-key")) {
- key = value;
- required_headers |= 8;
+ found_headers |= FOUND_CONNECTION;
+ } else if (std.ascii.eqlIgnoreCase(key, "sec-websocket-key")) {
+ sec_websocket_key = value;
+ found_headers |= FOUND_KEY;
}
-
- const next = index + 2;
- buf = buf[next..];
}
- if (required_headers != 15) {
+ // Check if we've received all related headers.
+ if (found_headers != FOUND_ALL) {
return error.MissingHeaders;
}
@@ -513,7 +513,7 @@ pub fn upgrade(self: *Connection, request: []u8) !void {
const key_pos = res.len - 32;
var h: [20]u8 = undefined;
var hasher = std.crypto.hash.Sha1.init(.{});
- hasher.update(key);
+ hasher.update(sec_websocket_key);
// websocket spec always used this value
hasher.update("258EAFA5-E914-47DA-95CA-C5AB0DC85B11");
hasher.final(&h);
@@ -598,11 +598,3 @@ fn websocketHeader(buf: []u8, op_code: WS.OpCode, payload_len: usize) []const u8
buf[9] = @intCast(len & 0xFF);
return buf[0..10];
}
-
-// In-place string lowercase
-fn toLower(str: []u8) []u8 {
- for (str, 0..) |ch, i| {
- str[i] = std.ascii.toLower(ch);
- }
- return str;
-}
diff --git a/src/cdp/domains/dom.zig b/src/cdp/domains/dom.zig
index 3b0505142..99bb8260f 100644
--- a/src/cdp/domains/dom.zig
+++ b/src/cdp/domains/dom.zig
@@ -346,32 +346,33 @@ fn resolveNode(cmd: *CDP.Command) !void {
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
const frame = bc.mainFrame() orelse return error.FrameNotLoaded;
- var ls: ?js.Local.Scope = null;
- defer if (ls) |*_ls| {
- _ls.deinit();
+ var ls: js.Local.Scope = undefined;
+ var ls_open = false;
+ defer if (ls_open) {
+ ls.deinit();
};
if (params.executionContextId) |context_id| blk: {
- ls = undefined;
- frame.js.localScope(&ls.?);
- if (ls.?.local.debugContextId() == context_id) {
+ frame.js.localScope(&ls);
+ ls_open = true;
+ if (ls.local.debugContextId() == context_id) {
break :blk;
}
// not the default scope, check the other ones
for (bc.isolated_worlds.items) |isolated_world| {
- ls.?.deinit();
- ls = null;
+ ls.deinit();
+ ls_open = false;
const ctx = (isolated_world.context orelse return error.ContextNotFound);
- ls = undefined;
- ctx.localScope(&ls.?);
- if (ls.?.local.debugContextId() == context_id) {
+ ctx.localScope(&ls);
+ ls_open = true;
+ if (ls.local.debugContextId() == context_id) {
break :blk;
}
} else return error.ContextNotFound;
} else {
- ls = undefined;
- frame.js.localScope(&ls.?);
+ frame.js.localScope(&ls);
+ ls_open = true;
}
const input_node_id = params.nodeId orelse params.backendNodeId orelse return error.InvalidParam;
@@ -380,7 +381,7 @@ fn resolveNode(cmd: *CDP.Command) !void {
// node._node is a *DOMNode we need this to be able to find its most derived type e.g. Node -> Element -> HTMLElement
// So we use the Node.Union when retrieve the value from the environment
const remote_object = try bc.inspector_session.getRemoteObject(
- &ls.?.local,
+ &ls.local,
params.objectGroup orelse "",
node.dom,
);
diff --git a/src/cdp/domains/fetch.zig b/src/cdp/domains/fetch.zig
index c32bf0d03..733dd8b77 100644
--- a/src/cdp/domains/fetch.zig
+++ b/src/cdp/domains/fetch.zig
@@ -50,15 +50,15 @@ pub fn processMessage(cmd: *CDP.Command) !void {
}
}
-// Stored in CDP. Holds *transfer ids* (not *Transfer pointers) of paused
-// transfers waiting for CDP continueRequest/fulfillRequest/failRequest/
-// continueWithAuth. Anyone resolving an entry must look the transfer up via
-// `Client.findTransfer(id)` — if the transfer has been destroyed out-of-band
-// (e.g. frame shutdown), the lookup returns null and the CDP command should
-// no-op rather than UAF.
+// Stored in CDP. Maps intercept ids to *transfer ids* (not *Transfer
+// pointers) of paused transfers waiting for CDP continueRequest/
+// fulfillRequest/failRequest/continueWithAuth. Anyone resolving an entry must
+// look the transfer up via `Client.findTransfer(id)` — if the transfer has been
+// destroyed out-of-band (e.g. frame shutdown), the lookup returns null.
pub const InterceptState = struct {
allocator: Allocator,
- waiting: std.AutoArrayHashMapUnmanaged(u32, void),
+ next_id: u32 = 1,
+ waiting: std.AutoArrayHashMapUnmanaged(u32, u32),
pub fn init(allocator: Allocator) !InterceptState {
return .{
@@ -67,17 +67,17 @@ pub const InterceptState = struct {
};
}
- pub fn empty(self: *const InterceptState) bool {
- return self.waiting.count() == 0;
+ pub fn put(self: *InterceptState, transfer_id: u32) !u32 {
+ const intercept_id = self.next_id;
+ try self.waiting.put(self.allocator, intercept_id, transfer_id);
+ self.next_id +%= 1;
+ return intercept_id;
}
- pub fn put(self: *InterceptState, transfer_id: u32) !void {
- return self.waiting.put(self.allocator, transfer_id, {});
- }
-
- // Returns true if the id was present and removed, false otherwise.
- pub fn remove(self: *InterceptState, transfer_id: u32) bool {
- return self.waiting.swapRemove(transfer_id);
+ // Returns the transfer id if the intercept id was present and removed.
+ pub fn remove(self: *InterceptState, intercept_id: u32) ?u32 {
+ const entry = self.waiting.fetchSwapRemove(intercept_id) orelse return null;
+ return entry.value;
}
pub fn deinit(self: *InterceptState) void {
@@ -85,7 +85,7 @@ pub const InterceptState = struct {
}
pub fn pendingIntercepts(self: *const InterceptState) []u32 {
- return self.waiting.keys();
+ return self.waiting.values();
}
};
@@ -143,9 +143,16 @@ const ErrorReason = enum {
BlockedByResponse,
};
+fn commandSessionId(cmd: *CDP.Command, bc: *CDP.BrowserContext) ![]const u8 {
+ if (cmd.input.session_id) |session_id| {
+ return cmd.cdp.resolveSessionId(session_id) orelse error.UnknownSessionId;
+ }
+ return bc.session_id orelse error.UnknownSessionId;
+}
+
fn disable(cmd: *CDP.Command) !void {
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
- bc.fetchDisable();
+ bc.fetchDisableForSession(try commandSessionId(cmd, bc));
return cmd.sendResult(null, .{});
}
@@ -157,7 +164,7 @@ fn enable(cmd: *CDP.Command) !void {
}
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
- try bc.fetchEnable(params.handleAuthRequests);
+ try bc.fetchEnable(params.handleAuthRequests, try commandSessionId(cmd, bc));
return cmd.sendResult(null, .{});
}
@@ -188,22 +195,21 @@ fn arePatternsSupported(patterns: []RequestPattern) bool {
return true;
}
-pub fn requestIntercept(bc: *CDP.BrowserContext, intercept: *const Notification.RequestIntercept) !void {
- // detachTarget could be called, in which case, we still have a frame doing
- // things, but no session.
- const session_id = bc.session_id orelse return;
+pub fn requestIntercept(arena: Allocator, bc: *CDP.BrowserContext, intercept: *const Notification.RequestIntercept) !void {
+ // The session that enabled Fetch owns its interception events.
+ const session_id = bc.fetch_session_id orelse return;
// We keep it around to wait for modifications to the request.
// TODO: What to do when receiving replies for a previous frame's requests?
const transfer = intercept.transfer;
- try bc.intercept_state.put(transfer.id);
- errdefer _ = bc.intercept_state.remove(transfer.id);
+ const intercept_id = try bc.intercept_state.put(transfer.id);
+ errdefer _ = bc.intercept_state.remove(intercept_id);
try bc.cdp.sendEvent("Fetch.requestPaused", .{
- .requestId = &id.toInterceptId(transfer.id),
+ .requestId = &id.toInterceptId(intercept_id),
.frameId = &id.toFrameId(transfer.req.frame_id),
- .request = network.RequestWriter.init(transfer),
+ .request = network.RequestWriter.init(arena, transfer),
.resourceType = transfer.req.resource_type.string(),
.networkId = &id.toRequestId(transfer), // matches the Network REQ-ID
}, .{ .session_id = session_id });
@@ -235,9 +241,9 @@ fn continueRequest(cmd: *CDP.Command) !void {
const client = &bc.cdp.browser.http_client;
var intercept_state = &bc.intercept_state;
- const transfer_id = try idFromRequestId(params.requestId);
+ const intercept_id = try idFromRequestId(params.requestId);
- if (!intercept_state.remove(transfer_id)) return error.RequestNotFound;
+ const transfer_id = intercept_state.remove(intercept_id) orelse return error.RequestNotFound;
// Transfer may have been destroyed out-of-band between pause and now
// (e.g. frame shutdown). Treat as a no-op rather than an error — the CDP
// client's view of "this request still exists" is just stale.
@@ -298,9 +304,9 @@ fn continueWithAuth(cmd: *CDP.Command) !void {
const client = &bc.cdp.browser.http_client;
var intercept_state = &bc.intercept_state;
- const transfer_id = try idFromRequestId(params.requestId);
+ const intercept_id = try idFromRequestId(params.requestId);
- if (!intercept_state.remove(transfer_id)) return error.RequestNotFound;
+ const transfer_id = intercept_state.remove(intercept_id) orelse return error.RequestNotFound;
const transfer = client.findTransfer(transfer_id) orelse {
log.debug(.cdp, "intercept lookup miss", .{ .id = transfer_id, .op = "auth" });
return cmd.sendResult(null, .{});
@@ -356,9 +362,9 @@ fn fulfillRequest(cmd: *CDP.Command) !void {
const client = &bc.cdp.browser.http_client;
var intercept_state = &bc.intercept_state;
- const transfer_id = try idFromRequestId(params.requestId);
+ const intercept_id = try idFromRequestId(params.requestId);
- if (!intercept_state.remove(transfer_id)) return error.RequestNotFound;
+ const transfer_id = intercept_state.remove(intercept_id) orelse return error.RequestNotFound;
const transfer = client.findTransfer(transfer_id) orelse {
log.debug(.cdp, "intercept lookup miss", .{ .id = transfer_id, .op = "fulfill" });
return cmd.sendResult(null, .{});
@@ -393,9 +399,9 @@ fn failRequest(cmd: *CDP.Command) !void {
const client = &bc.cdp.browser.http_client;
var intercept_state = &bc.intercept_state;
- const transfer_id = try idFromRequestId(params.requestId);
+ const intercept_id = try idFromRequestId(params.requestId);
- if (!intercept_state.remove(transfer_id)) return error.RequestNotFound;
+ const transfer_id = intercept_state.remove(intercept_id) orelse return error.RequestNotFound;
const transfer = client.findTransfer(transfer_id) orelse {
log.debug(.cdp, "intercept lookup miss", .{ .id = transfer_id, .op = "fail" });
return cmd.sendResult(null, .{});
@@ -412,26 +418,24 @@ fn failRequest(cmd: *CDP.Command) !void {
return cmd.sendResult(null, .{});
}
-pub fn requestAuthRequired(bc: *CDP.BrowserContext, intercept: *const Notification.RequestAuthRequired) !void {
- // detachTarget could be called, in which case, we still have a frame doing
- // things, but no session.
- const session_id = bc.session_id orelse return;
+pub fn requestAuthRequired(arena: Allocator, bc: *CDP.BrowserContext, intercept: *const Notification.RequestAuthRequired) !void {
+ const session_id = bc.fetch_session_id orelse return;
// We keep it around to wait for modifications to the request.
// NOTE: we assume whomever created the request created it with a lifetime of the Page.
// TODO: What to do when receiving replies for a previous frame's requests?
const transfer = intercept.transfer;
- try bc.intercept_state.put(transfer.id);
- errdefer _ = bc.intercept_state.remove(transfer.id);
+ const intercept_id = try bc.intercept_state.put(transfer.id);
+ errdefer _ = bc.intercept_state.remove(intercept_id);
const request = &transfer.req;
const challenge = transfer._auth_challenge orelse return error.NullAuthChallenge;
try bc.cdp.sendEvent("Fetch.authRequired", .{
- .requestId = &id.toInterceptId(transfer.id),
+ .requestId = &id.toInterceptId(intercept_id),
.frameId = &id.toFrameId(request.frame_id),
- .request = network.RequestWriter.init(transfer),
+ .request = network.RequestWriter.init(arena, transfer),
.resourceType = request.resource_type.string(),
.authChallenge = .{
.origin = "", // TODO get origin, could be the proxy address for example.
@@ -459,3 +463,55 @@ fn idFromRequestId(request_id: []const u8) !u32 {
}
return std.fmt.parseInt(u32, request_id[4..], 10) catch return error.InvalidParams;
}
+
+const testing = @import("../testing.zig");
+test "cdp.Fetch: interception events belong to the enabling session" {
+ var ctx = try testing.context();
+ defer ctx.deinit();
+ const bc = try ctx.loadBrowserContext(.{
+ .session_id = "SID-PRIMARY",
+ .target_id = "TID-000000000B".*,
+ });
+ try bc.attached_sessions.append(bc.arena, .{
+ .id = "SID-AUX",
+ .parent_id = null,
+ });
+
+ try ctx.processMessage(.{
+ .id = 1,
+ .method = "Fetch.enable",
+ .sessionId = "SID-AUX",
+ });
+ try testing.expect(std.mem.eql(u8, "SID-AUX", bc.fetch_session_id.?));
+ try ctx.expectSentResult(null, .{ .id = 1, .session_id = "SID-AUX" });
+
+ try ctx.processMessage(.{
+ .id = 2,
+ .method = "Fetch.disable",
+ .sessionId = "SID-PRIMARY",
+ });
+ try testing.expect(std.mem.eql(u8, "SID-AUX", bc.fetch_session_id.?));
+
+ try ctx.processMessage(.{
+ .id = 3,
+ .method = "Fetch.disable",
+ .sessionId = "SID-AUX",
+ });
+ try testing.expectEqual(null, bc.fetch_session_id);
+}
+
+test "cdp.Fetch: InterceptState issues a fresh id per pause" {
+ var state = try InterceptState.init(testing.allocator);
+ defer state.deinit();
+
+ const first = try state.put(7);
+ const second = try state.put(7);
+ try testing.expectEqual(false, first == second);
+ try testing.expectEqual(2, state.pendingIntercepts().len);
+ try testing.expectEqual(7, state.pendingIntercepts()[0]);
+
+ try testing.expectEqual(7, state.remove(first).?);
+ try testing.expectEqual(null, state.remove(first));
+ try testing.expectEqual(7, state.remove(second).?);
+ try testing.expectEqual(null, state.remove(second));
+}
diff --git a/src/cdp/domains/lp.zig b/src/cdp/domains/lp.zig
index 9313e2e5b..8c4617c72 100644
--- a/src/cdp/domains/lp.zig
+++ b/src/cdp/domains/lp.zig
@@ -344,7 +344,7 @@ fn waitForSelector(cmd: anytype) !void {
const bc = cmd.browser_context orelse return error.NoBrowserContext;
const frame = bc.mainFrame() orelse return error.FrameNotLoaded;
- const timeout_ms = params.timeout orelse 5000;
+ const timeout_ms = params.timeout orelse lp.tools.defaultWaitTimeout(frame);
const selector_z = try cmd.arena.dupeZ(u8, params.selector);
const node = lp.actions.waitForSelector(selector_z, timeout_ms, frame._frame_id, bc.session) catch |err| {
diff --git a/src/cdp/domains/network.zig b/src/cdp/domains/network.zig
index a08f85518..d8e334ac9 100644
--- a/src/cdp/domains/network.zig
+++ b/src/cdp/domains/network.zig
@@ -37,6 +37,7 @@ const CdpStorage = @import("storage.zig");
const log = lp.log;
const Allocator = std.mem.Allocator;
+pub const max_post_data_size = 64 * 1024;
pub fn processMessage(cmd: *CDP.Command) !void {
const action = std.meta.stringToEnum(enum {
@@ -55,6 +56,7 @@ pub fn processMessage(cmd: *CDP.Command) !void {
getCookies,
getAllCookies,
getResponseBody,
+ getRequestPostData,
}, cmd.input.action) orelse return error.UnknownMethod;
switch (action) {
@@ -73,6 +75,7 @@ pub fn processMessage(cmd: *CDP.Command) !void {
.getCookies => return getCookies(cmd),
.getAllCookies => return getAllCookies(cmd),
.getResponseBody => return getResponseBody(cmd),
+ .getRequestPostData => return getRequestPostData(cmd),
}
}
@@ -322,6 +325,18 @@ fn getResponseBody(cmd: *CDP.Command) !void {
}, .{});
}
+fn getRequestPostData(cmd: *CDP.Command) !void {
+ const params = (try cmd.params(struct {
+ requestId: []const u8, // "REQ-{d}" or "LID-{d}"
+ })) orelse return error.InvalidParams;
+
+ const key = try keyFromRequestId(params.requestId);
+ const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
+ const body = bc.captured_requests.get(key) orelse return error.RequestNotFound;
+
+ return cmd.sendResult(.{ .postData = SafeString.wrap(body) }, .{});
+}
+
pub fn httpRequestFail(bc: *CDP.BrowserContext, msg: *const Notification.RequestFail) !void {
// It's possible that the request failed because we aborted when the client
// sent Target.closeTarget. In that case, bc.session_id will be cleared
@@ -344,7 +359,7 @@ pub fn httpRequestFail(bc: *CDP.BrowserContext, msg: *const Notification.Request
}, .{ .session_id = session_id });
}
-pub fn httpRequestStart(bc: *CDP.BrowserContext, msg: *const Notification.RequestStart) !void {
+pub fn httpRequestStart(arena: Allocator, bc: *CDP.BrowserContext, msg: *const Notification.RequestStart) !void {
// detachTarget could be called, in which case, we still have a frame doing
// things, but no session.
const session_id = bc.session_id orelse return;
@@ -368,7 +383,7 @@ pub fn httpRequestStart(bc: *CDP.BrowserContext, msg: *const Notification.Reques
.loaderId = &id.toLoaderId(req.loader_id),
.type = req.resource_type.string(),
.documentURL = frame.url,
- .request = RequestWriter.init(transfer),
+ .request = RequestWriter.init(arena, transfer),
.initiator = .{ .type = "other" },
.redirectHasExtraInfo = false, // TODO change after adding Network.requestWillBeSentExtraInfo
.hasUserGesture = false,
@@ -418,10 +433,12 @@ pub fn httpServedFromCache(bc: *CDP.BrowserContext, msg: *const Notification.Req
}
pub const RequestWriter = struct {
+ arena: Allocator,
transfer: *Transfer,
- pub fn init(transfer: *Transfer) RequestWriter {
+ pub fn init(arena: Allocator, transfer: *Transfer) RequestWriter {
return .{
+ .arena = arena,
.transfer = transfer,
};
}
@@ -458,6 +475,22 @@ pub const RequestWriter = struct {
try jws.write(request.body != null);
}
+ if (request.body) |body| {
+ if (body.len <= max_post_data_size) {
+ try jws.objectField("postData");
+ try jws.write(SafeString.wrap(body));
+
+ // postDataEntries is the binary-safe representation
+ // (postData is lossy for non-UTF-8 bodies).
+ const encoder = std.base64.standard.Encoder;
+ const encoded = try self.arena.alloc(u8, encoder.calcSize(body.len));
+ try jws.objectField("postDataEntries");
+ try jws.write(&[_]struct { bytes: []const u8 }{
+ .{ .bytes = encoder.encode(encoded, body) },
+ });
+ }
+ }
+
{
try jws.objectField("initialPriority");
try jws.write(initialPriority(request.resource_type));
@@ -625,7 +658,11 @@ fn securityState(url: [:0]const u8) []const u8 {
return "unknown";
}
-fn keyFromRequestId(request_id: []const u8) !CDP.BrowserContext.CapturedResponseKey {
+fn keyFromRequestId(request_id: []const u8) !CDP.BrowserContext.CapturedKey {
+ if (request_id.len < 4) {
+ return error.InvalidParams;
+ }
+
const key = std.fmt.parseInt(u32, request_id[4..], 10) catch return error.InvalidParams;
return if (std.mem.startsWith(u8, request_id, "LID-"))
@@ -1078,6 +1115,64 @@ test "cdp.Network: setBlockedURLs blocks requests with inspector reason" {
try testing.expectEqual(error.UrlBlocked, error_context.err.?);
}
+test "cdp.Network: POST body exposed as postData" {
+ var ctx = try testing.context();
+ defer ctx.deinit();
+
+ const bc = try ctx.loadBrowserContext(.{ .id = "BID-PD", .session_id = "SID-PD" });
+ const page = try bc.session.createPage();
+ const client = &bc.cdp.browser.http_client;
+
+ try ctx.processMessage(.{ .id = 1, .method = "Network.enable" });
+ try ctx.expectSentResult(null, .{ .id = 1 });
+
+ var request_id: [14]u8 = undefined;
+ _ = std.fmt.bufPrint(&request_id, "REQ-{d:0>10}", .{client.next_request_id +% 1}) catch unreachable;
+
+ // \xE9 exercises the Latin-1 -> UTF-8 transcode in postData;
+ // postDataEntries carry the raw bytes in base64.
+ const body = "name=Zig¬e=caf\xE9";
+
+ try client.request(.{
+ .frame_id = page.frame_id,
+ .loader_id = 1,
+ .method = .POST,
+ .url = "http://127.0.0.1:9582/echo_body",
+ .body = body,
+ .cookie_jar = null,
+ .cookie_origin = "http://127.0.0.1:9582/",
+ .resource_type = .fetch,
+ .notification = bc.session.notification,
+ .shutdown_callback = HttpClient.noopShutdown,
+ }, null);
+
+ try ctx.expectSentEvent("Network.requestWillBeSent", .{
+ .requestId = &request_id,
+ .request = .{
+ .method = "POST",
+ .hasPostData = true,
+ .postData = "name=Zig¬e=café",
+ .postDataEntries = &[_]struct { bytes: []const u8 }{
+ .{ .bytes = "bmFtZT1aaWcmbm90ZT1jYWbp" },
+ },
+ },
+ }, .{ .session_id = "SID-PD" });
+
+ try ctx.processMessage(.{
+ .id = 2,
+ .method = "Network.getRequestPostData",
+ .params = .{ .requestId = &request_id },
+ });
+ try ctx.expectSentResult(.{ .postData = "name=Zig¬e=café" }, .{ .id = 2 });
+
+ try ctx.processMessage(.{
+ .id = 3,
+ .method = "Network.getRequestPostData",
+ .params = .{ .requestId = "REQ-4294967295" },
+ });
+ try ctx.expectSentError(-31998, "RequestNotFound", .{ .id = 3 });
+}
+
test "cdp.Network: worker requests emit network events" {
var ctx = try testing.context();
defer ctx.deinit();
diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig
index 8215990ee..0b3e89338 100644
--- a/src/cdp/domains/page.zig
+++ b/src/cdp/domains/page.zig
@@ -244,6 +244,17 @@ fn createIsolatedWorld(cmd: *CDP.Command) !void {
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
const world = try bc.createIsolatedWorld(params.worldName, params.grantUniveralAccess);
+
+ // An existing world already has a live, inspector-registered context for
+ // the current document: return its id without re-registering.
+ if (world.context) |js_context| {
+ var ls: js.Local.Scope = undefined;
+ js_context.localScope(&ls);
+ defer ls.deinit();
+ const context_id = bc.inspector_session.inspector.getContextId(&ls.local);
+ return cmd.sendResult(.{ .executionContextId = context_id }, .{});
+ }
+
const frame = bc.mainFrame() orelse return error.FrameNotLoaded;
const js_context = try world.createContext(frame);
@@ -531,11 +542,12 @@ pub fn frameCreated(bc: *CDP.BrowserContext, frame: *Frame) !void {
_ = try isolated_world.createContext(frame);
}
- if (!in_commit) {
+ if (in_commit == false) {
// Only retain captured responses until a navigation event. In CDP
// terms, this is called a "renderer" and the cache-duration can be
// controlled via Network.configureDurableMessages (which we don't
// support).
+ bc.captured_requests = .empty;
bc.captured_responses = .empty;
}
}
@@ -1127,6 +1139,36 @@ test "cdp.frame: getFrameTree" {
}
}
+test "cdp.frame: createIsolatedWorld is idempotent per name" {
+ var ctx = try testing.context();
+ defer ctx.deinit();
+
+ const bc = try ctx.loadBrowserContext(.{ .id = "BID-9", .url = "hi.html", .target_id = "FID-000000000X".* });
+
+ try ctx.processMessage(.{ .id = 20, .method = "Page.createIsolatedWorld", .params = .{
+ .frameId = "FID-000000000X",
+ .worldName = "utility",
+ .grantUniveralAccess = true,
+ } });
+ try testing.expectEqual(1, bc.isolated_worlds.items.len);
+ const world_context = bc.isolated_worlds.items[0].context.?;
+
+ try ctx.processMessage(.{ .id = 21, .method = "Page.createIsolatedWorld", .params = .{
+ .frameId = "FID-000000000X",
+ .worldName = "utility",
+ .grantUniveralAccess = true,
+ } });
+ try testing.expectEqual(1, bc.isolated_worlds.items.len);
+ try testing.expectEqual(world_context, bc.isolated_worlds.items[0].context.?);
+
+ try ctx.processMessage(.{ .id = 22, .method = "Page.createIsolatedWorld", .params = .{
+ .frameId = "FID-000000000X",
+ .worldName = "other",
+ .grantUniveralAccess = true,
+ } });
+ try testing.expectEqual(2, bc.isolated_worlds.items.len);
+}
+
test "cdp.frame: child frame metadata" {
var ctx = try testing.context();
defer ctx.deinit();
diff --git a/src/cdp/domains/target.zig b/src/cdp/domains/target.zig
index 703cd3d5f..e764e9c7f 100644
--- a/src/cdp/domains/target.zig
+++ b/src/cdp/domains/target.zig
@@ -246,9 +246,37 @@ fn attachToTarget(cmd: *CDP.Command) !void {
return error.UnknownTargetId;
}
- try doAttachtoTarget(cmd, target_id);
+ // The first attach becomes the page's primary session, which all
+ // Page/Network/... events are emitted to. Later attaches — e.g.
+ // Playwright's browserContext.newCDPSession(page), sent through its
+ // browser session while the page session is live — get a distinct
+ // auxiliary session so they don't collide with the primary one.
+ if (bc.session_id == null) {
+ try doAttachtoTarget(cmd, target_id);
+ return cmd.sendResult(.{ .sessionId = bc.session_id }, .{});
+ }
- return cmd.sendResult(.{ .sessionId = bc.session_id }, .{});
+ const parent_id = if (cmd.input.session_id) |session_id|
+ cmd.cdp.resolveSessionId(session_id) orelse return error.UnknownSessionId
+ else
+ null;
+ const session_id = try bc.arena.dupe(u8, cmd.cdp.session_id_gen.next());
+ try bc.attached_sessions.append(bc.arena, .{
+ .id = session_id,
+ .parent_id = parent_id,
+ });
+
+ try cmd.sendEvent("Target.attachedToTarget", AttachToTarget{
+ .sessionId = session_id,
+ .targetInfo = TargetInfo{
+ .targetId = target_id,
+ .title = bc.getTitle() orelse "",
+ .url = bc.getURL() orelse "about:blank",
+ .browserContextId = bc.id,
+ },
+ }, .{ .session_id = parent_id });
+
+ return cmd.sendResult(.{ .sessionId = session_id }, .{});
}
fn attachToBrowserTarget(cmd: *CDP.Command) !void {
@@ -290,8 +318,22 @@ fn closeTarget(cmd: *CDP.Command) !void {
try cmd.sendResult(.{ .success = true }, .{ .include_session_id = false });
+ for (bc.attached_sessions.items) |session| {
+ bc.fetchDisableForSession(session.id);
+ try cmd.sendEvent("Inspector.detached", .{
+ .reason = "Render process gone.",
+ }, .{ .session_id = session.id });
+ try cmd.sendEvent("Target.detachedFromTarget", .{
+ .targetId = target_id,
+ .sessionId = session.id,
+ .reason = "Render process gone.",
+ }, .{ .session_id = session.parent_id });
+ }
+ bc.attached_sessions.clearRetainingCapacity();
+
// could be null, created but never attached
if (bc.session_id) |session_id| {
+ bc.fetchDisableForSession(session_id);
// Inspector.detached event
try cmd.sendEvent("Inspector.detached", .{
.reason = "Render process gone.",
@@ -386,8 +428,33 @@ fn sendMessageToTarget(cmd: *CDP.Command) !void {
}
fn detachFromTarget(cmd: *CDP.Command) !void {
+ const Params = struct {
+ sessionId: ?[]const u8 = null,
+ targetId: ?[]const u8 = null,
+ };
+ const params = (try cmd.params(Params)) orelse Params{};
+
if (cmd.browser_context) |bc| {
+ if (params.sessionId) |requested_session_id| {
+ for (bc.attached_sessions.items, 0..) |session, index| {
+ if (!std.mem.eql(u8, session.id, requested_session_id)) continue;
+
+ _ = bc.attached_sessions.orderedRemove(index);
+ bc.fetchDisableForSession(session.id);
+ try cmd.sendEvent("Target.detachedFromTarget", .{
+ .sessionId = session.id,
+ }, .{ .session_id = session.parent_id });
+ return cmd.sendResult(null, .{});
+ }
+
+ const session_id = bc.session_id orelse return error.UnknownSessionId;
+ if (!std.mem.eql(u8, session_id, requested_session_id)) {
+ return error.UnknownSessionId;
+ }
+ }
+
if (bc.session_id) |session_id| {
+ bc.fetchDisableForSession(session_id);
try cmd.sendEvent("Target.detachedFromTarget", .{
.sessionId = session_id,
}, .{});
@@ -418,6 +485,7 @@ fn setAutoAttach(cmd: *CDP.Command) !void {
// detach from all currently attached targets.
if (cmd.browser_context) |bc| {
if (bc.session_id) |session_id| {
+ bc.fetchDisableForSession(session_id);
try cmd.sendEvent("Target.detachedFromTarget", .{
.sessionId = session_id,
}, .{});
@@ -465,7 +533,9 @@ fn setAutoAttach(cmd: *CDP.Command) !void {
fn doAttachtoTarget(cmd: *CDP.Command, target_id: []const u8) !void {
const bc = cmd.browser_context.?;
- const session_id = bc.session_id orelse cmd.cdp.session_id_gen.next();
+ const session_id = bc.session_id orelse blk: {
+ break :blk try bc.arena.dupe(u8, cmd.cdp.session_id_gen.next());
+ };
if (bc.session_id == null) {
// extra_headers should not be kept on a new frame or tab,
@@ -746,13 +816,52 @@ test "cdp.target: attachToTarget" {
}
{
+ // the first attach becomes the primary session
try ctx.processMessage(.{ .id = 11, .method = "Target.attachToTarget", .params = .{ .targetId = "TID-000000000B" } });
const session_id = bc.session_id.?;
try ctx.expectSentResult(.{ .sessionId = session_id }, .{ .id = 11 });
try ctx.expectSentEvent("Target.attachedToTarget", .{ .sessionId = session_id, .targetInfo = .{ .url = "about:blank", .title = "", .attached = true, .type = "page", .canAccessOpener = false, .browserContextId = "BID-9", .targetId = bc.target_id.? } }, .{});
+ try testing.expectEqual(0, bc.attached_sessions.items.len);
}
}
+test "cdp.target: auxiliary session is unique and routed through its parent" {
+ var ctx = try testing.context();
+ defer ctx.deinit();
+ const bc = try ctx.loadBrowserContext(.{
+ .id = "BID-9",
+ .session_id = "SID-PRIMARY",
+ .target_id = "TID-000000000B".*,
+ });
+
+ try ctx.processMessage(.{ .id = 1, .method = "Target.attachToBrowserTarget" });
+ try ctx.processMessage(.{
+ .id = 2,
+ .method = "Target.attachToTarget",
+ .sessionId = "BSID-1",
+ .params = .{ .targetId = "TID-000000000B" },
+ });
+
+ const session_id = bc.attached_sessions.items[0].id;
+ try testing.expect(!std.mem.eql(u8, session_id, bc.session_id.?));
+ try ctx.expectSentEvent("Target.attachedToTarget", .{
+ .sessionId = session_id,
+ .targetInfo = .{
+ .url = "about:blank",
+ .title = "",
+ .attached = true,
+ .type = "page",
+ .canAccessOpener = false,
+ .browserContextId = "BID-9",
+ .targetId = "TID-000000000B",
+ },
+ }, .{ .index = 2, .session_id = "BSID-1" });
+ try ctx.expectSentResult(.{ .sessionId = session_id }, .{
+ .id = 2,
+ .session_id = "BSID-1",
+ });
+}
+
test "cdp.target: getTargetInfo" {
var ctx = try testing.context();
defer ctx.deinit();
@@ -823,23 +932,39 @@ test "cdp.target: detachFromTarget" {
var ctx = try testing.context();
defer ctx.deinit();
const bc = try ctx.loadBrowserContext(.{ .id = "BID-9" });
- {
- try ctx.processMessage(.{ .id = 10, .method = "Target.createTarget", .params = .{ .browserContextId = "BID-9" } });
- try testing.expectEqual(true, bc.target_id != null);
- try ctx.expectSentResult(.{ .targetId = bc.target_id.? }, .{ .id = 10 });
+ try ctx.processMessage(.{ .id = 10, .method = "Target.createTarget", .params = .{ .browserContextId = "BID-9" } });
+ try ctx.processMessage(.{ .id = 11, .method = "Target.attachToTarget", .params = .{ .targetId = bc.target_id.? } });
+ const session_id = bc.session_id.?;
- try ctx.processMessage(.{ .id = 11, .method = "Target.attachToTarget", .params = .{ .targetId = bc.target_id.? } });
- const session_id = bc.session_id.?;
- try ctx.expectSentResult(.{ .sessionId = session_id }, .{ .id = 11 });
+ try ctx.processMessage(.{ .id = 12, .method = "Target.detachFromTarget", .params = .{ .sessionId = session_id } });
- try ctx.processMessage(.{ .id = 12, .method = "Target.detachFromTarget", .params = .{ .targetId = bc.target_id.? } });
- try ctx.expectSentEvent("Target.detachedFromTarget", .{ .sessionId = session_id }, .{});
- try testing.expectEqual(null, bc.session_id);
- try ctx.expectSentResult(null, .{ .id = 12 });
+ try ctx.expectSentEvent("Target.detachedFromTarget", .{ .sessionId = session_id }, .{});
+ try testing.expectEqual(null, bc.session_id);
+ try ctx.expectSentResult(null, .{ .id = 12 });
- try ctx.processMessage(.{ .id = 13, .method = "Target.attachToTarget", .params = .{ .targetId = bc.target_id.? } });
- try ctx.expectSentResult(.{ .sessionId = bc.session_id.? }, .{ .id = 13 });
- }
+ try ctx.processMessage(.{ .id = 13, .method = "Target.attachToTarget", .params = .{ .targetId = bc.target_id.? } });
+ try testing.expect(!std.mem.eql(u8, session_id, bc.session_id.?));
+ try ctx.expectSentResult(.{ .sessionId = bc.session_id.? }, .{ .id = 13 });
+}
+
+test "cdp.target: detachFromTarget auxiliary session" {
+ var ctx = try testing.context();
+ defer ctx.deinit();
+ const bc = try ctx.loadBrowserContext(.{
+ .id = "BID-9",
+ .session_id = "SID-PRIMARY",
+ .target_id = "TID-000000000B".*,
+ });
+
+ try ctx.processMessage(.{ .id = 10, .method = "Target.attachToTarget", .params = .{ .targetId = "TID-000000000B" } });
+ const session_id = bc.attached_sessions.items[0].id;
+ try testing.expect(!std.mem.eql(u8, session_id, bc.session_id.?));
+
+ try ctx.processMessage(.{ .id = 11, .method = "Target.detachFromTarget", .params = .{ .sessionId = session_id } });
+ try ctx.expectSentEvent("Target.detachedFromTarget", .{ .sessionId = session_id }, .{});
+ try testing.expectEqual(0, bc.attached_sessions.items.len);
+ try testing.expectEqual(true, bc.session_id != null);
+ try ctx.expectSentResult(null, .{ .id = 11 });
}
test "cdp.target: detachFromTarget without session" {
@@ -858,19 +983,13 @@ test "cdp.target: setAutoAttach false sends detachedFromTarget" {
var ctx = try testing.context();
defer ctx.deinit();
const bc = try ctx.loadBrowserContext(.{ .id = "BID-9" });
- {
- try ctx.processMessage(.{ .id = 10, .method = "Target.createTarget", .params = .{ .browserContextId = "BID-9" } });
- try testing.expectEqual(true, bc.target_id != null);
- try ctx.expectSentResult(.{ .targetId = bc.target_id.? }, .{ .id = 10 });
+ try ctx.processMessage(.{ .id = 10, .method = "Target.setAutoAttach", .params = .{ .autoAttach = true, .waitForDebuggerOnStart = false } });
+ try ctx.processMessage(.{ .id = 11, .method = "Target.createTarget", .params = .{ .browserContextId = "BID-9" } });
+ const session_id = bc.session_id.?;
- try ctx.processMessage(.{ .id = 11, .method = "Target.attachToTarget", .params = .{ .targetId = bc.target_id.? } });
- const session_id = bc.session_id.?;
- try ctx.expectSentResult(.{ .sessionId = session_id }, .{ .id = 11 });
+ try ctx.processMessage(.{ .id = 12, .method = "Target.setAutoAttach", .params = .{ .autoAttach = false, .waitForDebuggerOnStart = false } });
- // setAutoAttach false should fire detachedFromTarget event
- try ctx.processMessage(.{ .id = 12, .method = "Target.setAutoAttach", .params = .{ .autoAttach = false, .waitForDebuggerOnStart = false } });
- try ctx.expectSentEvent("Target.detachedFromTarget", .{ .sessionId = session_id }, .{});
- try testing.expectEqual(null, bc.session_id);
- try ctx.expectSentResult(null, .{ .id = 12 });
- }
+ try ctx.expectSentEvent("Target.detachedFromTarget", .{ .sessionId = session_id }, .{});
+ try testing.expectEqual(null, bc.session_id);
+ try ctx.expectSentResult(null, .{ .id = 12 });
}
diff --git a/src/cdp/domains/webmcp.zig b/src/cdp/domains/webmcp.zig
index 9d53b776b..5995648c0 100644
--- a/src/cdp/domains/webmcp.zig
+++ b/src/cdp/domains/webmcp.zig
@@ -141,7 +141,7 @@ fn invokeTool(cmd: *CDP.Command) !void {
const callback = local.toLocal(tool.execute);
- var caught: js.TryCatch.Caught = undefined;
+ var caught: js.TryCatch.Caught = .{};
const result = callback.tryCall(js.Value, .{ input_value, ModelContextClient{} }, &caught) catch {
const msg = caught.exception orelse "tool threw";
try respondError(cmd.cdp, bc, invocation, msg);
diff --git a/src/crash_handler.zig b/src/crash_handler.zig
index 41200409b..b3b86c41a 100644
--- a/src/crash_handler.zig
+++ b/src/crash_handler.zig
@@ -101,6 +101,11 @@ fn report(reason: []const u8, begin_addr: usize, args: anytype) !void {
const body = blk: {
var writer: std.Io.Writer = .fixed(body_buffer[0..8191]); // reserve 1 space
inline for (@typeInfo(@TypeOf(args)).@"struct".fields) |f| {
+ // remove url value from the crash report.
+ if (comptime std.mem.eql(u8, f.name, "url")) {
+ writer.writeAll("url: REDACTED\n") catch break;
+ continue;
+ }
writer.writeAll(f.name ++ ": ") catch break;
lp.log.writeValue(.pretty, @field(args, f.name), &writer) catch {};
writer.writeByte('\n') catch {};
diff --git a/src/help.zon b/src/help.zon
index e88ede3b0..83104a8c3 100644
--- a/src/help.zon
+++ b/src/help.zon
@@ -114,10 +114,13 @@
\\ --wait-until condition is met.
\\ --wait-until
\\ Wait until the specified event. Checked before other --wait-* options.
- \\ Defaults to 'done'. If --wait-selector, --wait-script or
+ \\ Defaults to 'load'. If --wait-selector, --wait-script or
\\ --wait-script-file specified, defaults to none.
\\ Allowed values: "load", "domcontentloaded", "networkalmostidle",
\\ "networkidle", "done".
+ \\ 'done' waits for full quiescence (no pending scripts or network);
+ \\ pages with constant background activity never reach it and run
+ \\ to --wait-ms.
\\ --with-base
\\ Add a tag in dump.
\\ Defaults to false.
@@ -342,6 +345,9 @@
\\ Directory used as a filesystem cache for network resources. Omitting
\\ this disables caching.
\\ Defaults to no caching.
+ \\ --http-cache-entry-limit
+ \\ Maximum number of entries that can be stored in the HTTP cache. 0 means no limit.
+ \\ Defaults to 1000.
\\ --http-connect-timeout
\\ Time in ms to establish an HTTP connection before timing out. 0 means
\\ never.
diff --git a/src/lightpanda.zig b/src/lightpanda.zig
index 01769456f..12f882a3d 100644
--- a/src/lightpanda.zig
+++ b/src/lightpanda.zig
@@ -235,10 +235,22 @@ pub const FetchOpts = struct {
writer: ?*std.Io.Writer = null,
json: bool = false,
};
+
+/// `.load`, not `.done`: pages with constant background activity never go
+/// quiescent, so `.done` just rides the `wait_ms` cap. A lone
+/// `wait_selector`/`wait_script` is itself the wait, so no level applies
+/// unless given explicitly.
+fn resolveWaitUntil(opts: FetchOpts) ?Config.WaitUntil {
+ if (opts.wait_until) |wu| return wu;
+ if (opts.wait_selector == null and opts.wait_script == null) return .load;
+ return null;
+}
/// Loads each url in `urls` in a fresh session and waits per `opts`.
///
/// Errors:
-/// - `error.Timeout` if the wait deadline (`opts.wait_ms`) expires.
+/// - `error.Timeout` if the deadline expires while a `wait_selector` or
+/// `wait_script` is still unmet. The `wait_until` phase never raises it:
+/// when the budget runs out the page is dumped as-is.
/// - `error.Cancelled` if the embedder installed a `Session.cancel_hook`
/// that returned true during the wait. The hook is opt-in via
/// `session.cancel_hook = .{...}`; without it, this error never fires.
@@ -285,13 +297,8 @@ pub fn fetch(app: *App, browser: *Browser, urls: []const [:0]const u8, opts: Fet
var timer: std.Io.Timestamp = .now(io, .boot);
- if (opts.wait_until) |wu| {
+ if (resolveWaitUntil(opts)) |wu| {
try runner.waitForAll(opts.wait_ms, .{ .until = wu });
- } else if (opts.wait_selector == null and opts.wait_script == null) {
- // We default to .done if both wait_selector and wait_script are null
- // This allows the caller to ONLY --wait-selector or ONLY --wait-script
- // or combine --wait-until WITH --wait-selector/script
- try runner.waitForAll(opts.wait_ms, .{ .until = .done });
}
if (opts.wait_selector) |selector| {
@@ -554,6 +561,17 @@ test "writeJsonEnvelope: null frame with dump mode and content" {
}, aw.written());
}
+test "fetch: resolveWaitUntil" {
+ try testing.expectEqual(.load, resolveWaitUntil(.{ .dump = .{} }));
+ try testing.expectEqual(.done, resolveWaitUntil(.{ .dump = .{}, .wait_until = .done }));
+ try testing.expectEqual(null, resolveWaitUntil(.{ .dump = .{}, .wait_selector = "#main" }));
+ try testing.expectEqual(null, resolveWaitUntil(.{ .dump = .{}, .wait_script = "true" }));
+ try testing.expectEqual(
+ .networkidle,
+ resolveWaitUntil(.{ .dump = .{}, .wait_until = .networkidle, .wait_selector = "#main" }),
+ );
+}
+
test {
std.testing.refAllDecls(@This());
}
diff --git a/src/main.zig b/src/main.zig
index 26ae7f24d..12f9e9c99 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -196,8 +196,10 @@ fn run(allocator: Allocator, main_arena: Allocator, proc_args: std.process.Args)
try sighandler.deadline(ms);
}
- var worker_thread = try std.Thread.spawn(.{}, fetchThread, .{ app, &ft, urls, fetch_opts });
+ var fetch_err: ?anyerror = null;
+ var worker_thread = try std.Thread.spawn(.{}, fetchThread, .{ app, &ft, urls, fetch_opts, &fetch_err });
worker_thread.join();
+ if (fetch_err) |err| return err;
},
.mcp => |opts| {
log.info(.mcp, "starting server", .{});
@@ -209,11 +211,11 @@ fn run(allocator: Allocator, main_arena: Allocator, proc_args: std.process.Args)
if (opts.port) |port| {
if (opts.cdp_port != null) {
log.fatal(.mcp, "port conflicts with cdp-port", .{ .hint = "both need the single network listener" });
- return;
+ return error.InvalidArgument;
}
const address = std.Io.net.IpAddress.parse(opts.host, port) catch |err| {
log.fatal(.mcp, "invalid address", .{ .err = err, .host = opts.host, .port = port });
- return;
+ return err;
};
const http_server = try lp.mcp.HttpServer.init(allocator, app);
defer http_server.deinit();
@@ -221,6 +223,7 @@ fn run(allocator: Allocator, main_arena: Allocator, proc_args: std.process.Args)
// a signal stops the accept loop, run() returns, deinit joins.
http_server.run(address) catch |err| {
log.fatal(.mcp, "mcp http error", .{ .err = err });
+ return err;
};
return;
}
@@ -236,15 +239,19 @@ fn run(allocator: Allocator, main_arena: Allocator, proc_args: std.process.Args)
}
defer if (cdp_server) |s| s.deinit();
- var worker_thread = try std.Thread.spawn(.{}, mcpThread, .{ allocator, app });
- defer worker_thread.join();
+ var mcp_err: ?anyerror = null;
+ {
+ var worker_thread = try std.Thread.spawn(.{}, mcpThread, .{ allocator, app, &mcp_err });
+ defer worker_thread.join();
- // mcp talks over stdio on mcpThread. Only run the CDP accept/read
- // loop when an optional CDP server was started; otherwise the main
- // thread just waits for the worker.
- if (cdp_server != null) {
- app.network.run();
+ // mcp talks over stdio on mcpThread. Only run the CDP accept/read
+ // loop when an optional CDP server was started; otherwise the main
+ // thread just waits for the worker.
+ if (cdp_server != null) {
+ app.network.run();
+ }
}
+ if (mcp_err) |err| return err;
},
.agent => |opts| {
log.info(.app, "starting agent", .{});
@@ -343,11 +350,12 @@ const FetchTerminator = struct {
}
};
-fn fetchThread(app: *App, ft: *FetchTerminator, urls: []const [:0]const u8, fetch_opts: lp.FetchOpts) void {
+fn fetchThread(app: *App, ft: *FetchTerminator, urls: []const [:0]const u8, fetch_opts: lp.FetchOpts, err_out: *?anyerror) void {
defer app.network.stop();
var browser: lp.Browser = undefined;
browser.init(app, .{}, null) catch |err| {
+ err_out.* = err;
log.fatal(.app, "browser init error", .{ .err = err });
return;
};
@@ -360,15 +368,17 @@ fn fetchThread(app: *App, ft: *FetchTerminator, urls: []const [:0]const u8, fetc
defer ft.releaseBrowser();
lp.fetch(app, &browser, urls, fetch_opts) catch |err| {
+ err_out.* = err;
log.fatal(.app, "fetch error", .{ .err = err, .url_count = urls.len });
};
}
-fn mcpThread(allocator: std.mem.Allocator, app: *App) void {
+fn mcpThread(allocator: std.mem.Allocator, app: *App, err_out: *?anyerror) void {
defer app.network.stop();
var stdout = std.Io.File.stdout().writerStreaming(lp.io, &.{});
var mcp_server: *lp.mcp.Server = lp.mcp.Server.init(allocator, app, &stdout.interface) catch |err| {
+ err_out.* = err;
log.fatal(.mcp, "mcp init error", .{ .err = err });
return;
};
@@ -377,6 +387,7 @@ fn mcpThread(allocator: std.mem.Allocator, app: *App) void {
var stdin_buf: [64 * 1024]u8 = undefined;
var stdin = std.Io.File.stdin().readerStreaming(lp.io, &stdin_buf);
lp.mcp.router.processRequests(mcp_server, &stdin.interface, std.Io.File.stdin()) catch |err| {
+ err_out.* = err;
log.fatal(.mcp, "mcp error", .{ .err = err });
};
}
diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig
index 5b815077c..47ff1c746 100644
--- a/src/mcp/tools.zig
+++ b/src/mcp/tools.zig
@@ -139,7 +139,7 @@ fn dispatchBrowserTool(
error.Timeout => .Timeout,
error.NavigationFailed, error.InternalError, error.OutOfMemory => .InternalError,
};
- return server.sendError(id, code, @errorName(err));
+ return server.sendError(id, code, browser_tools.errorMessage(err));
};
try sendToolResultText(server, id, result.text, result.is_error);
@@ -930,6 +930,34 @@ test "MCP - tree rejects stale backendNodeId instead of dumping whole document"
try testing.expect(std.mem.indexOf(u8, written, "NodeNotFound") != null);
}
+test "MCP - tree treats zero-filled backendNodeId as omitted" {
+ var out: std.Io.Writer.Allocating = .init(testing.arena_allocator);
+ const server = try testLoadPage("http://localhost:9582/src/browser/tests/mcp_actions.html", &out.writer);
+ defer server.deinit();
+
+ const msg =
+ \\{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"tree","arguments":{"backendNodeId":0,"maxDepth":3}}}
+ ;
+ try router.handleMessage(server, testing.arena_allocator, msg);
+ const written = out.written();
+ try testing.expect(std.mem.indexOf(u8, written, "NodeNotFound") == null);
+ try testing.expect(std.mem.indexOf(u8, written, "\"isError\":true") == null);
+}
+
+test "MCP - stale backendNodeId surfaces recovery guidance" {
+ var out: std.Io.Writer.Allocating = .init(testing.arena_allocator);
+ const server = try testLoadPage("http://localhost:9582/src/browser/tests/mcp_actions.html", &out.writer);
+ defer server.deinit();
+
+ const msg =
+ \\{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"tree","arguments":{"backendNodeId":999999}}}
+ ;
+ try router.handleMessage(server, testing.arena_allocator, msg);
+ const written = out.written();
+ try testing.expect(std.mem.indexOf(u8, written, "NodeNotFound") != null);
+ try testing.expect(std.mem.indexOf(u8, written, "omit backendNodeId") != null);
+}
+
test "MCP - PascalCase argument keys from LLMs are normalized to canonical" {
var out: std.Io.Writer.Allocating = .init(testing.arena_allocator);
const server = try testLoadPage("http://localhost:9582/src/browser/tests/mcp_actions.html", &out.writer);
@@ -1268,7 +1296,7 @@ test "MCP - waitForSelector: timeout" {
try router.handleMessage(server, testing.arena_allocator, msg);
try testing.expectJson(.{
.id = 1,
- .@"error" = .{ .message = "NodeNotFound" },
+ .@"error" = .{ .message = browser_tools.errorMessage(error.NodeNotFound) },
}, out.written());
}
diff --git a/src/network/HttpClient.zig b/src/network/HttpClient.zig
index 32155136b..90a2c4eab 100644
--- a/src/network/HttpClient.zig
+++ b/src/network/HttpClient.zig
@@ -26,6 +26,7 @@ const Notification = @import("../Notification.zig");
const CDP = @import("../cdp/CDP.zig");
const Watchdog = @import("../Watchdog.zig");
const URL = @import("../browser/URL.zig");
+const referrer = @import("../browser/referrer.zig");
const WebSocket = @import("../browser/webapi/net/WebSocket.zig");
const CookieJar = @import("../browser/webapi/storage/Cookie.zig").Jar;
@@ -1071,7 +1072,7 @@ const SyncContext = struct {
lp.assert(transfer.responseStatus() != null, "HttpClient.SyncRequest.headerCallback", .{ .value = transfer.responseStatus() });
self.status = transfer.responseStatus().?;
if (transfer.getContentLength()) |cl| {
- try self.body.ensureTotalCapacity(try self.bodyAllocator(cl), cl);
+ try self.body.ensureTotalCapacityPrecise(try self.bodyAllocator(cl), cl);
}
return .proceed;
}
@@ -1447,11 +1448,24 @@ fn processOneMessage(self: *Client, msg: http.Handles.MultiMessage, transfer: *T
// Handle redirects: reuse the same connection to preserve TCP state.
// A redirect status without a Location header is not a redirect, it's a
// final response and falls through so its body is delivered.
- if (effective_err == null) {
+ // When the server closes the TLS connection without a close_notify alert,
+ // BoringSSL reports RecvError. If we already received valid HTTP headers,
+ // this is a normal end-of-body (the connection closure signals the end
+ // of the response per HTTP/1.1 when there is no Content-Length).
+ // We must check this before endTransfer, which may reset the easy handle.
+ const is_conn_close_recv = blk: {
+ const err = effective_err orelse break :blk false;
+ if (err != error.RecvError) break :blk false;
+ const hdr = msg.conn.getResponseHeader("connection", 0) orelse break :blk true;
+ break :blk std.ascii.eqlIgnoreCase(hdr.value, "close");
+ };
+
+ if (effective_err == null or is_conn_close_recv) {
const status = try msg.conn.getResponseCode();
if (isRedirectStatus(status)) {
if (msg.conn.getResponseHeader("location", 0)) |location| switch (transfer.req.redirect) {
.follow => {
+ transfer.restoreInterceptHeaders();
try transfer.handleRedirect(location.value);
if (self.isUrlBlocked(transfer.req.url, transfer.req.internal)) {
@@ -1464,6 +1478,28 @@ fn processOneMessage(self: *Client, msg: http.Handles.MultiMessage, transfer: *T
if (!transfer.req.internal) lp.metrics.http_redirects.incr();
+ if (self.serve_mode) { // e.g. cdp
+ var wait_for_interception = false;
+ transfer.req.notification.dispatch(.http_request_intercept, &.{
+ .transfer = transfer,
+ .wait_for_interception = &wait_for_interception,
+ });
+
+ if (wait_for_interception) {
+ transfer.req.notification.dispatch(.http_request_start, &.{ .transfer = transfer });
+
+ // Same shape as the auth-interception park above:
+ // give up the connection, wait for the CDP client.
+ self.removeConn(msg.conn);
+ transfer._conn = null;
+ transfer.reset();
+ transfer.state = .created;
+ self.intercepted += 1;
+ transfer.park(.intercept_request);
+ return false;
+ }
+ }
+
const conn = transfer._conn.?;
try self.handles.remove(conn);
@@ -1502,18 +1538,6 @@ fn processOneMessage(self: *Client, msg: http.Handles.MultiMessage, transfer: *T
// transfer's arena, release the conn, and buffer the events — user
// callbacks run later, from dispatch(), never from here.
- // When the server closes the TLS onnection without a close_notify alert,
- // BoringSSL reports RecvError. If we already received valid HTTP headers,
- // this is a normal end-of-body (the connection closure signals the end
- // of the response per HTTP/1.1 when there is no Content-Length).
- // We must check this before endTransfer, which may reset the easy handle.
- const is_conn_close_recv = blk: {
- const err = effective_err orelse break :blk false;
- if (err != error.RecvError) break :blk false;
- const hdr = msg.conn.getResponseHeader("connection", 0) orelse break :blk true;
- break :blk std.ascii.eqlIgnoreCase(hdr.value, "close");
- };
-
if (effective_err != null and !is_conn_close_recv) {
self.removeConn(msg.conn);
transfer._conn = null;
@@ -1634,6 +1658,7 @@ pub const Request = struct {
cookie_origin: [:0]const u8,
resource_type: ResourceType,
redirect: RedirectMode = .follow,
+ referrer_policy: ?referrer.Policy = null,
credentials: ?[:0]const u8 = null,
notification: *Notification,
timeout_ms: u32 = 0,
@@ -1913,6 +1938,10 @@ pub const Transfer = struct {
_tries: u8 = 0,
_redirect_count: u8 = 0,
+ // Fetch.continueRequest header overrides apply to a single network hop. We
+ // need to restore (and hence capture) the original headers.
+ _intercept_original_headers: ?[]const RequestHeader = null,
+
// Linked into client.pending_queue while .queued; reused to link the
// retired transfer into client.graveyard (deinit unlinks it from the
// pending queue first, so the node is always free by then).
@@ -2577,6 +2606,21 @@ pub const Transfer = struct {
}
}
+ // A Referrer-Policy header on a redirect response applies to the
+ // remaining hops.
+ if (req.referrer_policy != null) {
+ // referrer-policy is re-applied on every hop.
+ var i: usize = 0;
+ while (conn.getResponseHeader("referrer-policy", i)) |hdr| : (i += 1) {
+ if (referrer.parseHeader(hdr.value)) |policy| {
+ req.referrer_policy = policy;
+ }
+ if (i >= hdr.amount) {
+ break;
+ }
+ }
+ }
+
// base_url and location are owned by curl; applyRedirectTarget resolves a
// fresh arena-owned copy that gets stored in transfer.req.url.
const base_url = try conn.getEffectiveUrl();
@@ -2624,6 +2668,21 @@ pub const Transfer = struct {
req.method = .GET;
req.body = null;
}
+
+ if (req.referrer_policy) |policy| {
+ // Referer header was applied based on the original target. It
+ // needs to be updated based on the redirect target. A redirect can
+ // only strip it (full -> origin -> none), so we can use whatever
+ // value we have now as the base
+ if (transfer.findRequestHeader("referer")) |current| {
+ const alloc = arena.allocator();
+ if (try referrer.compute(alloc, policy, try alloc.dupeZ(u8, current), req.url)) |value| {
+ try transfer.setHeader("Referer", value, .{});
+ } else {
+ transfer.removeHeader("Referer");
+ }
+ }
+ }
}
fn detectAuthChallenge(transfer: *Transfer, conn: *const http.Connection) void {
@@ -2675,6 +2734,26 @@ pub const Transfer = struct {
});
}
+ pub fn findRequestHeader(self: *const Transfer, name: []const u8) ?[]const u8 {
+ for (self.req_headers.items) |hdr| {
+ if (std.ascii.eqlIgnoreCase(hdr.name, name)) {
+ return hdr.value;
+ }
+ }
+ return null;
+ }
+
+ fn removeHeader(self: *Transfer, name: []const u8) void {
+ var i: usize = 0;
+ while (i < self.req_headers.items.len) {
+ if (std.ascii.eqlIgnoreCase(self.req_headers.items[i].name, name)) {
+ _ = self.req_headers.orderedRemove(i);
+ continue;
+ }
+ i += 1;
+ }
+ }
+
// Adds, replacing every existing header with the same case-insensitive name
pub fn setHeader(self: *Transfer, name: []const u8, value: []const u8, opts: HeaderOpts) !void {
var found = false;
@@ -2709,6 +2788,8 @@ pub const Transfer = struct {
// CDP Fetch.continueRequest: the intercepting client supplies the
// complete header set, replacing whatever the request carried.
pub fn replaceRequestHeaders(self: *Transfer, headers: []const http.Header) !void {
+ lp.assert(self._intercept_original_headers == null, "Transfer.replaceRequestHeaders", .{ .id = self.id });
+ self._intercept_original_headers = try self.arena.allocator().dupe(RequestHeader, self.req_headers.items);
self.req_headers.clearRetainingCapacity();
try self.seedHeaders();
for (headers) |hdr| {
@@ -2716,6 +2797,15 @@ pub const Transfer = struct {
}
}
+ fn restoreInterceptHeaders(self: *Transfer) void {
+ const headers = self._intercept_original_headers orelse return;
+ self.req_headers.clearRetainingCapacity();
+ // _intercept_original_headers.items.len MIGHT be larger than self.req_headers.items.len
+ // but the capacity never shrank from when _intercept_original_headers WAS req_headers.
+ self.req_headers.appendSliceAssumeCapacity(headers);
+ self._intercept_original_headers = null;
+ }
+
// abortAuthChallenge is called when an auth challenge interception is
// abort. We don't call self.releaseConn here b/c it has been done
// before interception process.
@@ -2761,7 +2851,7 @@ pub const Transfer = struct {
res.callback_error = error.ResponseTooLarge;
return http.writefunc_error;
}
- res.buffer.ensureTotalCapacity(transfer.arena.allocator(), cl) catch {};
+ res.buffer.ensureTotalCapacityPrecise(transfer.arena.allocator(), cl) catch {};
}
}
@@ -3373,6 +3463,33 @@ test "HttpClient: Transfer.setHeader replaces by case-insensitive name" {
try testing.expectEqual("yes", headers[2].value);
}
+test "HttpClient: Fetch header overrides restore after one hop" {
+ const original = [_]Transfer.RequestHeader{
+ .{ .name = "User-Agent", .value = "original" },
+ .{ .name = "X-Original", .value = "yes" },
+ };
+ var overridden = [_]Transfer.RequestHeader{
+ .{ .name = "User-Agent", .value = "override" },
+ .{ .name = "X-Override", .value = "yes" },
+ };
+
+ var transfer: Transfer = undefined;
+ transfer.req_headers = .{ .items = &overridden, .capacity = overridden.len };
+ transfer._intercept_original_headers = &original;
+ transfer.restoreInterceptHeaders();
+
+ try testing.expectEqual(2, transfer.req_headers.items.len);
+ try testing.expectEqual("User-Agent", transfer.req_headers.items[0].name);
+ try testing.expectEqual("original", transfer.req_headers.items[0].value);
+ try testing.expectEqual("X-Original", transfer.req_headers.items[1].name);
+ try testing.expectEqual("yes", transfer.req_headers.items[1].value);
+ try testing.expectEqual(null, transfer._intercept_original_headers);
+
+ // idempotent once restored
+ transfer.restoreInterceptHeaders();
+ try testing.expectEqual(2, transfer.req_headers.items.len);
+}
+
test "HttpClient: fulfillIntercepted survives a done_callback that tears down the owner" {
// Regression: the fulfilled response's done_callback runs JS which
// navigates / closes the page, re-entrantly killing the transfer
diff --git a/src/network/Network.zig b/src/network/Network.zig
index ceaf93f0d..a48205d0b 100644
--- a/src/network/Network.zig
+++ b/src/network/Network.zig
@@ -233,7 +233,11 @@ pub fn init(allocator: Allocator, app: *App, config: *const Config) !Network {
const cache = if (config.httpCacheDir()) |cache_dir_path|
Cache{
.kind = .{
- .sqlite = SqliteCache.init(allocator, .{ .path = cache_dir_path }) catch |e| {
+ .sqlite = SqliteCache.init(
+ allocator,
+ .{ .path = cache_dir_path },
+ config.httpCacheEntryLimit(),
+ ) catch |e| {
log.err(.cache, "failed to init", .{
.kind = "SqliteCache",
.path = cache_dir_path,
diff --git a/src/network/RobotsGate.zig b/src/network/RobotsGate.zig
index a4d025c54..552132a89 100644
--- a/src/network/RobotsGate.zig
+++ b/src/network/RobotsGate.zig
@@ -214,7 +214,7 @@ const RobotsContext = struct {
}
lp.metrics.robots_status.incr(http.statusCategory(self.status));
if (transfer.getContentLength()) |cl| {
- try self.buffer.ensureTotalCapacity(self.arena.allocator(), cl);
+ try self.buffer.ensureTotalCapacityPrecise(self.arena.allocator(), cl);
}
return .proceed;
}
diff --git a/src/network/cache/SqliteCache.zig b/src/network/cache/SqliteCache.zig
index 88541f127..45322e45e 100644
--- a/src/network/cache/SqliteCache.zig
+++ b/src/network/cache/SqliteCache.zig
@@ -41,6 +41,7 @@ pub const SqliteCache = @This();
allocator: std.mem.Allocator,
pool: Pool,
+entry_limit: u32,
const cache_migrations: []const Migration = &.{
.{ .sql =
@@ -67,6 +68,7 @@ const cache_migrations: []const Migration = &.{
\\ ) strict
},
.{ .sql = "create index header_url on header(url)" },
+ .{ .sql = "create index cache_stored_at on cache(stored_at)" },
};
pub const SqliteCachePath = union(enum) {
@@ -85,7 +87,7 @@ pub const SqliteCachePath = union(enum) {
}
};
-pub fn init(allocator: std.mem.Allocator, path: SqliteCachePath) !SqliteCache {
+pub fn init(allocator: std.mem.Allocator, path: SqliteCachePath, entry_limit: u32) !SqliteCache {
var pool = switch (path) {
.memory => try Pool.init(allocator, ":memory:"),
.path => |cache_dir| blk: {
@@ -124,14 +126,28 @@ pub fn init(allocator: std.mem.Allocator, path: SqliteCachePath) !SqliteCache {
try conn.exec("pragma foreign_keys=on", .{});
}
- log.info(.cache, "sqlite cache initialized", .{ .path = path, .version = version });
- return .{ .allocator = allocator, .pool = pool };
+ log.info(.cache, "sqlite cache initialized", .{ .path = path, .entry_limit = entry_limit, .version = version });
+ return .{ .allocator = allocator, .pool = pool, .entry_limit = entry_limit };
}
pub fn deinit(self: *SqliteCache) void {
self.pool.deinit(self.allocator);
}
+fn evictOverflow(self: *SqliteCache, conn: Conn) !void {
+ const limit = self.entry_limit;
+ if (limit == 0) return;
+
+ try conn.exec(
+ \\ delete from cache
+ \\ where rowid in (
+ \\ select rowid from cache
+ \\ order by stored_at desc
+ \\ limit -1 offset $1
+ \\ )
+ , .{@as(i64, @intCast(limit))});
+}
+
pub fn get(self: *SqliteCache, arena: std.mem.Allocator, req: CacheGetRequest) !CacheGetResult {
const conn = try self.pool.acquire();
defer self.pool.release(conn);
@@ -270,6 +286,7 @@ pub fn put(self: *SqliteCache, req: CachePutRequest, body: []const u8) !void {
);
}
+ try self.evictOverflow(conn);
try conn.commit();
log.debug(.cache, "put", .{ .url = req.url, .body_len = body.len });
@@ -376,7 +393,7 @@ pub fn renew(self: *SqliteCache, _: std.mem.Allocator, req: RenewResponse) !void
const testing = std.testing;
fn setupCache(allocator: std.mem.Allocator) !Cache {
- return Cache{ .kind = .{ .sqlite = try .init(allocator, .memory) } };
+ return Cache{ .kind = .{ .sqlite = try .init(allocator, .memory, 0) } };
}
test "SqliteCache: Migrations" {
@@ -900,3 +917,50 @@ test "SqliteCache: renew preserves body" {
try testing.expect(result == .hit);
try testing.expectEqualStrings("original body", result.hit.data.buffer);
}
+
+test "SqliteCache: evicts oldest entries over the limit" {
+ var cache = Cache{ .kind = .{ .sqlite = try .init(testing.allocator, .memory, 3) } };
+ defer cache.deinit();
+
+ var arena = std.heap.ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+
+ const now: u64 = @intCast(std.Io.Timestamp.now(testing.io, .boot).toSeconds());
+
+ const urls = [_][:0]const u8{
+ "https://example.com/a",
+ "https://example.com/b",
+ "https://example.com/c",
+ "https://example.com/d",
+ };
+
+ for (urls, 0..) |url, i| {
+ try cache.put(.{
+ .url = url,
+ .content_type = "text/html",
+ .status = 200,
+ .stored_at = now + i,
+ .age_at_store = 0,
+ .cache_control = .{ .max_age = 600 },
+ .headers = &.{},
+ .vary_headers = &.{},
+ }, url);
+ }
+
+ const evicted = try cache.get(arena.allocator(), .{
+ .url = "https://example.com/a",
+ .timestamp = now,
+ .request_headers = &.{},
+ });
+ try testing.expect(evicted == .miss);
+
+ for (urls[1..]) |url| {
+ const hit = try cache.get(arena.allocator(), .{
+ .url = url,
+ .timestamp = now + urls.len,
+ .request_headers = &.{},
+ });
+ try testing.expect(hit == .hit);
+ try testing.expectEqualStrings(url, hit.hit.data.buffer);
+ }
+}
diff --git a/src/network/header_parser.zig b/src/network/header_parser.zig
index c1842ee8c..23545a233 100644
--- a/src/network/header_parser.zig
+++ b/src/network/header_parser.zig
@@ -197,7 +197,11 @@ pub const Header = struct {
// Found where header value starts.
const val_start = cursor.current();
matchHeaderValue(cursor);
- const val_end = cursor.current();
+ var val_end = cursor.current();
+ // Trailing OWS is legal on the wire but not part of the field value;
+ // parsers must exclude it (RFC 9112 §5.1). Spaces and HTABs are valid
+ // mid-value, so the scan above can't do this on its own.
+ while (val_end != val_start and ((val_end - 1)[0] == ' ' or (val_end - 1)[0] == '\t')) : (val_end -= 1) {}
// Buffer has been consumed fully without a line ending; the caller
// can read more data and try to parse again.
@@ -228,8 +232,218 @@ pub const Header = struct {
.value = val_start[0 .. val_end - val_start],
};
}
+
+ pub const Iterator = struct {
+ cursor: Cursor,
+ /// Set once the blank line terminating the header block is consumed.
+ done: bool = false,
+
+ pub fn next(self: *Iterator) ParseError!?Header {
+ if (self.done) {
+ return null;
+ }
+ // Running out of buffer before the terminating blank line is a
+ // truncation, not a clean end; only the blank line yields null.
+ if (self.cursor.reachedEnd()) {
+ return error.Incomplete;
+ }
+ // Check if headers part has finished.
+ switch (self.cursor.char()) {
+ '\n' => {
+ // End of headers.
+ self.cursor.advance(1);
+ self.done = true;
+ return null;
+ },
+ '\r' => {
+ // We need an LF too.
+ if (!self.cursor.hasLength(2)) {
+ return error.Incomplete;
+ }
+ if (!self.cursor.peek2('\r', '\n')) {
+ return error.Invalid;
+ }
+ // End of headers.
+ self.cursor.advance(2);
+ self.done = true;
+ return null;
+ },
+ else => {},
+ }
+
+ var header: Header = undefined;
+ try header.parse(&self.cursor);
+ return header;
+ }
+ };
};
+pub const Method = union(enum) {
+ get: void,
+ post: void,
+ head: void,
+ put: void,
+ delete: void,
+ connect: void,
+ options: void,
+ trace: void,
+ patch: void,
+ /// Non-standard HTTP method.
+ custom: []const u8,
+};
+
+/// Parses an HTTP method, checks for shortest possible request length too.
+fn parseMethod(cursor: *Cursor) !Method {
+ // Though the shortest standardized HTTP method is GET, we can receive
+ // a single character for method on non-standard ones.
+ //
+ // A / HTTP/1.1\n
+ const min_request_len = 13;
+ if (cursor.hasLength(min_request_len) == false) {
+ return error.Incomplete;
+ }
+
+ const m_u32: u32 = cursor.asInteger(u32);
+ // Advance as much as integer value.
+ cursor.advance(4);
+ switch (m_u32) {
+ // "GET "
+ @as(u32, @bitCast([_]u8{ 'G', 'E', 'T', ' ' })) => {
+ return .{ .get = {} };
+ },
+ // "POST"
+ @as(u32, @bitCast([_]u8{ 'P', 'O', 'S', 'T' })) => {
+ if (cursor.peek(' ')) {
+ cursor.advance(1);
+ return .{ .post = {} };
+ }
+ },
+ // "HEAD"
+ @as(u32, @bitCast([_]u8{ 'H', 'E', 'A', 'D' })) => {
+ if (cursor.peek(' ')) {
+ cursor.advance(1);
+ return .{ .head = {} };
+ }
+ },
+ // "PUT "
+ @as(u32, @bitCast([_]u8{ 'P', 'U', 'T', ' ' })) => {
+ return .{ .put = {} };
+ },
+ // "DELE"
+ @as(u32, @bitCast([_]u8{ 'D', 'E', 'L', 'E' })) => {
+ // "TE "
+ if (cursor.peek3('T', 'E', ' ')) {
+ cursor.advance(3);
+ return .{ .delete = {} };
+ }
+ },
+ // "CONN"
+ @as(u32, @bitCast([_]u8{ 'C', 'O', 'N', 'N' })) => {
+ // "ECT "
+ if (cursor.peek4('E', 'C', 'T', ' ')) {
+ cursor.advance(4);
+ return .{ .connect = {} };
+ }
+ },
+ // "OPTI"
+ @as(u32, @bitCast([_]u8{ 'O', 'P', 'T', 'I' })) => {
+ // "ONS "
+ if (cursor.peek4('O', 'N', 'S', ' ')) {
+ cursor.advance(4);
+ return .{ .options = {} };
+ }
+ },
+ // "TRAC"
+ @as(u32, @bitCast([_]u8{ 'T', 'R', 'A', 'C' })) => {
+ // "E "
+ if (cursor.peek2('E', ' ')) {
+ cursor.advance(2);
+ return .{ .trace = {} };
+ }
+ },
+ // "PATC"
+ @as(u32, @bitCast([_]u8{ 'P', 'A', 'T', 'C' })) => {
+ // "H "
+ if (cursor.peek2('H', ' ')) {
+ cursor.advance(2);
+ return .{ .patch = {} };
+ }
+ },
+ // Custom method.
+ else => {},
+ }
+
+ // That 4 bytes we've consumed earlier might've a delimiter in them.
+ cursor.rewind(4);
+ // Continue until first space appearance.
+ const method_start = cursor.current();
+ while (cursor.end - cursor.current() > 0 and cursor.char() != ' ') : (cursor.advance(1)) {}
+ if (cursor.reachedEnd()) {
+ return error.Incomplete;
+ }
+ const method_end = cursor.current();
+ // 0 length.
+ if (method_end == method_start) {
+ return error.Invalid;
+ }
+ // Consume the delimiting space.
+ cursor.advance(1);
+ return .{ .custom = method_start[0 .. method_end - method_start] };
+}
+
+pub const HttpVersion = enum(u1) { @"1.1", @"1.0" };
+
+pub fn parseRequest(bytes: []const u8) !struct { Method, []const u8, HttpVersion, Header.Iterator } {
+ var cursor = Cursor{ .idx = bytes.ptr, .start = bytes.ptr, .end = bytes.ptr + bytes.len };
+ const method = try parseMethod(&cursor);
+ // Parse path.
+ const path_start = cursor.current();
+ // Find the first space.
+ while (cursor.end - cursor.current() > 0 and cursor.char() != ' ') : (cursor.advance(1)) {}
+ const path_end = cursor.current();
+ // 0 length path.
+ if (path_start == path_end) {
+ return error.Invalid;
+ }
+ const path = path_start[0 .. path_end - path_start];
+
+ // Skip past the delimiting space(s); the scan above guarantees we're on
+ // a space or at the end, and recipients may parse on whitespace
+ // boundaries (RFC 9112 §3).
+ cursor.skipSpaces();
+
+ // HTTP/1.1(\r)\n
+ if (cursor.hasLength(9) == false) {
+ return error.Incomplete;
+ }
+ const version: HttpVersion = switch (cursor.asInteger(u64)) {
+ @as(u64, @bitCast(@as([]const u8, "HTTP/1.1")[0..8].*)) => .@"1.1",
+ @as(u64, @bitCast(@as([]const u8, "HTTP/1.0")[0..8].*)) => .@"1.0",
+ else => return error.Invalid,
+ };
+ cursor.advance(8);
+
+ // Trailing (CR)LF.
+ switch (cursor.char()) {
+ '\n' => cursor.advance(1),
+ '\r' => {
+ // We need an LF too.
+ if (!cursor.hasLength(2)) {
+ return error.Incomplete;
+ }
+ if (!cursor.peek2('\r', '\n')) {
+ @branchHint(.unlikely);
+ return error.Invalid;
+ }
+ cursor.advance(2);
+ },
+ // Any other character is invalid.
+ else => return error.Invalid,
+ }
+
+ return .{ method, path, version, .{ .cursor = cursor } };
+}
+
pub const Disposition = struct {
name: ?[]const u8 = null,
filename: ?[]const u8 = null,
@@ -243,7 +457,8 @@ pub const Disposition = struct {
// `filename*=UTF-8''...`, which real servers emit — are skipped rather than
// failing the whole parse, matching browser leniency.
pub fn parseDisposition(value: []const u8) !Disposition {
- var rest = std.mem.trim(u8, value, " \t");
+ // `Header.parse` already excludes the surrounding OWS from the value.
+ var rest = value;
if (!std.ascii.startsWithIgnoreCase(rest, "form-data")) {
return error.InvalidFormData;
}
@@ -317,6 +532,12 @@ pub const Cursor = struct {
cursor.idx += by;
}
+ /// Rewinds the position of the cursor by given value.
+ /// SAFETY: This function doesn't check if out of bounds reachable.
+ pub fn rewind(cursor: *Cursor, by: usize) void {
+ cursor.idx -= by;
+ }
+
/// Checks if buffer has `len` length of characters.
/// `(cursor.end - cursor.idx >= len)`
pub fn hasLength(cursor: *const Cursor, len: usize) bool {
@@ -336,12 +557,29 @@ pub const Cursor = struct {
return @bitCast(cursor.idx[0 .. @bitSizeOf(T) / @bitSizeOf(u8)].*);
}
+ /// Peek the current character but don't advance.
+ pub fn peek(cursor: *const Cursor, c: u8) bool {
+ return cursor.idx[0] == c;
+ }
+
/// Peek the current and the next but don't advance.
/// SAFETY: This function doesn't check if out of bounds reachable.
pub fn peek2(cursor: *const Cursor, c0: u8, c1: u8) bool {
return cursor.asInteger(u16) == @as(u16, @bitCast([2]u8{ c0, c1 }));
}
+ /// Peek the current and next 2 characters but don't advance.
+ /// SAFETY: This function doesn't check if out of bounds reachable.
+ pub fn peek3(cursor: *const Cursor, c0: u8, c1: u8, c2: u8) bool {
+ return cursor.idx[0] == c0 and cursor.idx[1] == c1 and cursor.idx[2] == c2;
+ }
+
+ /// Peek the current and next 3 characters but don't advance.
+ /// SAFETY: This function doesn't check if out of bounds reachable.
+ pub fn peek4(cursor: *const Cursor, c0: u8, c1: u8, c2: u8, c3: u8) bool {
+ return cursor.asInteger(u32) == @as(u32, @bitCast([4]u8{ c0, c1, c2, c3 }));
+ }
+
/// Moves the cursor until no leading spaces there are.
pub fn skipSpaces(cursor: *Cursor) void {
while (cursor.end - cursor.current() > 0 and (cursor.char() == ' ' or cursor.char() == '\t')) : (cursor.advance(1)) {}
@@ -368,6 +606,10 @@ fn consumed(cursor: *const Cursor) usize {
return cursor.idx - cursor.start;
}
+fn drain(it: *Header.Iterator) Header.ParseError!void {
+ while (try it.next()) |_| {}
+}
+
test "header_parser: parse HTTP header" {
const bytes = "Content-Disposition: attachment; filename*=UTF-8''file%20name.jpg\r\nrest";
var cursor = initCursor(bytes);
@@ -389,17 +631,20 @@ test "header_parser: parse HTTP header" {
try testing.expectString("def", header.value);
try testing.expectEqual(true, cursor.reachedEnd());
- // Leading whitespaces of the value — spaces and HTABs — are skipped,
- // trailing ones are kept.
+ // Leading and trailing whitespace of the value — spaces and HTABs — is
+ // excluded from the field value (RFC 9112 §5.1).
cursor = initCursor("Key: \t value \t\r\n");
try header.parse(&cursor);
- try testing.expectString("value \t", header.value);
+ try testing.expectString("value", header.value);
- // 0 length values are fine.
+ // 0 length values are fine, including whitespace-only ones.
cursor = initCursor("Key:\r\n");
try header.parse(&cursor);
try testing.expectString("Key", header.key);
try testing.expectString("", header.value);
+ cursor = initCursor("Key: \t \r\n");
+ try header.parse(&cursor);
+ try testing.expectString("", header.value);
// HTAB is legal field content, including next to spaces.
cursor = initCursor("Key: a\tb\t c\r\n");
@@ -439,14 +684,55 @@ test "header_parser: parse HTTP header invalid" {
}
}
+test "header_parser: header iterator" {
+ // Headers iterate until the terminating blank line; afterwards the
+ // iterator stays exhausted and the cursor is left at the body.
+ var it = Header.Iterator{ .cursor = initCursor("Host: a\r\nUpgrade: websocket\n\r\nbody") };
+ var header = (try it.next()).?;
+ try testing.expectString("Host", header.key);
+ try testing.expectString("a", header.value);
+ header = (try it.next()).?;
+ try testing.expectString("Upgrade", header.key);
+ try testing.expectString("websocket", header.value);
+ try testing.expectEqual(null, try it.next());
+ try testing.expectEqual(null, try it.next());
+ try testing.expectString("body", it.cursor.remaining());
+
+ // A blank line right away means no headers at all; both endings work.
+ it = .{ .cursor = initCursor("\r\n") };
+ try testing.expectEqual(null, try it.next());
+ it = .{ .cursor = initCursor("\n") };
+ try testing.expectEqual(null, try it.next());
+
+ // Running out of buffer before the terminating blank line is a
+ // truncation, not a clean end.
+ const truncated_cases = [_][]const u8{
+ "", // nothing at all
+ "Host: a\r\n", // header block never terminated
+ "Host: a\r\nUpg", // buffer ends mid-header
+ "\r", // `\r` still missing its `\n`
+ };
+ for (truncated_cases) |case| {
+ it = .{ .cursor = initCursor(case) };
+ try testing.expectError(error.Incomplete, drain(&it));
+ }
+
+ // Malformed headers surface as `Invalid`.
+ it = .{ .cursor = initCursor("Key\x01: v\r\n\r\n") };
+ try testing.expectError(error.Invalid, drain(&it));
+ it = .{ .cursor = initCursor("\rX\r\n") };
+ try testing.expectError(error.Invalid, drain(&it));
+}
+
test "header_parser: parse Content-Disposition" {
var d = try parseDisposition("form-data; name=\"a\"; filename=\"b.txt\"");
try testing.expectString("a", d.name.?);
try testing.expectString("b.txt", d.filename.?);
- // Type and parameter names are case-insensitive; surrounding whitespace
- // is tolerated.
- d = try parseDisposition(" Form-Data; NAME=\"x\" ");
+ // Type and parameter names are case-insensitive; whitespace between
+ // parameters is tolerated (the value's surrounding OWS is already
+ // excluded by `Header.parse`).
+ d = try parseDisposition("Form-Data; NAME=\"x\" ");
try testing.expectString("x", d.name.?);
try testing.expectEqual(null, d.filename);
@@ -501,6 +787,88 @@ test "header_parser: cursor" {
try testing.expectEqual(true, cursor.reachedEnd());
}
+test "header_parser: parse request" {
+ // Request line plus headers; the returned iterator picks up right after
+ // the line ending.
+ const bytes = "GET /json/version HTTP/1.1\r\nHost: a\r\n\r\n";
+ const method, const path, const version, var it = try parseRequest(bytes);
+ try testing.expectEqual(true, method == .get);
+ try testing.expectString("/json/version", path);
+ try testing.expectEqual(.@"1.1", version);
+ const header = (try it.next()).?;
+ try testing.expectString("Host", header.key);
+ try testing.expectString("a", header.value);
+ try testing.expectEqual(null, try it.next());
+
+ // HTTP/1.0 and lone `\n` line endings are accepted too.
+ const method10, _, const version10, _ = try parseRequest("GET / HTTP/1.0\n\n");
+ try testing.expectEqual(true, method10 == .get);
+ try testing.expectEqual(.@"1.0", version10);
+
+ // Multiple spaces before the version are tolerated (RFC 9112 §3).
+ const req = try parseRequest("GET / HTTP/1.1\r\n\r\n");
+ try testing.expectString("/", req[1]);
+
+ // Longer paths are accepted (but not validated).
+ const req2 = try parseRequest("GET /chat/room?id=1 HTTP/1.1\r\n\r\n");
+ try testing.expectString("/chat/room?id=1", req2[1]);
+}
+
+test "header_parser: parse request methods" {
+ const cases = [_]struct { []const u8, std.meta.Tag(Method) }{
+ .{ "GET / HTTP/1.1\r\n\r\n", .get },
+ .{ "POST / HTTP/1.1\r\n\r\n", .post },
+ .{ "HEAD / HTTP/1.1\r\n\r\n", .head },
+ .{ "PUT / HTTP/1.1\r\n\r\n", .put },
+ .{ "DELETE / HTTP/1.1\r\n\r\n", .delete },
+ .{ "CONNECT / HTTP/1.1\r\n\r\n", .connect },
+ .{ "OPTIONS / HTTP/1.1\r\n\r\n", .options },
+ .{ "TRACE / HTTP/1.1\r\n\r\n", .trace },
+ .{ "PATCH / HTTP/1.1\r\n\r\n", .patch },
+ };
+ for (cases) |case| {
+ const method, _, _, _ = try parseRequest(case[0]);
+ try testing.expectEqual(case[1], std.meta.activeTag(method));
+ }
+
+ // Unknown methods fall back to `custom`; a shared 4-byte prefix with a
+ // standard method or a single character both take that path.
+ const custom_cases = [_]struct { []const u8, []const u8 }{
+ .{ "BREW / HTTP/1.1\r\n\r\n", "BREW" },
+ .{ "POSTER / HTTP/1.1\r\n\r\n", "POSTER" },
+ .{ "GETX / HTTP/1.1\r\n\r\n", "GETX" },
+ .{ "A / HTTP/1.1\r\n\r\n", "A" },
+ };
+ for (custom_cases) |case| {
+ const method, _, _, _ = try parseRequest(case[0]);
+ try testing.expectString(case[1], method.custom);
+ }
+}
+
+test "header_parser: parse request invalid" {
+ const invalid_cases = [_][]const u8{
+ " / HTTP/1.1\r\n\r\n", // 0 length method
+ "GET HTTP/1.1\r\n\r\n", // 0 length path
+ "GET / HTTP/1.1\r\n\r\n", // double space, so a 0 length path
+ "GET / HTTP/2.0\r\n\r\n", // unknown version
+ "GET / http/1.1\r\n\r\n", // the version is case-sensitive
+ "GET / HTTP/1.1X\r\n\r\n", // junk after the version
+ "GET / HTTP/1.1\rX\n\r\n", // `\r` must be followed by `\n`
+ };
+ for (invalid_cases) |case| {
+ try testing.expectError(error.Invalid, parseRequest(case));
+ }
+
+ // Buffer may end anywhere before the request line ending is complete;
+ // `DELETE` exercises the multi-byte peek continuation and the path is
+ // longer than the minimum request length, proving neither scan can run
+ // past the end of a truncated buffer.
+ const bytes = "DELETE /websocket/endpoint HTTP/1.1\r\n";
+ for (0..bytes.len) |len| {
+ try testing.expectError(error.Incomplete, parseRequest(bytes[0..len]));
+ }
+}
+
fn expectMatchesReference(bytes: []const u8) !void {
var key_expected: usize = 0;
while (key_expected < bytes.len and isHeaderKeyByte(bytes[key_expected])) key_expected += 1;
diff --git a/src/script/Recorder.zig b/src/script/Recorder.zig
index 5eb8dd8a3..74fe8e5a0 100644
--- a/src/script/Recorder.zig
+++ b/src/script/Recorder.zig
@@ -38,6 +38,17 @@ content: std.Io.Writer.Allocating,
buf: std.Io.Writer.Allocating,
/// Reset per write — backs short-lived scrub allocations.
arena: std.heap.ArenaAllocator,
+/// The just-emitted `goto` line stays rewritable for exactly one step: a
+/// readiness wait recorded next (`Tool.waitsForReadiness`) swaps it for its
+/// `waitUntil: "domcontentloaded"` variant; anything else closes the window.
+pending_goto: ?PendingGoto,
+
+const PendingGoto = struct {
+ /// `content` length just before the emitted goto line.
+ start: usize,
+ /// Scrubbed replacement line; null when the call carried an explicit waitUntil.
+ downgraded: ?[]u8,
+};
pub fn init(allocator: std.mem.Allocator) Recorder {
return .{
@@ -47,20 +58,25 @@ pub fn init(allocator: std.mem.Allocator) Recorder {
.content = .init(allocator),
.buf = .init(allocator),
.arena = .init(allocator),
+ .pending_goto = null,
};
}
pub fn deinit(self: *Recorder) void {
+ self.freePendingGoto();
self.content.deinit();
self.buf.deinit();
self.arena.deinit();
}
pub fn bytes(self: *Recorder) []const u8 {
+ // A snapshot may be persisted verbatim — its goto line must not change afterwards.
+ self.freePendingGoto();
return self.content.written();
}
pub fn reset(self: *Recorder) void {
+ self.freePendingGoto();
self.lines = 0;
self.page_declared = false;
self.content.clearRetainingCapacity();
@@ -70,34 +86,86 @@ pub fn reset(self: *Recorder) void {
pub fn record(self: *Recorder, cmd: Command) !void {
if (!cmd.isRecorded()) return;
- self.buf.clearRetainingCapacity();
- _ = self.arena.reset(.retain_capacity);
- // `isRecorded` guarantees `.tool_call`. The page is born once, up front; every
- // recorded call is then a method on it — `goto` async, the rest sync.
+ // `isRecorded` guarantees `.tool_call`.
+ const tool = cmd.tool_call.tool;
+ if (self.pending_goto) |pending| {
+ if (tool.waitsForReadiness()) try self.downgradePendingGoto(pending);
+ self.freePendingGoto();
+ }
+
+ // The page is born once, up front; every recorded call is then a method
+ // on it — `goto` async, the rest sync.
if (!self.page_declared) {
- try self.buf.writer.writeAll("const page = new Page();\n");
+ try self.content.writer.writeAll("const page = new Page();\n");
+ self.lines += 1;
self.page_declared = true;
}
- if (cmd.tool_call.tool.isAsync()) try self.buf.writer.writeAll("await ");
- try self.buf.writer.writeAll("page.");
- try cmd.formatJs(self.arena.allocator(), &self.buf.writer);
- try self.buf.writer.writeByte('\n');
+
+ self.buf.clearRetainingCapacity();
+ _ = self.arena.reset(.retain_capacity);
+ try self.renderCall(cmd, &self.buf.writer);
+
+ const start = self.content.written().len;
try self.appendScrubbed();
+
+ if (tool == .goto) {
+ self.pending_goto = .{ .start = start, .downgraded = try self.renderDowngradedGoto(cmd) };
+ }
}
pub fn recordComment(self: *Recorder, comment: []const u8) !void {
+ self.freePendingGoto();
self.buf.clearRetainingCapacity();
try writeCommentLines(&self.buf.writer, comment);
try self.appendScrubbed();
}
pub fn recordRaw(self: *Recorder, line: []const u8) !void {
+ self.freePendingGoto();
self.buf.clearRetainingCapacity();
try self.buf.writer.writeAll(line);
try self.buf.writer.writeByte('\n');
try self.appendScrubbed();
}
+fn renderCall(self: *Recorder, cmd: Command, w: *std.Io.Writer) !void {
+ if (cmd.tool_call.tool.isAsync()) try w.writeAll("await ");
+ try w.writeAll("page.");
+ try cmd.formatJs(self.arena.allocator(), w);
+ try w.writeByte('\n');
+}
+
+/// Rendered and scrubbed up front — the command's args die with the caller.
+/// Null when there's nothing to downgrade (explicit waitUntil, non-object args).
+fn renderDowngradedGoto(self: *Recorder, cmd: Command) !?[]u8 {
+ // `isRecorded` guaranteed the required `url` arg.
+ const args = cmd.tool_call.args.?;
+ if (args != .object) return null;
+ if (args.object.get("waitUntil") != null) return null;
+
+ const aa = self.arena.allocator();
+ var cloned = try args.object.clone(aa);
+ try cloned.put(aa, "waitUntil", .{ .string = @tagName(lp.Config.WaitUntil.domcontentloaded) });
+
+ self.buf.clearRetainingCapacity();
+ try self.renderCall(.fromToolCall(.goto, .{ .object = cloned }), &self.buf.writer);
+ const scrubbed = try lp.tools.reverseSubstituteEnvVars(aa, self.buf.written());
+ return try self.allocator.dupe(u8, scrubbed);
+}
+
+fn downgradePendingGoto(self: *Recorder, pending: PendingGoto) !void {
+ const line = pending.downgraded orelse return;
+ // Both variants are exactly one line, so `lines` needs no adjustment.
+ self.content.shrinkRetainingCapacity(pending.start);
+ try self.content.writer.writeAll(line);
+}
+
+fn freePendingGoto(self: *Recorder) void {
+ const pending = self.pending_goto orelse return;
+ if (pending.downgraded) |d| self.allocator.free(d);
+ self.pending_goto = null;
+}
+
fn appendScrubbed(self: *Recorder) !void {
// Reverse-substitute any LP_* env-var values that snuck in as literals
// (e.g. an agent that retyped a username it saw via getUrl) so the saved
@@ -215,6 +283,72 @@ test "recordComment scrubs literal LP_* values back to placeholders" {
);
}
+test "record downgrades goto before waitForSelector to domcontentloaded" {
+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena.deinit();
+ const aa = arena.allocator();
+
+ var recorder: Recorder = .init(std.testing.allocator);
+ defer recorder.deinit();
+
+ try recorder.record(parseLine(aa, "/goto https://example.com"));
+ try recorder.record(parseLine(aa, "/waitForSelector .story"));
+
+ try std.testing.expectEqualStrings(
+ "const page = new Page();\nawait page.goto({ url: \"https://example.com\", waitUntil: \"domcontentloaded\" });\npage.waitForSelector(\".story\");\n",
+ recorder.bytes(),
+ );
+}
+
+test "record keeps the load wait when goto is followed by extract" {
+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena.deinit();
+ const aa = arena.allocator();
+
+ var recorder: Recorder = .init(std.testing.allocator);
+ defer recorder.deinit();
+
+ try recorder.record(parseLine(aa, "/goto https://example.com"));
+ try recorder.record(parseLine(aa, "/extract '{\"title\": \"h1\"}'"));
+
+ try std.testing.expectEqualStrings(
+ "const page = new Page();\nawait page.goto(\"https://example.com\");\npage.extract({ title: \"h1\" });\n",
+ recorder.bytes(),
+ );
+}
+
+test "record preserves an explicit waitUntil on goto" {
+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena.deinit();
+ const aa = arena.allocator();
+
+ var recorder: Recorder = .init(std.testing.allocator);
+ defer recorder.deinit();
+
+ try recorder.record(parseLine(aa, "/goto https://example.com waitUntil=networkidle"));
+ try recorder.record(parseLine(aa, "/waitForSelector .story"));
+
+ try std.testing.expectEqualStrings(
+ "const page = new Page();\nawait page.goto({ url: \"https://example.com\", waitUntil: \"networkidle\" });\npage.waitForSelector(\".story\");\n",
+ recorder.bytes(),
+ );
+}
+
+test "trailing goto is flushed unmodified by bytes" {
+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena.deinit();
+ const aa = arena.allocator();
+
+ var recorder: Recorder = .init(std.testing.allocator);
+ defer recorder.deinit();
+
+ try recorder.record(parseLine(aa, "/goto https://example.com"));
+ try std.testing.expectEqualStrings(
+ "const page = new Page();\nawait page.goto(\"https://example.com\");\n",
+ recorder.bytes(),
+ );
+}
+
test "record scrubs literal LP_* values in JavaScript calls" {
var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer arena.deinit();
diff --git a/src/script/Runtime.zig b/src/script/Runtime.zig
index 1f76d98b0..ee4ceee41 100644
--- a/src/script/Runtime.zig
+++ b/src/script/Runtime.zig
@@ -74,6 +74,7 @@ const PendingGoto = struct {
receiver: v8.Global,
/// `run_timer` reading (ms) past which the navigation is abandoned.
deadline_ms: u64,
+ until: lp.Config.WaitUntil,
fn reset(self: *PendingGoto) void {
v8.v8__Global__Reset(&self.resolver);
@@ -513,6 +514,7 @@ fn invokeGoto(
.resolver = undefined,
.receiver = undefined,
.deadline_ms = @as(u64, @intCast(self.run_timer.untilNow(lp.io, .boot).toMilliseconds())) + started.timeout_ms,
+ .until = started.until,
};
v8.v8__Global__New(self.env.isolate.handle, resolver, &pending.resolver);
v8.v8__Global__New(self.env.isolate.handle, this, &pending.receiver);
@@ -551,7 +553,7 @@ fn driveAsync(self: *Runtime, context: *const v8.Context, try_catch: *const v8.T
break;
};
for (self.pending_gotos.items, conditions) |pending, *condition| {
- condition.* = .{ .frame_id = pending.frame_id, .until = .load };
+ condition.* = .{ .frame_id = pending.frame_id, .until = pending.until };
}
// Browser-side tick: run under the browser's isolate, exit before the
diff --git a/src/script/Schema.zig b/src/script/Schema.zig
index c81c9dc01..8439ba52d 100644
--- a/src/script/Schema.zig
+++ b/src/script/Schema.zig
@@ -482,7 +482,7 @@ fn enumValuesOf(arena: std.mem.Allocator, value: std.json.Value) ![]const []cons
fn jsonStringArray(arena: std.mem.Allocator, value: std.json.Value) ![]const []const u8 {
if (value != .array) return &.{};
var out: std.ArrayList([]const u8) = .empty;
- try out.ensureTotalCapacity(arena, value.array.items.len);
+ try out.ensureTotalCapacityPrecise(arena, value.array.items.len);
for (value.array.items) |item| {
if (item != .string) continue;
out.appendAssumeCapacity(item.string);
diff --git a/src/script/command.zig b/src/script/command.zig
index 9fc402e90..5d7381cb1 100644
--- a/src/script/command.zig
+++ b/src/script/command.zig
@@ -169,7 +169,7 @@ pub const Command = union(enum) {
return .{ .tool_call = .{ .tool = s.tool, .args = args } };
}
- /// JavaScript recorder format for `lightpanda agent