// 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 . const std = @import("std"); const lp = @import("lightpanda"); const builtin = @import("builtin"); const HttpClient = @import("HttpClient.zig"); const http = @import("../network/http.zig"); const js = @import("js/js.zig"); const URL = @import("URL.zig"); const Session = @import("Session.zig"); const Frame = @import("Frame.zig"); const ImportMap = @import("ImportMap.zig"); const WorkerGlobalScope = @import("webapi/WorkerGlobalScope.zig"); const Element = @import("webapi/Element.zig"); const log = lp.log; const String = lp.String; const Allocator = std.mem.Allocator; const IS_DEBUG = builtin.mode == .Debug; const ScriptManagerBase = @This(); // Either a *Frame (for page ScriptManagers) or *WorkerGlobalScope (for workers). // Used from HTTP callbacks that only have a *Script in hand; the Script reaches // the owner through its manager pointer. pub const Owner = union(enum) { frame: *Frame, worker: *WorkerGlobalScope, pub fn url(self: Owner) [:0]const u8 { return switch (self) { inline else => |g| g.url, }; } pub fn frameId(self: Owner) u32 { return switch (self) { .frame => |f| f._frame_id, .worker => |w| w._worker._frame_id, }; } pub fn loaderId(self: Owner) u32 { return switch (self) { .frame => |f| f._loader_id, .worker => |w| w._worker._loader_id, }; } pub fn session(self: Owner) *Session { return switch (self) { inline else => |g| g._session, }; } pub fn jsContext(self: Owner) *js.Context { return switch (self) { inline else => |g| g.js, }; } pub fn addHeaders(self: Owner, headers: *HttpClient.Headers) !void { return switch (self) { inline else => |g| g.headersForRequest(headers), }; } pub fn makeRequest(self: Owner, req: HttpClient.Request) !void { return switch (self) { inline else => |g| g.makeRequest(req), }; } }; owner: Owner, // used to prevent recursive evaluation is_evaluating: bool, // Only once this is true can deferred scripts be run static_scripts_done: bool, // List of async scripts. We don't care about the execution order of these, but // on shutdown/abort, we need to cleanup any pending ones. Used for both // frame-side .async scripts and .import / .import_async modules. async_scripts: std.DoublyLinkedList, // List of deferred scripts. These must be executed in order, but only once // dom_loaded == true. Workers never populate this list. defer_scripts: std.DoublyLinkedList, // When an async script is ready, it's queued here. ready_scripts: std.DoublyLinkedList, shutdown: bool = false, client: *HttpClient, allocator: Allocator, // See ScriptManager.zig for the type's documentation. imported_modules: std.StringHashMapUnmanaged(ImportedModule), // For workers this stays empty importmap: ImportMap, // Called at the end of evaluate() after all Base-owned work has run. Frame // wrapper uses this to drain defer_scripts and fire documentIsLoaded / // scriptsCompletedLoading. Null for workers. tail_hook: ?*const fn (*ScriptManagerBase) void, pub fn init(allocator: Allocator, http_client: *HttpClient, owner: Owner) ScriptManagerBase { return .{ .owner = owner, .async_scripts = .{}, .defer_scripts = .{}, .ready_scripts = .{}, .importmap = .empty, .is_evaluating = false, .allocator = allocator, .imported_modules = .empty, .client = http_client, .static_scripts_done = false, .tail_hook = null, }; } pub fn deinit(self: *ScriptManagerBase) void { // necessary to free any arenas scripts may be referencing self.reset(); self.imported_modules.deinit(self.allocator); } pub fn reset(self: *ScriptManagerBase) void { var it = self.imported_modules.valueIterator(); while (it.next()) |value_ptr| { switch (value_ptr.state) { .done => |script| script.deinit(), else => {}, } } self.imported_modules.clearRetainingCapacity(); // The importmap's contents were allocated from the owner's arena, which // has been reset, so just zero the struct. self.importmap = .empty; clearList(&self.defer_scripts); clearList(&self.async_scripts); clearList(&self.ready_scripts); self.static_scripts_done = false; } fn clearList(list: *std.DoublyLinkedList) void { while (list.popFirst()) |n| { const script: *Script = @fieldParentPtr("node", n); script.deinit(); } } pub fn getHeaders(self: *ScriptManagerBase) !http.Headers { var headers = try self.client.newHeaders(); try self.owner.addHeaders(&headers); return headers; } fn acquireArena(self: *ScriptManagerBase, size_or_bucket: anytype, debug: []const u8) !Allocator { return self.owner.session().getArena(size_or_bucket, debug); } fn releaseArena(self: *ScriptManagerBase, arena: Allocator) void { self.owner.session().releaseArena(arena); } pub fn scriptList(self: *ScriptManagerBase, script: *const Script) *std.DoublyLinkedList { return switch (script.extra) { .import, .import_async => &self.async_scripts, .frame => |fe| switch (fe.mode) { .normal => unreachable, // not added to a list, executed immediately .@"defer" => &self.defer_scripts, .async => &self.async_scripts, }, }; } // Resolve a module specifier to a valid URL. pub fn resolveSpecifier(self: *ScriptManagerBase, arena: Allocator, base: [:0]const u8, specifier: [:0]const u8) ![:0]const u8 { if (try self.importmap.resolve(arena, base, specifier)) |url| { return url; } // The importmap _always_ resolves specifies if they're valid, falling back // to the base + specifier itself. So we can only be here on something invalid. return error.SpecifierResolutionFailed; } pub fn preloadImport(self: *ScriptManagerBase, url: [:0]const u8, referrer: []const u8) !void { const gop = try self.imported_modules.getOrPut(self.allocator, url); if (gop.found_existing) { gop.value_ptr.waiters += 1; return; } errdefer _ = self.imported_modules.remove(url); const arena = try self.acquireArena(.large, "SM.preloadImport"); errdefer self.releaseArena(arena); const script = try arena.create(Script); script.* = .{ .arena = arena, .url = url, .node = .{}, .manager = self, .complete = false, .source = .{ .remote = .{} }, .extra = .import, }; gop.value_ptr.* = ImportedModule{}; if (comptime IS_DEBUG) { var ls: js.Local.Scope = undefined; self.owner.jsContext().localScope(&ls); defer ls.deinit(); log.debug(.http, "script queue", .{ .url = url, .ctx = "module", .referrer = referrer, .stack = ls.local.stackTrace() catch "???", }); } // This seems wrong since we're not dealing with an async import (unlike // getAsyncModule below), but all we're trying to do here is pre-load the // script for execution at some point in the future (when waitForImport is // called). self.async_scripts.append(&script.node); const owner = self.owner; const session = owner.session(); owner.makeRequest(.{ .ctx = script, .url = url, .method = .GET, .frame_id = owner.frameId(), .loader_id = owner.loaderId(), .headers = try self.getHeaders(), .cookie_jar = &session.cookie_jar, .cookie_origin = owner.url(), .resource_type = .script, .notification = 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, }) catch |err| { self.async_scripts.remove(&script.node); return err; }; } pub fn waitForImport(self: *ScriptManagerBase, url: [:0]const u8) !ModuleSource { const entry = self.imported_modules.getEntry(url) orelse { // It shouldn't be possible for v8 to ask for a module that we didn't // `preloadImport` above. return error.UnknownModule; }; const was_evaluating = self.is_evaluating; self.is_evaluating = true; defer self.is_evaluating = was_evaluating; var client = self.client; while (true) { switch (entry.value_ptr.state) { .loading => { _ = try client.tick(200, .sync_wait); continue; }, .done => |script| { var shared = false; const buffer = entry.value_ptr.buffer; const waiters = entry.value_ptr.waiters; if (waiters == 1) { self.imported_modules.removeByPtr(entry.key_ptr); } else { shared = true; entry.value_ptr.waiters = waiters - 1; } return .{ .buffer = buffer, .shared = shared, .script = script, }; }, .err => return error.Failed, } } } pub fn getAsyncImport(self: *ScriptManagerBase, url: [:0]const u8, cb: ImportAsync.Callback, cb_data: *anyopaque, referrer: []const u8) !void { const arena = try self.acquireArena(.large, "SM.getAsyncImport"); errdefer self.releaseArena(arena); const script = try arena.create(Script); script.* = .{ .arena = arena, .url = url, .node = .{}, .manager = self, .complete = false, .source = .{ .remote = .{} }, .extra = .{ .import_async = .{ .callback = cb, .data = cb_data, } }, }; if (comptime IS_DEBUG) { var ls: js.Local.Scope = undefined; self.owner.jsContext().localScope(&ls); defer ls.deinit(); log.debug(.http, "script queue", .{ .url = url, .ctx = "dynamic module", .referrer = referrer, .stack = ls.local.stackTrace() catch "???", }); } // It's possible, but unlikely, for client.request to immediately finish // a request, thus calling our callback. We generally don't want a call // from v8 (which is why we're here), to result in a new script evaluation. // So we block even the slightest change that `client.request` immediately // executes a callback. const was_evaluating = self.is_evaluating; self.is_evaluating = true; defer self.is_evaluating = was_evaluating; const owner = self.owner; const session = self.owner.session(); self.async_scripts.append(&script.node); owner.makeRequest(.{ .ctx = script, .url = url, .method = .GET, .frame_id = owner.frameId(), .loader_id = owner.loaderId(), .headers = try self.getHeaders(), .resource_type = .script, .cookie_jar = &session.cookie_jar, .cookie_origin = owner.url(), .notification = 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, }) catch |err| { self.async_scripts.remove(&script.node); return err; }; } // Called from the Page / Frame to signal it's done parsing the HTML, so // deferred scripts can start evaluating. Workers never call this. pub fn staticScriptsDone(self: *ScriptManagerBase) void { lp.assert(self.static_scripts_done == false, "ScriptManagerBase.staticScriptsDone", .{}); self.static_scripts_done = true; self.evaluate(); } // A script-created parser (document.open/write/close) finished. Run any // deferred scripts it produced. Unlike staticScriptsDone, this can run after // the initial parse already completed (so it must not re-assert the flag): a // frame that was loaded (or document.write'd into multiple times) keeps // static_scripts_done set, and evaluate() only drains defer_scripts when it is. pub fn scriptCreatedParseDone(self: *ScriptManagerBase) void { self.static_scripts_done = true; self.evaluate(); } pub fn evaluate(self: *ScriptManagerBase) void { if (self.is_evaluating) { // It's possible for a script.eval to cause evaluate to be called again. return; } self.is_evaluating = true; defer self.is_evaluating = false; while (self.ready_scripts.popFirst()) |n| { var script: *Script = @fieldParentPtr("node", n); switch (script.extra) { .frame => { // Only .async mode reaches ready_scripts (defer stays in // defer_scripts, normal is sync and never queued). defer script.deinit(); script.eval(); }, .import_async => |ia| { if (script.status < 200 or script.status > 299) { script.deinit(); ia.callback(ia.data, error.FailedToLoad); } else { ia.callback(ia.data, .{ .shared = false, .script = script, .buffer = script.source.remote, }); } }, .import => unreachable, // .import doesn't go through ready_scripts } } if (self.static_scripts_done == false) { // We can only execute deferred scripts if // 1 - all the normal scripts are done // 2 - we've finished parsing the HTML and at least queued all the scripts // The last one isn't obvious, but it's possible for self.scripts to // be empty not because we're done executing all the normal scripts // but because we're done executing some (or maybe none), but we're still // parsing the HTML. return; } while (self.defer_scripts.first) |n| { var script: *Script = @fieldParentPtr("node", n); if (script.complete == false) return; defer { _ = self.defer_scripts.popFirst(); script.deinit(); } // Only frame scripts populate defer_scripts. script.eval(); } // Frame wrapper uses this to fire documentIsLoaded and // scriptsCompletedLoading. Null for workers. if (self.tail_hook) |hook| hook(self); } pub const Script = struct { complete: bool, status: u16 = 0, source: Source, url: []const u8, arena: Allocator, extra: Extra, node: std.DoublyLinkedList.Node, manager: *ScriptManagerBase, // for debugging a rare production issue header_callback_called: bool = false, // for debugging a rare production issue debug_transfer_id: u32 = 0, debug_transfer_tries: u8 = 0, debug_transfer_aborted: bool = false, debug_transfer_bytes_received: usize = 0, debug_transfer_notified_fail: bool = false, debug_transfer_auth_challenge: bool = false, debug_transfer_easy_id: usize = 0, pub const Source = union(enum) { @"inline": []const u8, remote: std.ArrayList(u8), pub fn content(self: Source) []const u8 { return switch (self) { .remote => |buf| buf.items, .@"inline" => |c| c, }; } }; // The mode-specific extension. Only `.frame` carries frame-only state // (script_element, kind, *Frame); workers and dynamic JS imports use // `.import` / `.import_async` and never reach the .frame arm. pub const Extra = union(enum) { // Static module import — V8 resolution via imported_modules. import, // Dynamic JS import() — resolved via ready_scripts callback. import_async: ImportAsync, //