Merge pull request #2965 from lightpanda-io/script-preload

perf: Pre-parse HTML to find and preload scripts
This commit is contained in:
Karl Seguin
2026-07-17 07:49:39 +08:00
committed by GitHub
13 changed files with 813 additions and 153 deletions

View File

@@ -71,6 +71,7 @@ const milliTimestamp = @import("../datetime.zig").milliTimestamp;
const GlobalEventHandlersLookup = @import("webapi/global_event_handlers.zig").Lookup;
pub const preload = @import("frame/preload.zig");
pub const observers = @import("frame/observers.zig");
pub const user_input = @import("frame/user_input.zig");
pub const node_factory = @import("frame/node_factory.zig");
@@ -1558,6 +1559,8 @@ fn frameDoneCallback(ctx: *anyopaque) !void {
const raw_html = html.buffer.items;
preload.prescan(self, raw_html);
if (std.mem.eql(u8, self.charset, "UTF-8")) {
parser.parse(raw_html);
} else {
@@ -2096,40 +2099,6 @@ pub fn queueHashChange(self: *Frame, old_url: []const u8, new_url: []const u8) !
// splitting by route anyway).
const MAX_STYLESHEET_BYTES: usize = 2 * 1024 * 1024;
// start prefetching <link rel="preload" as="script" href=...>`
pub fn preloadScriptHint(self: *Frame, element: *Element.Html, href: []const u8) bool {
if (self.isGoingAway() or self._parse_mode == .fragment) {
return false;
}
const arena = self.getArena(.small, "Frame.preloadScriptHint") catch return false;
defer self.releaseArena(arena);
const resolved = URL.resolve(arena, self.base(), href, .{ .encoding = self.charset }) catch return false;
if (!std.ascii.startsWithIgnoreCase(resolved, "http:") and !std.ascii.startsWithIgnoreCase(resolved, "https:")) {
// data:/blob: are synthesized locally — no round-trip to hide.
return false;
}
return self._script_manager.preloadScript(element, resolved) catch false;
}
// start prefetching <link rel="modulepreload" href=...>
pub fn preloadModuleHint(self: *Frame, element: *Element.Html, href: []const u8) bool {
if (self.isGoingAway() or self._parse_mode == .fragment) {
return false;
}
// The url becomes the imported_modules key, which must outlive the fetch
// so it lives on the frame arena
const resolved = URL.resolve(self.arena, self.base(), href, .{ .encoding = self.charset }) catch return false;
if (!std.ascii.startsWithIgnoreCase(resolved, "http:") and !std.ascii.startsWithIgnoreCase(resolved, "https:")) {
// data:/blob: are synthesized locally — no round-trip to hide.
return false;
}
return self._script_manager.base.preloadModuleHint(element, resolved, self.url) catch false;
}
// Synchronously fetch and parse an external `<link rel=stylesheet>`.
// href is passed in as an optimization since the [currently] only callsite has
// it, so why look it up again?

View File

@@ -46,7 +46,8 @@ frame: *Frame,
// "load" event).
frame_notified_of_completion: bool,
// scripts loaded based on a <link rel=preload as=script href=...> found during parsing
// Scripts loaded based on a <link rel=preload as=script href=...> found during
// parsing, keyed by resolved URL.
preloaded_scripts: std.StringHashMapUnmanaged(PreloadedScript),
pub fn init(allocator: Allocator, http_client: *HttpClient, frame: *Frame) ScriptManager {
@@ -104,7 +105,8 @@ fn getHeaders(self: *ScriptManager) !HttpClient.Headers {
// Returns true when a fetch was started: the link's load/error event fires
// when the fetch settles. false (duplicate hint) = no event will fire.
pub fn preloadScript(self: *ScriptManager, element: *Element.Html, url: []const u8) !bool {
// element is null when the hint came from the prescan rather than a <link>.
pub fn preloadScript(self: *ScriptManager, element: ?*Element.Html, url: []const u8) !bool {
if (self.preloaded_scripts.contains(url)) {
return false;
}
@@ -225,54 +227,28 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
return;
};
var handover = false;
const frame = self.frame;
const base_url = frame.base();
// A consumed preload (waitForPreload below) is owned by us: its buffer is
// borrowed by `script`, so it must outlive eval.
var consumed_preload: ?*Script = null;
defer if (consumed_preload) |p| {
p.deinit();
const src = element.getAttributeSafe(comptime .wrap("src")) orelse {
return self.addInlineScript(script_element, kind);
};
// The script is remote (even data: and blob: are synthesized via HttpClient)
// Set once arena ownership is resolved — transferred to `script`, or
// released early on the adoption path — so the errdefer can't double-free.
var handover = false;
const arena = try frame.getArena(.large, "SM.addFromElement");
errdefer if (!handover) {
errdefer if (handover == false) {
frame.releaseArena(arena);
};
var source: Script.Source = undefined;
var remote_url: ?[:0]const u8 = null;
const base_url = frame.base();
if (element.getAttributeSafe(comptime .wrap("src"))) |src| {
// data: and blob: srcs flow through the normal request path; HttpClient
// synthesizes the response. Execution mode (blocking vs async/defer) is
// attribute-driven, the same as any other src.
remote_url = try URL.resolve(arena, base_url, src, .{ .encoding = frame.charset });
source = .{ .remote = .{} };
} else {
var buf = std.Io.Writer.Allocating.init(arena);
try element.asNode().getChildTextContent(&buf.writer);
try buf.writer.writeByte(0);
const data = buf.written();
const inline_source: [:0]const u8 = data[0 .. data.len - 1 :0];
if (inline_source.len == 0) {
// we haven't set script_element._executed = true yet, which is good.
// If content is appended to the script, we will execute it then.
frame.releaseArena(arena);
return;
}
source = .{ .@"inline" = inline_source };
}
// Only set _executed (already-started) when we actually have content to execute
const remote_url = try URL.resolve(arena, base_url, src, .{ .encoding = frame.charset });
script_element._executed = true;
const is_inline = source == .@"inline";
const mode: Script.Extra.FrameExtra.Mode = blk: {
if (source == .@"inline") {
break :blk if (kind == .module) .@"defer" else .normal;
}
if (element.getAttributeSafe(comptime .wrap("async")) != null) {
break :blk .async;
}
@@ -294,54 +270,84 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
break :blk .normal;
};
if (comptime IS_DEBUG) {
var ls: js.Local.Scope = undefined;
frame.js.localScope(&ls);
defer ls.deinit();
log.debug(.http, "script queue", .{
.ctx = ctx,
.url = remote_url,
.element = element,
.stack = ls.local.stackTrace() catch "???",
});
}
const frame_extra: Script.Extra = .{ .frame = .{
.kind = kind,
.mode = mode,
.script_element = script_element,
.frame = frame,
} };
if (mode != .normal) {
var preloaded = self.takePreload(remote_url);
if (preloaded == null and kind == .module) {
preloaded = self.base.takeModuleHint(remote_url);
}
if (preloaded) |pre| {
if (comptime IS_DEBUG) {
log.debug(.http, "script adopt", .{ .url = remote_url, .ctx = ctx, .state = if (pre.complete) "done" else "loading" });
}
pre.extra = frame_extra;
// The adopted Script has its own arena; ours held only the URL
// resolution, which nothing below needs.
handover = true;
frame.releaseArena(arena);
if (pre.complete and mode == .async) {
// The fetch already finished, so no doneCallback will move it
// to ready_scripts; queue it there directly.
self.base.ready_scripts.append(&pre.node);
} else {
self.base.scriptList(pre).append(&pre.node);
}
if (pre.complete) {
// ...and no doneCallback will trigger evaluation.
self.base.evaluate();
}
return;
}
}
const script = try arena.create(Script);
script.* = .{
.node = .{},
.arena = arena,
.manager = &self.base,
.source = source,
.complete = is_inline,
.status = if (is_inline) 200 else 0,
.url = remote_url orelse base_url,
.extra = .{ .frame = .{
.kind = kind,
.mode = mode,
.script_element = script_element,
.frame = frame,
} },
.source = .{ .remote = .{} },
.complete = false,
.url = remote_url,
.extra = frame_extra,
};
const is_blocking = mode == .normal;
if (mode == .normal) {
// Blocking: fetch synchronously and evaluate before the parser resumes.
// Once parsing is done, the deferred-script batch has already drained and
// won't run again, so a non-blocking script inserted afterwards would go
// unprocessed. Run it immediately instead. Remote scripts still need to
// be queued so they execute when their fetch completes.
const run_immediately = is_blocking or (self.base.static_scripts_done and remote_url == null);
if (run_immediately == false) {
self.base.scriptList(script).append(&script.node);
}
// A consumed preload (waitForPreload below) is owned by us: its buffer
// is borrowed by `script`, so it must outlive eval.
var consumed_preload: ?*Script = null;
defer if (consumed_preload) |p| {
p.deinit();
};
if (remote_url) |url| {
if (comptime IS_DEBUG) {
var ls: js.Local.Scope = undefined;
frame.js.localScope(&ls);
defer ls.deinit();
{
const was_evaluating = self.base.is_evaluating;
self.base.is_evaluating = true;
defer self.base.endEvaluationWindow(was_evaluating);
log.debug(.http, "script queue", .{
.ctx = ctx,
.url = remote_url.?,
.element = element,
.stack = ls.local.stackTrace() catch "???",
});
}
const was_evaluating = self.base.is_evaluating;
self.base.is_evaluating = true;
defer self.base.endEvaluationWindow(was_evaluating);
if (is_blocking) {
if (self.waitForPreload(url)) |pre| {
if (self.waitForPreload(remote_url)) |pre| {
// There was a preloaded script, we borrow it's source and status
consumed_preload = pre;
script.source = pre.source;
@@ -349,7 +355,7 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
script.complete = true;
} else {
const response = try self.base.client.syncRequest(arena, .{
.url = url,
.url = remote_url,
.method = .GET,
.frame_id = frame._frame_id,
.loader_id = frame._loader_id,
@@ -365,48 +371,107 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
script.status = response.status;
script.complete = true;
}
} else {
errdefer {
self.base.scriptList(script).remove(&script.node);
// Let the outer errdefer handle releasing the arena if client.request fails
}
try frame.makeRequest(.{
.ctx = script,
.url = url,
.method = .GET,
.frame_id = frame._frame_id,
.loader_id = frame._loader_id,
.headers = try self.getHeaders(),
.cookie_jar = &frame._session.cookie_jar,
.cookie_origin = frame.url,
.resource_type = .script,
.notification = frame._session.notification,
.start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null,
.header_callback = Script.headerCallback,
.data_callback = Script.dataCallback,
.done_callback = Script.doneCallback,
.error_callback = Script.errorCallback,
// Nothing holds the transfer; teardown cleanup runs through
// the manager's script lists.
.shutdown_callback = HttpClient.noopShutdown,
});
handover = true;
}
handover = true;
if (script.status < 200 or script.status > 299) {
log.info(.http, "script load error", .{ .status = script.status });
script.executeCallback(comptime .wrap("error"));
script.deinit();
return;
}
return self.evalNow(script);
}
if (run_immediately == false) {
// async/defer: queue the script and fetch in the background; doneCallback
// routes it through the ready_scripts / defer_scripts draining.
self.base.scriptList(script).append(&script.node);
const was_evaluating = self.base.is_evaluating;
self.base.is_evaluating = true;
defer self.base.endEvaluationWindow(was_evaluating);
errdefer self.base.scriptList(script).remove(&script.node);
try frame.makeRequest(.{
.ctx = script,
.url = remote_url,
.method = .GET,
.frame_id = frame._frame_id,
.loader_id = frame._loader_id,
.headers = try self.getHeaders(),
.cookie_jar = &frame._session.cookie_jar,
.cookie_origin = frame.url,
.resource_type = .script,
.notification = frame._session.notification,
.start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null,
.header_callback = Script.headerCallback,
.data_callback = Script.dataCallback,
.done_callback = Script.doneCallback,
.error_callback = Script.errorCallback,
// Nothing holds the transfer; teardown cleanup runs through
// the manager's script lists.
.shutdown_callback = HttpClient.noopShutdown,
});
handover = true;
}
// A <script> with no src. Runs synchronously right now, except an inline
// module during parsing, which waits its turn in defer_scripts.
fn addInlineScript(self: *ScriptManager, script_element: *Element.Html.Script, kind: Script.Extra.FrameExtra.Kind) !void {
const node = script_element.asElement().asNode();
const source_len = node.childTextContentLen();
if (source_len == 0) {
// if content is appended later, we'll execute it then since
// script_element._executed is still false
return;
}
if (script.status < 200 or script.status > 299) {
log.info(.http, "script load error", .{ .status = script.status });
script.executeCallback(comptime .wrap("error"));
script.deinit();
const frame = self.frame;
const arena = try frame.getArena(source_len + @sizeOf(Script) + 1, "SM.addInlineScript");
errdefer frame.releaseArena(arena);
const source = blk: {
const buf = try arena.alloc(u8, source_len + 1);
var writer: std.Io.Writer = .fixed(buf);
try node.getChildTextContent(&writer);
buf[source_len] = 0;
break :blk buf[0..source_len :0];
};
script_element._executed = true;
const mode: Script.Extra.FrameExtra.Mode = if (kind == .module) .@"defer" else .normal;
const script = try arena.create(Script);
script.* = .{
.node = .{},
.arena = arena,
.manager = &self.base,
.source = .{ .@"inline" = source },
.complete = true,
.status = 200,
.url = frame.base(),
.extra = .{ .frame = .{
.kind = kind,
.mode = mode,
.script_element = script_element,
.frame = frame,
} },
};
// An inline module found during parsing waits its turn in document order.
// Once parsing is done, the deferred batch has already drained and won't
// run again, so run it immediately instead.
if (mode == .@"defer" and self.base.static_scripts_done == false) {
self.base.scriptList(script).append(&script.node);
return;
}
self.evalNow(script);
}
fn evalNow(self: *ScriptManager, script: *Script) void {
// could have already been evaluating if this is dynamically added
const was_evaluating = self.base.is_evaluating;
self.base.is_evaluating = true;
@@ -422,6 +487,14 @@ pub fn staticScriptsDone(self: *ScriptManager) void {
self.base.staticScriptsDone();
}
// Removes and returns the preload entry for `url` in whatever state it's in.
fn takePreload(self: *ScriptManager, url: [:0]const u8) ?*Script {
const kv = self.preloaded_scripts.fetchRemove(url) orelse return null;
return switch (kv.value.state) {
inline else => |script| script,
};
}
const PreloadedScript = struct {
state: State,
@@ -438,6 +511,11 @@ const PreloadedScript = struct {
fn doneCallback(ctx: *anyopaque) !void {
const script: *Script = @ptrCast(@alignCast(ctx));
if (script.extra != .preload) {
// Adopted by a real <script> element (addFromElement) while the
// fetch was in flight; complete it as a normal frame script.
return Script.doneCallback(ctx);
}
script.complete = true;
if (comptime IS_DEBUG) {
log.debug(.http, "script fetch complete", .{ .req = script.url });
@@ -450,6 +528,11 @@ const PreloadedScript = struct {
fn errorCallback(ctx: *anyopaque, err: anyerror) void {
const script: *Script = @ptrCast(@alignCast(ctx));
if (script.extra != .preload) {
// Adopted mid-flight; fail it as a normal frame script (list
// unlink, error event on the element and the hint link).
return Script.errorCallback(ctx, err);
}
if (script.status == 404) {
log.info(.http, "script 404", .{ .req = script.url, .extra = "preload" });
} else {
@@ -469,6 +552,11 @@ const PreloadedScript = struct {
// errorCallback, since the owner is being torn down.
fn shutdownCallback(ctx: *anyopaque) void {
const script: *Script = @ptrCast(@alignCast(ctx));
if (script.extra != .preload) {
// Adopted: the Script lives in the manager's script lists and is
// reaped by reset(), same as any async script (noopShutdown).
return;
}
const self: *ScriptManager = @fieldParentPtr("base", script.manager);
_ = self.preloaded_scripts.remove(script.url);
script.deinit();

View File

@@ -216,7 +216,12 @@ pub fn resolveSpecifier(self: *ScriptManagerBase, arena: Allocator, base: [:0]co
}
const PreloadOpts = struct {
// set when the preload comes from a <link rel=modulepreload> hint
// true when the preload is a hint (modulepreload link or prescan) and can
// be taken by anyone.
hint: bool = false,
// the <link rel=modulepreload> element to fire load/error on, when the
// hint came from one.
hint_element: ?*Element.Html = null,
};
pub fn preloadImport(self: *ScriptManagerBase, url: [:0]const u8, referrer: []const u8, opts: PreloadOpts) !void {
@@ -248,7 +253,7 @@ pub fn preloadImport(self: *ScriptManagerBase, url: [:0]const u8, referrer: []co
.hint_element = opts.hint_element,
};
gop.value_ptr.* = .{ .state = .{ .loading = script }, .hint = opts.hint_element != null };
gop.value_ptr.* = .{ .state = .{ .loading = script }, .hint = opts.hint };
if (comptime IS_DEBUG) {
var ls: js.Local.Scope = undefined;
@@ -294,17 +299,45 @@ pub fn preloadImport(self: *ScriptManagerBase, url: [:0]const u8, referrer: []co
};
}
// <link rel=modulepreload href=...> — start fetching a module before import
// resolution discovers it. Returns false when no fetch was started (the
// module is already fetched/fetching): no event will fire on the link.
pub fn preloadModuleHint(self: *ScriptManagerBase, element: *Element.Html, url: [:0]const u8, referrer: []const u8) !bool {
// <link rel=modulepreload href=...> (element set) or the prescan finding a
// <script type=module src=...> (element null) — start fetching a module
// before import resolution discovers it. Returns false when no fetch was
// started (the module is already fetched/fetching): no event will fire on
// the link.
pub fn preloadModuleHint(self: *ScriptManagerBase, element: ?*Element.Html, url: [:0]const u8, referrer: []const u8) !bool {
if (self.imported_modules.contains(url)) {
return false;
}
try self.preloadImport(url, referrer, .{ .hint_element = element });
try self.preloadImport(url, referrer, .{ .hint = true, .hint_element = element });
return true;
}
// A <script type=module src=...> whose URL was hinted (modulepreload link or
// prescan)
pub fn takeModuleHint(self: *ScriptManagerBase, url: [:0]const u8) ?*Script {
const entry = self.imported_modules.getEntry(url) orelse return null;
if (entry.value_ptr.hint == false) {
// The script was preloaded, but not because of a hint. It came from v8
// telling us to preload the module. We cannot take it here because we know
// v8 is going to come back and wait for it.
return null;
}
const script = switch (entry.value_ptr.state) {
.loading => |script| blk: {
self.async_scripts.remove(&script.node);
break :blk script;
},
.done => |script| script,
// The hint's fetch failed; give the script its own attempt.
// I'm not sure if this is the right behavior. Why would a preload fail
// but the "real" load work? But it's definetly safer.
.err => return null,
};
self.imported_modules.removeByPtr(entry.key_ptr);
return script;
}
pub fn waitForImport(self: *ScriptManagerBase, url: [:0]const u8) !ModuleSource {
const was_evaluating = self.is_evaluating;
self.is_evaluating = true;

View File

@@ -0,0 +1,196 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// Speculative script fetching: <link rel=preload/modulepreload> hints and the
// pre-parse scan of the raw HTML. All of it only starts downloads early —
// consumption happens in ScriptManager / ScriptManagerBase when the real
// <script> element or import shows up.
const std = @import("std");
const URL = @import("../URL.zig");
const Frame = @import("../Frame.zig");
const Parser = @import("../parser/Parser.zig");
const Element = @import("../webapi/Element.zig");
const Allocator = std.mem.Allocator;
// start prefetching <link rel="preload" as="script" href=...>`. element is the
// hint <link> to fire load/error on, null when the hint came from the prescan.
pub fn scriptHint(frame: *Frame, element: ?*Element.Html, href: []const u8) bool {
if (frame.isGoingAway() or frame._parse_mode == .fragment) {
return false;
}
const arena = frame.getArena(.small, "preload.scriptHint") catch return false;
defer frame.releaseArena(arena);
const resolved = URL.resolve(arena, frame.base(), href, .{ .encoding = frame.charset }) catch return false;
if (!isRemoteScheme(resolved)) {
return false;
}
return frame._script_manager.preloadScript(element, resolved) catch false;
}
// start prefetching <link rel="modulepreload" href=...>. element is the hint
// <link> to fire load/error on, null when the hint came from the prescan.
pub fn moduleHint(frame: *Frame, element: ?*Element.Html, href: []const u8) bool {
if (frame.isGoingAway() or frame._parse_mode == .fragment) {
return false;
}
if (hasNonRemoteScheme(href)) {
return false;
}
// The url becomes the imported_modules key, which must outlive the fetch
// so it lives on the frame arena
const resolved = URL.resolve(frame.arena, frame.base(), href, .{ .encoding = frame.charset }) catch return false;
if (!isRemoteScheme(resolved)) {
return false;
}
return frame._script_manager.base.preloadModuleHint(element, resolved, frame.url) catch false;
}
// Scan the HTML for <script src=...> before parsing so that we can start
// fetching them ASAP. The downside is we might accidentally fetch more than we
// should, but the upside can be a pretty significant performance improvement.
// Without this, N large blocking <script src=...> tags are downloaded serially.
// This essentially does the same thing as the <link preload/preloadModule> but
// without needing anything special from the HTML.
pub fn prescan(frame: *Frame, html: []const u8) void {
if (frame.isGoingAway() or frame._parse_mode == .fragment) {
return;
}
const arena = frame.getArena(.small, "preload.prescan") catch return;
defer frame.releaseArena(arena);
var scan = Prescan{ .frame = frame, .base = frame.base(), .arena = arena };
Parser.prescan(html, frame.charset, &scan, Prescan.callback);
}
const Prescan = struct {
frame: *Frame,
arena: Allocator,
base: [:0]const u8,
fn callback(ctx: *anyopaque, kind: Parser.PrescanResource, url_ptr: [*c]const u8, url_len: usize) callconv(.c) void {
const self: *Prescan = @ptrCast(@alignCast(ctx));
if (url_len == 0) {
return;
}
const href = url_ptr[0..url_len];
const frame = self.frame;
switch (kind) {
.base => {
self.base = URL.resolve(self.arena, self.base, href, .{ .encoding = frame.charset }) catch return;
},
.script => {
if (hasNonRemoteScheme(href)) {
return;
}
const resolved = URL.resolve(self.arena, self.base, href, .{ .encoding = frame.charset }) catch return;
if (isRemoteScheme(resolved) == false) {
return;
}
_ = frame._script_manager.preloadScript(null, resolved) catch {};
},
.module => {
if (hasNonRemoteScheme(href)) {
return;
}
// The url becomes the imported_modules key, which must
// outlive the fetch so it lives on the frame arena.
const resolved = URL.resolve(frame.arena, self.base, href, .{ .encoding = frame.charset }) catch return;
if (isRemoteScheme(resolved) == false) {
return;
}
_ = frame._script_manager.base.preloadModuleHint(null, resolved, frame.url) catch {};
},
_ => {},
}
}
};
fn isRemoteScheme(url: []const u8) bool {
return std.ascii.startsWithIgnoreCase(url, "http:") or std.ascii.startsWithIgnoreCase(url, "https:");
}
// Non-http(s) scheme (e.g. data:, blob:) never resolve to a remote URL. We
// detect this upfront to prevent uncessary URL.resolves that would dupe the
// (potentially very lage) data.
fn hasNonRemoteScheme(href: []const u8) bool {
if (isRemoteScheme(href)) {
return false;
}
if (href.len == 0 or !std.ascii.isAlphabetic(href[0])) {
return false;
}
for (href[1..]) |c| {
switch (c) {
':' => return true,
'a'...'z', 'A'...'Z', '0'...'9', '+', '-', '.' => {},
else => return false,
}
}
return false;
}
const testing = @import("../../testing.zig");
test "preload: prescan" {
defer testing.reset();
const page = try testing.pageTest("mcp_nav.html", .{});
defer page.close();
const frame = page.frame().?;
// The fetches this starts stay in flight (nothing ticks the client before
// page.close aborts them), so the maps hold exactly what the scan found.
// All srcs sit under /serve-count/ because the test server panics on
// unknown paths elsewhere; unknown names there get a plain 404.
prescan(frame,
\\<html><head>
\\<script src="/serve-count/unit_a.js"></script>
\\<script src="/serve-count/unit_a.js"></script>
\\<script type="module" src="/serve-count/unit_m.js"></script>
\\<script src="/serve-count/unit_skipped.js" nomodule></script>
\\<script type="application/json" src="/serve-count/unit_data.js"></script>
\\<script src="data:text/javascript,window.unit_data_url = 1;"></script>
\\<script type="module" src="data:text/javascript,window.unit_data_module = 1;"></script>
\\<script>var trap = "</scr" + "ipt><script src=/serve-count/unit_inline.js>";</script>
\\<!-- <script src="/serve-count/unit_commented.js"></script> -->
\\<template><script src="/serve-count/unit_templated.js"></script></template>
\\<noscript><script src="/serve-count/unit_noscripted.js"></script></noscript>
\\<style>p:before { content: "<script src=/serve-count/unit_styled.js></script>" }</style>
\\<base href="/serve-count/sub/">
\\<base href="/serve-count/ignored/">
\\<script src="based.js"></script>
\\</head><body></body></html>
);
const sm = &frame._script_manager;
try testing.expectEqual(2, sm.preloaded_scripts.count());
try testing.expectEqual(true, sm.preloaded_scripts.contains("http://127.0.0.1:9582/serve-count/unit_a.js"));
try testing.expectEqual(true, sm.preloaded_scripts.contains("http://127.0.0.1:9582/serve-count/sub/based.js"));
try testing.expectEqual(1, sm.base.imported_modules.count());
const module = sm.base.imported_modules.get("http://127.0.0.1:9582/serve-count/unit_m.js") orelse return error.MissingModule;
try testing.expectEqual(true, module.hint);
}

View File

@@ -171,6 +171,16 @@ const Error = struct {
};
};
pub const PrescanResource = h5e.PrescanResource;
pub const PrescanCallback = h5e.PrescanCallback;
// Preload scanner: a tokenizer-only pass over a buffered document, reporting
// fetchable script resources (and the first <base href>) through `callback`.
// Builds no tree; purely a hint source.
pub fn prescan(html: []const u8, charset: []const u8, ctx: *anyopaque, callback: PrescanCallback) void {
h5e.html5ever_prescan(html.ptr, html.len, charset.ptr, charset.len, ctx, callback);
}
pub fn parse(self: *Parser, html: []const u8) void {
h5e.html5ever_parse_document(
html.ptr,

View File

@@ -315,3 +315,23 @@ pub extern "c" fn encoding_max_encode_buffer_length(
handle: *anyopaque,
input_len: usize,
) usize;
// Preload scanner (see prescan.rs). Kinds must stay in sync with the
// KIND_* constants there.
pub const PrescanResource = enum(u32) {
script = 0,
module = 1,
base = 2,
_,
};
pub const PrescanCallback = *const fn (ctx: *anyopaque, kind: PrescanResource, url: [*c]const u8, url_len: usize) callconv(.c) void;
pub extern "c" fn html5ever_prescan(
html: [*c]const u8,
len: usize,
charset: [*c]const u8,
charset_len: usize,
ctx: *anyopaque,
callback: PrescanCallback,
) void;

View File

@@ -0,0 +1,56 @@
<!DOCTYPE html>
<script src="../../../testing.js"></script>
<!--
Non-blocking consumers of <link rel=preload as=script>: async, defer, and
dynamically-inserted scripts adopt the preloaded Script (takePreload in
addFromElement) instead of fetching the URL a second time.
Each /serve-count/ URL is served by the test server with a body of
`window.__serve_count_<name> = N;` where N counts serves of that URL — so the
body the consumer executes records whether the preload was reused (1) or a
duplicate fetch happened (2). The responses are Cache-Control: no-store, so
the HTTP cache can't mask a duplicate fetch.
-->
<!--
Adopted by a defer script. The parser doesn't pump HTTP between the link and
the script tag, so the entry is still in flight at adoption: this exercises
loading-adoption, where the transfer completes after the Script has been
rewritten into a .frame defer script (PreloadedScript.doneCallback delegates
to Script.doneCallback).
-->
<link rel="preload" as="script" href="/serve-count/defer.js" onload="window.link_defer_load = true">
<script defer src="/serve-count/defer.js"></script>
<!-- Same, adopted by an async script. -->
<link rel="preload" as="script" href="/serve-count/async.js" onload="window.link_async_load = true">
<script async src="/serve-count/async.js"></script>
<!--
Adopted by a dynamically-inserted script (defaults to async). The blocking
empty.js fetch below pumps the HTTP client, so by insertion time the preload
may be done (done-adoption: queued straight onto ready_scripts) or still in
flight (loading-adoption) — both must produce the same result.
-->
<link rel="preload" as="script" href="/serve-count/dynamic.js" onload="window.link_dynamic_load = true">
<script src="empty.js"></script>
<script>
const s = document.createElement('script');
s.src = '/serve-count/dynamic.js';
document.head.appendChild(s);
</script>
<script id="checks">
testing.onload(() => {
// Every consumer executed the first (and only) serve of its URL.
testing.expectEqual(1, window.__serve_count_defer);
testing.expectEqual(1, window.__serve_count_async);
testing.expectEqual(1, window.__serve_count_dynamic);
// Adoption must not lose the hint links' load events (hint_element rides
// on the Script, outside extra).
testing.expectEqual(true, window.link_defer_load);
testing.expectEqual(true, window.link_async_load);
testing.expectEqual(true, window.link_dynamic_load);
});
</script>

View File

@@ -0,0 +1,38 @@
<!DOCTYPE html>
<script src="../../../testing.js"></script>
<!--
No <link> hints anywhere here: the preload scanner (Frame.prescanScripts)
runs over the raw buffered HTML before parsing and starts every script
download up front. Each consumer below must then reuse that fetch rather
than issue its own.
/serve-count/ bodies record how many times the URL was served (see
testing.zig); the body a consumer executes carries 1 when the prescan's
fetch was reused, 2 when a duplicate fetch happened.
-->
<!-- Blocking: waitForPreload consumes the prescan's in-flight fetch. -->
<script src="/serve-count/prescan_blocking.js"></script>
<!-- Defer: takePreload adopts the prescan's Script. -->
<script defer src="/serve-count/prescan_defer.js"></script>
<!--
Module: the prescan parks module hints in imported_modules; the script
element's own fetch adopts it via takeModuleHint.
-->
<script type="module" src="/serve-count/prescan_module.js"></script>
<script id="order">
// The blocking script above executed in document order, before us.
testing.expectEqual(1, window.__serve_count_prescan_blocking);
</script>
<script id="checks">
testing.onload(() => {
testing.expectEqual(1, window.__serve_count_prescan_blocking);
testing.expectEqual(1, window.__serve_count_prescan_defer);
testing.expectEqual(1, window.__serve_count_prescan_module);
});
</script>

View File

@@ -431,6 +431,19 @@ pub fn getChildTextContent(self: *Node, writer: *std.Io.Writer) error{WriteFaile
}
}
// The byte length of what getChildTextContent writes; lets callers size a
// buffer (or arena) before extracting.
pub fn childTextContentLen(self: *Node) usize {
var len: usize = 0;
var it = self.childrenIterator();
while (it.next()) |child| {
if (child.is(CData.Text)) |text| {
len += text._proto._data.str().len;
}
}
return len;
}
pub fn setTextContent(self: *Node, data: []const u8, frame: *Frame) !void {
switch (self._type) {
.element => |el| {

View File

@@ -230,7 +230,7 @@ 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.preloadScriptHint(self._proto, href)) {
if (Frame.preload.scriptHint(frame, self._proto, href)) {
// load/error fires when the fetch settles
return;
}
@@ -243,7 +243,7 @@ pub fn linkAddedCallback(self: *Link, frame: *Frame) !void {
// "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.preloadModuleHint(self._proto, href)) {
if (Frame.preload.moduleHint(frame, self._proto, href)) {
// load/error fires when the fetch settles
return;
}

View File

@@ -16,6 +16,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
mod prescan;
mod sink;
mod types;
mod url;

202
src/html5ever/prescan.rs Normal file
View File

@@ -0,0 +1,202 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// A preload scanner: a lightweight tokenizer-only pass over a document that
// reports fetchable script resources, so downloads can start before the tree
// builder (which stalls on every blocking <script src>) reaches them. Purely
// a hint source — it builds no tree and a wrong guess only costs a fetch.
// Same idea as Servo's dom/servoparser/prefetch.rs.
use std::cell::Cell;
use std::os::raw::{c_uchar, c_void};
use encoding_rs::Encoding;
use html5ever::tendril::StrTendril;
use html5ever::tokenizer::states::RawKind;
use html5ever::tokenizer::{
BufferQueue, Tag, TagKind, Token, TokenSink, TokenSinkResult, Tokenizer, TokenizerOpts,
};
use html5ever::{local_name, ns, Attribute, LocalName};
// Keep in sync with PrescanResource in src/browser/parser/html5ever.zig.
const KIND_SCRIPT: u32 = 0;
const KIND_MODULE: u32 = 1;
const KIND_BASE: u32 = 2;
pub type PrescanCallback =
extern "C" fn(ctx: *mut c_void, kind: u32, url: *const c_uchar, url_len: usize);
struct PrescanSink {
ctx: *mut c_void,
callback: PrescanCallback,
// Only the first <base href> counts, per spec.
base_seen: Cell<bool>,
// Scripts inside <template> are inert until cloned; don't fetch them.
template_depth: Cell<u32>,
}
impl PrescanSink {
fn attr<'a>(tag: &'a Tag, name: &LocalName) -> Option<&'a Attribute> {
tag.attrs
.iter()
.find(|a| a.name.ns == ns!() && a.name.local == *name)
}
fn emit(&self, kind: u32, url: &str) {
(self.callback)(self.ctx, kind, url.as_ptr(), url.len());
}
fn scan_script(&self, tag: &Tag) {
// Mirrors ScriptManager.addFromElement: nomodule scripts are skipped,
// and only classic javascript and module types are fetched.
if Self::attr(tag, &local_name!("nomodule")).is_some() {
return;
}
let Some(src) = Self::attr(tag, &local_name!("src")) else {
return;
};
if src.value.is_empty() {
return;
}
let kind = match Self::attr(tag, &local_name!("type")) {
None => KIND_SCRIPT,
Some(attr) => {
let t: &str = &attr.value;
if t.is_empty()
|| t.eq_ignore_ascii_case("application/javascript")
|| t.eq_ignore_ascii_case("text/javascript")
{
KIND_SCRIPT
} else if t.eq_ignore_ascii_case("module") {
KIND_MODULE
} else {
return;
}
}
};
self.emit(kind, &src.value);
}
}
impl TokenSink for PrescanSink {
type Handle = ();
fn process_token(&self, token: Token, _line_number: u64) -> TokenSinkResult<()> {
let Token::TagToken(tag) = token else {
return TokenSinkResult::Continue;
};
if tag.kind == TagKind::EndTag {
if tag.name == local_name!("template") {
let depth = self.template_depth.get();
self.template_depth.set(depth.saturating_sub(1));
}
return TokenSinkResult::Continue;
}
if tag.name == local_name!("script") {
if self.template_depth.get() == 0 {
self.scan_script(&tag);
}
// The tokenizer has no tree builder giving it state feedback, so
// the sink must request the script-data state itself or the
// script body would be tokenized as markup.
return TokenSinkResult::RawData(RawKind::ScriptData);
}
if tag.name == local_name!("base") {
if !self.base_seen.get() {
if let Some(href) = Self::attr(&tag, &local_name!("href")) {
self.base_seen.set(true);
self.emit(KIND_BASE, &href.value);
}
}
return TokenSinkResult::Continue;
}
if tag.name == local_name!("template") {
self.template_depth.set(self.template_depth.get() + 1);
return TokenSinkResult::Continue;
}
// The remaining raw-text/rcdata elements: their content must not be
// tokenized as markup, or text mentioning "<script src=…>" (a style
// rule, a <noscript> fallback, an <iframe> srcdoc-ish body) would
// produce phantom fetches. noscript is raw text because scripting is
// enabled in this browser.
if tag.name == local_name!("style")
|| tag.name == local_name!("noscript")
|| tag.name == local_name!("iframe")
|| tag.name == local_name!("xmp")
|| tag.name == local_name!("noembed")
|| tag.name == local_name!("noframes")
{
return TokenSinkResult::RawData(RawKind::Rawtext);
}
if tag.name == local_name!("title") || tag.name == local_name!("textarea") {
return TokenSinkResult::RawData(RawKind::Rcdata);
}
if tag.name == local_name!("plaintext") {
return TokenSinkResult::Plaintext;
}
TokenSinkResult::Continue
}
}
/// Tokenize the (fully buffered) document and report each fetchable script
/// resource through `callback`: raw (unresolved) src values for classic and
/// module scripts, plus the first <base href> so the caller can resolve
/// subsequent URLs the way the real parse will.
#[no_mangle]
pub extern "C" fn html5ever_prescan(
html: *const c_uchar,
len: usize,
charset: *const c_uchar,
charset_len: usize,
ctx: *mut c_void,
callback: PrescanCallback,
) {
if html.is_null() || len == 0 {
return;
}
let input = unsafe { std::slice::from_raw_parts(html, len) };
let charset_bytes: &[u8] = if charset.is_null() {
&[]
} else {
unsafe { std::slice::from_raw_parts(charset, charset_len) }
};
// No allocation when the content is already valid UTF-8.
let encoding = Encoding::for_label(charset_bytes).unwrap_or(encoding_rs::UTF_8);
let (decoded, _, _) = encoding.decode(input);
// A panic must not unwind across the FFI boundary; hints are best-effort,
// so just stop scanning.
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let sink = PrescanSink {
ctx,
callback,
base_seen: Cell::new(false),
template_depth: Cell::new(0),
};
let tokenizer = Tokenizer::new(sink, TokenizerOpts::default());
let queue = BufferQueue::default();
queue.push_back(StrTendril::from(decoded.as_ref()));
// The sink never returns TokenSinkResult::Script, so one feed
// consumes the whole buffer.
let _ = tokenizer.feed(&queue);
tokenizer.end();
}));
}

View File

@@ -605,6 +605,16 @@ fn serveCDP(wg: *std.Thread.WaitGroup) !void {
test_app.network.run();
}
// /serve-count/ counters; only ever touched from the test HTTP server thread.
var serve_counts = [_]struct { name: []const u8, count: u32 = 0 }{
.{ .name = "defer" },
.{ .name = "async" },
.{ .name = "dynamic" },
.{ .name = "prescan_blocking" },
.{ .name = "prescan_defer" },
.{ .name = "prescan_module" },
};
fn testHTTPHandler(req: *std.http.Server.Request) !void {
const path = req.head.target;
@@ -732,6 +742,30 @@ fn testHTTPHandler(req: *std.http.Server.Request) !void {
});
}
if (std.mem.startsWith(u8, path, "/serve-count/") and std.mem.endsWith(u8, path, ".js")) {
// Serves `window.__serve_count_<name> = N;` where N counts how many
// times this URL has been served. Lets a fixture assert a preloaded
// script was fetched exactly once: the body the consumer executes
// carries N == 1, while a duplicate fetch would execute N == 2.
// no-store so the second fetch can't be satisfied by the HTTP cache.
const name = path["/serve-count/".len .. path.len - ".js".len];
const slot: *u32 = blk: {
for (&serve_counts) |*sc| {
if (std.mem.eql(u8, sc.name, name)) break :blk &sc.count;
}
return req.respond("unknown counter", .{ .status = .not_found });
};
slot.* += 1;
var buf: [64]u8 = undefined;
const body = try std.fmt.bufPrint(&buf, "window.__serve_count_{s} = {d};", .{ name, slot.* });
return req.respond(body, .{
.extra_headers = &.{
.{ .name = "Content-Type", .value = "application/javascript" },
.{ .name = "Cache-Control", .value = "no-store" },
},
});
}
if (std.mem.eql(u8, path, "/xhr/500")) {
return req.respond("Internal Server Error", .{
.status = .internal_server_error,