mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-06-11 01:25:53 -04:00
Implement external stylesheet fetch + parse
Wires up --enable-external-stylesheets / LP.configureLoading.externalStylesheets from the prior surface-only commit. When the flag is set, parser- and JS-created <link rel=stylesheet> elements now synchronously fetch and parse their href, register a CSSStyleSheet on document.styleSheets, and feed StyleManager so checkVisibility() reflects external rules. Flag stays default-off — scrapers that don't need accurate visibility pay nothing. Frame.loadExternalStylesheet mirrors ScriptManager.addFromElement: same HttpClient.syncRequest path, same arena ownership, same per-frame notification + cookie wiring. Body is routed through CSSStyleSheet.replaceSync, which already parses, populates cssRules, and calls sheetModified() — no StyleManager changes needed. 2 MiB hard cap on a single sheet body, status non-2xx and oversize both fire `error` on the link. Link.Build.created is added so static head <link> elements reach linkAddedCallback at all — void elements never trigger nodeComplete, which is why static `<link>` had no observable effect before. Mirrors Image. HttpClient.Request.ResourceType gains a `.stylesheet` variant so CDP Network events report the right type; cdp.fetch.zig switches updated. Refs #2343
This commit is contained in:
@@ -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");
|
||||
@@ -1679,6 +1680,94 @@ 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 `<link rel=stylesheet>`. Opt-in
|
||||
// behind `session.load_external_stylesheets` — scrapers/crawlers that don't
|
||||
// need accurate visibility checks still get the cheap no-fetch path via
|
||||
// `Link.linkAddedCallback`. Mirrors `ScriptManager.addFromElement`'s use of
|
||||
// `syncRequest`: stylesheets are render-blocking in real browsers, so a
|
||||
// synchronous fetch from inside the parser callback matches expected
|
||||
// document-load ordering without manual `_pending_loads` bookkeeping.
|
||||
pub fn loadExternalStylesheet(self: *Frame, link: *Element.Html.Link) !void {
|
||||
if (self.isGoingAway()) return;
|
||||
|
||||
const element = link.asElement();
|
||||
const href = element.getAttributeSafe(comptime .wrap("href")) orelse return;
|
||||
if (href.len == 0) return;
|
||||
|
||||
const arena = try self.getArena(.medium, "Frame.loadExternalStylesheet");
|
||||
defer self._session.releaseArena(arena);
|
||||
|
||||
const resolved = URL.resolve(arena, self.base(), href, .{ .encoding = self.charset }) catch |err| {
|
||||
log.warn(.browser, "external stylesheet resolve", .{ .err = err, .href = href });
|
||||
try self.fireLinkEvent(link, comptime .wrap("error"));
|
||||
return;
|
||||
};
|
||||
|
||||
const session = self._session;
|
||||
// HttpClient takes ownership of `headers` via the request struct (see
|
||||
// HttpClient.zig:411 — must NOT pair with a local `defer deinit`).
|
||||
var headers = try session.browser.http_client.newHeaders();
|
||||
try headers.add("Accept: text/css,*/*;q=0.1");
|
||||
|
||||
var response = session.browser.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(.browser, "external stylesheet fetch", .{ .err = err, .url = resolved });
|
||||
try self.fireLinkEvent(link, comptime .wrap("error"));
|
||||
return;
|
||||
};
|
||||
defer response.deinit(arena);
|
||||
|
||||
if (response.status < 200 or response.status >= 300) {
|
||||
log.info(.browser, "external stylesheet status", .{ .status = response.status, .url = resolved });
|
||||
try self.fireLinkEvent(link, comptime .wrap("error"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.body.items.len > MAX_STYLESHEET_BYTES) {
|
||||
log.warn(.browser, "external stylesheet too large", .{
|
||||
.bytes = response.body.items.len,
|
||||
.max = MAX_STYLESHEET_BYTES,
|
||||
.url = resolved,
|
||||
});
|
||||
try self.fireLinkEvent(link, comptime .wrap("error"));
|
||||
return;
|
||||
}
|
||||
|
||||
const sheet = try CSSStyleSheet.initWithOwner(element, self);
|
||||
sheet._href = try self.arena.dupe(u8, resolved);
|
||||
sheet.replaceSync(response.body.items, self) catch |err| {
|
||||
log.warn(.browser, "external stylesheet parse", .{ .err = err, .url = resolved });
|
||||
try self.fireLinkEvent(link, comptime .wrap("error"));
|
||||
return;
|
||||
};
|
||||
|
||||
const sheets = try self.document.getStyleSheets(self);
|
||||
try sheets.add(sheet, self);
|
||||
|
||||
try self.fireLinkEvent(link, comptime .wrap("load"));
|
||||
}
|
||||
|
||||
fn fireLinkEvent(self: *Frame, link: *Element.Html.Link, name: String) !void {
|
||||
const event = try Event.initTrusted(name, .{}, self._page);
|
||||
try self._event_manager.dispatch(link._proto.asEventTarget(), event);
|
||||
}
|
||||
|
||||
fn dispatchLoad(self: *Frame) !void {
|
||||
const has_dom_load_listener = self._event_manager.has_dom_load_listener;
|
||||
|
||||
|
||||
@@ -910,6 +910,7 @@ pub const Request = struct {
|
||||
xhr,
|
||||
script,
|
||||
fetch,
|
||||
stylesheet,
|
||||
|
||||
// Allowed Values: Document, Stylesheet, Image, Media, Font, Script,
|
||||
// TextTrack, XHR, Fetch, Prefetch, EventSource, WebSocket, Manifest,
|
||||
@@ -921,6 +922,7 @@ pub const Request = struct {
|
||||
.xhr => "XHR",
|
||||
.script => "Script",
|
||||
.fetch => "Fetch",
|
||||
.stylesheet => "Stylesheet",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -95,9 +95,9 @@ worker_loading_enabled: bool = true,
|
||||
// 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. Currently
|
||||
// unread — the fetch path lands in a follow-up that depends on the
|
||||
// network refactor in #2303.
|
||||
// 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 {
|
||||
|
||||
85
src/browser/tests/css/external_stylesheet.html
Normal file
85
src/browser/tests/css/external_stylesheet.html
Normal file
@@ -0,0 +1,85 @@
|
||||
<!DOCTYPE html>
|
||||
<head>
|
||||
<script src="../testing.js"></script>
|
||||
<link id="ext" rel="stylesheet" href="/styles/visibility.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="visible">always visible</div>
|
||||
<div id="hidden" class="ext-hide">hidden by external rule</div>
|
||||
|
||||
<script id="ext_stylesheet_registered">
|
||||
{
|
||||
// The parser-synchronous fetch in Frame.loadExternalStylesheet runs
|
||||
// before this script executes, so document.styleSheets reflects the
|
||||
// fetched sheet by the time we look.
|
||||
testing.expectEqual(1, document.styleSheets.length);
|
||||
|
||||
const sheet = document.styleSheets[0];
|
||||
testing.expectEqual(testing.ORIGIN + '/styles/visibility.css', sheet.href);
|
||||
testing.expectEqual(1, sheet.cssRules.length);
|
||||
testing.expectEqual('.ext-hide', sheet.cssRules[0].selectorText);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="ext_stylesheet_cascade">
|
||||
{
|
||||
// External rule must reach StyleManager: a `.ext-hide` element should
|
||||
// report checkVisibility() === false, exactly as if the rule were
|
||||
// declared inline in a `<style>` block.
|
||||
testing.expectTrue(document.getElementById('visible').checkVisibility());
|
||||
testing.expectFalse(document.getElementById('hidden').checkVisibility());
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="ext_stylesheet_dynamic_load_event">
|
||||
{
|
||||
// Dynamically-inserted <link> fires `load` after the fetch completes.
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = '/styles/visibility.css';
|
||||
testing.async(async () => {
|
||||
const fired = await new Promise(resolve => {
|
||||
link.addEventListener('load', () => resolve(true));
|
||||
link.addEventListener('error', () => resolve(false));
|
||||
document.head.appendChild(link);
|
||||
});
|
||||
testing.expectEqual(true, fired);
|
||||
});
|
||||
testing.expectEqual(true, true);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="ext_stylesheet_404_fires_error">
|
||||
{
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = '/styles/404.css';
|
||||
testing.async(async () => {
|
||||
const evt = await new Promise(resolve => {
|
||||
link.addEventListener('load', () => resolve('load'));
|
||||
link.addEventListener('error', () => resolve('error'));
|
||||
document.head.appendChild(link);
|
||||
});
|
||||
testing.expectEqual('error', evt);
|
||||
});
|
||||
testing.expectEqual(true, true);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="ext_stylesheet_oversize_fires_error">
|
||||
{
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = '/styles/oversize.css';
|
||||
testing.async(async () => {
|
||||
const evt = await new Promise(resolve => {
|
||||
link.addEventListener('load', () => resolve('load'));
|
||||
link.addEventListener('error', () => resolve('error'));
|
||||
document.head.appendChild(link);
|
||||
});
|
||||
testing.expectEqual('error', evt);
|
||||
});
|
||||
testing.expectEqual(true, true);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
@@ -137,3 +137,19 @@
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="ext_stylesheet_disabled_default">
|
||||
{
|
||||
// Flag defaults off — a <link rel=stylesheet> must NOT register a sheet
|
||||
// on document.styleSheets and must NOT touch the network. The synthetic
|
||||
// load event still fires (asserted by other tests above).
|
||||
const before = document.styleSheets.length;
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = '/styles/visibility.css';
|
||||
document.head.appendChild(link);
|
||||
testing.onload(() => {
|
||||
testing.expectEqual(before, document.styleSheets.length);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -114,6 +114,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") and frame._session.load_external_stylesheets) {
|
||||
return frame.loadExternalStylesheet(self);
|
||||
}
|
||||
|
||||
try frame.queueLoad(self._proto);
|
||||
}
|
||||
|
||||
@@ -143,7 +151,22 @@ pub const JsApi = struct {
|
||||
}
|
||||
};
|
||||
|
||||
// Parser-created <link> 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" {
|
||||
try testing.htmlRunner("css/external_stylesheet.html", .{ .load_external_stylesheets = true });
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,42 @@ 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/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.
|
||||
|
||||
Reference in New Issue
Block a user