diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index 17f90e714..863fe5594 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -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 ` -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 -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 ``. // href is passed in as an optimization since the [currently] only callsite has // it, so why look it up again? diff --git a/src/browser/ScriptManager.zig b/src/browser/ScriptManager.zig index 0d3d2de84..26c90f0cb 100644 --- a/src/browser/ScriptManager.zig +++ b/src/browser/ScriptManager.zig @@ -46,7 +46,8 @@ frame: *Frame, // "load" event). frame_notified_of_completion: bool, -// scripts loaded based on a found during parsing +// Scripts loaded based on a 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 . +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 + \\ + \\ + \\ + \\ + \\ + \\ + \\ + \\ + \\ + \\ + \\ + \\ + \\ + \\ + \\ + ); + + 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); +} diff --git a/src/browser/parser/Parser.zig b/src/browser/parser/Parser.zig index 159dbe372..d252aa9db 100644 --- a/src/browser/parser/Parser.zig +++ b/src/browser/parser/Parser.zig @@ -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 ) 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, diff --git a/src/browser/parser/html5ever.zig b/src/browser/parser/html5ever.zig index 3c5905302..b6d1c7ff3 100644 --- a/src/browser/parser/html5ever.zig +++ b/src/browser/parser/html5ever.zig @@ -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; diff --git a/src/browser/tests/element/html/script/preload_nonblocking.html b/src/browser/tests/element/html/script/preload_nonblocking.html new file mode 100644 index 000000000..04bc18c8d --- /dev/null +++ b/src/browser/tests/element/html/script/preload_nonblocking.html @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/tests/element/html/script/prescan.html b/src/browser/tests/element/html/script/prescan.html new file mode 100644 index 000000000..61989552e --- /dev/null +++ b/src/browser/tests/element/html/script/prescan.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig index 0e473e317..42e00d1bf 100644 --- a/src/browser/webapi/Node.zig +++ b/src/browser/webapi/Node.zig @@ -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| { diff --git a/src/browser/webapi/element/html/Link.zig b/src/browser/webapi/element/html/Link.zig index 2c06d3d8b..fcd221d42 100644 --- a/src/browser/webapi/element/html/Link.zig +++ b/src/browser/webapi/element/html/Link.zig @@ -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; } diff --git a/src/html5ever/lib.rs b/src/html5ever/lib.rs index f778dbeb7..8504da071 100644 --- a/src/html5ever/lib.rs +++ b/src/html5ever/lib.rs @@ -16,6 +16,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +mod prescan; mod sink; mod types; mod url; diff --git a/src/html5ever/prescan.rs b/src/html5ever/prescan.rs new file mode 100644 index 000000000..e5702f22f --- /dev/null +++ b/src/html5ever/prescan.rs @@ -0,0 +1,202 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// 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 . + +// 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