Files
browser/src/browser/ScriptManager.zig
Karl Seguin a74c5cc54c On error, don't free headers
There's ambiguity in the http_client.request() call on whether or not the caller
is responsible for freeing the header. It depends how request() fails, and it's
impossible for the caller to know. This needs a fundamental fix, but, in the
meantime, we get to pick between: a possible leak or a double free.

This commit opts for a possible leak. Why? Because overwhelmingly, if request
fails, it'll fail at a point where it will handle the free. In those cases
where it doesn't then the system is probably in trouble anyways (OOM).

(Also, as I was debugging, I noticed that the function.src() debug helper
wasn't working, so I fixed it).
2026-05-07 19:47:29 +08:00

399 lines
14 KiB
Zig

// Copyright (C) 2023-2025 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/>.
const std = @import("std");
const lp = @import("lightpanda");
const builtin = @import("builtin");
const HttpClient = @import("HttpClient.zig");
const js = @import("js/js.zig");
const URL = @import("URL.zig");
const Frame = @import("Frame.zig");
const ScriptManagerBase = @import("ScriptManagerBase.zig");
const Element = @import("webapi/Element.zig");
const log = lp.log;
const Allocator = std.mem.Allocator;
const IS_DEBUG = builtin.mode == .Debug;
const ScriptManager = @This();
// Re-exports so Frame / Context callers don't need to import Base directly.
pub const Script = ScriptManagerBase.Script;
pub const ModuleSource = ScriptManagerBase.ModuleSource;
base: ScriptManagerBase,
frame: *Frame,
// have we notified the frame that all scripts are loaded (used to fire the
// "load" event).
frame_notified_of_completion: bool,
pub fn init(allocator: Allocator, http_client: *HttpClient, frame: *Frame) ScriptManager {
var base = ScriptManagerBase.init(allocator, http_client, .{ .frame = frame });
base.tail_hook = tailHook;
return .{
.frame = frame,
.base = base,
.frame_notified_of_completion = false,
};
}
pub fn deinit(self: *ScriptManager) void {
self.base.deinit();
}
pub fn reset(self: *ScriptManager) void {
self.base.reset();
self.frame_notified_of_completion = false;
}
// Frame wrapper uses this to fire documentIsLoaded and scriptsCompletedLoading
// once Base has finished processing its ready / defer queues.
pub fn tailHook(base: *ScriptManagerBase) void {
const self: *ScriptManager = @fieldParentPtr("base", base);
const frame = self.frame;
// When all scripts (normal and deferred) are done loading, the document
// state changes (this ultimately triggers the DOMContentLoaded event).
// Page makes this safe to call multiple times.
frame.documentIsLoaded();
if (base.async_scripts.first == null and self.frame_notified_of_completion == false) {
self.frame_notified_of_completion = true;
frame.scriptsCompletedLoading();
}
}
fn getHeaders(self: *ScriptManager) !HttpClient.Headers {
return self.base.getHeaders();
}
pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_element: *Element.Html.Script, comptime ctx: []const u8) !void {
if (script_element._executed) {
// If a script tag gets dynamically created and added to the dom:
// document.getElementsByTagName('head')[0].appendChild(script)
// that script tag will immediately get executed by our scriptAddedCallback.
// However, if the location where the script tag is inserted happens to be
// below where processHTMLDoc currently is, then we'll re-run that same script
// again in processHTMLDoc. This flag is used to let us know if a specific
// <script> has already been processed.
return;
}
const element = script_element.asElement();
if (element.getAttributeSafe(comptime .wrap("nomodule")) != null) {
// these scripts should only be loaded if we don't support modules
// but since we do support modules, we can just skip them.
return;
}
const kind: Script.Extra.FrameExtra.Kind = blk: {
const script_type = element.getAttributeSafe(comptime .wrap("type")) orelse break :blk .javascript;
if (script_type.len == 0) {
break :blk .javascript;
}
if (std.ascii.eqlIgnoreCase(script_type, "application/javascript")) {
break :blk .javascript;
}
if (std.ascii.eqlIgnoreCase(script_type, "text/javascript")) {
break :blk .javascript;
}
if (std.ascii.eqlIgnoreCase(script_type, "module")) {
break :blk .module;
}
if (std.ascii.eqlIgnoreCase(script_type, "importmap")) {
break :blk .importmap;
}
// "type" could be anything, but only the above are ones we need to process.
// Common other ones are application/json, application/ld+json, text/template
return;
};
var handover = false;
const frame = self.frame;
const arena = try frame.getArena(.large, "SM.addFromElement");
errdefer if (!handover) {
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| {
if (try parseDataURI(arena, src)) |data_uri| {
source = .{ .@"inline" = data_uri };
} else {
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
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;
}
// Check for defer or module (before checking dynamic script default)
if (kind == .module or element.getAttributeSafe(comptime .wrap("defer")) != null) {
break :blk .@"defer";
}
// For dynamically-inserted scripts (not from parser), default to async
// unless async was explicitly set to false (which removes the attribute)
// and defer was set to true (checked above)
if (comptime !from_parser) {
// Script has src and no explicit async/defer attributes
// Per HTML spec, dynamically created scripts default to async
break :blk .async;
}
break :blk .normal;
};
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,
} },
};
const is_blocking = mode == .normal;
if (is_blocking == false) {
self.base.scriptList(script).append(&script.node);
}
if (remote_url) |url| {
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 was_evaluating = self.base.is_evaluating;
self.base.is_evaluating = true;
defer self.base.is_evaluating = was_evaluating;
const headers = try self.getHeaders();
if (is_blocking) {
const response = try self.base.client.syncRequest(arena, .{
.url = url,
.method = .GET,
.frame_id = frame._frame_id,
.loader_id = frame._loader_id,
.headers = headers,
.cookie_jar = &frame._session.cookie_jar,
.cookie_origin = frame.url,
.resource_type = .script,
.notification = frame._session.notification,
});
script.source = .{ .remote = response.body };
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 self.base.client.request(.{
.ctx = script,
.params = .{
.url = url,
.method = .GET,
.frame_id = frame._frame_id,
.loader_id = frame._loader_id,
.headers = headers,
.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,
});
}
handover = true;
}
if (is_blocking == false) {
return;
}
if (script.status == 0) {
// an error (that we already logged)
script.deinit();
return;
}
// could have already been evaluating if this is dynamically added
const was_evaluating = self.base.is_evaluating;
self.base.is_evaluating = true;
defer {
self.base.is_evaluating = was_evaluating;
script.deinit();
}
script.eval();
}
pub fn parseImportmap(self: *ScriptManager, script: *const Script) !void {
const content = script.source.content();
const Imports = struct {
imports: std.json.ArrayHashMap([]const u8),
};
const imports = try std.json.parseFromSliceLeaky(
Imports,
self.frame.arena,
content,
.{ .allocate = .alloc_always },
);
var iter = imports.imports.map.iterator();
while (iter.next()) |entry| {
// > Relative URLs are resolved to absolute URL addresses using the
// > base URL of the document containing the import map.
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules#importing_modules_using_import_maps
const resolved_url = try URL.resolve(
self.frame.arena,
self.frame.base(),
entry.value_ptr.*,
.{},
);
try self.base.importmap.put(self.frame.arena, entry.key_ptr.*, resolved_url);
}
}
pub fn staticScriptsDone(self: *ScriptManager) void {
self.base.staticScriptsDone();
}
// Parses data:[<media-type>][;base64],<data>
fn parseDataURI(allocator: Allocator, src: []const u8) !?[]const u8 {
if (!std.mem.startsWith(u8, src, "data:")) {
return null;
}
const uri = src[5..];
const data_starts = std.mem.indexOfScalar(u8, uri, ',') orelse return null;
const data = uri[data_starts + 1 ..];
const unescaped = try URL.unescape(allocator, data);
const metadata = uri[0..data_starts];
if (std.mem.endsWith(u8, metadata, ";base64") == false) {
return unescaped;
}
// Forgiving base64 decode per WHATWG spec:
// https://infra.spec.whatwg.org/#forgiving-base64-decode
// Step 1: Remove all ASCII whitespace
var stripped = try std.ArrayList(u8).initCapacity(allocator, unescaped.len);
for (unescaped) |c| {
if (!std.ascii.isWhitespace(c)) {
stripped.appendAssumeCapacity(c);
}
}
const trimmed = std.mem.trimRight(u8, stripped.items, "=");
// Length % 4 == 1 is invalid
if (trimmed.len % 4 == 1) {
return error.InvalidCharacterError;
}
const decoded_size = std.base64.standard_no_pad.Decoder.calcSizeForSlice(trimmed) catch return error.InvalidCharacterError;
const buffer = try allocator.alloc(u8, decoded_size);
std.base64.standard_no_pad.Decoder.decode(buffer, trimmed) catch return error.InvalidCharacterError;
return buffer;
}
const testing = @import("../testing.zig");
test "DataURI: parse valid" {
try assertValidDataURI("data:text/javascript; charset=utf-8;base64,Zm9v", "foo");
try assertValidDataURI("data:text/javascript; charset=utf-8;,foo", "foo");
try assertValidDataURI("data:,foo", "foo");
}
test "DataURI: parse invalid" {
try assertInvalidDataURI("atad:,foo");
try assertInvalidDataURI("data:foo");
try assertInvalidDataURI("data:");
}
fn assertValidDataURI(uri: []const u8, expected: []const u8) !void {
defer testing.reset();
const data_uri = try parseDataURI(testing.arena_allocator, uri) orelse return error.TestFailed;
try testing.expectEqual(expected, data_uri);
}
fn assertInvalidDataURI(uri: []const u8) !void {
try testing.expectEqual(null, parseDataURI(undefined, uri));
}