diff --git a/src/Config.zig b/src/Config.zig index 0c3fe019..83d7a361 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -100,6 +100,7 @@ const CommonOptions = .{ .{ .name = "storage_sqlite_path", .type = ?[:0]const u8 }, .{ .name = "disable_subframes", .type = bool }, .{ .name = "disable_workers", .type = bool }, + .{ .name = "enable_external_stylesheets", .type = bool }, }; fn dumpValidator(_: Allocator, args: *std.process.ArgIterator) !?DumpFormat { @@ -255,6 +256,13 @@ pub fn disableWorkers(self: *const Config) bool { }; } +pub fn enableExternalStylesheets(self: *const Config) bool { + return switch (self.mode) { + inline .serve, .fetch, .mcp => |opts| opts.enable_external_stylesheets, + else => unreachable, + }; +} + pub fn httpProxy(self: *const Config) ?[:0]const u8 { return switch (self.mode) { inline .serve, .fetch, .mcp => |opts| opts.http_proxy, diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index 24694c94..870db179 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -52,6 +52,7 @@ const AbstractRange = @import("webapi/AbstractRange.zig"); const MutationObserver = @import("webapi/MutationObserver.zig"); const IntersectionObserver = @import("webapi/IntersectionObserver.zig"); const Worker = @import("webapi/Worker.zig"); +const CSSStyleSheet = @import("webapi/css/CSSStyleSheet.zig"); const CustomElementDefinition = @import("webapi/CustomElementDefinition.zig"); const PageTransitionEvent = @import("webapi/event/PageTransitionEvent.zig"); const SubmitEvent = @import("webapi/event/SubmitEvent.zig"); @@ -1712,6 +1713,131 @@ pub fn queueLoad(self: *Frame, html: *Element.Html) !void { } } +// Hard cap on a single external stylesheet body. CSS rule storage is per- +// arena so a hostile sheet could otherwise inflate page memory; 2 MiB is +// well above anything seen on real sites (Tailwind's `preflight + utilities` +// build is ~400 KiB gzipped, ~3 MiB raw — at which point a site should be +// splitting by route anyway). +const MAX_STYLESHEET_BYTES: usize = 2 * 1024 * 1024; + +// Synchronously fetch and parse an external ``. +// href is passed in as an optimization since the [currently] only callsite has +// it, so why look it up again? +pub fn loadExternalStylesheet(self: *Frame, link: *Element.Html.Link, href: []const u8) !void { + if (self.isGoingAway() or href.len == 0) { + return; + } + + const session = self._session; + + // this feature is disabled by default, and can be turned on via a command + // line flag or via an CDP command + if (session.load_external_stylesheets == false) { + return self.queueLoad(link._proto); + } + + // Fragment-parsed links (innerHTML, DOMParser, ...) may not be attached. + // TODO: this isn't correct in all cases. If the link is added into an + // attached node, I think we SHOULD load it. + if (self._parse_mode == .fragment) { + return; + } + const element = link.asElement(); + + const arena = try session.getArena(.medium, "Frame.loadExternalStylesheet"); + defer session.releaseArena(arena); + + const resolved = URL.resolve(arena, self.base(), href, .{ .encoding = self.charset }) catch |err| { + log.warn(.http, "external stylesheet resolve", .{ .err = err, .href = href }); + try self.fireElementEvent(element, comptime .wrap("error")); + return; + }; + + const http_client = &session.browser.http_client; + var headers = try http_client.newHeaders(); + try headers.add("Accept: text/css,*/*;q=0.1"); + try self.headersForRequest(&headers); + + // Set the script-manager `is_evaluating` flag for the same reason + // `ScriptManager.addFromElement` does: `syncRequest` pumps the CDP + // socket inline, so a `Target.closeTarget` / `Page.close` arriving + // mid-fetch would otherwise drive `Session.removePage` while this + // function still holds pointers to `self`. The check in + // `Session.removePage` (Session.zig:253) consults + // `frame.anyScriptEvaluating()`, which only sees this flag. + const sm = &self._script_manager.base; + const was_evaluating = sm.is_evaluating; + sm.is_evaluating = true; + defer sm.is_evaluating = was_evaluating; + + var response = http_client.syncRequest(arena, .{ + .url = resolved, + .method = .GET, + .frame_id = self._frame_id, + .loader_id = self._loader_id, + .headers = headers, + .cookie_jar = &session.cookie_jar, + .cookie_origin = self.url, + .resource_type = .stylesheet, + .notification = session.notification, + }) catch |err| { + log.warn(.http, "external stylesheet fetch", .{ .err = err, .url = resolved }); + return self.fireElementEvent(element, comptime .wrap("error")); + }; + defer response.deinit(arena); + + if (response.status < 200 or response.status >= 300) { + log.info(.http, "external stylesheet status", .{ .status = response.status, .url = resolved }); + return self.fireElementEvent(element, comptime .wrap("error")); + } + + if (response.body.items.len > MAX_STYLESHEET_BYTES) { + log.warn(.http, "external stylesheet too large", .{ + .bytes = response.body.items.len, + .max = MAX_STYLESHEET_BYTES, + .url = resolved, + }); + return self.fireElementEvent(element, comptime .wrap("error")); + } + + // Reuse the cached sheet on re-fetch (href mutation on a connected + // link) so `document.styleSheets` keeps a single entry per + // instead of accumulating one per href change. On first load, create + // and register; on subsequent loads, replace content in place. + // + // First-load creation assigns `link._sheet` AFTER `sheets.add` + // succeeds so an OOM during registration doesn't cache an unregistered + // sheet (which would short-circuit every future re-fetch via the + // `orelse` branch, leaving the sheet permanently unreachable through + // `document.styleSheets`). + const sheet = link._sheet orelse blk: { + const new_sheet = try CSSStyleSheet.initWithOwner(element, self); + const sheets = try self.document.getStyleSheets(self); + try sheets.add(new_sheet, self); + link._sheet = new_sheet; + break :blk new_sheet; + }; + + // Parse first, only swap `_href` on success. `replaceSync` itself is + // not atomic (clears rules before the insert loop), so a mid-parse + // OOM still drops the old rules — full atomicity would require a + // scratch-list pattern in `CSSStyleSheet.replaceSync`. Keeping + // `_href` consistent with what the sheet actually contains is the + // minimum. + sheet.replaceSync(response.body.items, self) catch |err| { + log.warn(.http, "external stylesheet parse", .{ .err = err, .url = resolved }); + return self.fireElementEvent(element, comptime .wrap("error")); + }; + sheet._href = try self.arena.dupe(u8, resolved); + + try self.fireElementEvent(element, comptime .wrap("load")); +} + +fn fireElementEvent(self: *Frame, el: *Element, name: String) !void { + const event = try Event.initTrusted(name, .{}, self._page); + try self._event_manager.dispatch(el.asEventTarget(), event); +} + fn dispatchLoad(self: *Frame) !void { const has_dom_load_listener = self._event_manager.has_dom_load_listener; @@ -1724,8 +1850,7 @@ fn dispatchLoad(self: *Frame) !void { for (to_process.items) |html_element| { if (has_dom_load_listener or html_element.hasAttributeFunction(.onload, self)) { - const event = try Event.initTrusted(comptime .wrap("load"), .{}, self._page); - try self._event_manager.dispatch(html_element.asEventTarget(), event); + try self.fireElementEvent(html_element.asElement(), comptime .wrap("load")); } } @@ -3045,6 +3170,20 @@ pub fn removeNode(self: *Frame, parent: *Node, child: *Node, opts: RemoveNodeOpt style._sheet = null; } self._style_manager.sheetModified(); + } else if (el.is(Element.Html.Link)) |link| { + // External stylesheet links registered via Frame.loadExternalStylesheet + // must be symmetrically deregistered on disconnect, or + // `document.styleSheets` accumulates phantom entries and the + // visibility cascade keeps honoring rules from removed links — + // exactly the SPA theme-switch pattern (append new sheet, + // remove old) the feature exists to serve. + if (link._sheet) |sheet| { + if (self.document._style_sheets) |sheets| { + sheets.remove(sheet); + } + link._sheet = null; + self._style_manager.sheetModified(); + } } } } diff --git a/src/browser/HttpClient.zig b/src/browser/HttpClient.zig index 3641511c..df91ac42 100644 --- a/src/browser/HttpClient.zig +++ b/src/browser/HttpClient.zig @@ -977,6 +977,7 @@ pub const Request = struct { xhr, script, fetch, + stylesheet, // Allowed Values: Document, Stylesheet, Image, Media, Font, Script, // TextTrack, XHR, Fetch, Prefetch, EventSource, WebSocket, Manifest, @@ -988,6 +989,7 @@ pub const Request = struct { .xhr => "XHR", .script => "Script", .fetch => "Fetch", + .stylesheet => "Stylesheet", }; } }; diff --git a/src/browser/Session.zig b/src/browser/Session.zig index aa34370d..70ec5d3e 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -91,6 +91,15 @@ subframe_loading_enabled: bool = true, // session init; the LP.configureLoading CDP method can flip it per-session. worker_loading_enabled: bool = true, +// Opt-in fetch of external resources. Defaults to +// false to preserve the current rendering-free fast path: drivers that +// don't need accurate visibility checks pay nothing. Set from the +// `--enable-external-stylesheets` CLI flag at session init; the +// LP.configureLoading CDP method can flip it per-session. When true, +// `Link.linkAddedCallback` routes to `Frame.loadExternalStylesheet` +// (synchronous fetch + parse + register on `document.styleSheets`). +load_external_stylesheets: bool = false, + pub fn init(self: *Session, browser: *Browser, notification: *Notification) !void { const allocator = browser.app.allocator; const arena_pool = browser.arena_pool; @@ -112,6 +121,7 @@ pub fn init(self: *Session, browser: *Browser, notification: *Notification) !voi // CLI defaults; LP.configureLoading can flip these per-session. .subframe_loading_enabled = !browser.app.config.disableSubframes(), .worker_loading_enabled = !browser.app.config.disableWorkers(), + .load_external_stylesheets = browser.app.config.enableExternalStylesheets(), }; } diff --git a/src/browser/tests/css/external_stylesheet.html b/src/browser/tests/css/external_stylesheet.html new file mode 100644 index 00000000..a98520ad --- /dev/null +++ b/src/browser/tests/css/external_stylesheet.html @@ -0,0 +1,185 @@ + + + + + + +
always visible
+
hidden by external rule
+ + + + + + + + + + + + + + + + + diff --git a/src/browser/tests/element/html/link.html b/src/browser/tests/element/html/link.html index 9f4dd6a8..d3f2faa0 100644 --- a/src/browser/tests/element/html/link.html +++ b/src/browser/tests/element/html/link.html @@ -137,3 +137,19 @@ }); } + + diff --git a/src/browser/webapi/DOMParser.zig b/src/browser/webapi/DOMParser.zig index f06027c5..d0356803 100644 --- a/src/browser/webapi/DOMParser.zig +++ b/src/browser/webapi/DOMParser.zig @@ -53,6 +53,17 @@ pub fn parseFromString( const arena = try frame.getArena(.medium, "DOMParser.parseFromString"); defer frame.releaseArena(arena); + // DOMParser builds a detached Document. Borrow the same fragment + // parse-mode that `parseHtmlAsChildren` uses so frame-side hooks + // triggered from `Build.created` / `nodeIsReady` (external stylesheet + // fetches, script execution, mutation-observer fan-out, default-script + // injection) treat the parsed nodes as detached and skip + // side effects on the live document. The frame's `_parse_mode` is + // restored on exit. + const previous_parse_mode = frame._parse_mode; + frame._parse_mode = .fragment; + defer frame._parse_mode = previous_parse_mode; + return switch (target_mime) { .@"text/html" => { // Create a new HTMLDocument diff --git a/src/browser/webapi/element/html/Link.zig b/src/browser/webapi/element/html/Link.zig index 1b83dacf..17b2f96c 100644 --- a/src/browser/webapi/element/html/Link.zig +++ b/src/browser/webapi/element/html/Link.zig @@ -26,6 +26,12 @@ const HtmlElement = @import("../Html.zig"); const Link = @This(); _proto: *HtmlElement, +// 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 +// old rules are dropped instead of accumulating in `document.styleSheets`. +// Mirrors `Style._sheet`. +_sheet: ?*@import("../../css/CSSStyleSheet.zig") = null, pub fn asElement(self: *Link) *Element { return self._proto._proto; @@ -114,6 +120,14 @@ pub fn linkAddedCallback(self: *Link, frame: *Frame) !void { return; } + // Opt-in fetch for `rel="stylesheet"` — drives `frame.loadExternalStylesheet`, + // which fires the load/error event itself. Other rels (preload, + // modulepreload) and the disabled case keep the rendering-free stub that + // fires a synthetic `load` event without touching the network. + if (std.mem.eql(u8, rel, "stylesheet")) { + return frame.loadExternalStylesheet(self, href); + } + try frame.queueLoad(self._proto); } @@ -143,7 +157,24 @@ pub const JsApi = struct { } }; +// Parser-created elements are void (no closing tag) so they never +// reach `Frame.nodeComplete`. Mirror `Image.Build.created` so static head +// links in HTML go through `linkAddedCallback` at element-create time, +// with attributes already populated by `populateElementAttributes`. +pub const Build = struct { + pub fn created(node: *Node, frame: *Frame) !void { + const self = node.as(Link); + return self.linkAddedCallback(frame); + } +}; + const testing = @import("../../../../testing.zig"); test "WebApi: HTML.Link" { try testing.htmlRunner("element/html/link.html", .{}); } + +test "WebApi: HTML.Link external stylesheet" { + const filter: testing.LogFilter = .init(&.{.http}); + defer filter.deinit(); + try testing.htmlRunner("css/external_stylesheet.html", .{ .load_external_stylesheets = true }); +} diff --git a/src/cdp/domains/fetch.zig b/src/cdp/domains/fetch.zig index 64a1ec7e..b2831abe 100644 --- a/src/cdp/domains/fetch.zig +++ b/src/cdp/domains/fetch.zig @@ -209,6 +209,7 @@ pub fn requestIntercept(bc: *CDP.BrowserContext, intercept: *const Notification. .xhr => "XHR", .document => "Document", .fetch => "Fetch", + .stylesheet => "Stylesheet", }, .networkId = &id.toRequestId(transfer), // matches the Network REQ-ID }, .{ .session_id = session_id }); @@ -453,6 +454,7 @@ pub fn requestAuthRequired(bc: *CDP.BrowserContext, intercept: *const Notificati .xhr => "XHR", .document => "Document", .fetch => "Fetch", + .stylesheet => "Stylesheet", }, .authChallenge = .{ .origin = "", // TODO get origin, could be the proxy address for example. diff --git a/src/cdp/domains/lp.zig b/src/cdp/domains/lp.zig index ffc2cf92..b3b3aec0 100644 --- a/src/cdp/domains/lp.zig +++ b/src/cdp/domains/lp.zig @@ -68,11 +68,13 @@ fn configureLoading(cmd: *CDP.Command) !void { const params = (try cmd.params(struct { subFrame: ?bool = null, worker: ?bool = null, + externalStylesheets: ?bool = null, })) orelse return error.InvalidParams; const bc = cmd.browser_context orelse return error.NoBrowserContext; if (params.subFrame) |v| bc.session.subframe_loading_enabled = v; if (params.worker) |v| bc.session.worker_loading_enabled = v; + if (params.externalStylesheets) |v| bc.session.load_external_stylesheets = v; return cmd.sendResult(null, .{}); } @@ -688,3 +690,36 @@ test "cdp.lp: configureLoading toggles subFrame and worker independently" { try testing.expectEqual(true, bc.session.subframe_loading_enabled); try testing.expectEqual(true, bc.session.worker_loading_enabled); } + +test "cdp.lp: configureLoading toggles externalStylesheets independently" { + var ctx = try testing.context(); + defer ctx.deinit(); + + const bc = try ctx.loadBrowserContext(.{}); + _ = try bc.session.createPage(); + + // Default is opt-in: off unless the CLI flag or CDP toggle enables it. + try testing.expectEqual(false, bc.session.load_external_stylesheets); + + // Enable via CDP; the other two loading toggles stay at their defaults. + try ctx.processMessage(.{ + .id = 1, + .method = "LP.configureLoading", + .params = .{ .externalStylesheets = true }, + }); + try ctx.expectSentResult(null, .{ .id = 1 }); + try testing.expectEqual(true, bc.session.load_external_stylesheets); + try testing.expectEqual(true, bc.session.subframe_loading_enabled); + try testing.expectEqual(true, bc.session.worker_loading_enabled); + + // Flip back off; partial params must not reset the other fields. + try ctx.processMessage(.{ + .id = 2, + .method = "LP.configureLoading", + .params = .{ .externalStylesheets = false }, + }); + try ctx.expectSentResult(null, .{ .id = 2 }); + try testing.expectEqual(false, bc.session.load_external_stylesheets); + try testing.expectEqual(true, bc.session.subframe_loading_enabled); + try testing.expectEqual(true, bc.session.worker_loading_enabled); +} diff --git a/src/help.zon b/src/help.zon index 0197f422..3ae0eed0 100644 --- a/src/help.zon +++ b/src/help.zon @@ -157,6 +157,13 @@ \\ script fetch is initiated and its scope never runs. \\ Defaults to false. \\ + \\--enable-external-stylesheets + \\ Fetch external resources so + \\ their rules contribute to computed styles (and + \\ therefore to visibility checks like display, + \\ visibility, opacity, pointer-events). + \\ Defaults to false. + \\ \\--block-private-networks Block HTTP requests to private/internal IP \\ addresses after DNS resolution. \\ Defaults to false. diff --git a/src/testing.zig b/src/testing.zig index 3897a06a..62b1c67d 100644 --- a/src/testing.zig +++ b/src/testing.zig @@ -341,6 +341,7 @@ const WEB_API_TEST_ROOT = "src/browser/tests/"; const HtmlRunnerOpts = struct { timeout_ms: u32 = 2000, inject_script: ?[]const u8 = null, + load_external_stylesheets: bool = false, }; pub fn htmlRunner(comptime path: []const u8, opts: HtmlRunnerOpts) !void { @@ -353,6 +354,9 @@ pub fn htmlRunner(comptime path: []const u8, opts: HtmlRunnerOpts) !void { } defer test_session.inject_scripts = &.{}; + test_session.load_external_stylesheets = opts.load_external_stylesheets; + defer test_session.load_external_stylesheets = false; + const root = try std.fs.path.joinZ(arena_allocator, &.{ WEB_API_TEST_ROOT, path }); const stat = std.fs.cwd().statFile(root) catch |err| { std.debug.print("Failed to stat file: '{s}'", .{root}); @@ -678,6 +682,53 @@ fn testHTTPHandler(req: *std.http.Server.Request) !void { }); } + if (std.mem.eql(u8, path, "/styles/visibility.css")) { + // Used by css/external_stylesheet.html — drives the visibility + // cascade through StyleManager via Frame.loadExternalStylesheet + // so a `.ext-hide` element is observable to checkVisibility(). + return req.respond(".ext-hide { display: none; }", .{ + .extra_headers = &.{ + .{ .name = "Content-Type", .value = "text/css" }, + }, + }); + } + + if (std.mem.eql(u8, path, "/styles/visibility2.css")) { + // Second visibility sheet used by the href-change regression test: + // mutating link.href must replace the cached sheet's rules in place, + // not append a new entry to document.styleSheets. + return req.respond(".ext-hide-2 { display: none; }", .{ + .extra_headers = &.{ + .{ .name = "Content-Type", .value = "text/css" }, + }, + }); + } + + if (std.mem.eql(u8, path, "/styles/404.css")) { + return req.respond("/* unused */", .{ + .status = .not_found, + .extra_headers = &.{ + .{ .name = "Content-Type", .value = "text/css" }, + }, + }); + } + + if (std.mem.eql(u8, path, "/styles/oversize.css")) { + // Body that exceeds Frame.MAX_STYLESHEET_BYTES (2 MiB) — written as a + // long sequence of valid declarations so the response itself parses + // fine and the error path is exercised by the size cap, not by a + // CSS parse failure. + const chunk = ".pad { color: #abcdef; } "; // 25 bytes + const repeats = (2 * 1024 * 1024 / chunk.len) + 1024; + var body = try std.ArrayList(u8).initCapacity(arena_allocator, chunk.len * repeats); + for (0..repeats) |_| body.appendSliceAssumeCapacity(chunk); + return req.respond(body.items, .{ + .extra_headers = &.{ + .{ .name = "Content-Type", .value = "text/css" }, + }, + }); + } + if (std.mem.eql(u8, path, "/echo_referer")) { // Echo the request's Referer header back as HTML so tests can assert // what Referer the navigation sent. Used by the cross-page Referer test.