mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-09-28 23:47:31 -04:00
3714 lines
145 KiB
Zig
3714 lines
145 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 JS = @import("js/js.zig");
|
|
const Mime = @import("Mime.zig");
|
|
const Page = @import("Page.zig");
|
|
const Factory = @import("Factory.zig");
|
|
const Session = @import("Session.zig");
|
|
const EventManager = @import("EventManager.zig");
|
|
const ScriptManager = @import("ScriptManager.zig");
|
|
const StyleManager = @import("StyleManager.zig");
|
|
|
|
const Parser = @import("parser/Parser.zig");
|
|
const h5e = @import("parser/html5ever.zig");
|
|
|
|
const CustomElementReactions = @import("CustomElementReactions.zig");
|
|
|
|
const URL = @import("URL.zig");
|
|
const referrer = @import("referrer.zig");
|
|
const Blob = @import("webapi/Blob.zig");
|
|
const FileList = @import("webapi/FileList.zig");
|
|
const Node = @import("webapi/Node.zig");
|
|
const Event = @import("webapi/Event.zig");
|
|
const EventTarget = @import("webapi/EventTarget.zig");
|
|
const Element = @import("webapi/Element.zig");
|
|
const HtmlElement = @import("webapi/element/Html.zig");
|
|
const Window = @import("webapi/Window.zig");
|
|
const Location = @import("webapi/Location.zig");
|
|
const Document = @import("webapi/Document.zig");
|
|
const ShadowRoot = @import("webapi/ShadowRoot.zig");
|
|
const Performance = @import("webapi/Performance.zig");
|
|
const Screen = @import("webapi/Screen.zig");
|
|
const VisualViewport = @import("webapi/VisualViewport.zig");
|
|
const AbstractRange = @import("webapi/AbstractRange.zig");
|
|
const DOMNodeIterator = @import("webapi/DOMNodeIterator.zig");
|
|
const Worker = @import("webapi/Worker.zig");
|
|
const TreeWalker = @import("webapi/TreeWalker.zig");
|
|
const MessagePort = @import("webapi/MessagePort.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");
|
|
const HashChangeEvent = @import("webapi/event/HashChangeEvent.zig");
|
|
const popover = @import("webapi/element/popover.zig");
|
|
const slotting = @import("webapi/element/slotting.zig");
|
|
const NavigationKind = @import("webapi/navigation/root.zig").NavigationKind;
|
|
|
|
const PointList = @import("webapi/svg/PointList.zig");
|
|
const StringList = @import("webapi/svg/StringList.zig");
|
|
const AnimatedEnumeration = @import("webapi/svg/AnimatedEnumeration.zig");
|
|
const AnimatedLength = @import("webapi/svg/AnimatedLength.zig");
|
|
const AnimatedNumber = @import("webapi/svg/AnimatedNumber.zig");
|
|
const AnimatedString = @import("webapi/svg/AnimatedString.zig");
|
|
const AnimatedTransformList = @import("webapi/svg/AnimatedTransformList.zig");
|
|
const AnimatedPreserveAspectRatio = @import("webapi/svg/AnimatedPreserveAspectRatio.zig");
|
|
|
|
const sys_url = @import("../sys/url.zig");
|
|
const HttpClient = @import("../network/HttpClient.zig");
|
|
|
|
const GlobalEventHandlersLookup = @import("webapi/global_event_handlers.zig").Lookup;
|
|
|
|
pub const parse = @import("frame/parse.zig");
|
|
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");
|
|
|
|
const log = lp.log;
|
|
const String = lp.String;
|
|
const IFrame = Element.Html.IFrame;
|
|
const Allocator = std.mem.Allocator;
|
|
|
|
pub const BUF_SIZE = 1024;
|
|
|
|
const Frame = @This();
|
|
|
|
// This is the "id" of the frame. It can be re-used from frame-to-frame, e.g.
|
|
// when navigating.
|
|
_frame_id: u32,
|
|
|
|
// This is the "id" of this specific instance of the frame. It changes on every
|
|
// navigate.
|
|
_loader_id: u32,
|
|
|
|
_page: *Page,
|
|
|
|
_session: *Session,
|
|
|
|
_event_manager: EventManager,
|
|
|
|
_parse_mode: enum { document, fragment, document_write } = .document,
|
|
|
|
// While fragment-parsing (e.g. innerHTML), scripts are normally marked
|
|
// "already started" so they never run. The one exception is
|
|
// Range.createContextualFragment(), whose scripts DO run when the fragment is
|
|
// inserted into a document
|
|
_fragment_scripts_runnable: bool = false,
|
|
|
|
// See Attribute.List for what this is. TL;DR: proper DOM Attribute Nodes are
|
|
// fat yet rarely needed. We only create them on-demand, but still need proper
|
|
// identity (a given attribute should return the same *Attribute), so we do
|
|
// a look here, keyed by (list, name). We don't store this in the Element or
|
|
// Attribute.List.Entry because that would require additional space per
|
|
// element / Attribute.List.Entry even though we'll create very few (if any)
|
|
// actual *Attributes.
|
|
_attribute_lookup: Element.Attribute.List.Lookup = .empty,
|
|
|
|
// Canonical pool for attribute names that aren't in String.intern's.
|
|
// Every Attribute's entry's name is either a String intern or held here.
|
|
// This is both a memory optimization (deduping attribute names) and a performance
|
|
// optimization (since we can compare strings by just their pointer)
|
|
_attribute_names: std.StringHashMapUnmanaged(void) = .empty,
|
|
|
|
// Same as _atlribute_lookup, but instead of individual attributes, this is for
|
|
// the return of elements.attributes.
|
|
_attribute_named_node_map_lookup: std.AutoHashMapUnmanaged(usize, *Element.Attribute.NamedNodeMap) = .empty,
|
|
|
|
// Lazily-created style, classList, and dataset objects. Only stored for elements
|
|
// that actually access these features via JavaScript, saving 24 bytes per element.
|
|
_element_styles: Element.StyleLookup = .empty,
|
|
// Computed-style views handed out by window.getComputedStyle. The computed
|
|
// variant is a stateless lazy view, so one per (element, pseudo-element)
|
|
// suffices — and Chrome returns the same object for repeated calls, so
|
|
// identity is also conformance.
|
|
_element_computed_styles: Element.ComputedStyleLookup = .empty,
|
|
_element_datasets: Element.DatasetLookup = .empty,
|
|
_element_class_lists: Element.ClassListLookup = .empty,
|
|
_element_rel_lists: Element.RelListLookup = .empty,
|
|
_element_token_lists: Element.TokenListLookup = .empty,
|
|
_element_shadow_roots: Element.ShadowRootLookup = .empty,
|
|
_node_owner_documents: Node.OwnerDocumentLookup = .empty,
|
|
_element_scroll_positions: Element.ScrollPositionLookup = .empty,
|
|
_element_namespace_uris: Element.NamespaceUriLookup = .empty,
|
|
_svg_animated_enumerations: AnimatedEnumeration.Lookup = .empty,
|
|
_svg_animated_lengths: AnimatedLength.Lookup = .empty,
|
|
_svg_animated_numbers: AnimatedNumber.Lookup = .empty,
|
|
_svg_animated_preserve_aspect_ratios: AnimatedPreserveAspectRatio.Lookup = .empty,
|
|
_svg_animated_strings: AnimatedString.Lookup = .empty,
|
|
_svg_animated_transform_lists: AnimatedTransformList.Lookup = .empty,
|
|
_svg_point_lists: PointList.Lookup = .empty,
|
|
_svg_string_lists: StringList.Lookup = .empty,
|
|
|
|
// Same as above, but for Nodes (slot assigments apply to both Element AND
|
|
// Text nodes)
|
|
_assigned_slots: Node.AssignedSlotLookup = .empty,
|
|
_manual_slot_assignments: Node.AssignedSlotLookup = .empty,
|
|
|
|
/// Lazily-created inline event listeners (or listeners provided as attributes).
|
|
/// Avoids bloating all elements with extra function fields for rare usage.
|
|
///
|
|
/// Use this when a listener provided like this:
|
|
///
|
|
/// ```js
|
|
/// img.onload = () => { ... };
|
|
/// ```
|
|
///
|
|
/// Its also used as cache for such cases after lazy evaluation:
|
|
///
|
|
/// ```html
|
|
/// <img onload="(() => { ... })()" />
|
|
/// ```
|
|
///
|
|
/// ```js
|
|
/// img.setAttribute("onload", "(() => { ... })()");
|
|
/// ```
|
|
_event_target_attr_listeners: GlobalEventHandlersLookup = .empty,
|
|
|
|
// FileLists owned by `<input type=file>` elements. Each holds refs on its
|
|
// File objects (reference counted via their Blob proto); released at teardown.
|
|
_file_lists: std.ArrayList(*FileList) = .empty,
|
|
|
|
/// Element `load`/`error` events queued to fire on the next scheduler tick,
|
|
/// and flushed before window's `load` event.
|
|
/// A call to `documentIsComplete` (which calls `_documentIsComplete`) resets it.
|
|
/// Double-buffered so that dispatching events (which may trigger JS that
|
|
/// creates new elements) doesn't invalidate the list while iterating.
|
|
_queued_events_1: std.ArrayList(QueuedEvent) = .empty,
|
|
_queued_events_2: std.ArrayList(QueuedEvent) = .empty,
|
|
_queued_events: *std.ArrayList(QueuedEvent) = undefined,
|
|
|
|
_style_manager: StyleManager,
|
|
_script_manager: ScriptManager,
|
|
|
|
_http_owner: HttpClient.Owner,
|
|
|
|
// List of active live ranges (for mutation updates per DOM spec)
|
|
_live_ranges: std.DoublyLinkedList = .{},
|
|
// Live NodeIterators for the DOM pre-removing steps. Iterators are
|
|
// slab-allocated (frame lifetime) and never unlinked.
|
|
_live_node_iterators: std.DoublyLinkedList = .{},
|
|
|
|
// List of open BroadcastChannels, used to route postMessage between same-named
|
|
// channels in this frame's origin
|
|
_broadcast_channels: std.DoublyLinkedList = .{},
|
|
|
|
// List of MessagePorts living in this frame's context.
|
|
_message_ports: std.DoublyLinkedList = .{},
|
|
|
|
// See frame/observers.zig.
|
|
_resize: observers.Resize = .{},
|
|
_mutation: observers.Mutation = .{},
|
|
_intersection: observers.Intersection = .{},
|
|
|
|
// Slots that need slotchange events to be fired, in signal order. Delivered
|
|
// by deliverMutations because there is specific timing with for these events
|
|
// with respect to mutations
|
|
_slots_pending_slotchange: std.AutoArrayHashMapUnmanaged(*Element.Html.Slot, void) = .{},
|
|
|
|
// Lookup for customized built-in elements. Maps element pointer to definition.
|
|
_customized_builtin_definitions: std.AutoHashMapUnmanaged(*Element, *CustomElementDefinition) = .{},
|
|
_customized_builtin_connected_callback_invoked: std.AutoHashMapUnmanaged(*Element, void) = .{},
|
|
_customized_builtin_disconnected_callback_invoked: std.AutoHashMapUnmanaged(*Element, void) = .{},
|
|
|
|
// This is set when an element is being upgraded (constructor is called).
|
|
// The constructor can access this to get the element being upgraded.
|
|
_upgrading_element: ?*Node = null,
|
|
|
|
// _upgrading_element can be consumed once. A second HTMLElement construction
|
|
// during upgrade is a TypeError.
|
|
_upgrading_consumed: bool = false,
|
|
|
|
// Set when materializing the fragment parser's context element. The element
|
|
// is never inserted into the tree so if its a custom element ,we must not run
|
|
// its constructor (else we'll end up in an endless loop if the constructor
|
|
// sets this.innerHTML = '...', which happens).
|
|
_skip_custom_element_upgrade: bool = false,
|
|
|
|
// List of custom elements that were created before their definition was registered
|
|
_undefined_custom_elements: std.ArrayList(*Element.Html.Custom) = .empty,
|
|
|
|
// Pending custom-element reactions (connected/disconnected/adopted/attribute
|
|
// changed). Reactions are enqueued during DOM mutation and drained at the
|
|
// outer algorithm boundary — set up by the JS bridge for [CEReactions]
|
|
// methods and by the parser pump on each yield.
|
|
_ce_reactions: CustomElementReactions,
|
|
|
|
// for heap allocations and managing WebAPI objects
|
|
_factory: *Factory,
|
|
|
|
_load_state: LoadState = .waiting,
|
|
|
|
// The navigation response's MIME essence, recorded when the first chunk
|
|
// arrives and applied to the new document (document.contentType) once the
|
|
// navigation commits. null means the text/html default.
|
|
_pending_content_type: ?[]const u8 = null,
|
|
_parse_state: ParseState = .pre,
|
|
|
|
/// `frameErrorCallback` swallows the failure into a placeholder page;
|
|
/// callers that need to detect it read this.
|
|
_last_navigate_error: ?anyerror = null,
|
|
|
|
_notified_network_idle: IdleNotification = .init,
|
|
_notified_network_almost_idle: IdleNotification = .init,
|
|
|
|
// A navigation event that happens from a script gets scheduled to run on the
|
|
// next tick.
|
|
_queued_navigation: ?*QueuedNavigation = null,
|
|
|
|
// The URL of the current frame
|
|
url: [:0]const u8 = "about:blank",
|
|
|
|
origin: ?[]const u8 = null,
|
|
|
|
// The base url specifies the base URL used to resolve the relative urls.
|
|
// It is set by a <base> tag.
|
|
// If null the url must be used.
|
|
base_url: ?[:0]const u8 = null,
|
|
|
|
// Document charset (canonical name from encoding_rs, static lifetime)
|
|
charset: []const u8 = "UTF-8",
|
|
|
|
// Arbitrary buffer. Need to temporarily lowercase a value? Use this. No lifetime
|
|
// guarantee - it's valid until someone else uses it.
|
|
buf: [BUF_SIZE]u8 = undefined,
|
|
|
|
// access to the JavaScript engine
|
|
js: *JS.Context,
|
|
|
|
// An arena for the lifetime of the frame.
|
|
arena: Allocator,
|
|
|
|
// An arena with a lifetime for at least the scope of one Zig invocation from
|
|
// JS. Prefer local_arena where possible. Use call_arena when allocations may
|
|
// need to call back into JS (event dispatch, forEach callback, ....)
|
|
_call_arena: *lp.Arena,
|
|
call_arena: Allocator,
|
|
|
|
// An arena with a lifetime guaranteed to be for exactly 1 invoking of a Zig
|
|
// function from JS. Best arena to use, when possible.
|
|
_local_arena: *lp.Arena,
|
|
local_arena: Allocator,
|
|
|
|
parent: ?*Frame,
|
|
window: *Window,
|
|
document: *Document,
|
|
iframe: ?*IFrame = null,
|
|
|
|
child_frames_sorted: bool = true,
|
|
child_frames: std.ArrayList(*Frame) = .empty,
|
|
|
|
// Workers created by this frame. Cleaned up when frame is destroyed.
|
|
workers: std.ArrayList(*Worker) = .empty,
|
|
|
|
// This is maybe not great. It's a counter on the number of events that we're
|
|
// waiting on before triggering the "load" event. Essentially, we need all
|
|
// synchronous scripts and all iframes to be loaded. Scripts are handled by the
|
|
// ScriptManager, so all scripts just count as 1 pending load.
|
|
_pending_loads: u32,
|
|
|
|
_parent_notified: bool = false,
|
|
|
|
// whether our parent's load event is waiting for us to load. For a root page,
|
|
// this is meaningless. For an iframe, it's false when loading=lazy. We'll still
|
|
// load the iframe, but we won't delay the parent
|
|
_delays_parent_load: bool = true,
|
|
|
|
_type: enum { root, frame }, // only used for logs right now
|
|
_req_id: u32 = 0,
|
|
_navigated_options: ?NavigatedOpts = null,
|
|
_http_status: ?u16 = null,
|
|
_http_headers: std.ArrayList(HttpHeader) = .empty,
|
|
|
|
_referrer: ?[]const u8 = null,
|
|
referrer_policy: referrer.Policy = .default,
|
|
|
|
pub const HttpHeader = struct {
|
|
name: []const u8,
|
|
value: []const u8,
|
|
};
|
|
|
|
pub const InitOpts = struct {
|
|
parent: ?*Frame = null,
|
|
|
|
// When a frame/popup re-navigates, we should preserve the same window.
|
|
// There are a couple reasons for this. First, iframe.contentWindow should
|
|
// maintain the same identity. Secondly, a reference to the window can be
|
|
// acquired prior to navigation, and then used after. So it should remain valid.
|
|
reuse_window: ?*Window = null,
|
|
};
|
|
|
|
pub fn init(self: *Frame, frame_id: u32, page: *Page, opts: InitOpts) !void {
|
|
if (comptime lp.IS_DEBUG) {
|
|
log.debug(.frame, "frame.init", .{});
|
|
}
|
|
|
|
const parent = opts.parent;
|
|
|
|
const session = page.session;
|
|
const call_arena = try session.getArena(.medium, "call_arena");
|
|
errdefer call_arena.release();
|
|
|
|
const local_arena = try session.getArena(.medium, "local_arena");
|
|
errdefer local_arena.release();
|
|
|
|
const factory = &page.factory;
|
|
const document = (try factory.document(Node.Document.HTMLDocument{
|
|
._proto = undefined,
|
|
})).asDocument();
|
|
|
|
const arena = page.frame_arena;
|
|
|
|
self.* = .{
|
|
.js = undefined,
|
|
.arena = arena,
|
|
.parent = parent,
|
|
.document = document,
|
|
.window = undefined,
|
|
._call_arena = call_arena,
|
|
._local_arena = local_arena,
|
|
.call_arena = call_arena.allocator(),
|
|
.local_arena = local_arena.allocator(),
|
|
._frame_id = frame_id,
|
|
._page = page,
|
|
._session = session,
|
|
._loader_id = session.nextLoaderId(),
|
|
._factory = factory,
|
|
._pending_loads = 1, // always 1 for the ScriptManager
|
|
._type = if (parent == null) .root else .frame,
|
|
._style_manager = undefined,
|
|
._script_manager = undefined,
|
|
._ce_reactions = .{ .allocator = arena },
|
|
._event_manager = EventManager.init(arena, self),
|
|
._http_owner = undefined,
|
|
};
|
|
self._queued_events = &self._queued_events_1;
|
|
self._http_owner = .init(&page.blob_urls, &self.origin);
|
|
|
|
var screen: *Screen = undefined;
|
|
var visual_viewport: *VisualViewport = undefined;
|
|
if (parent) |p| {
|
|
screen = p.window._screen;
|
|
visual_viewport = p.window._visual_viewport;
|
|
} else {
|
|
screen = try factory.eventTarget(Screen{
|
|
._proto = undefined,
|
|
._orientation = null,
|
|
});
|
|
visual_viewport = try factory.eventTarget(VisualViewport{
|
|
._proto = undefined,
|
|
});
|
|
}
|
|
|
|
const window_template = Window{
|
|
._frame = self,
|
|
._proto = undefined,
|
|
._document = self.document,
|
|
._location = undefined,
|
|
._performance = .init(),
|
|
._screen = screen,
|
|
._visual_viewport = visual_viewport,
|
|
._cross_origin_wrapper = undefined,
|
|
};
|
|
|
|
if (opts.reuse_window) |w| {
|
|
const proto = w._proto;
|
|
w.* = window_template;
|
|
w._proto = proto;
|
|
self.window = w;
|
|
} else {
|
|
self.window = try factory.eventTarget(window_template);
|
|
}
|
|
self.window._cross_origin_wrapper = .{ .window = self.window };
|
|
|
|
self._style_manager = try StyleManager.init(self);
|
|
errdefer self._style_manager.deinit();
|
|
|
|
const browser = session.browser;
|
|
self._script_manager = ScriptManager.init(browser.allocator, &browser.http_client, self);
|
|
errdefer self._script_manager.deinit();
|
|
|
|
self.js = try browser.env.createContext(self, .{
|
|
.identity = &page.identity,
|
|
.identity_arena = arena,
|
|
.call_arena = self.call_arena,
|
|
.local_arena = self.local_arena,
|
|
});
|
|
errdefer browser.env.destroyContext(self.js);
|
|
|
|
const location = try Location.init("about:blank", self);
|
|
// We're holding a reference in Zig-side.
|
|
location.acquireRef();
|
|
self.window._location = location;
|
|
|
|
document._frame = self;
|
|
|
|
if (comptime lp.IS_TEST == false) {
|
|
if (parent == null) {
|
|
// HTML test runner manually calls these as necessary
|
|
try self.js.scheduler.add(session.browser, struct {
|
|
fn runIdleTasks(ctx: *anyopaque) !?u32 {
|
|
const b: *@import("Browser.zig") = @ptrCast(@alignCast(ctx));
|
|
b.runIdleTasks();
|
|
return 200;
|
|
}
|
|
}.runIdleTasks, 200, .{ .name = "frame.runIdleTasks", .blocks_done = false });
|
|
}
|
|
}
|
|
|
|
if (parent == null) {
|
|
// no point reporting this for each child page
|
|
session.browser.reportJsHeap();
|
|
}
|
|
}
|
|
|
|
pub fn deinit(self: *Frame) void {
|
|
for (self.child_frames.items) |frame| {
|
|
frame.deinit();
|
|
}
|
|
|
|
if (comptime lp.IS_DEBUG) {
|
|
log.debug(.frame, "frame.deinit", .{ .url = self.url, .type = self._type });
|
|
|
|
// Uncomment if you want slab statistics to print.
|
|
// const stats = self._factory._slab.getStats(self.arena) catch unreachable;
|
|
// var buffer: [256]u8 = undefined;
|
|
// var stream = std.Io.File.stderr().writerStreaming(lp.io, &buffer).interface;
|
|
// stats.print(&stream) catch unreachable;
|
|
}
|
|
|
|
self._parse_state.deinit(self);
|
|
|
|
// Unregister CookieStore from session notifications before the JS
|
|
// context (and thus the scheduler) is destroyed, otherwise a late
|
|
// mutation could schedule a callback that never runs.
|
|
if (self.window._cookie_store) |cs| cs.detach();
|
|
|
|
const page = self._page;
|
|
|
|
if (self._queued_navigation) |qn| {
|
|
qn.arena.release();
|
|
}
|
|
|
|
while (self._message_ports.first) |node| {
|
|
const port: *MessagePort = @alignCast(@fieldParentPtr("_node", node));
|
|
port.close(); // removes from self._message_ports
|
|
}
|
|
|
|
{
|
|
// Release all objects we're referencing
|
|
page.revokeBlobUrlsFor(self._frame_id);
|
|
|
|
for (self._file_lists.items) |file_list| {
|
|
for (file_list._files) |file| {
|
|
file._proto.releaseRef(page);
|
|
}
|
|
}
|
|
|
|
observers.deinit(self, page);
|
|
|
|
var svg_point_lists = self._svg_point_lists.valueIterator();
|
|
while (svg_point_lists.next()) |list| {
|
|
list.*.deinit(page);
|
|
}
|
|
|
|
var svg_transform_lists = self._svg_animated_transform_lists.valueIterator();
|
|
while (svg_transform_lists.next()) |list| {
|
|
list.*.deinit(page);
|
|
}
|
|
|
|
var document = self.window._document;
|
|
document._selection.releaseRef(page);
|
|
|
|
if (document._fonts) |f| {
|
|
f.releaseRef(page);
|
|
}
|
|
|
|
// Release our reference to location.
|
|
self.window._location.releaseRef(page);
|
|
}
|
|
|
|
const browser = page.session.browser;
|
|
|
|
browser.http_client.abortOwner(&self._http_owner);
|
|
if (self.parent == null) {
|
|
browser.reportJsHeap();
|
|
}
|
|
|
|
browser.env.destroyContext(self.js);
|
|
|
|
// Must be after context is destroyed. A finalizer can reach into the *Worker
|
|
// (e.g. Worker.ReceiveMessageCallback) so the worker must still be valid.
|
|
for (self.workers.items) |worker| {
|
|
worker.deinit();
|
|
}
|
|
|
|
self._script_manager.base.shutdown = true;
|
|
|
|
self._script_manager.deinit();
|
|
self._style_manager.deinit();
|
|
|
|
self._call_arena.release();
|
|
self._local_arena.release();
|
|
}
|
|
|
|
pub fn trackWorker(self: *Frame, worker: *Worker) !void {
|
|
try self.workers.append(self.arena, worker);
|
|
}
|
|
|
|
pub fn removeWorker(self: *Frame, worker: *Worker) void {
|
|
for (self.workers.items, 0..) |w, i| {
|
|
if (w == worker) {
|
|
_ = self.workers.swapRemove(i);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn base(self: *const Frame) [:0]const u8 {
|
|
return self.base_url orelse self.url;
|
|
}
|
|
|
|
fn referrerSource(self: *const Frame) [:0]const u8 {
|
|
var frame = self;
|
|
while (std.mem.startsWith(u8, frame.url, "about:")) {
|
|
// about:blank and about:srcdoc documents aren't valid referrer sources,
|
|
// use the parents
|
|
frame = frame.parent orelse return frame.url;
|
|
}
|
|
return frame.url;
|
|
}
|
|
|
|
pub fn getTitle(self: *Frame) !?[]const u8 {
|
|
if (self.window._document.is(Document.HTMLDocument)) |html_doc| {
|
|
return try html_doc.getTitle(self);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
pub const HttpMetadata = struct {
|
|
url: [:0]const u8,
|
|
status: ?u16,
|
|
headers: []const HttpHeader,
|
|
};
|
|
|
|
pub fn httpMetadata(self: *const Frame) HttpMetadata {
|
|
return .{
|
|
.url = self.url,
|
|
.status = self._http_status,
|
|
.headers = self._http_headers.items,
|
|
};
|
|
}
|
|
|
|
// Add common headers for a request:
|
|
// * referer
|
|
pub fn headersForRequest(self: *Frame, transfer: *HttpClient.Transfer) !void {
|
|
const arena = transfer.arena.allocator();
|
|
if (try referrer.compute(arena, self.referrer_policy, self.referrerSource(), transfer.req.url)) |ref| {
|
|
try transfer.addHeader("Referer", ref, .{});
|
|
transfer.req.referrer_policy = self.referrer_policy;
|
|
}
|
|
}
|
|
|
|
pub fn getArena(self: *Frame, size_or_bucket: anytype, debug: []const u8) !*lp.Arena {
|
|
return self._session.getArena(size_or_bucket, debug);
|
|
}
|
|
|
|
pub fn getPinnedArena(self: *Frame, size_or_bucket: anytype, debug: []const u8) !*lp.Arena {
|
|
return self._session.getPinnedArena(size_or_bucket, debug);
|
|
}
|
|
|
|
pub fn isSameOrigin(self: *const Frame, url: [:0]const u8) bool {
|
|
const current_origin = self.origin orelse return false;
|
|
return URL.isSameOrigin(url, current_origin);
|
|
}
|
|
|
|
pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !void {
|
|
lp.assert(self._load_state == .waiting, "frame.renavigate", .{});
|
|
const session = self._session;
|
|
self._load_state = .parsing;
|
|
self._last_navigate_error = null;
|
|
|
|
log.info(.frame, "navigate", .{
|
|
.url = request_url,
|
|
.method = opts.method,
|
|
.reason = opts.reason,
|
|
.body = opts.body != null,
|
|
.type = self._type,
|
|
});
|
|
|
|
const http_client = &session.browser.http_client;
|
|
|
|
// Handle synthetic navigations: about:blank, about:srcdoc and blob: URLs
|
|
const is_about_blank = std.mem.eql(u8, "about:blank", request_url);
|
|
const is_srcdoc = !is_about_blank and std.mem.eql(u8, "about:srcdoc", request_url);
|
|
const is_blob = !is_about_blank and !is_srcdoc and std.mem.startsWith(u8, request_url, "blob:");
|
|
|
|
if (is_about_blank or is_srcdoc or is_blob) {
|
|
if (is_blob) {
|
|
if (!Blob.urlBelongsToOrigin(request_url, opts.initiator_origin)) {
|
|
log.warn(.js, "invalid blob", .{ .url = request_url });
|
|
return error.BlobNotFound;
|
|
}
|
|
}
|
|
|
|
self.url = if (is_about_blank)
|
|
"about:blank"
|
|
else if (is_srcdoc)
|
|
"about:srcdoc"
|
|
else
|
|
try self.arena.dupeZ(u8, request_url);
|
|
|
|
// even though about:blank navigations may share the same _data_, we
|
|
// have to do this to make sure window.location is at a unique _address_.
|
|
// If we don't do this, multiple window._location will have the same
|
|
// address and thus be mapped to the same v8::Object in the identity map.
|
|
const location = try Location.init(self.url, self);
|
|
location.acquireRef();
|
|
// We're not holding a ref to old location anymore.
|
|
self.window._location.releaseRef(self._page);
|
|
self.window._location = location;
|
|
|
|
if (is_blob) {
|
|
// strip out blob:
|
|
self.origin = try URL.getOrigin(self.arena, request_url[5.. :0]);
|
|
} else if (self.parent) |parent| {
|
|
self.origin = parent.origin;
|
|
if (is_about_blank or is_srcdoc) {
|
|
self.base_url = parent.base();
|
|
// about:blank and about:srcdoc documents inherit their
|
|
// creator's policy container, including the referrer policy
|
|
self.referrer_policy = parent.referrer_policy;
|
|
}
|
|
} else if (self.window._opener) |opener| {
|
|
self.origin = opener._frame.origin;
|
|
if (is_about_blank) {
|
|
self.base_url = opener._frame.base();
|
|
self.referrer_policy = opener._frame.referrer_policy;
|
|
}
|
|
} else {
|
|
self.origin = null;
|
|
}
|
|
try self.js.setOrigin(self.origin);
|
|
|
|
// Assume we parsed the document.
|
|
// It's important to force a reset during the following navigation.
|
|
self._parse_state = .complete;
|
|
|
|
// Content injection
|
|
if (is_blob) {
|
|
const blob = blk: {
|
|
if (self._page.blob_urls.get(request_url)) |entry| break :blk entry.blob;
|
|
log.warn(.js, "invalid blob", .{ .url = request_url });
|
|
return error.BlobNotFound;
|
|
};
|
|
const parse_arena = try self.getArena(.medium, "Frame.parseBlob");
|
|
defer parse_arena.release();
|
|
// A script executed mid-parse can revoke the blob URL, letting GC
|
|
// free the buffer under the parser; parse a copy.
|
|
const html = try parse_arena.dupe(u8, blob._slice);
|
|
var parser = Parser.init(parse_arena.allocator(), self.document.asNode(), self, .{ .allow_declarative_shadow = true });
|
|
parser.parse(html);
|
|
} else if (is_srcdoc) {
|
|
// The "response body" is the iframe's srcdoc attribute. Only an
|
|
// iframe can navigate here (e.g. location = 'about:srcdoc' on a
|
|
// root frame ends up with an empty document, like Chrome).
|
|
const content = blk: {
|
|
const iframe = self.iframe orelse break :blk "";
|
|
break :blk iframe.asElement().getAttributeSafe(comptime .wrap("srcdoc")) orelse "";
|
|
};
|
|
if (content.len == 0) {
|
|
// the parser emits nothing for an empty input; commit the
|
|
// same html/head/body scaffolding an empty srcdoc implies
|
|
self.document.injectBlank(self) catch |err| {
|
|
log.err(.browser, "inject blank", .{ .err = err });
|
|
return error.InjectBlankFailed;
|
|
};
|
|
} else {
|
|
const parse_arena = try self.getArena(content.len, "Frame.parseSrcdoc");
|
|
defer parse_arena.release();
|
|
// A script executed mid-parse can rewrite the srcdoc attribute,
|
|
// freeing the value under the parser; parse a copy.
|
|
const html = try parse_arena.dupe(u8, content);
|
|
var parser = Parser.init(parse_arena.allocator(), self.document.asNode(), self, .{ .allow_declarative_shadow = true });
|
|
parser.parse(html);
|
|
}
|
|
} else {
|
|
self.document.injectBlank(self) catch |err| {
|
|
log.err(.browser, "inject blank", .{ .err = err });
|
|
return error.InjectBlankFailed;
|
|
};
|
|
}
|
|
|
|
// No real request is made, but CDP still correlates the navigation
|
|
// events by request id, so consume one.
|
|
const req_id = http_client.incrReqId();
|
|
|
|
session.notification.dispatch(.frame_navigate, &.{
|
|
.opts = opts,
|
|
.req_id = req_id,
|
|
.frame_id = self._frame_id,
|
|
.loader_id = self._loader_id,
|
|
.url = request_url,
|
|
.timestamp = lp.datetime.timestamp(.boot),
|
|
});
|
|
|
|
self.recordNavigateTelemetry(false);
|
|
|
|
session.notification.dispatch(.frame_navigated, &.{
|
|
.req_id = req_id,
|
|
.frame_id = self._frame_id,
|
|
.loader_id = self._loader_id,
|
|
.opts = .{
|
|
.cdp_id = opts.cdp_id,
|
|
.reason = opts.reason,
|
|
.method = opts.method,
|
|
},
|
|
.url = request_url,
|
|
.timestamp = lp.datetime.timestamp(.boot),
|
|
});
|
|
|
|
if (self.parent == null) {
|
|
session.navigation._current_navigation_kind = opts.kind;
|
|
try session.navigation.commitNavigation(self);
|
|
}
|
|
|
|
self.documentIsComplete();
|
|
return;
|
|
}
|
|
|
|
self._http_status = null;
|
|
self._http_headers = .empty;
|
|
|
|
self._referrer = null;
|
|
self.referrer_policy = .default;
|
|
|
|
self.url = blk: {
|
|
if (URL.isCompleteHTTPUrl(request_url)) {
|
|
break :blk try self.arena.dupeZ(u8, request_url);
|
|
}
|
|
break :blk try std.mem.concatWithSentinel(self.arena, u8, &.{ "http://", request_url }, 0);
|
|
};
|
|
self.origin = try URL.getOrigin(self.arena, self.url);
|
|
|
|
self._navigated_options = .{
|
|
.cdp_id = opts.cdp_id,
|
|
.reason = opts.reason,
|
|
.method = opts.method,
|
|
.body = if (opts.body) |b| try self.arena.dupe(u8, b) else null,
|
|
.header = if (opts.header) |h| try self.arena.dupeZ(u8, h) else null,
|
|
};
|
|
|
|
const transfer = try http_client.newRequest(.{
|
|
.ctx = self,
|
|
.url = self.url,
|
|
.frame_id = self._frame_id,
|
|
.loader_id = self._loader_id,
|
|
.method = opts.method,
|
|
.body = opts.body,
|
|
// don't cache top-level pages, most cases won't revisit this, and, if they
|
|
// do, they probably don't want the cached version.
|
|
.skip_cache = self.parent == null,
|
|
.cookie_jar = &session.cookie_jar,
|
|
.cookie_origin = opts.initiator_url orelse self.url,
|
|
.resource_type = .document,
|
|
.notification = self._session.notification,
|
|
.header_callback = frameHeaderDoneCallback,
|
|
.data_callback = frameDataCallback,
|
|
.done_callback = frameDoneCallback,
|
|
.error_callback = frameErrorCallback,
|
|
// The frame tracks its navigation by id (_req_id), never by pointer.
|
|
.shutdown_callback = HttpClient.noopShutdown,
|
|
}, &self._http_owner);
|
|
self._req_id = transfer.id;
|
|
|
|
{
|
|
// Ours until submit; clean up if header setup fails.
|
|
errdefer transfer.deinit();
|
|
try transfer.addHeader("Accept", lp.Config.HttpHeaders.navigation_accept, .{});
|
|
if (opts.header) |hdr| {
|
|
// Arrives pre-joined ("Name: Value"), e.g. from the CLI.
|
|
if (HttpClient.Header.parse(hdr)) |parsed| {
|
|
try transfer.addHeader(parsed.name, parsed.value, .{});
|
|
}
|
|
}
|
|
if (opts.referer) |ref| {
|
|
try transfer.addHeader("Referer", ref, .{});
|
|
self._referrer = try self.arena.dupe(u8, ref);
|
|
transfer.req.referrer_policy = opts.referrer_policy;
|
|
}
|
|
}
|
|
|
|
// A root navigation issued against a pending Page (i.e. one allocated by
|
|
// Session.initiateRootNavigation) flags both the notification and the
|
|
// HTTP request itself: CDP skips its node-registry reset until commit,
|
|
// and the in-flight transfer survives the OLD page's frame.deinit which
|
|
// calls http_client.abortList() on the shared frame_id during
|
|
// commitPendingPage.
|
|
const is_pending_root = self._page.replaces != null;
|
|
|
|
// We dispatch frame_navigate event before sending the request.
|
|
// It ensures the event frame_navigated is not dispatched before this one.
|
|
session.notification.dispatch(.frame_navigate, &.{
|
|
.opts = opts,
|
|
.url = self.url,
|
|
.req_id = transfer.id,
|
|
.frame_id = self._frame_id,
|
|
.loader_id = self._loader_id,
|
|
.timestamp = lp.datetime.timestamp(.boot),
|
|
.is_pending_root = is_pending_root,
|
|
});
|
|
|
|
// Record telemetry for navigation
|
|
self.recordNavigateTelemetry(std.ascii.startsWithIgnoreCase(self.url, "https://"));
|
|
|
|
session.navigation._current_navigation_kind = opts.kind;
|
|
|
|
transfer.submit() catch |err| {
|
|
log.err(.frame, "navigate request", .{ .url = self.url, .err = err, .type = self._type });
|
|
return err;
|
|
};
|
|
}
|
|
|
|
fn recordNavigateTelemetry(self: *Frame, tls: bool) void {
|
|
const TE = @import("../telemetry/telemetry.zig").Event;
|
|
const context: TE.Navigate.Context = if (self.parent != null)
|
|
.iframe
|
|
else if (self.window._opener != null)
|
|
.popup
|
|
else
|
|
.page;
|
|
lp.metrics.navigate.incr(context);
|
|
self._session.browser.app.telemetry.record(.{ .navigate = .{
|
|
.tls = tls,
|
|
.context = context,
|
|
} });
|
|
}
|
|
|
|
// Navigation can happen in many places, such as executing a <script> tag or
|
|
// a JavaScript callback, a CDP command, etc...It's rarely safe to do immediately
|
|
// as the caller almost certainly doesn't expect the frame to go away during the
|
|
// call. So, we schedule the navigation for the next tick.
|
|
pub fn scheduleNavigation(self: *Frame, request_url: []const u8, opts: NavigateOpts, nt: Navigation) !void {
|
|
if (self.canScheduleNavigation(std.meta.activeTag(nt)) == false) {
|
|
return;
|
|
}
|
|
const arena = try self._session.getArena(.small, "scheduleNavigation");
|
|
errdefer arena.release();
|
|
return self.scheduleNavigationWithArena(arena, request_url, opts, nt);
|
|
}
|
|
|
|
// Don't name the first parameter "self", because the target of this navigation
|
|
// might change inside the function. So the code should be explicit about the
|
|
// frame that it's acting on.
|
|
fn scheduleNavigationWithArena(originator: *Frame, arena: *lp.Arena, request_url: []const u8, opts: NavigateOpts, nt: Navigation) !void {
|
|
const resolved_url, const is_about_something = blk: {
|
|
if (URL.isCompleteHTTPUrl(request_url)) {
|
|
break :blk .{ try arena.dupeZ(u8, request_url), false };
|
|
}
|
|
|
|
if (std.mem.eql(u8, request_url, "about:blank")) {
|
|
// navigate will handle this special case
|
|
break :blk .{ "about:blank", true };
|
|
}
|
|
|
|
if (std.mem.eql(u8, request_url, "about:srcdoc")) {
|
|
// like about:blank, a synchronous navigation handled by navigate
|
|
break :blk .{ "about:srcdoc", true };
|
|
}
|
|
|
|
// request_url isn't a "complete" URL, so it has to be resolved with the
|
|
// originator's base. Unless, originator's base is "about:blank", in which
|
|
// case we have to walk up the parents and find a real base.
|
|
const frame_base = base_blk: {
|
|
var maybe_not_blank_frame = originator;
|
|
while (true) {
|
|
const maybe_base = maybe_not_blank_frame.base();
|
|
if (std.mem.eql(u8, maybe_base, "about:blank") == false) {
|
|
break :base_blk maybe_base;
|
|
}
|
|
// The orelse here is probably an invalid case, but there isn't
|
|
// anything we can do about it. It should never happen?
|
|
maybe_not_blank_frame = maybe_not_blank_frame.parent orelse break :base_blk "";
|
|
}
|
|
};
|
|
|
|
const u = try URL.resolve(
|
|
arena.allocator(),
|
|
frame_base,
|
|
request_url,
|
|
.{ .encoding = originator.charset },
|
|
);
|
|
break :blk .{ u, false };
|
|
};
|
|
|
|
const target = switch (nt) {
|
|
.form, .anchor => |p| p,
|
|
.script => |p| p orelse originator,
|
|
.iframe => |iframe| iframe._window.?._frame, // only an frame with existing content (i.e. a window) can be navigated
|
|
};
|
|
|
|
const session = target._session;
|
|
|
|
// Re-navigating to the exact current URL is only a reload when the URL
|
|
// has no fragment. With a fragment it's a fragment navigation per the
|
|
// HTML "navigate" steps (url equals the document's URL excluding
|
|
// fragments and url's fragment is non-null): no reload, and since the
|
|
// fragment didn't change, no hashchange and no new history entry either.
|
|
if (!opts.force and
|
|
opts.kind != .reload and
|
|
std.mem.eql(u8, target.url, resolved_url) and
|
|
std.mem.indexOfScalar(u8, resolved_url, '#') != null)
|
|
{
|
|
arena.release();
|
|
return;
|
|
}
|
|
|
|
// Short-circuit only true fragment-only navigations (same path/query, different
|
|
// fragment). Identical URLs fall through and trigger a real reload.
|
|
const is_fragment_navigation = !std.mem.eql(u8, target.url, resolved_url) and URL.eqlDocument(target.url, resolved_url);
|
|
if (!opts.force and is_fragment_navigation) {
|
|
const old_url = target.url;
|
|
target.url = try target.arena.dupeZ(u8, resolved_url);
|
|
|
|
const location = try Location.init(target.url, target);
|
|
location.acquireRef();
|
|
target.window._location.releaseRef(target._page);
|
|
target.window._location = location;
|
|
|
|
if (target.parent == null) {
|
|
try session.navigation.updateEntries(target.url, opts.kind, target, true);
|
|
}
|
|
|
|
try target.queueHashChange(old_url, target.url);
|
|
|
|
// don't defer this, the caller is responsible for freeing it on error
|
|
arena.release();
|
|
return;
|
|
}
|
|
|
|
log.info(.browser, "schedule navigation", .{
|
|
.url = resolved_url,
|
|
.reason = opts.reason,
|
|
.type = target._type,
|
|
});
|
|
|
|
// Navigation: kill in-flight HTTP transfers, but leave WebSockets
|
|
// alive — they're cross-document by spec.
|
|
session.browser.http_client.abortRequests(&target._http_owner);
|
|
|
|
// Capture the originating frame's URL as the Referer for this
|
|
// navigation. The originator's frame may be torn down before navigate()
|
|
// runs (processRootQueuedNavigation rebuilds the Page in-place), so
|
|
// allocate from the QueuedNavigation arena which outlives that tear-down.
|
|
var nav_opts = opts;
|
|
const referrer_source = originator.referrerSource();
|
|
if (std.mem.startsWith(u8, referrer_source, "http")) {
|
|
if (nav_opts.referer == null) {
|
|
nav_opts.referer = try referrer.compute(arena.allocator(), originator.referrer_policy, referrer_source, resolved_url);
|
|
nav_opts.referrer_policy = originator.referrer_policy;
|
|
}
|
|
if (nav_opts.initiator_url == null) {
|
|
nav_opts.initiator_url = try arena.dupeZ(u8, referrer_source);
|
|
}
|
|
}
|
|
if (nav_opts.initiator_origin == null) {
|
|
if (originator.origin) |o| {
|
|
nav_opts.initiator_origin = try arena.dupe(u8, o);
|
|
}
|
|
}
|
|
|
|
const qn = try arena.create(QueuedNavigation);
|
|
qn.* = .{
|
|
.opts = nav_opts,
|
|
.arena = arena,
|
|
.url = resolved_url,
|
|
.is_about_something = is_about_something,
|
|
.navigation_type = std.meta.activeTag(nt),
|
|
};
|
|
|
|
if (target._queued_navigation) |existing| {
|
|
existing.arena.release();
|
|
}
|
|
|
|
target._queued_navigation = qn;
|
|
return session.scheduleNavigation(target);
|
|
}
|
|
|
|
// A script can have multiple competing navigation events, say it starts off
|
|
// by doing top.location = 'x' and then does a form submission.
|
|
// You might think that we just stop at the first one, but that doesn't seem
|
|
// to be what browsers do, and it isn't particularly well supported by v8 (i.e.
|
|
// halting execution mid-script).
|
|
// From what I can tell, there are 4 "levels" of priority, in order:
|
|
// 1 - form submission
|
|
// 2 - JavaScript apis (e.g. top.location)
|
|
// 3 - anchor clicks
|
|
// 4 - iframe.src =
|
|
// Within, each category, it's last-one-wins.
|
|
fn canScheduleNavigation(self: *Frame, new_target_type: NavigationType) bool {
|
|
if (self.parent) |parent| {
|
|
if (parent.isGoingAway()) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
const existing_target_type = (self._queued_navigation orelse return true).navigation_type;
|
|
|
|
if (existing_target_type == new_target_type) {
|
|
// same reason, than this latest one wins
|
|
return true;
|
|
}
|
|
|
|
return switch (existing_target_type) {
|
|
.iframe => true, // everything is higher priority than iframe.src = "x"
|
|
.anchor => new_target_type != .iframe, // an anchor is only higher priority than an iframe
|
|
.form => false, // nothing is higher priority than a form
|
|
.script => new_target_type == .form, // a form is higher priority than a script
|
|
};
|
|
}
|
|
|
|
pub fn makeRequest(self: *Frame, req: HttpClient.Request) !void {
|
|
const transfer = try self._session.browser.http_client.newRequest(req, &self._http_owner);
|
|
{
|
|
errdefer transfer.deinit();
|
|
try self.headersForRequest(transfer);
|
|
}
|
|
return transfer.submit();
|
|
}
|
|
|
|
// Two-phase variant; see HttpClient.newRequest for the ownership contract.
|
|
pub fn newRequest(self: *Frame, req: HttpClient.Request) !*HttpClient.Transfer {
|
|
var r = req;
|
|
r.document_frame_id = self._frame_id;
|
|
return self._session.browser.http_client.newRequest(r, &self._http_owner);
|
|
}
|
|
|
|
// Synchronously abort every transfer and WebSocket owned by this frame
|
|
// and all of its descendants.
|
|
pub fn abortTransfers(self: *Frame) void {
|
|
for (self.child_frames.items) |child| {
|
|
child.abortTransfers();
|
|
}
|
|
const http_client = &self._session.browser.http_client;
|
|
http_client.abortOwner(&self._http_owner);
|
|
}
|
|
|
|
pub fn documentIsLoaded(self: *Frame) void {
|
|
if (self._load_state != .parsing) {
|
|
// Ideally, documentIsLoaded would only be called once, but if a
|
|
// script is dynamically added from an async script after
|
|
// documentIsLoaded is already called, then ScriptManager will call
|
|
// it again.
|
|
return;
|
|
}
|
|
|
|
self._load_state = .load;
|
|
self.document._ready_state = .interactive;
|
|
self._documentIsLoaded() catch |err| switch (err) {
|
|
error.JsException => {}, // already logged
|
|
else => log.err(.frame, "document is loaded2", .{ .err = err, .type = self._type, .url = self.url }),
|
|
};
|
|
}
|
|
|
|
pub fn _documentIsLoaded(self: *Frame) !void {
|
|
try self.dispatchReadyStateChange();
|
|
|
|
const event = try Event.initTrusted(.wrap("DOMContentLoaded"), .{ .bubbles = true }, self._page);
|
|
try self._event_manager.dispatch(
|
|
self.document.asEventTarget(),
|
|
event,
|
|
);
|
|
|
|
self._session.notification.dispatch(.frame_dom_content_loaded, &.{
|
|
.req_id = self._req_id,
|
|
.frame_id = self._frame_id,
|
|
.loader_id = self._loader_id,
|
|
.timestamp = lp.datetime.timestamp(.boot),
|
|
});
|
|
}
|
|
|
|
// Fired at the document on every change to document.readyState: before
|
|
// DOMContentLoaded (readiness -> interactive) and before the load event
|
|
// (readiness -> complete). Does not bubble.
|
|
// https://html.spec.whatwg.org/multipage/dom.html#current-document-readiness
|
|
fn dispatchReadyStateChange(self: *Frame) !void {
|
|
const event = try Event.initTrusted(.wrap("readystatechange"), .{}, self._page);
|
|
try self._event_manager.dispatch(
|
|
self.document.asEventTarget(),
|
|
event,
|
|
);
|
|
}
|
|
|
|
pub fn scriptsCompletedLoading(self: *Frame) void {
|
|
self.pendingLoadCompleted();
|
|
}
|
|
|
|
pub fn iframeCompletedLoading(self: *Frame, iframe: *IFrame, delays_load: bool) void {
|
|
// When parsing HTML, fire any load event for an iframe on the next tick.
|
|
const parsing_html = switch (self._parse_state) {
|
|
.html => true,
|
|
else => false,
|
|
};
|
|
if (parsing_html and (iframe._src.len > 0 or iframe.hasSrcdoc())) {
|
|
self.queueElementEvent(Factory.protoOf(iframe), .load) catch |err| {
|
|
log.err(.frame, "iframe queue load", .{ .err = err, .url = iframe._src });
|
|
};
|
|
if (delays_load) {
|
|
self.pendingLoadCompleted();
|
|
}
|
|
return;
|
|
}
|
|
|
|
var hs: JS.HandleScope = undefined;
|
|
const entered = self.js.enter(&hs);
|
|
defer entered.exit();
|
|
|
|
blk: {
|
|
const event = Event.initTrusted(comptime .wrap("load"), .{}, self._page) catch |err| {
|
|
log.err(.frame, "iframe event init", .{ .err = err, .url = iframe._src });
|
|
break :blk;
|
|
};
|
|
self._event_manager.dispatch(iframe.asNode().asEventTarget(), event) catch |err| {
|
|
log.warn(.js, "iframe onload", .{ .err = err, .url = iframe._src });
|
|
};
|
|
}
|
|
|
|
if (delays_load) {
|
|
self.pendingLoadCompleted();
|
|
}
|
|
}
|
|
|
|
fn pendingLoadCompleted(self: *Frame) void {
|
|
const pending_loads = self._pending_loads;
|
|
if (pending_loads == 1) {
|
|
self._pending_loads = 0;
|
|
self.documentIsComplete();
|
|
} else {
|
|
self._pending_loads = pending_loads - 1;
|
|
}
|
|
}
|
|
|
|
pub fn documentIsComplete(self: *Frame) void {
|
|
if (self._load_state == .complete) {
|
|
// Ideally, documentIsComplete would only be called once, but with
|
|
// dynamic scripts, it can be hard to keep track of that. An async
|
|
// script could be evaluated AFTER Loaded and Complete and load its
|
|
// own non non-async script - which, upon completion, needs to check
|
|
// whether Laoded/Complete have already been called, which is what
|
|
// this guard is.
|
|
return;
|
|
}
|
|
|
|
// documentIsComplete could be called directly, without first calling
|
|
// documentIsLoaded, if there were _only_ async scripts
|
|
if (self._load_state == .parsing) {
|
|
self.documentIsLoaded();
|
|
}
|
|
|
|
self._load_state = .complete;
|
|
self._documentIsComplete() catch |err| switch (err) {
|
|
error.JsException => {}, // already logged
|
|
else => log.err(.frame, "document is complete", .{ .err = err, .type = self._type, .url = self.url }),
|
|
};
|
|
|
|
if (self.parent == null) {
|
|
self._session.browser.reportJsHeap();
|
|
}
|
|
}
|
|
|
|
fn _documentIsComplete(self: *Frame) !void {
|
|
self.document._ready_state = .complete;
|
|
try self.dispatchReadyStateChange();
|
|
|
|
// Run element load/error events before window.load.
|
|
try self.dispatchQueuedEvents();
|
|
|
|
// Dispatch window.load event.
|
|
const window_target = self.window.asEventTarget();
|
|
if (self._event_manager.hasDirectListeners(window_target, "load", self.window._on_load)) {
|
|
const event = try Event.initTrusted(comptime .wrap("load"), .{}, self._page);
|
|
// This event is weird, it's dispatched directly on the window, but
|
|
// with the document as the target.
|
|
event._target = self.document.asEventTarget();
|
|
try self._event_manager.dispatchDirect(window_target, event, self.window._on_load, .{ .inject_target = false, .context = "page load" });
|
|
}
|
|
|
|
self._session.notification.dispatch(.frame_loaded, &.{
|
|
.req_id = self._req_id,
|
|
.frame_id = self._frame_id,
|
|
.loader_id = self._loader_id,
|
|
.timestamp = lp.datetime.timestamp(.boot),
|
|
});
|
|
|
|
if (self._event_manager.hasDirectListeners(window_target, "pageshow", self.window._on_pageshow)) {
|
|
const pageshow_event = (try PageTransitionEvent.initTrusted(comptime .wrap("pageshow"), .{}, self)).asEvent();
|
|
try self._event_manager.dispatchDirect(window_target, pageshow_event, self.window._on_pageshow, .{ .context = "page show" });
|
|
}
|
|
|
|
if (comptime lp.IS_DEBUG) {
|
|
log.debug(.frame, "load", .{ .url = self.url, .type = self._type });
|
|
}
|
|
|
|
self.notifyParentLoadComplete();
|
|
}
|
|
|
|
fn notifyParentLoadComplete(self: *Frame) void {
|
|
const parent = self.parent orelse return;
|
|
|
|
if (self._parent_notified == true) {
|
|
if (comptime lp.IS_DEBUG) {
|
|
std.debug.assert(false);
|
|
}
|
|
// shouldn't happen, don't want to crash a release build over it
|
|
return;
|
|
}
|
|
|
|
self._parent_notified = true;
|
|
parent.iframeCompletedLoading(self.iframe.?, self._delays_parent_load);
|
|
}
|
|
|
|
fn frameHeaderDoneCallback(transfer: *HttpClient.Transfer) !HttpClient.Transfer.HeaderResult {
|
|
var self: *Frame = @ptrCast(@alignCast(transfer.req.ctx));
|
|
|
|
// Commit point for a pending root navigation. The session has been
|
|
// holding the OLD page alive during the round-trip; now that response
|
|
// headers have arrived, swap pending → active. This dispatches
|
|
// frame_remove (clears OLD V8 context group + CDP node_registry),
|
|
// tears down the OLD page, flips the pointer, and dispatches
|
|
// frame_created against the new (now active) frame.
|
|
if (self._page.replaces != null) {
|
|
try self._session.commitPendingPage(self._page);
|
|
}
|
|
|
|
const response_url = transfer.req.url;
|
|
if (std.mem.eql(u8, response_url, self.url) == false) {
|
|
// would be different than self.url in the case of a redirect
|
|
self.url = try self.arena.dupeZ(u8, response_url);
|
|
self.origin = try URL.getOrigin(self.arena, self.url);
|
|
}
|
|
try self.js.setOrigin(self.origin);
|
|
|
|
// After any redirect, drop the original method/body/header so a later
|
|
// Page.reload doesn't re-POST form data to the redirect target. Conservative
|
|
// default — 307/308 technically preserve the method per RFC 7231, but
|
|
// resubmitting form data is the more dangerous failure mode.
|
|
if ((transfer.redirectCount() orelse 0) > 0) {
|
|
if (self._navigated_options) |*no| {
|
|
no.method = .GET;
|
|
no.body = null;
|
|
no.header = null;
|
|
}
|
|
|
|
// The Referer may have been recomputed at each hop; document.referrer
|
|
// reports what the final request actually sent.
|
|
self._referrer = if (transfer.findRequestHeader("referer")) |ref|
|
|
try self.arena.dupe(u8, ref)
|
|
else
|
|
null;
|
|
}
|
|
|
|
// Init new location.
|
|
const location = try Location.init(self.url, self);
|
|
location.acquireRef();
|
|
self.window._location.releaseRef(self._page);
|
|
self.window._location = location;
|
|
|
|
if (comptime lp.IS_DEBUG) {
|
|
log.debug(.frame, "navigate header", .{
|
|
.url = self.url,
|
|
.status = transfer.responseStatus(),
|
|
.content_type = transfer.contentType(),
|
|
.type = self._type,
|
|
});
|
|
}
|
|
|
|
self._http_status = transfer.responseStatus();
|
|
var it = transfer.responseHeaderIterator();
|
|
while (it.next()) |hdr| {
|
|
try self._http_headers.append(self.arena, .{
|
|
.name = try self.arena.dupe(u8, hdr.name),
|
|
.value = try self.arena.dupe(u8, hdr.value),
|
|
});
|
|
if (std.ascii.eqlIgnoreCase(hdr.name, "referrer-policy")) {
|
|
if (referrer.parseHeader(hdr.value)) |rp| {
|
|
self.referrer_policy = rp;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (self._navigated_options) |no| {
|
|
// _navigated_options will be null in special short-circuit cases, like
|
|
// "navigating" to about:blank, in which case this notification has
|
|
// already been sent
|
|
self._session.notification.dispatch(.frame_navigated, &.{
|
|
.opts = no,
|
|
.url = self.url,
|
|
.req_id = self._req_id,
|
|
.frame_id = self._frame_id,
|
|
.loader_id = self._loader_id,
|
|
.timestamp = lp.datetime.timestamp(.boot),
|
|
});
|
|
}
|
|
|
|
// If the response is a file download, stream its body to disk instead of
|
|
// parsing it as a page. This sets _parse_state to .download, which the
|
|
// data/done callbacks below special-case.
|
|
_ = try self.maybeStartDownload(transfer);
|
|
|
|
return .proceed;
|
|
}
|
|
|
|
// Returns true when the response was set up as a file download. A response is
|
|
// treated as a download when Browser.setDownloadBehavior opted in
|
|
// (allow/allowAndName) and the response carries Content-Disposition: attachment.
|
|
// See issue #2701.
|
|
fn maybeStartDownload(self: *Frame, transfer: *HttpClient.Transfer) !bool {
|
|
const uuidv4 = @import("../id.zig").uuidv4;
|
|
const latin1ToUtf8 = @import("../string.zig").latin1ToUtf8;
|
|
|
|
const session = self._session;
|
|
switch (session.download_behavior) {
|
|
.allow, .allow_and_name => {},
|
|
.deny => return false,
|
|
}
|
|
|
|
const disposition: HttpClient.Header = blk: {
|
|
var it = transfer.responseHeaderIterator();
|
|
while (it.next()) |hdr| {
|
|
if (std.ascii.eqlIgnoreCase(hdr.name, "content-disposition")) {
|
|
break :blk hdr;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
if (std.ascii.eqlIgnoreCase(disposition.firstValue(), "attachment") == false) {
|
|
return false;
|
|
}
|
|
|
|
const download_path = session.download_path orelse {
|
|
log.warn(.frame, "download without downloadPath", .{ .url = self.url });
|
|
return false;
|
|
};
|
|
|
|
// `guid` is the CDP "Global Unique Identifier" that ties the
|
|
// downloadWillBegin / downloadProgress events to one download.
|
|
var guid_buf: [36]u8 = undefined;
|
|
uuidv4(&guid_buf);
|
|
const guid = try self.arena.dupe(u8, &guid_buf);
|
|
|
|
// Content-Disposition filenames can be in a legacy encoding (e.g.
|
|
// Shift_JIS) but must yield valid UTF-8
|
|
const suggested = dispositionFilename(disposition) orelse (try urlBasename(self.arena, self.url)) orelse guid;
|
|
const suggested_filename = if (std.unicode.utf8ValidateSlice(suggested))
|
|
try self.arena.dupe(u8, suggested)
|
|
else
|
|
try latin1ToUtf8(self.arena, suggested);
|
|
|
|
// allowAndName stores the file under its guid; allow uses the suggested name.
|
|
const on_disk_name = switch (session.download_behavior) {
|
|
.allow_and_name => guid,
|
|
else => suggested_filename,
|
|
};
|
|
|
|
std.Io.Dir.cwd().createDirPath(lp.io, download_path) catch |err| {
|
|
log.err(.frame, "download makePath", .{ .err = err, .path = download_path });
|
|
return false;
|
|
};
|
|
var dir = std.Io.Dir.cwd().openDir(lp.io, download_path, .{}) catch |err| {
|
|
log.err(.frame, "download openDir", .{ .err = err, .path = download_path });
|
|
return false;
|
|
};
|
|
defer dir.close(lp.io);
|
|
const file = dir.createFile(lp.io, on_disk_name, .{ .truncate = true }) catch |err| {
|
|
log.err(.frame, "download createFile", .{ .err = err, .name = on_disk_name });
|
|
return false;
|
|
};
|
|
|
|
const total: ?u64 = if (transfer.getContentLength()) |cl| cl else null;
|
|
|
|
self._parse_state = .{ .download = .{
|
|
.guid = guid,
|
|
.file = file,
|
|
.filename = suggested_filename,
|
|
.received = 0,
|
|
.total = total,
|
|
} };
|
|
|
|
if (session.download_events_enabled) {
|
|
session.notification.dispatch(.download_will_begin, &.{
|
|
.frame_id = self._frame_id,
|
|
.guid = guid,
|
|
.url = self.url,
|
|
.suggested_filename = suggested_filename,
|
|
});
|
|
session.notification.dispatch(.download_progress, &.{
|
|
.guid = guid,
|
|
.total_bytes = total orelse 0,
|
|
.received_bytes = 0,
|
|
.state = .in_progress,
|
|
});
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
// Extracts the filename from a Content-Disposition header, handling the quoted,
|
|
// unquoted, and RFC 5987 (filename*=charset''value) forms. Path components are
|
|
// stripped so the result is always a bare basename.
|
|
fn dispositionFilename(disposition: HttpClient.Header) ?[]const u8 {
|
|
// Prefer the extended filename*= form when present, per RFC 6266.
|
|
if (disposition.param("filename*")) |ext| {
|
|
// charset'lang'value — take everything after the second quote.
|
|
if (std.mem.indexOfScalar(u8, ext, '\'')) |first| {
|
|
if (std.mem.indexOfScalarPos(u8, ext, first + 1, '\'')) |second| {
|
|
return sanitizeFilename(ext[second + 1 ..]);
|
|
}
|
|
}
|
|
return sanitizeFilename(ext);
|
|
}
|
|
if (disposition.param("filename")) |name| {
|
|
return sanitizeFilename(name);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Strips any directory components, guarding against path traversal. Content-
|
|
// Disposition can carry Windows separators, so backslashes are stripped too,
|
|
// regardless of the host platform.
|
|
fn sanitizeFilename(name: []const u8) ?[]const u8 {
|
|
var out = std.fs.path.basename(name);
|
|
if (std.mem.lastIndexOfScalar(u8, out, '\\')) |i| {
|
|
out = out[i + 1 ..];
|
|
}
|
|
if (out.len == 0 or std.mem.eql(u8, out, ".") or std.mem.eql(u8, out, "..")) {
|
|
return null;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// Derives a filename from a URL's last path segment. The path is taken from a
|
|
// real URL parse (rust-url) so query/fragment and percent-encoding are handled
|
|
// the same way the rest of the browser handles URLs. The result is duped into
|
|
// `arena`, since the parsed URL is freed before this returns.
|
|
fn urlBasename(arena: Allocator, url: []const u8) !?[]const u8 {
|
|
var err: i32 = 0;
|
|
const u = sys_url.url_parse(url.ptr, url.len, &err) orelse return null;
|
|
defer sys_url.url_free(u);
|
|
|
|
var ptr: [*]const u8 = undefined;
|
|
var len: usize = undefined;
|
|
sys_url.url_get_path(u, &ptr, &len);
|
|
|
|
const name = sanitizeFilename(ptr[0..len]) orelse return null;
|
|
return try arena.dupe(u8, name);
|
|
}
|
|
|
|
fn isUtf16Encoding(charset: []const u8) bool {
|
|
return std.mem.eql(u8, charset, "UTF-16LE") or std.mem.eql(u8, charset, "UTF-16BE");
|
|
}
|
|
|
|
fn frameDataCallback(transfer: *HttpClient.Transfer, data: []const u8) !void {
|
|
var self: *Frame = @ptrCast(@alignCast(transfer.req.ctx));
|
|
|
|
if (self._parse_state == .pre) {
|
|
// we lazily do this, because we might need the first chunk of data
|
|
// to sniff the content type
|
|
var mime: Mime = blk: {
|
|
if (transfer.contentType()) |ct| {
|
|
break :blk try Mime.parse(ct);
|
|
}
|
|
break :blk Mime.sniff(data);
|
|
} orelse .unknown;
|
|
|
|
// If the HTTP Content-Type header didn't specify a charset and this is HTML,
|
|
// prescan the first 1024 bytes for a <meta charset> declaration.
|
|
var html_prescan_found_charset = false;
|
|
if (mime.content_type == .text_html and mime.is_default_charset) {
|
|
if (Mime.prescanCharset(data)) |charset| {
|
|
html_prescan_found_charset = true;
|
|
if (charset.len <= 40) {
|
|
@memcpy(mime.charset[0..charset.len], charset);
|
|
mime.charset[charset.len] = 0;
|
|
mime.charset_len = charset.len;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (comptime lp.IS_DEBUG) {
|
|
log.debug(.frame, "navigate first chunk", .{
|
|
.content_type = mime.content_type,
|
|
.len = data.len,
|
|
.type = self._type,
|
|
.url = self.url,
|
|
});
|
|
}
|
|
|
|
// Record the response's MIME essence so the resulting document
|
|
// reports it (document.contentType). text/html keeps the default, so an
|
|
// HTML response (parsed from the header, or sniffed when there's no
|
|
// header) never needs an override; inside this branch the essence can
|
|
// no longer be text/html.
|
|
self._pending_content_type = null;
|
|
if (mime.content_type != .text_html) {
|
|
if (transfer.contentType()) |ct| {
|
|
const end = std.mem.indexOfScalarPos(u8, ct, 0, ';') orelse ct.len;
|
|
const essence = std.mem.trim(u8, ct[0..end], " \t");
|
|
if (essence.len > 0) {
|
|
self._pending_content_type = try std.ascii.allocLowerString(self.arena, essence);
|
|
}
|
|
}
|
|
}
|
|
|
|
switch (mime.content_type) {
|
|
.text_html => {
|
|
// Normalize and store the charset using encoding_rs canonical names
|
|
const charset_str = mime.charsetString();
|
|
const info = h5e.encoding_for_label(charset_str.ptr, charset_str.len);
|
|
if (info.isValid()) {
|
|
const name = info.name();
|
|
self.charset = if (html_prescan_found_charset and isUtf16Encoding(name)) "UTF-8" else name;
|
|
}
|
|
self._parse_state = .{ .html = .{
|
|
.buffer = .empty,
|
|
.arena = try self.getArena(.large, "Frame.navigate"),
|
|
} };
|
|
},
|
|
.application_json, .text_javascript, .text_css, .text_plain, .text_markdown => {
|
|
var arr: std.ArrayList(u8) = .empty;
|
|
try arr.appendSlice(self.arena, "<html><head><meta charset=\"utf-8\"></head><body><pre>");
|
|
self._parse_state = .{ .text = arr };
|
|
},
|
|
.image_jpeg, .image_gif, .image_png, .image_webp => {
|
|
self._parse_state = .{ .image = .empty };
|
|
},
|
|
else => self._parse_state = .{ .raw = .empty },
|
|
}
|
|
}
|
|
|
|
switch (self._parse_state) {
|
|
.html => |*html| try html.buffer.appendSlice(html.arena.allocator(), data),
|
|
.text => |*buf| {
|
|
// we have to escape the data...
|
|
var v = data;
|
|
while (v.len > 0) {
|
|
const index = std.mem.indexOfAnyPos(u8, v, 0, &.{ '<', '>' }) orelse {
|
|
return buf.appendSlice(self.arena, v);
|
|
};
|
|
try buf.appendSlice(self.arena, v[0..index]);
|
|
switch (v[index]) {
|
|
'<' => try buf.appendSlice(self.arena, "<"),
|
|
'>' => try buf.appendSlice(self.arena, ">"),
|
|
else => unreachable,
|
|
}
|
|
v = v[index + 1 ..];
|
|
}
|
|
},
|
|
.raw, .image => |*buf| try buf.appendSlice(self.arena, data),
|
|
.download => |*download| {
|
|
download.file.writeStreamingAll(lp.io, data) catch |err| {
|
|
// TODO(#2701 follow-up): surface the write failure properly. We
|
|
// can't set `_parse_state = .err` here because the next chunk
|
|
// would then hit the `.err => unreachable` branch below, and we
|
|
// should also emit a `canceled` downloadProgress and remove the
|
|
// partial file on disk. For now we just log and keep going.
|
|
log.err(.frame, "download write", .{ .err = err, .guid = download.guid });
|
|
return;
|
|
};
|
|
download.received += data.len;
|
|
},
|
|
.pre => unreachable,
|
|
.complete => unreachable,
|
|
.err => unreachable,
|
|
.raw_done => unreachable,
|
|
}
|
|
}
|
|
|
|
fn frameDoneCallback(ctx: *anyopaque) !void {
|
|
var self: *Frame = @ptrCast(@alignCast(ctx));
|
|
|
|
if (comptime lp.IS_DEBUG) {
|
|
log.debug(.frame, "navigate done", .{ .type = self._type, .url = self.url });
|
|
}
|
|
|
|
//We need to handle different navigation types differently.
|
|
try self._session.navigation.commitNavigation(self);
|
|
|
|
if (self._pending_content_type) |content_type| {
|
|
self.document._content_type = content_type;
|
|
self._pending_content_type = null;
|
|
}
|
|
|
|
defer if (comptime lp.IS_DEBUG) {
|
|
log.debug(.frame, "frame load complete", .{
|
|
.url = self.url,
|
|
.type = self._type,
|
|
.state = std.meta.activeTag(self._parse_state),
|
|
});
|
|
};
|
|
|
|
const parse_arena = try self.getArena(.medium, "Frame.parse");
|
|
defer parse_arena.release();
|
|
|
|
var parser = Parser.init(parse_arena.allocator(), self.document.asNode(), self, .{ .allow_declarative_shadow = true });
|
|
|
|
switch (self._parse_state) {
|
|
.html => |*html| {
|
|
{
|
|
defer {
|
|
html.arena.release();
|
|
self._parse_state = .complete;
|
|
}
|
|
|
|
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 {
|
|
parser.parseWithEncoding(raw_html, self.charset);
|
|
}
|
|
}
|
|
self._script_manager.staticScriptsDone();
|
|
},
|
|
.text => |*buf| {
|
|
try buf.appendSlice(self.arena, "</pre></body></html>");
|
|
parser.parse(buf.items);
|
|
self.documentIsComplete();
|
|
},
|
|
.image => |buf| {
|
|
self._parse_state = .{ .raw_done = buf.items };
|
|
|
|
// Use empty an HTML containing the image.
|
|
const html = try std.mem.concat(parse_arena.allocator(), u8, &.{
|
|
"<html><head><meta charset=\"utf-8\"></head><body><img src=\"",
|
|
self.url,
|
|
"\"></body></html>",
|
|
});
|
|
parser.parse(html);
|
|
self.documentIsComplete();
|
|
},
|
|
.raw => |buf| {
|
|
self._parse_state = .{ .raw_done = buf.items };
|
|
|
|
// Use empty an empty HTML document.
|
|
parser.parse("<html><head><meta charset=\"utf-8\"></head><body></body></html>");
|
|
self.documentIsComplete();
|
|
},
|
|
.pre => {
|
|
// Received a response without a body like: https://httpbin.io/status/200
|
|
// We assume we have received an OK status (checked in Client.headerCallback)
|
|
// so we load a blank document to navigate away from any prior frame.
|
|
self._parse_state = .{ .complete = {} };
|
|
|
|
// Use empty an empty HTML document.
|
|
parser.parse("<html><head><meta charset=\"utf-8\"></head><body></body></html>");
|
|
self.documentIsComplete();
|
|
},
|
|
.err => |err| {
|
|
// Generate a pseudo HTML page indicating the failure.
|
|
const html = try std.mem.concat(parse_arena.allocator(), u8, &.{
|
|
"<html><head><meta charset=\"utf-8\"></head><body><h1>Navigation failed</h1><p>Reason: ",
|
|
@errorName(err),
|
|
"</p></body></html>",
|
|
});
|
|
|
|
parser.parse(html);
|
|
self._parse_state = .complete;
|
|
self.documentIsComplete();
|
|
},
|
|
.download => |*download| {
|
|
download.file.close(lp.io);
|
|
|
|
// Capture before invalidating the union below.
|
|
const guid = download.guid;
|
|
const received = download.received;
|
|
const total = download.total orelse download.received;
|
|
self._parse_state = .complete;
|
|
|
|
const session = self._session;
|
|
if (session.download_events_enabled) {
|
|
session.notification.dispatch(.download_progress, &.{
|
|
.guid = guid,
|
|
.total_bytes = total,
|
|
.received_bytes = received,
|
|
.state = .completed,
|
|
});
|
|
}
|
|
|
|
// The body went to disk; commit an empty document so the frame
|
|
// navigation still completes cleanly (mirrors the .raw path).
|
|
parser.parse("<html><head><meta charset=\"utf-8\"></head><body></body></html>");
|
|
self.documentIsComplete();
|
|
},
|
|
else => unreachable,
|
|
}
|
|
}
|
|
|
|
fn frameErrorCallback(ctx: *anyopaque, err: anyerror) void {
|
|
var self: *Frame = @ptrCast(@alignCast(ctx));
|
|
|
|
self._last_navigate_error = err;
|
|
log.err(.frame, "navigate failed", .{ .err = err, .type = self._type, .url = self.url });
|
|
|
|
// A navigation that fails before any response headers arrive never
|
|
// reaches the frame_navigated dispatch in frameHeaderCallback, so the
|
|
// Page.navigate command that initiated it would stay unanswered forever.
|
|
// Tell CDP so it can answer with an errorText (Chrome semantics).
|
|
// _http_status is set as soon as headers are processed; non-null means
|
|
// frameHeaderCallback already answered the command — don't answer twice.
|
|
if (self._http_status == null) {
|
|
self._session.notification.dispatch(.frame_navigate_failed, &.{
|
|
.frame_id = self._frame_id,
|
|
.loader_id = self._loader_id,
|
|
.timestamp = lp.datetime.timestamp(.boot),
|
|
.url = self.url,
|
|
.err = err,
|
|
.opts = self._navigated_options orelse .{},
|
|
});
|
|
}
|
|
|
|
// A pending root navigation that failed before commit: discard the
|
|
// pending Page; the OLD active Page (and its V8 context) is untouched.
|
|
// We do NOT run frameDoneCallback against the pending frame — the frame
|
|
// is about to be freed.
|
|
if (self._page.replaces != null) {
|
|
self._session.discardPendingPage(self._page);
|
|
return;
|
|
}
|
|
|
|
self._parse_state.deinit(self);
|
|
self._parse_state = .{ .err = err };
|
|
|
|
// In case of error, we want to complete the frame with a custom HTML
|
|
// containing the error.
|
|
frameDoneCallback(ctx) catch |e| {
|
|
log.err(.browser, "frameErrorCallback", .{ .err = e, .type = self._type, .url = self.url });
|
|
return;
|
|
};
|
|
}
|
|
pub fn isGoingAway(self: *const Frame) bool {
|
|
if (self._queued_navigation != null) {
|
|
return true;
|
|
}
|
|
const parent = self.parent orelse return false;
|
|
return parent.isGoingAway();
|
|
}
|
|
|
|
pub fn scriptAddedCallback(self: *Frame, comptime from_parser: bool, script: *Element.Html.Script) !void {
|
|
if (self.isGoingAway()) {
|
|
// if we're planning on navigating to another frame, don't run this script
|
|
return;
|
|
}
|
|
|
|
if (comptime from_parser) {
|
|
// parser-inserted scripts have force-async set to false, but only if
|
|
// they have src or non-empty content
|
|
if (script._src.len > 0 or script.asNode().firstChild() != null) {
|
|
script._force_async = false;
|
|
}
|
|
}
|
|
|
|
self._script_manager.addFromElement(from_parser, script, "parsing") catch |err| {
|
|
log.err(.frame, "frame.scriptAddedCallback", .{
|
|
.err = err,
|
|
.url = self.url,
|
|
.src = script.asElement().getAttributeSafe(comptime .wrap("src")),
|
|
.type = self._type,
|
|
});
|
|
};
|
|
}
|
|
|
|
pub fn iframeAddedCallback(self: *Frame, iframe: *IFrame) !void {
|
|
if (self.isGoingAway()) {
|
|
// if we're planning on navigating to another frame, don't load this iframe
|
|
return;
|
|
}
|
|
if (iframe._executed) {
|
|
return;
|
|
}
|
|
if (!self._session.subframe_loading_enabled) {
|
|
// configured not to load frames
|
|
iframe._executed = true;
|
|
return;
|
|
}
|
|
|
|
const src = blk: {
|
|
if (iframe.hasSrcdoc()) {
|
|
// srcdoc takes precedence over src, even when empty
|
|
break :blk "about:srcdoc";
|
|
}
|
|
|
|
var src = iframe.asElement().getAttributeSafe(comptime .wrap("src")) orelse "";
|
|
if (src.len == 0) {
|
|
src = "about:blank";
|
|
}
|
|
|
|
if (URL.isCompleteHTTPUrl(src) and !URL.canParse(src, null)) {
|
|
// per spec, if we can't parse the URL, we should load about:blank
|
|
src = "about:blank";
|
|
}
|
|
break :blk src;
|
|
};
|
|
|
|
if (iframe._window != null) {
|
|
// This frame is being re-navigated. We need to do this through a
|
|
// scheduleNavigation phase. We can't navigate immediately here, for
|
|
// the same reason that a "root" frame can't immediately navigate:
|
|
// we could be in the middle of a JS callback or something else that
|
|
// doesn't exit the frame to just suddenly go away.
|
|
return self.scheduleNavigation(src, .{
|
|
.reason = .script,
|
|
.kind = .{ .push = null },
|
|
}, .{ .iframe = iframe });
|
|
}
|
|
|
|
iframe._executed = true;
|
|
const session = self._session;
|
|
|
|
const new_frame = try self.arena.create(Frame);
|
|
const frame_id = session.nextFrameId();
|
|
|
|
try Frame.init(new_frame, frame_id, self._page, .{ .parent = self });
|
|
errdefer new_frame.deinit();
|
|
|
|
const delays_load = iframe.isLazyLoading() == false;
|
|
new_frame._delays_parent_load = delays_load;
|
|
if (delays_load) {
|
|
self._pending_loads += 1;
|
|
}
|
|
new_frame.iframe = iframe;
|
|
iframe._window = new_frame.window;
|
|
errdefer iframe._window = null;
|
|
|
|
// on first load, dispatch frame_created event
|
|
self._session.notification.dispatch(.frame_child_frame_created, &.{
|
|
.parent_id = self._frame_id,
|
|
.frame_id = new_frame._frame_id,
|
|
.loader_id = new_frame._loader_id,
|
|
.timestamp = lp.datetime.timestamp(.boot),
|
|
});
|
|
|
|
const url = blk: {
|
|
if (std.mem.eql(u8, src, "about:blank")) {
|
|
break :blk "about:blank"; // navigate will handle this special case
|
|
}
|
|
if (std.mem.eql(u8, src, "about:srcdoc")) {
|
|
break :blk "about:srcdoc"; // navigate will handle this special case
|
|
}
|
|
break :blk try URL.resolve(
|
|
self.call_arena, // ok to use, frame.navigate dupes this
|
|
self.base(),
|
|
src,
|
|
.{ .encoding = self.charset },
|
|
);
|
|
};
|
|
|
|
// Append the new frame before navigate() so synchronous navigation paths
|
|
// (about:blank, blob:) and the notifications they dispatch can see this
|
|
// frame in self.child_frames.
|
|
try self.child_frames.append(self.arena, new_frame);
|
|
|
|
// navigate() may run JS that reads window[N]; flag the list unsorted until
|
|
// we've verified ordering post-navigate.
|
|
const was_sorted = self.child_frames_sorted;
|
|
self.child_frames_sorted = false;
|
|
|
|
// Iframe's initial src request carries the parent's URL as Referer
|
|
// (subject to the parent's Referrer-Policy) and as the SameSite
|
|
// initiator. When this frame is itself an about: document, the nearest
|
|
// ancestor's URL is the referrer source. Parent frame outlives this
|
|
// navigate() call, so the slice is safe; navigate dupes what it keeps.
|
|
const referrer_source = self.referrerSource();
|
|
const parent_url: ?[:0]const u8 = if (std.mem.startsWith(u8, referrer_source, "http")) referrer_source else null;
|
|
new_frame.navigate(url, .{
|
|
.reason = .initialFrameNavigation,
|
|
.referer = try referrer.compute(self.call_arena, self.referrer_policy, referrer_source, url),
|
|
.referrer_policy = self.referrer_policy,
|
|
.initiator_url = parent_url,
|
|
.initiator_origin = self.origin,
|
|
}) catch |err| {
|
|
// extra defensive..maybe navigate added a new frame, and the index it
|
|
// was added at was removed. Or maybe this frame was removed somehow
|
|
// (which I don't think is possible)
|
|
if (std.mem.indexOfScalar(*Frame, self.child_frames.items, new_frame)) |idx| {
|
|
_ = self.child_frames.swapRemove(idx);
|
|
}
|
|
log.warn(.frame, "iframe navigate failure", .{ .url = url, .err = err });
|
|
if (delays_load) {
|
|
self._pending_loads -= 1;
|
|
}
|
|
iframe._window = null;
|
|
return error.IFrameLoadError;
|
|
};
|
|
|
|
// window[N] is based on document order. We appended above and rely on
|
|
// child_frames_sorted to tell window.getFrame whether it has to sort.
|
|
// Since we expect frames to often be added in document order, do a quick
|
|
// check to keep the list flagged as sorted when possible.
|
|
const frames_len = self.child_frames.items.len;
|
|
if (frames_len == 1) {
|
|
// this is the only frame, it must be sorted.
|
|
self.child_frames_sorted = true;
|
|
return;
|
|
}
|
|
|
|
if (!was_sorted) {
|
|
// it was already unsorted; leave flag false
|
|
return;
|
|
}
|
|
|
|
// So we added a frame into a sorted list. If this frame is sorted relative
|
|
// to the last frame, it's still sorted
|
|
const iframe_a = self.child_frames.items[frames_len - 2].iframe.?;
|
|
const iframe_b = self.child_frames.items[frames_len - 1].iframe.?;
|
|
|
|
if (iframe_a.asNode().compareDocumentPosition(iframe_b.asNode()) & 0x04 != 0) {
|
|
// b follows a (& 0x04 == 0x04), so the appended frame is in document
|
|
// order relative to the previous tail — the list is still sorted.
|
|
self.child_frames_sorted = true;
|
|
}
|
|
}
|
|
|
|
const OpenPopupOpts = struct {
|
|
url: []const u8,
|
|
name: []const u8,
|
|
opener: ?*Window,
|
|
};
|
|
|
|
// Create a new top-level browsing context as a sibling of the root frame.
|
|
// The popup shares the Page's arena, factory, and identity map, but has no
|
|
// parent and is not attached to the frame tree — it lives in page.popups.
|
|
pub fn openPopup(self: *Frame, opts: OpenPopupOpts) !*Frame {
|
|
const page = self._page;
|
|
const session = self._session;
|
|
|
|
const resolved_url: [:0]const u8 = blk: {
|
|
if (opts.url.len == 0) {
|
|
break :blk "about:blank";
|
|
}
|
|
if (std.mem.eql(u8, opts.url, "about:blank")) {
|
|
break :blk "about:blank";
|
|
}
|
|
const frame_base = base_blk: {
|
|
var frame = self;
|
|
while (true) {
|
|
const maybe_base = frame.base();
|
|
if (!std.mem.eql(u8, maybe_base, "about:blank")) {
|
|
break :base_blk maybe_base;
|
|
}
|
|
frame = frame.parent orelse break :base_blk "";
|
|
}
|
|
};
|
|
break :blk try URL.resolve(self.call_arena, frame_base, opts.url, .{ .encoding = self.charset });
|
|
};
|
|
|
|
const popup = try page.frame_arena.create(Frame);
|
|
errdefer page.frame_arena.destroy(popup);
|
|
|
|
const frame_id = session.nextFrameId();
|
|
try Frame.init(popup, frame_id, page, .{});
|
|
errdefer popup.deinit();
|
|
|
|
popup.window._opener = opts.opener;
|
|
|
|
if (opts.name.len > 0 and
|
|
!std.ascii.eqlIgnoreCase(opts.name, "_blank") and
|
|
!std.ascii.eqlIgnoreCase(opts.name, "_self") and
|
|
!std.ascii.eqlIgnoreCase(opts.name, "_parent") and
|
|
!std.ascii.eqlIgnoreCase(opts.name, "_top"))
|
|
{
|
|
popup.window._name = try page.frame_arena.dupe(u8, opts.name);
|
|
}
|
|
|
|
const popup_index = page.popups.items.len;
|
|
try page.popups.append(page.frame_arena, popup);
|
|
// not impossible that navigate adds popups, so remove by index
|
|
errdefer _ = page.popups.swapRemove(popup_index);
|
|
|
|
popup.navigate(resolved_url, .{ .reason = .script, .initiator_origin = self.origin }) catch |err| {
|
|
log.warn(.frame, "popup navigate failure", .{ .url = resolved_url, .err = err });
|
|
return err;
|
|
};
|
|
|
|
// A bit hacky. The type of window we return depends on whether or not its
|
|
// origin is the same as the opener. I believe that we're supposed to do
|
|
// this check lazily, per function invocation. But we don't have that in
|
|
// place right now. So the best we can do is set the origin now, before the
|
|
// async navigation completed.
|
|
try popup.js.setOrigin(popup.origin);
|
|
|
|
return popup;
|
|
}
|
|
|
|
pub fn domChanged(self: *Frame) void {
|
|
self._page.dom_version += 1;
|
|
|
|
// A DOM change is our "rendering opportunity": re-evaluate the layout
|
|
// observers. Both are no-ops unless something they track actually changed.
|
|
observers.scheduleIntersectionChecks(self);
|
|
observers.scheduleResizeChecks(self);
|
|
}
|
|
|
|
const ElementIdMaps = struct { lookup: *std.StringHashMapUnmanaged(*Element), removed_ids: *std.StringHashMapUnmanaged(void) };
|
|
|
|
fn getElementIdMap(frame: *Frame, node: *Node) ElementIdMaps {
|
|
return idMapsForRoot(node.getRootNode(.{})) orelse {
|
|
// Detached nodes should not have IDs registered
|
|
if (lp.IS_DEBUG) {
|
|
std.debug.assert(false);
|
|
}
|
|
return .{
|
|
.lookup = &frame.document._elements_by_id,
|
|
.removed_ids = &frame.document._removed_ids,
|
|
};
|
|
};
|
|
}
|
|
|
|
// Ids are registered per tree scope: a ShadowRoot or a Document each own a
|
|
// map. A root that is neither (a detached tree) has none.
|
|
fn idMapsForRoot(root: *Node) ?ElementIdMaps {
|
|
if (root.is(ShadowRoot)) |shadow_root| {
|
|
return .{
|
|
.lookup = &shadow_root._elements_by_id,
|
|
.removed_ids = &shadow_root._removed_ids,
|
|
};
|
|
}
|
|
if (root._type == .document) {
|
|
const doc = root.subtype(Document);
|
|
return .{
|
|
.lookup = &doc._elements_by_id,
|
|
.removed_ids = &doc._removed_ids,
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Node.isConnected re-walks the ancestor chain; callers here already have the
|
|
// root in hand.
|
|
fn rootIsConnected(root: *Node) bool {
|
|
if (root._type == .document) {
|
|
return true;
|
|
}
|
|
const shadow_root = root.is(ShadowRoot) orelse return false;
|
|
return shadow_root._host.asNode().isConnected();
|
|
}
|
|
|
|
pub fn addElementId(self: *Frame, parent: *Node, element: *Element, id: []const u8) !void {
|
|
return self.addElementIdWithMaps(self.getElementIdMap(parent), element, id);
|
|
}
|
|
|
|
fn addElementIdWithMaps(self: *Frame, id_maps: ElementIdMaps, element: *Element, id: []const u8) !void {
|
|
const gop = try id_maps.lookup.getOrPut(self.arena, id);
|
|
if (!gop.found_existing) {
|
|
gop.value_ptr.* = element;
|
|
return;
|
|
}
|
|
|
|
const existing = gop.value_ptr.*.asNode();
|
|
switch (element.asNode().compareDocumentPosition(existing)) {
|
|
0x04 => gop.value_ptr.* = element,
|
|
else => {},
|
|
}
|
|
}
|
|
|
|
pub fn removeElementId(self: *Frame, element: *Element, id: []const u8) void {
|
|
const node = element.asNode();
|
|
self.removeElementIdWithMaps(self.getElementIdMap(node), id);
|
|
}
|
|
|
|
pub fn removeElementIdWithMaps(self: *Frame, id_maps: ElementIdMaps, id: []const u8) void {
|
|
if (id_maps.lookup.remove(id)) {
|
|
const owned_id = self.dupeString(id) catch return;
|
|
id_maps.removed_ids.put(self.arena, owned_id, {}) catch |err| {
|
|
log.warn(.frame, "removeElementIdWithMaps", .{ .err = err });
|
|
};
|
|
}
|
|
}
|
|
|
|
pub fn getElementByIdFromNode(self: *Frame, node: *Node, id: []const u8) ?*Element {
|
|
// The id map lives on the node's root: a Document, or a ShadowRoot for
|
|
// shadow DOM. Walk to the root once and consult the matching map.
|
|
const root = node.getRootNode(.{});
|
|
if (root._type == .document) {
|
|
return root.subtype(Document).getElementById(id, self);
|
|
}
|
|
if (root.is(ShadowRoot)) |shadow_root| {
|
|
return shadow_root.getElementById(id, self);
|
|
}
|
|
// Detached subtree (root is neither a Document nor a ShadowRoot): no id map
|
|
// exists, so scan it.
|
|
var tw = TreeWalker.Full.Elements.init(node, .{});
|
|
while (tw.next()) |el| {
|
|
const element_id = el.getAttributeSafe(comptime .wrap("id")) orelse continue;
|
|
if (std.mem.eql(u8, element_id, id)) {
|
|
return el;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
pub fn performance(self: *Frame) *Performance {
|
|
return &self.window._performance;
|
|
}
|
|
|
|
// Tracks a file input's FileList so its File refs are released at teardown.
|
|
pub fn trackFileList(self: *Frame, file_list: *FileList) !void {
|
|
try self._file_lists.append(self.arena, file_list);
|
|
}
|
|
|
|
pub const QueuedEvent = struct {
|
|
kind: Kind,
|
|
element: *Element.Html,
|
|
|
|
pub const Kind = enum { load, @"error" };
|
|
};
|
|
|
|
pub fn queueLoad(self: *Frame, html: *Element.Html) !void {
|
|
try self.queueElementEvent(html, .load);
|
|
}
|
|
|
|
pub fn queueElementEvent(self: *Frame, element: *Element.Html, kind: QueuedEvent.Kind) !void {
|
|
try self._queued_events.append(self.arena, .{ .element = element, .kind = kind });
|
|
if (self._queued_events.items.len == 1) {
|
|
try self.js.scheduler.add(self, struct {
|
|
fn cleanup(ctx: *anyopaque) !?u32 {
|
|
const f: *Frame = @ptrCast(@alignCast(ctx));
|
|
try f.dispatchQueuedEvents();
|
|
return null;
|
|
}
|
|
}.cleanup, 0, .{ .name = "frame.dispatchQueuedEvents" });
|
|
}
|
|
}
|
|
|
|
const HashChangeCallback = struct {
|
|
frame: *Frame,
|
|
old_url: []const u8,
|
|
new_url: []const u8,
|
|
|
|
// Called by the scheduler if the task is dropped before it runs (e.g. the
|
|
// page is torn down).
|
|
fn cancelled(ctx: *anyopaque) void {
|
|
const self: *HashChangeCallback = @ptrCast(@alignCast(ctx));
|
|
self.frame._factory.destroy(self);
|
|
}
|
|
|
|
fn run(ctx: *anyopaque) !?u32 {
|
|
const self: *HashChangeCallback = @ptrCast(@alignCast(ctx));
|
|
defer self.frame._factory.destroy(self);
|
|
|
|
const frame = self.frame;
|
|
const target = frame.window.asEventTarget();
|
|
if (!frame._event_manager.hasDirectListeners(target, "hashchange", frame.window._on_hashchange)) {
|
|
return null;
|
|
}
|
|
|
|
const event = (try HashChangeEvent.initTrusted(comptime .wrap("hashchange"), .{
|
|
.oldURL = self.old_url,
|
|
.newURL = self.new_url,
|
|
}, frame)).asEvent();
|
|
try frame._event_manager.dispatchDirect(target, event, frame.window._on_hashchange, .{ .context = "Hash Change" });
|
|
return null;
|
|
}
|
|
};
|
|
|
|
pub fn queueHashChange(self: *Frame, old_url: []const u8, new_url: []const u8) !void {
|
|
const callback = try self._factory.create(HashChangeCallback{
|
|
.frame = self,
|
|
.old_url = old_url,
|
|
.new_url = new_url,
|
|
});
|
|
try self.js.scheduler.add(callback, HashChangeCallback.run, 0, .{
|
|
.name = "frame.hashChange",
|
|
.finalizer = HashChangeCallback.cancelled,
|
|
});
|
|
}
|
|
|
|
// 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>`.
|
|
// href is passed in as an optimization since the [currently] only callsite has
|
|
// it, so why look it up again?
|
|
pub fn loadExternalStylesheet(self: *Frame, link: *Element.Html.Link, href: []const u8) !void {
|
|
if (self.isGoingAway() or href.len == 0) {
|
|
return;
|
|
}
|
|
|
|
const session = self._session;
|
|
|
|
// this feature is disabled by default, and can be turned on via a command
|
|
// line flag or via an CDP command
|
|
if (session.load_external_stylesheets == false) {
|
|
return self.queueLoad(Factory.protoOf(link));
|
|
}
|
|
|
|
// Fragment-parsed links (innerHTML, DOMParser, ...) may not be attached.
|
|
// TODO: this isn't correct in all cases. If the link is added into an
|
|
// attached node, I think we SHOULD load it.
|
|
if (self._parse_mode == .fragment) {
|
|
return;
|
|
}
|
|
const element = link.asElement();
|
|
|
|
// HttpClient will take out a larger arena for the body, if necessary
|
|
const arena = try session.getArena(.small, "Frame.loadExternalStylesheet");
|
|
defer arena.release();
|
|
|
|
const resolved = URL.resolve(arena.allocator(), self.base(), href, .{ .encoding = self.charset }) catch |err| {
|
|
log.warn(.http, "external stylesheet resolve", .{ .err = err, .href = href });
|
|
try self.fireElementEvent(element, comptime .wrap("error"));
|
|
return;
|
|
};
|
|
|
|
const http_client = &session.browser.http_client;
|
|
|
|
// `syncRequest` below registers a blocking request for this frame; the
|
|
// client's dispatcher holds back every OTHER transfer's callbacks for
|
|
// the frame while it's registered (they'd run JS on the parser's stack)
|
|
// and delivers them on the next tick after the sync fetch returns.
|
|
|
|
const transfer = http_client.newRequest(.{
|
|
.url = resolved,
|
|
.method = .GET,
|
|
.frame_id = self._frame_id,
|
|
.loader_id = self._loader_id,
|
|
.cookie_jar = &session.cookie_jar,
|
|
.cookie_origin = self.url,
|
|
.resource_type = .stylesheet,
|
|
.notification = session.notification,
|
|
.shutdown_callback = HttpClient.noopShutdown, // syncRequest installs its own
|
|
}, &self._http_owner) catch |err| {
|
|
log.warn(.http, "external stylesheet fetch", .{ .err = err, .url = resolved });
|
|
return self.fireElementEvent(element, comptime .wrap("error"));
|
|
};
|
|
{
|
|
errdefer transfer.deinit();
|
|
try transfer.addHeader("Accept", "text/css,*/*;q=0.1", .{});
|
|
try self.headersForRequest(transfer);
|
|
}
|
|
|
|
// Set the script-manager `is_evaluating` flag for the same reason
|
|
// `ScriptManager.addFromElement` does: `syncRequest` pumps the CDP
|
|
// socket inline, so a `Target.closeTarget` / `Page.close` arriving
|
|
// mid-fetch would otherwise drive `Session.removePage` while this
|
|
// function still holds pointers to `self`. The check in
|
|
// `Session.removePage` (Session.zig:253) consults
|
|
// `frame.anyScriptEvaluating()`, which only sees this flag.
|
|
const sm = &self._script_manager.base;
|
|
const was_evaluating = sm.is_evaluating;
|
|
sm.is_evaluating = true;
|
|
defer sm.endEvaluationWindow(was_evaluating);
|
|
|
|
var response = http_client.syncRequest(transfer) catch |err| {
|
|
log.warn(.http, "external stylesheet fetch", .{ .err = err, .url = resolved });
|
|
return self.fireElementEvent(element, comptime .wrap("error"));
|
|
};
|
|
defer response.deinit();
|
|
|
|
if (response.status < 200 or response.status >= 300) {
|
|
log.info(.http, "external stylesheet status", .{ .status = response.status, .url = resolved });
|
|
return self.fireElementEvent(element, comptime .wrap("error"));
|
|
}
|
|
|
|
if (response.body.items.len > MAX_STYLESHEET_BYTES) {
|
|
log.warn(.http, "external stylesheet too large", .{
|
|
.bytes = response.body.items.len,
|
|
.max = MAX_STYLESHEET_BYTES,
|
|
.url = resolved,
|
|
});
|
|
return self.fireElementEvent(element, comptime .wrap("error"));
|
|
}
|
|
|
|
// Reuse the cached sheet on re-fetch (href mutation on a connected
|
|
// link) so `document.styleSheets` keeps a single entry per <link>
|
|
// instead of accumulating one per href change. On first load, create
|
|
// and register; on subsequent loads, replace content in place.
|
|
//
|
|
// First-load creation assigns `link._sheet` AFTER `sheets.add`
|
|
// succeeds so an OOM during registration doesn't cache an unregistered
|
|
// sheet (which would short-circuit every future re-fetch via the
|
|
// `orelse` branch, leaving the sheet permanently unreachable through
|
|
// `document.styleSheets`).
|
|
const sheet = link._sheet orelse blk: {
|
|
const new_sheet = try CSSStyleSheet.initWithOwner(element, self);
|
|
const sheets = try self.document.getStyleSheets(self);
|
|
try sheets.add(new_sheet, self);
|
|
link._sheet = new_sheet;
|
|
break :blk new_sheet;
|
|
};
|
|
|
|
// Parse first, only swap `_href` on success. `replaceSync` itself is
|
|
// not atomic (clears rules before the insert loop), so a mid-parse
|
|
// OOM still drops the old rules — full atomicity would require a
|
|
// scratch-list pattern in `CSSStyleSheet.replaceSync`. Keeping
|
|
// `_href` consistent with what the sheet actually contains is the
|
|
// minimum.
|
|
sheet.replaceSync(response.body.items, self) catch |err| {
|
|
log.warn(.http, "external stylesheet parse", .{ .err = err, .url = resolved });
|
|
return self.fireElementEvent(element, comptime .wrap("error"));
|
|
};
|
|
sheet._href = try self.arena.dupe(u8, resolved);
|
|
|
|
try self.fireElementEvent(element, comptime .wrap("load"));
|
|
}
|
|
|
|
fn fireElementEvent(self: *Frame, el: *Element, name: String) !void {
|
|
const event = try Event.initTrusted(name, .{}, self._page);
|
|
try self._event_manager.dispatch(el.asEventTarget(), event);
|
|
}
|
|
|
|
fn dispatchQueuedEvents(self: *Frame) !void {
|
|
const has_dom_load_listener = self._event_manager.has_dom_load_listener;
|
|
|
|
// Swap buffers - new additions during dispatch go to the other buffer
|
|
const to_process = self._queued_events;
|
|
self._queued_events = if (self._queued_events == &self._queued_events_1)
|
|
&self._queued_events_2
|
|
else
|
|
&self._queued_events_1;
|
|
|
|
for (to_process.items) |queued| {
|
|
const html_element = queued.element;
|
|
const element = html_element.asElement();
|
|
switch (queued.kind) {
|
|
// hasAttributeFunction only sees handlers compiled via property
|
|
// access; a parsed `onload="..."` attribute is compiled lazily at
|
|
// dispatch (EventManager.getInlineHandler), so check it raw too.
|
|
.load => {
|
|
if (has_dom_load_listener or
|
|
html_element.hasAttributeFunction(.onload, self) or
|
|
element.getAttributeSafe(comptime .wrap("onload")) != null)
|
|
{
|
|
try self.fireElementEvent(element, comptime .wrap("load"));
|
|
}
|
|
},
|
|
.@"error" => {
|
|
// errors are rare; not worth a listener-presence check
|
|
try self.fireElementEvent(element, comptime .wrap("error"));
|
|
},
|
|
}
|
|
}
|
|
|
|
to_process.clearRetainingCapacity();
|
|
}
|
|
|
|
pub fn scheduleCustomElementBackupDrain(self: *Frame) !void {
|
|
try self.js.queueCustomElementBackupDrain();
|
|
}
|
|
|
|
// Run the network-idle notification checks for this frame and, recursively,
|
|
// its child frames. CDP clients (e.g. puppeteer's networkidle0) expect the
|
|
// networkIdle/networkAlmostIdle lifecycle events on every frame, like Chrome
|
|
// emits them, not just on the root frame.
|
|
pub fn checkIdleNotifications(self: *Frame, total_http_activity: usize) void {
|
|
switch (self._parse_state) {
|
|
.html, .complete => {
|
|
if (self._notified_network_almost_idle.check(total_http_activity <= 2)) {
|
|
self.notifyNetworkAlmostIdle();
|
|
}
|
|
if (self._notified_network_idle.check(total_http_activity == 0)) {
|
|
self.notifyNetworkIdle();
|
|
}
|
|
},
|
|
else => {},
|
|
}
|
|
for (self.child_frames.items) |child| {
|
|
child.checkIdleNotifications(total_http_activity);
|
|
}
|
|
}
|
|
|
|
pub fn notifyNetworkIdle(self: *Frame) void {
|
|
lp.assert(self._notified_network_idle == .done, "Frame.notifyNetworkIdle", .{});
|
|
self._session.notification.dispatch(.frame_network_idle, &.{
|
|
.req_id = self._req_id,
|
|
.frame_id = self._frame_id,
|
|
.loader_id = self._loader_id,
|
|
.timestamp = lp.datetime.timestamp(.boot),
|
|
});
|
|
}
|
|
|
|
pub fn notifyNetworkAlmostIdle(self: *Frame) void {
|
|
lp.assert(self._notified_network_almost_idle == .done, "Frame.notifyNetworkAlmostIdle", .{});
|
|
self._session.notification.dispatch(.frame_network_almost_idle, &.{
|
|
.req_id = self._req_id,
|
|
.frame_id = self._frame_id,
|
|
.loader_id = self._loader_id,
|
|
.timestamp = lp.datetime.timestamp(.boot),
|
|
});
|
|
}
|
|
|
|
// called from the parser. Text-node merging is the parser's responsibility
|
|
// (see Parser.appendTextChunk in src/browser/parser/Parser.zig); this is the
|
|
// "insert this fully-formed node as a new last child of parent" entry point.
|
|
pub fn appendNew(self: *Frame, parent: *Node, child: *Node) !void {
|
|
lp.assert(child._parent == null, "Frame.appendNew", .{});
|
|
// opts is meaningless when from_parser (the first param) is true.
|
|
try self._insertNodeRelative(true, parent, child, .append, .{});
|
|
}
|
|
|
|
// called from the parser when the node and all its children have been added
|
|
pub fn nodeComplete(self: *Frame, node: *Node) !void {
|
|
Node.Build.call(node, "complete", .{ node, self }) catch |err| {
|
|
log.err(.bug, "build.complete", .{ .tag = node.getNodeName(&self.buf), .err = err, .type = self._type, .url = self.url });
|
|
return err;
|
|
};
|
|
return self.nodeIsReady(true, node);
|
|
}
|
|
|
|
// Sets the owner document for a node. Only stores entries for nodes whose owner
|
|
// is NOT frame.document to minimize memory overhead.
|
|
pub fn setNodeOwnerDocument(self: *Frame, node: *Node, owner: *Document) !void {
|
|
if (owner == self.document) {
|
|
// No need to store if it's the main document - remove if present
|
|
_ = self._node_owner_documents.remove(node);
|
|
} else {
|
|
try self._node_owner_documents.put(self.arena, node, owner);
|
|
}
|
|
}
|
|
|
|
// Recursively sets the owner document for a node and all its descendants
|
|
pub fn adoptNodeTree(self: *Frame, node: *Node, old_owner: *Document, new_owner: *Document) !void {
|
|
try self.setNodeOwnerDocument(node, new_owner);
|
|
|
|
// Per spec, adopted steps run on each element after its document is set.
|
|
if (node.is(Element)) |el| {
|
|
Element.Html.Custom.enqueueAdoptedCallbackOnElement(el, old_owner, new_owner, self);
|
|
}
|
|
|
|
var it = node.childrenIterator();
|
|
while (it.next()) |child| {
|
|
try self.adoptNodeTree(child, old_owner, new_owner);
|
|
}
|
|
}
|
|
|
|
pub fn dupeString(self: *Frame, value: []const u8) ![]const u8 {
|
|
if (String.intern(value)) |v| {
|
|
return v;
|
|
}
|
|
return self.arena.dupe(u8, value);
|
|
}
|
|
|
|
// Direct (non-propagating) dispatch of an event. Mirrors WorkerGlobalScope.dispatch
|
|
// so worker-compatible APIs can uniformly call `global.dispatch(...)` across both
|
|
// Frame and Worker contexts.
|
|
pub fn dispatch(
|
|
self: *Frame,
|
|
target: *EventTarget,
|
|
event: *Event,
|
|
handler: anytype,
|
|
comptime opts: EventManager.DispatchDirectOptions,
|
|
) !void {
|
|
return self._event_manager.dispatchDirect(target, event, handler, opts);
|
|
}
|
|
|
|
pub fn hasDirectListeners(self: *Frame, target: *EventTarget, typ: []const u8, handler: anytype) bool {
|
|
return self._event_manager.hasDirectListeners(target, typ, handler);
|
|
}
|
|
|
|
pub fn dupeSSO(self: *Frame, value: []const u8) !String {
|
|
return String.init(self.arena, value, .{ .dupe = true });
|
|
}
|
|
|
|
const RemoveNodeOpts = struct {
|
|
// The parent the child is going to be attached to after it is removed. null
|
|
// if it isn't AND null if the parent is cross-document. Why cross-document?
|
|
// because a remove + add-to-another-document behaves like a distinct remove
|
|
// + add. Where as a remove+add-to-the-same-document behaves more like a
|
|
// "move", e.g. it doesn't require idmap changes NOR disconnectedCallbacks.
|
|
reconnect_to: ?*Node,
|
|
// Set to false when the caller queues its own combined mutation record
|
|
notify_observers: bool = true,
|
|
};
|
|
pub fn removeNode(self: *Frame, parent: *Node, child: *Node, opts: RemoveNodeOpts) void {
|
|
// NodeIterator pre-removing steps must run while the tree is intact.
|
|
if (self._live_node_iterators.first != null) {
|
|
var it: ?*std.DoublyLinkedList.Node = self._live_node_iterators.first;
|
|
while (it) |link| : (it = link.next) {
|
|
const iterator: *DOMNodeIterator = @fieldParentPtr("_iterator_link", link);
|
|
iterator.nodeWillBeRemoved(child);
|
|
}
|
|
}
|
|
|
|
// Capture siblings before removing
|
|
const previous_sibling = child.previousSibling();
|
|
const next_sibling = child.nextSibling();
|
|
|
|
// Capture child's index before removal for live range updates (DOM spec remove steps 4-7)
|
|
const child_index_for_ranges: ?u32 = if (self._live_ranges.first != null)
|
|
parent.getChildIndex(child)
|
|
else
|
|
null;
|
|
|
|
parent.unlink(child);
|
|
|
|
const old_root = child.getRootNode(.{});
|
|
const was_connected = rootIsConnected(old_root);
|
|
const old_id_maps = idMapsForRoot(old_root);
|
|
|
|
child._parent = null;
|
|
|
|
// Update live ranges for removal (DOM spec remove steps 4-7)
|
|
if (child_index_for_ranges) |idx| {
|
|
self.updateRangesForNodeRemoval(parent, child, idx);
|
|
}
|
|
|
|
slotting.removalSteps(parent, child, self);
|
|
|
|
if (opts.notify_observers and observers.hasMutationObservers(self)) {
|
|
const removed = [_]*Node{child};
|
|
observers.notifyChildListChange(self, parent, &.{}, &removed, previous_sibling, next_sibling);
|
|
}
|
|
|
|
if (opts.reconnect_to) |dest| {
|
|
const dest_root = dest.getRootNode(.{});
|
|
if (dest_root == old_root) {
|
|
// reconnected under the same root, nothing to do (e.g. if it WAS in the
|
|
// idmap, it'll stay there)
|
|
return;
|
|
}
|
|
if (rootIsConnected(dest_root)) {
|
|
// The node is going to be reconnected to a different root which IS
|
|
// connected, we need to remove it from the old root's idmap.
|
|
if (old_id_maps) |id_maps| {
|
|
self.unregisterSubtreeIds(child, id_maps);
|
|
}
|
|
// but it isn't a REAL disconnect (more like a move), so there's
|
|
// nothing else to do, e.g. no disconnectedCallback
|
|
return;
|
|
}
|
|
// The destination is detached: this is a real disconnect.
|
|
}
|
|
|
|
if (was_connected == false) {
|
|
// the node wasn't connected, but it could still have lived in a shadow
|
|
// root's id map
|
|
if (old_id_maps) |id_maps| {
|
|
self.unregisterSubtreeIds(child, id_maps);
|
|
}
|
|
// and because it wasn't disconnected, there's nothing else to do.
|
|
return;
|
|
}
|
|
|
|
// Focus goes with the removed subtree. The focused element can be inside a
|
|
// shadow tree hanging off it, which the walk below doesn't descend into,
|
|
// so ask it directly whether it's still in the document.
|
|
if (self.document._active_element) |active| {
|
|
if (active.asNode().isConnected() == false) {
|
|
self.document._active_element = null;
|
|
}
|
|
}
|
|
|
|
// The child was connected and now it no longer is. We need to "disconnect"
|
|
// it and all of its descendants. For now "disconnect" just means updating
|
|
// the ID map and invoking disconnectedCallback for custom elements
|
|
var tw = TreeWalker.Full.Elements.init(child, .{});
|
|
while (tw.next()) |el| {
|
|
if (el.getAttributeSafe(comptime .wrap("id"))) |id| {
|
|
self.removeElementIdWithMaps(old_id_maps.?, id);
|
|
}
|
|
|
|
Element.Html.Custom.enqueueDisconnectedCallbackOnElement(el, self);
|
|
|
|
popover.removeFromOpen(el, self);
|
|
|
|
// If a <style> element is being removed, remove its sheet from the list
|
|
if (el.is(Element.Html.Style)) |style| {
|
|
if (style._sheet) |sheet| {
|
|
if (self.document._style_sheets) |sheets| {
|
|
sheets.remove(sheet);
|
|
}
|
|
style._sheet = null;
|
|
}
|
|
self._style_manager.sheetModified();
|
|
} else if (el.is(Element.Html.Link)) |link| {
|
|
// External stylesheet links registered via Frame.loadExternalStylesheet
|
|
// must be symmetrically deregistered on disconnect, or
|
|
// `document.styleSheets` accumulates phantom entries and the
|
|
// visibility cascade keeps honoring rules from removed links —
|
|
// exactly the SPA theme-switch pattern (append new sheet,
|
|
// remove old) the feature exists to serve.
|
|
if (link._sheet) |sheet| {
|
|
if (self.document._style_sheets) |sheets| {
|
|
sheets.remove(sheet);
|
|
}
|
|
link._sheet = null;
|
|
self._style_manager.sheetModified();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// The TreeWalker isn't shadow DOM aware, so this is correctly scoped to direct
|
|
// descendants (ids nested under a shadow DOM are owned by that shadow DOM).
|
|
fn unregisterSubtreeIds(self: *Frame, node: *Node, id_maps: ElementIdMaps) void {
|
|
var tw = TreeWalker.Full.Elements.init(node, .{});
|
|
while (tw.next()) |el| {
|
|
if (el.getAttributeSafe(comptime .wrap("id"))) |id| {
|
|
self.removeElementIdWithMaps(id_maps, id);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn appendNode(self: *Frame, parent: *Node, child: *Node, opts: InsertNodeOpts) !void {
|
|
return self._insertNodeRelative(false, parent, child, .append, opts);
|
|
}
|
|
|
|
pub fn appendAllChildren(self: *Frame, parent: *Node, target: *Node) !void {
|
|
return self.moveAllChildren(parent, target, null, .records);
|
|
}
|
|
|
|
pub fn insertAllChildrenBefore(self: *Frame, fragment: *Node, parent: *Node, ref_node: *Node) !void {
|
|
return self.moveAllChildren(fragment, parent, ref_node, .records);
|
|
}
|
|
|
|
pub const MoveChildrenNotify = enum { records, silent_parent };
|
|
|
|
// Moves every child of `source` into `parent` (before `ref_node`, or
|
|
// appended). Per the DOM insert algorithm for fragments, observers get one
|
|
// removal record on the source and one addition record on the parent, not
|
|
// one record per child. `.silent_parent` suppresses only the parent record
|
|
// (replaceChild queues its own combined record); the source record is queued
|
|
// regardless — the spec's insert algorithm notes it "intentionally does not
|
|
// pay attention to suppressObservers".
|
|
pub fn moveAllChildren(self: *Frame, source: *Node, parent: *Node, ref_node: ?*Node, notify_mode: MoveChildrenNotify) !void {
|
|
self.domChanged();
|
|
const notify = observers.hasMutationObservers(self);
|
|
|
|
var moved: std.ArrayList(*Node) = .empty;
|
|
const previous_sibling = if (ref_node) |ref| ref.previousSibling() else parent.lastChild();
|
|
|
|
// Every child shares source's root, and source itself doesn't move.
|
|
const previous_root = source.getRootNode(.{});
|
|
|
|
var it = source.childrenIterator();
|
|
while (it.next()) |child| {
|
|
try moved.append(self.call_arena, child);
|
|
self.removeNode(source, child, .{ .reconnect_to = parent, .notify_observers = false });
|
|
if (ref_node) |ref| {
|
|
try self.insertNodeRelative(
|
|
parent,
|
|
child,
|
|
.{ .before = ref },
|
|
.{ .previous_root = previous_root, .notify_observers = false, .run_ready = false },
|
|
);
|
|
} else {
|
|
try self.appendNode(parent, child, .{ .previous_root = previous_root, .notify_observers = false, .run_ready = false });
|
|
}
|
|
}
|
|
|
|
if (notify and moved.items.len > 0) {
|
|
observers.notifyChildListChange(self, source, &.{}, moved.items, null, null);
|
|
if (notify_mode == .records) {
|
|
observers.notifyChildListChange(self, parent, moved.items, &.{}, previous_sibling, ref_node);
|
|
}
|
|
}
|
|
|
|
// Nodes moved into a not-yet-started script run the script's
|
|
// children-changed steps first (whatwg/html#10188), then each inserted
|
|
// node's own ready work runs, in tree order, with everything in place.
|
|
// Ready work only happens on becoming connected, so a detached
|
|
// destination has none.
|
|
if (parent.isConnected()) {
|
|
if (parent.is(Element.Html.Script)) |script| {
|
|
if (!script._executed) {
|
|
try self.nodeIsReady(false, parent);
|
|
}
|
|
}
|
|
for (moved.items) |child| {
|
|
try self.nodeIsReadySubtree(child);
|
|
}
|
|
}
|
|
}
|
|
|
|
const InsertNodeRelative = union(enum) {
|
|
append,
|
|
after: *Node,
|
|
before: *Node,
|
|
};
|
|
const InsertNodeOpts = struct {
|
|
// The other half of RemoveNodeOpts.reconnect_to. This is the root (not
|
|
// parent) of the node before it was moved. This is null when the operation
|
|
// isn't part of a "move" (remove+add) but was a new/cloned child.
|
|
previous_root: ?*Node = null,
|
|
adopting_to_new_document: bool = false,
|
|
// Set to false when the caller queues its own combined mutation record
|
|
notify_observers: bool = true,
|
|
// Set to false for multi-node insertions (fragments): the caller runs
|
|
// the ready work itself once every node is in place, so an earlier
|
|
// script observes its later siblings already inserted.
|
|
run_ready: bool = true,
|
|
};
|
|
pub fn insertNodeRelative(self: *Frame, parent: *Node, child: *Node, relative: InsertNodeRelative, opts: InsertNodeOpts) !void {
|
|
return self._insertNodeRelative(false, parent, child, relative, opts);
|
|
}
|
|
pub fn _insertNodeRelative(self: *Frame, comptime from_parser: bool, parent: *Node, child: *Node, relative: InsertNodeRelative, opts: InsertNodeOpts) !void {
|
|
// caller should have made sure this was the case
|
|
|
|
lp.assert(child._parent == null, "Frame.insertNodeRelative parent", .{});
|
|
|
|
switch (relative) {
|
|
.append => parent.linkLast(child),
|
|
.after => |ref_node| {
|
|
// caller should have made sure this was the case
|
|
lp.assert(ref_node._parent.? == parent, "Frame.insertNodeRelative after", .{ .url = self.url });
|
|
parent.linkAfter(ref_node, child);
|
|
},
|
|
.before => |ref_node| {
|
|
// caller should have made sure this was the case
|
|
lp.assert(ref_node._parent.? == parent, "Frame.insertNodeRelative before", .{ .url = self.url });
|
|
parent.linkBefore(ref_node, child);
|
|
},
|
|
}
|
|
child._parent = parent;
|
|
|
|
// Update live ranges for insertion (DOM spec insert step 6).
|
|
// For .before/.after the child was inserted at a specific position;
|
|
// ranges on parent with offsets past that position must be incremented.
|
|
// For .append no range update is needed (spec: "if child is non-null").
|
|
if (self._live_ranges.first != null) {
|
|
switch (relative) {
|
|
.append => {},
|
|
.before, .after => {
|
|
if (parent.getChildIndex(child)) |idx| {
|
|
self.updateRangesForNodeInsertion(parent, idx);
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
if (self._element_shadow_roots.count() != 0) {
|
|
// html5ever wraps fragment parses in a temporary <html> element that
|
|
// gets unwrapped later; it must not take part in slot assignment.
|
|
const in_fragment_parse = from_parser and self._parse_mode == .fragment;
|
|
const is_fragment_wrapper = in_fragment_parse and child.is(Element.Html.Html) != null;
|
|
if (is_fragment_wrapper == false) {
|
|
slotting.insertionSteps(parent, child, in_fragment_parse, self);
|
|
}
|
|
}
|
|
|
|
// The parser path does its own (limited) notification and
|
|
// connected-callback work, then returns.
|
|
if (comptime from_parser) {
|
|
// Main-document parser insertions notify per node: scripts running
|
|
// during parsing can observe the document. Fragment parses
|
|
// (innerHTML et al.) stay silent; Node.setHTML queues one combined
|
|
// "replace all" record instead.
|
|
if (self._parse_mode != .fragment) {
|
|
self.notifyChildInserted(parent, child);
|
|
}
|
|
|
|
if (child.is(Element)) |el| {
|
|
// Invoke connectedCallback for custom elements during parsing.
|
|
// For main document parsing we know nodes are connected (fast path);
|
|
// for fragment parsing (innerHTML) we check connectivity.
|
|
if (child.isConnected() or child.isInShadowTree()) {
|
|
if (el.getAttributeSafe(comptime .wrap("id"))) |id| {
|
|
try self.addElementId(parent, el, id);
|
|
}
|
|
try Element.Html.Custom.enqueueConnectedCallbackOnElement(true, el, self);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
const parent_root = parent.getRootNode(.{});
|
|
const parent_is_connected = rootIsConnected(parent_root);
|
|
|
|
// Mutation records queue synchronously at insertion, before any script
|
|
// runs: an inserted script can observe its own record via takeRecords.
|
|
if (opts.notify_observers) {
|
|
self.notifyChildInserted(parent, child);
|
|
}
|
|
|
|
// Inserting into a not-yet-started script runs the script's
|
|
// children-changed steps first, then the inserted nodes' own ready work
|
|
// (whatwg/html#10188: the outer script executes before an inner one).
|
|
// Ready work only happens on becoming connected, so a detached parent
|
|
// has none.
|
|
if (opts.run_ready and parent_is_connected) {
|
|
if (parent.is(Element.Html.Script)) |script| {
|
|
if (!script._executed) {
|
|
try self.nodeIsReady(false, parent);
|
|
}
|
|
}
|
|
|
|
// nodeIsReady resolves the node's owning frame itself (only for the few node
|
|
// types that have ready work), so pass the incumbent `self`.
|
|
try self.nodeIsReadySubtree(child);
|
|
}
|
|
|
|
// nothing to register, and nothing can become connected.
|
|
const new_id_maps = idMapsForRoot(parent_root) orelse {
|
|
// The destination has no id scope, it's detached and outside of a
|
|
// shadow root. There's nothing left to do.
|
|
return;
|
|
};
|
|
|
|
if (!opts.adopting_to_new_document) {
|
|
if (opts.previous_root) |previous_root| {
|
|
if (previous_root == parent_root) {
|
|
// The child already belongs to this scope; nothing to do.
|
|
return;
|
|
}
|
|
if (rootIsConnected(previous_root)) {
|
|
// The child is being moved to a different scope, but was and
|
|
// remains connected. We need to move it (and any children)'s
|
|
// id to the new parent...
|
|
var tw = TreeWalker.Full.Elements.init(child, .{});
|
|
while (tw.next()) |el| {
|
|
if (el.getAttributeSafe(comptime .wrap("id"))) |id| {
|
|
try self.addElementIdWithMaps(new_id_maps, el, id);
|
|
}
|
|
}
|
|
// but beause it was and stays connected, we don't trigger
|
|
// a connectedCallback.
|
|
return;
|
|
}
|
|
// The child was in a detached tree (removeNode already cleaned a
|
|
// detached-host shadow map, if any): a fresh connect, below.
|
|
}
|
|
}
|
|
|
|
// The child was not connected or is being adopted. We need to move the id
|
|
// (of it and its chidlren) and if it is newly becoming connected, invoke
|
|
// connectedCallback.
|
|
const should_invoke_connected = parent_is_connected;
|
|
|
|
var tw = TreeWalker.Full.Elements.init(child, .{});
|
|
while (tw.next()) |el| {
|
|
if (el.getAttributeSafe(comptime .wrap("id"))) |id| {
|
|
try self.addElementIdWithMaps(new_id_maps, el, id);
|
|
}
|
|
|
|
if (should_invoke_connected) {
|
|
try Element.Html.Custom.enqueueConnectedCallbackOnElement(false, el, self);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn notifyChildInserted(self: *Frame, parent: *Node, child: *Node) void {
|
|
if (!observers.hasMutationObservers(self)) {
|
|
return;
|
|
}
|
|
|
|
const previous_sibling = child.previousSibling();
|
|
const next_sibling = child.nextSibling();
|
|
const added = [_]*Node{child};
|
|
observers.notifyChildListChange(self, parent, &added, &.{}, previous_sibling, next_sibling);
|
|
}
|
|
|
|
pub fn attributeChange(self: *Frame, element: *Element, name: String, value: String, old_value: ?String) void {
|
|
_ = Element.Build.call(element, "attributeChange", .{ element, name, value, self }) catch |err| {
|
|
log.err(.bug, "build.attributeChange", .{ .tag = element.getTag(), .name = name, .value = value, .err = err, .type = self._type, .url = self.url });
|
|
};
|
|
|
|
Element.Html.Custom.enqueueAttributeChangedCallbackOnElement(element, name, old_value, value, null, self);
|
|
|
|
observers.notifyAttributeChange(self, element, name, old_value);
|
|
|
|
// Handle slot assignment changes
|
|
if (name.eql(comptime .wrap("slot"))) {
|
|
const old = if (old_value) |o| o.str() else "";
|
|
slotting.slotAttributeChanged(element.asNode(), old, value.str(), self);
|
|
} else if (name.eql(comptime .wrap("name"))) {
|
|
if (element.is(Element.Html.Slot)) |slot| {
|
|
const old = if (old_value) |o| o.str() else "";
|
|
slotting.nameAttributeChanged(slot, old, value.str(), self);
|
|
}
|
|
} else if (name.eql(comptime .wrap("popover"))) {
|
|
const old = if (old_value) |o| o.str() else null;
|
|
popover.attributeChanged(element, old, value.str(), self);
|
|
}
|
|
}
|
|
|
|
pub fn attributeRemove(self: *Frame, element: *Element, name: String, old_value: String) void {
|
|
_ = Element.Build.call(element, "attributeRemove", .{ element, name, self }) catch |err| {
|
|
log.err(.bug, "build.attributeRemove", .{ .tag = element.getTag(), .name = name, .err = err, .type = self._type, .url = self.url });
|
|
};
|
|
|
|
Element.Html.Custom.enqueueAttributeChangedCallbackOnElement(element, name, old_value, null, null, self);
|
|
|
|
observers.notifyAttributeChange(self, element, name, old_value);
|
|
|
|
// Handle slot assignment changes
|
|
if (name.eql(comptime .wrap("slot"))) {
|
|
slotting.slotAttributeChanged(element.asNode(), old_value.str(), "", self);
|
|
} else if (name.eql(comptime .wrap("name"))) {
|
|
if (element.is(Element.Html.Slot)) |slot| {
|
|
slotting.nameAttributeChanged(slot, old_value.str(), "", self);
|
|
}
|
|
} else if (name.eql(comptime .wrap("popover"))) {
|
|
popover.attributeChanged(element, old_value.str(), null, self);
|
|
}
|
|
}
|
|
|
|
pub fn signalSlotChange(self: *Frame, slot: *Element.Html.Slot) void {
|
|
self._slots_pending_slotchange.put(self.arena, slot, {}) catch |err| {
|
|
log.err(.frame, "signalSlotChange.put", .{ .err = err, .type = self._type, .url = self.url });
|
|
return;
|
|
};
|
|
observers.scheduleMutationDelivery(self) catch |err| {
|
|
log.err(.frame, "signalSlotChange.schedule", .{ .err = err, .type = self._type, .url = self.url });
|
|
};
|
|
}
|
|
|
|
pub fn getCustomizedBuiltInDefinition(self: *Frame, element: *Element) ?*CustomElementDefinition {
|
|
return self._customized_builtin_definitions.get(element);
|
|
}
|
|
|
|
pub fn setCustomizedBuiltInDefinition(self: *Frame, element: *Element, definition: *CustomElementDefinition) !void {
|
|
try self._customized_builtin_definitions.put(self.arena, element, definition);
|
|
}
|
|
|
|
// --- Live range update methods (DOM spec §4.2.3, §4.2.4, §4.7, §4.8) ---
|
|
|
|
/// Update all live ranges after a replaceData mutation on a CharacterData node.
|
|
/// Per DOM spec: insertData = replaceData(offset, 0, data),
|
|
/// deleteData = replaceData(offset, count, "").
|
|
/// All parameters are in UTF-16 code unit offsets.
|
|
pub fn updateRangesForCharacterDataReplace(self: *Frame, target: *Node, offset: u32, count: u32, data_len: u32) void {
|
|
var it: ?*std.DoublyLinkedList.Node = self._live_ranges.first;
|
|
while (it) |link| : (it = link.next) {
|
|
const ar: *AbstractRange = @fieldParentPtr("_range_link", link);
|
|
ar.updateForCharacterDataReplace(target, offset, count, data_len);
|
|
}
|
|
}
|
|
|
|
/// Update all live ranges after a splitText operation.
|
|
/// Steps 7b-7e of the DOM spec splitText algorithm.
|
|
/// Steps 7d-7e complement (not overlap) updateRangesForNodeInsertion:
|
|
/// the insert update handles offsets > child_index, while 7d/7e handle
|
|
/// offsets == node_index+1 (these are equal values but with > vs == checks).
|
|
pub fn updateRangesForSplitText(self: *Frame, target: *Node, new_node: *Node, offset: u32, parent: *Node, node_index: u32) void {
|
|
var it: ?*std.DoublyLinkedList.Node = self._live_ranges.first;
|
|
while (it) |link| : (it = link.next) {
|
|
const ar: *AbstractRange = @fieldParentPtr("_range_link", link);
|
|
ar.updateForSplitText(target, new_node, offset, parent, node_index);
|
|
}
|
|
}
|
|
|
|
/// Update all live ranges after a node insertion.
|
|
/// Per DOM spec insert algorithm step 6: only applies when inserting before a
|
|
/// non-null reference node.
|
|
pub fn updateRangesForNodeInsertion(self: *Frame, parent: *Node, child_index: u32) void {
|
|
var it: ?*std.DoublyLinkedList.Node = self._live_ranges.first;
|
|
while (it) |link| : (it = link.next) {
|
|
const ar: *AbstractRange = @fieldParentPtr("_range_link", link);
|
|
ar.updateForNodeInsertion(parent, child_index);
|
|
}
|
|
}
|
|
|
|
/// Update all live ranges after a node removal.
|
|
/// Per DOM spec remove algorithm steps 4-7.
|
|
pub fn updateRangesForNodeRemoval(self: *Frame, parent: *Node, child: *Node, child_index: u32) void {
|
|
var it: ?*std.DoublyLinkedList.Node = self._live_ranges.first;
|
|
while (it) |link| : (it = link.next) {
|
|
const ar: *AbstractRange = @fieldParentPtr("_range_link", link);
|
|
ar.updateForNodeRemoval(parent, child, child_index);
|
|
}
|
|
}
|
|
|
|
// Runs the "ready" work for an inserted node and, when it's an element with
|
|
// children, for its descendants in tree order: appending a subtree
|
|
// containing scripts must execute them all, after the whole insertion.
|
|
fn nodeIsReadySubtree(self: *Frame, node: *Node) !void {
|
|
if (node._type != .element or node.firstChild() == null) {
|
|
return self.nodeIsReady(false, node);
|
|
}
|
|
|
|
// Scripts can mutate the tree. Safe to do this since nodeIsReady re-checks
|
|
// connectivity.
|
|
var elements: std.ArrayList(*Node) = .empty;
|
|
var tw = TreeWalker.Full.Elements.init(node, .{});
|
|
while (tw.next()) |el| {
|
|
try elements.append(self.call_arena, el.asNode());
|
|
}
|
|
for (elements.items) |el| {
|
|
try self.nodeIsReady(false, el);
|
|
}
|
|
}
|
|
|
|
fn nodeIsReady(self: *Frame, comptime from_parser: bool, node: *Node) !void {
|
|
if ((comptime from_parser) and self._parse_mode == .fragment) {
|
|
if (self._fragment_scripts_runnable == false) {
|
|
// We don't execute scripts added via innerHTML = '<script...'. Mark
|
|
// them "already started" so they stay inert even after the parsed
|
|
// nodes are inserted into a connected document.
|
|
if (node.is(Element.Html.Script)) |script| {
|
|
script._executed = true;
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
// A node's "ready" work (running a <script>, loading an <iframe> / <link> /
|
|
// <style>) must happen in the frame that owns the node's document — not
|
|
// necessarily `self`. When an async callback (e.g. a postMessage listener)
|
|
// running in frame A appends a node to frame B's document, `self` is the
|
|
// incumbent frame A, but the script's base URL and execution realm must come
|
|
// from B (its node document). Resolving that owner frame is a parent-chain
|
|
// walk, so we only do it once we've matched a node type that has ready work
|
|
// (the common text/element insertion does nothing here). The parser inserts
|
|
// into its own document, so from_parser always uses `self`.
|
|
// Scripts, iframes, links and styles activate on becoming connected;
|
|
// appending them to a detached parent does nothing (they run/load later
|
|
// if the subtree gets inserted into the document).
|
|
if (comptime from_parser == false) {
|
|
switch (node._type) {
|
|
.element => if (!node.isConnected()) return,
|
|
else => {},
|
|
}
|
|
}
|
|
|
|
if (node.is(Element.Html.Script)) |script| {
|
|
if ((comptime from_parser == false) and script._src.len == 0) {
|
|
// Script was added via JavaScript without a src attribute.
|
|
// Only skip if it has no inline content either — scripts with
|
|
// textContent/text should still execute per spec.
|
|
if (node.firstChild() == null) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
const frame = if (comptime from_parser) self else node.ownerFrame(self);
|
|
frame.scriptAddedCallback(from_parser, script) catch |err| {
|
|
log.err(.frame, "frame.nodeIsReady", .{ .err = err, .element = "script", .type = frame._type, .url = frame.url });
|
|
return err;
|
|
};
|
|
} else if (node.is(IFrame)) |iframe| {
|
|
const frame = if (comptime from_parser) self else node.ownerFrame(self);
|
|
frame.iframeAddedCallback(iframe) catch |err| {
|
|
log.err(.frame, "frame.nodeIsReady", .{ .err = err, .element = "iframe", .type = frame._type, .url = frame.url });
|
|
return err;
|
|
};
|
|
} else if (node.is(Element.Html.Link)) |link| {
|
|
const frame = if (comptime from_parser) self else node.ownerFrame(self);
|
|
link.linkAddedCallback(frame) catch |err| {
|
|
log.err(.frame, "frame.nodeIsReady", .{ .err = err, .element = "link", .type = frame._type });
|
|
return error.LinkLoadError;
|
|
};
|
|
} else if (node.is(Element.Html.Style)) |style| {
|
|
const frame = if (comptime from_parser) self else node.ownerFrame(self);
|
|
style.styleAddedCallback(frame) catch |err| {
|
|
log.err(.frame, "frame.nodeIsReady", .{ .err = err, .element = "style", .type = frame._type });
|
|
return error.StyleLoadError;
|
|
};
|
|
}
|
|
}
|
|
|
|
const ParseState = union(enum) {
|
|
pre,
|
|
complete,
|
|
err: anyerror,
|
|
html: struct {
|
|
arena: *lp.Arena,
|
|
buffer: std.ArrayList(u8),
|
|
},
|
|
text: std.ArrayList(u8),
|
|
image: std.ArrayList(u8),
|
|
raw: std.ArrayList(u8),
|
|
raw_done: []const u8,
|
|
download: Download,
|
|
|
|
fn deinit(self: *ParseState, _: *Frame) void {
|
|
switch (self.*) {
|
|
.html => |html| html.arena.release(),
|
|
// Only reached when a frame is torn down mid-download (the normal
|
|
// completion path in frameDoneCallback already closes the file and
|
|
// transitions to .complete).
|
|
.download => |*download| download.file.close(lp.io),
|
|
else => {},
|
|
}
|
|
}
|
|
};
|
|
|
|
// An in-flight file download (Content-Disposition: attachment under an
|
|
// allow/allowAndName Browser.setDownloadBehavior). The response body is
|
|
// streamed straight to `file` rather than parsed as a page. See issue #2701.
|
|
const Download = struct {
|
|
// uuidv4, arena-owned. Matches the guid reported in the CDP events.
|
|
guid: []const u8,
|
|
file: std.Io.File,
|
|
// suggested filename surfaced to the client, arena-owned.
|
|
filename: []const u8,
|
|
received: u64,
|
|
// from Content-Length, when the response advertised one.
|
|
total: ?u64,
|
|
};
|
|
|
|
const LoadState = enum {
|
|
// waiting for the main HTML
|
|
waiting,
|
|
|
|
// the main HTML is being parsed (or downloaded)
|
|
parsing,
|
|
|
|
// the main HTML has been parsed and the JavaScript (including deferred
|
|
// scripts) have been loaded. Corresponds to the DOMContentLoaded event
|
|
load,
|
|
|
|
// the frame has been loaded and all async scripts (if any) are done
|
|
// Corresponds to the load event
|
|
complete,
|
|
};
|
|
|
|
const IdleNotification = union(enum) {
|
|
// hasn't started yet.
|
|
init,
|
|
|
|
// timestamp where the state was first triggered. If the state stays
|
|
// true (e.g. 0 network activity for NetworkIdle, or <= 2 for NetworkAlmostIdle)
|
|
// for 500ms, it'll send the notification and transition to .done. If
|
|
// the state doesn't stay true, it'll revert to .init.
|
|
triggered: u64,
|
|
|
|
// notification sent - should never be reset
|
|
done,
|
|
|
|
// Returns `true` if we should send a notification. Only returns true if it
|
|
// was previously triggered 500+ milliseconds ago.
|
|
// active == true when the condition for the notification is true
|
|
// active == false when the condition for the notification is false
|
|
pub fn check(self: *IdleNotification, active: bool) bool {
|
|
if (active) {
|
|
switch (self.*) {
|
|
.done => {
|
|
// Notification was already sent.
|
|
},
|
|
.init => {
|
|
// This is the first time the condition was triggered (or
|
|
// the first time after being un-triggered). Record the time
|
|
// so that if the condition holds for long enough, we can
|
|
// send a notification.
|
|
self.* = .{ .triggered = lp.datetime.milliTimestamp(.boot) };
|
|
},
|
|
.triggered => |ms| {
|
|
// The condition was already triggered and was triggered
|
|
// again. When this condition holds for 500+ms, we'll send
|
|
// a notification.
|
|
if (lp.datetime.milliTimestamp(.boot) - ms >= 500) {
|
|
// This is the only place in this function where we can
|
|
// return true. The only place where we can tell our caller
|
|
// "send the notification!".
|
|
self.* = .done;
|
|
return true;
|
|
}
|
|
// the state hasn't held for 500ms.
|
|
},
|
|
}
|
|
} else {
|
|
switch (self.*) {
|
|
.done => {
|
|
// The condition became false, but we already sent the notification
|
|
// There's nothing we can do, it stays .done. We never re-send
|
|
// a notification or "undo" a sent notification (not that we can).
|
|
},
|
|
.init => {
|
|
// The condition remains false
|
|
},
|
|
.triggered => {
|
|
// The condition _had_ been true, and we were waiting (500ms)
|
|
// for it to hold, but it hasn't. So we go back to waiting.
|
|
self.* = .init;
|
|
},
|
|
}
|
|
}
|
|
|
|
// See above for the only case where we ever return true. All other
|
|
// paths go here. This means "don't send the notification". Maybe
|
|
// because it's already been sent, maybe because active is false, or
|
|
// maybe because the condition hasn't held long enough.
|
|
return false;
|
|
}
|
|
};
|
|
|
|
pub const NavigateReason = enum {
|
|
anchor,
|
|
address_bar,
|
|
form,
|
|
script,
|
|
history,
|
|
navigation,
|
|
initialFrameNavigation,
|
|
};
|
|
|
|
pub const NavigateOpts = struct {
|
|
cdp_id: ?i64 = null,
|
|
reason: NavigateReason = .address_bar,
|
|
method: HttpClient.Method = .GET,
|
|
body: ?[]const u8 = null,
|
|
header: ?[:0]const u8 = null,
|
|
// Set by scheduleNavigationWithArena from the originating frame's URL so
|
|
// anchor click / form submit / location.href navigations carry a Referer.
|
|
// null on CDP Page.navigate (address-bar) and Page.reload — matches Chrome.
|
|
referer: ?[]const u8 = null,
|
|
// The originating frame's policy, paired with `referer` so redirect hops
|
|
// can recompute the header. null (e.g. a CDP-supplied referrer) leaves
|
|
// the Referer untouched across redirects.
|
|
referrer_policy: ?referrer.Policy = null,
|
|
// The URL of the document that initiated this navigation, used as the
|
|
// "site for cookies" when computing SameSite. Distinct from `referer`
|
|
// because a Referrer-Policy can suppress the Referer header without
|
|
// affecting SameSite (which always considers the real initiator).
|
|
initiator_url: ?[:0]const u8 = null,
|
|
initiator_origin: ?[]const u8 = null,
|
|
force: bool = false,
|
|
kind: NavigationKind = .{ .push = null },
|
|
};
|
|
|
|
pub const NavigatedOpts = struct {
|
|
cdp_id: ?i64 = null,
|
|
reason: NavigateReason = .address_bar,
|
|
method: HttpClient.Method = .GET,
|
|
// Retained on the frame's arena so Page.reload can replay the prior
|
|
// navigation's HTTP method — matches Chrome's F5 behavior on POST pages.
|
|
body: ?[]const u8 = null,
|
|
header: ?[:0]const u8 = null,
|
|
};
|
|
|
|
const NavigationType = enum {
|
|
form,
|
|
script,
|
|
anchor,
|
|
iframe,
|
|
};
|
|
|
|
const Navigation = union(NavigationType) {
|
|
form: *Frame,
|
|
script: ?*Frame,
|
|
anchor: *Frame,
|
|
iframe: *IFrame,
|
|
};
|
|
|
|
pub const QueuedNavigation = struct {
|
|
arena: *lp.Arena,
|
|
url: [:0]const u8,
|
|
opts: NavigateOpts,
|
|
is_about_something: bool, // about:blank or about:srcdoc
|
|
navigation_type: NavigationType,
|
|
};
|
|
|
|
/// Resolves a target attribute value (e.g., "_self", "_parent", "_top", or frame name)
|
|
/// to the appropriateFrame to navigate.
|
|
/// Returns null if the target is "_blank" (which would open a new window/tab).
|
|
/// Note: Callers should handle empty target separately (for owner document resolution).
|
|
pub fn resolveTargetFrame(self: *Frame, target_name: []const u8) ?*Frame {
|
|
if (std.ascii.eqlIgnoreCase(target_name, "_self")) {
|
|
return self;
|
|
}
|
|
|
|
if (std.ascii.eqlIgnoreCase(target_name, "_blank")) {
|
|
return null;
|
|
}
|
|
|
|
if (std.ascii.eqlIgnoreCase(target_name, "_parent")) {
|
|
return self.parent orelse self;
|
|
}
|
|
|
|
if (std.ascii.eqlIgnoreCase(target_name, "_top")) {
|
|
var frame = self;
|
|
while (frame.parent) |f| {
|
|
frame = f;
|
|
}
|
|
return frame;
|
|
}
|
|
|
|
// Named frame lookup: search current frame's descendants first, then from root
|
|
// This follows the HTML spec's "implementation-defined" search order.
|
|
if (findFrameByName(self, target_name)) |f| {
|
|
return f;
|
|
}
|
|
|
|
// If not found in descendants, search from root (catches siblings and ancestors' descendants)
|
|
var root = self;
|
|
while (root.parent) |f| {
|
|
root = f;
|
|
}
|
|
if (root != self) {
|
|
if (findFrameByName(root, target_name)) |f| {
|
|
return f;
|
|
}
|
|
}
|
|
|
|
// If no frame found with that name, navigate in current frame
|
|
// (this matches browser behavior - unknown targets act like _self)
|
|
return self;
|
|
}
|
|
|
|
fn findFrameByName(frame: *Frame, name: []const u8) ?*Frame {
|
|
for (frame.child_frames.items) |f| {
|
|
if (f.iframe) |iframe| {
|
|
const frame_name = iframe.asElement().getAttributeSafe(comptime .wrap("name")) orelse "";
|
|
if (std.mem.eql(u8, frame_name, name)) {
|
|
return f;
|
|
}
|
|
}
|
|
// Recursively search child frames
|
|
if (findFrameByName(f, name)) |found| {
|
|
return found;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const SubmitFormOpts = struct {
|
|
fire_event: bool = true,
|
|
};
|
|
pub fn submitForm(self: *Frame, submitter_: ?*Element, form_: ?*Element.Html.Form, submit_opts: SubmitFormOpts) !void {
|
|
const form = form_ orelse return;
|
|
|
|
if (submit_opts.fire_event and form.asNode().isConnected() == false) {
|
|
// interactive submission (e.g. submit button) noop if the form is disconnected
|
|
return;
|
|
}
|
|
|
|
// see the `_constructing_entry_list` field documentation
|
|
if (form._constructing_entry_list) {
|
|
return;
|
|
}
|
|
|
|
if (submitter_) |submitter| {
|
|
if (submitter.getAttributeSafe(comptime .wrap("disabled")) != null) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (self.canScheduleNavigation(.form) == false) {
|
|
return;
|
|
}
|
|
|
|
const form_element = form.asElement();
|
|
|
|
const submit_button: ?*Element = blk: {
|
|
const s = submitter_ orelse break :blk null;
|
|
break :blk if (Element.Html.Form.isSubmitButton(s)) s else null;
|
|
};
|
|
|
|
const target_name_: ?[]const u8 = blk: {
|
|
if (submit_button) |s| {
|
|
if (s.getAttributeSafe(comptime .wrap("formtarget"))) |ft| {
|
|
break :blk ft;
|
|
}
|
|
}
|
|
break :blk form_element.getAttributeSafe(comptime .wrap("target"));
|
|
};
|
|
|
|
const target_frame = blk: {
|
|
const target_name = target_name_ orelse {
|
|
break :blk form_element.ownerFrame(self);
|
|
};
|
|
break :blk self.resolveTargetFrame(target_name) orelse {
|
|
log.warn(.not_implemented, "target", .{ .type = self._type, .url = self.url, .target = target_name });
|
|
return;
|
|
};
|
|
};
|
|
|
|
if (submit_opts.fire_event) {
|
|
// Prevent a submit on the form from firing while we're submit the form.
|
|
// This is both spec-correct AND prevents infinite recursion.
|
|
if (form._firing_submission_events) {
|
|
return;
|
|
}
|
|
form._firing_submission_events = true;
|
|
defer form._firing_submission_events = false;
|
|
|
|
// Per the HTML "submit a form element" algorithm: unless the form (or the
|
|
// submitter, via formnovalidate) is in the no-validate state, interactively
|
|
// validate the form's constraints and abort submission if it fails.
|
|
// checkValidity() fires the `invalid` events on the offending controls.
|
|
// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#concept-form-submit
|
|
const skip_validation = form.getNoValidate() or blk: {
|
|
const s = submit_button orelse break :blk false;
|
|
if (s.is(Element.Html.Form.Input)) |input| break :blk input.getFormNoValidate();
|
|
if (s.is(Element.Html.Form.Button)) |button| break :blk button.getFormNoValidate();
|
|
break :blk false;
|
|
};
|
|
if (!skip_validation and !try form.checkValidity(self)) {
|
|
return;
|
|
}
|
|
|
|
// Per HTML spec "submit a form element" algorithm: SubmitEvent.submitter
|
|
// must be null when the submitter is the form itself, which is what
|
|
// Form.requestSubmit() passes when called with no submitter argument.
|
|
// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#concept-form-submit
|
|
const submitter_html: ?*HtmlElement = blk: {
|
|
const s = submitter_ orelse break :blk null;
|
|
if (s == form_element) break :blk null;
|
|
break :blk s.is(HtmlElement);
|
|
};
|
|
const submit_event = (try SubmitEvent.initTrusted(comptime .wrap("submit"), .{ .bubbles = true, .cancelable = true, .submitter = submitter_html }, self)).asEvent();
|
|
|
|
// so submit_event is still valid when we check _prevent_default
|
|
submit_event.acquireRef();
|
|
defer _ = submit_event.releaseRef(self._page);
|
|
|
|
try self._event_manager.dispatch(form_element.asEventTarget(), submit_event);
|
|
// If the submit event was prevented, don't submit the form
|
|
if (submit_event._prevent_default) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
const FormData = @import("webapi/net/FormData.zig");
|
|
|
|
// The submitter can be an input box (if enter was entered on the box)
|
|
// I don't think this is technically correct, but FormData handles it ok
|
|
const form_data = try FormData.init(form, submitter_, &self.js.execution);
|
|
form_data.acquireRef();
|
|
defer form_data.releaseRef(self._page);
|
|
|
|
// Per HTML spec form-submission algorithm, when the submitter is a submit
|
|
// button, its formaction/formmethod/formenctype attributes override the
|
|
// form's corresponding attributes (matching how formtarget is honored above).
|
|
// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#concept-form-submit
|
|
const enctype_attr = blk: {
|
|
if (submit_button) |s| {
|
|
if (s.getAttributeSafe(comptime .wrap("formenctype"))) |fe| break :blk fe;
|
|
}
|
|
break :blk form_element.getAttributeSafe(comptime .wrap("enctype"));
|
|
};
|
|
const method_attr: ?[]const u8 = blk: {
|
|
if (submit_button) |s| {
|
|
if (s.getAttributeSafe(comptime .wrap("formmethod"))) |fm| break :blk fm;
|
|
}
|
|
break :blk form_element.getAttributeSafe(comptime .wrap("method"));
|
|
};
|
|
const method = Element.Html.Form.normalizeMethod(method_attr, "get");
|
|
|
|
// Per the HTML form-submission algorithm, the dialog method closes the
|
|
// form's nearest ancestor dialog with the submitter's value and performs no
|
|
// navigation. Falling through here would submit the form as a GET, which
|
|
// both reloads the page and leaves the dialog open forever.
|
|
// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#form-submission-algorithm
|
|
if (std.mem.eql(u8, method, "dialog")) {
|
|
// A submit button always has a value (empty when unset); with no
|
|
// submitter at all the dialog's existing returnValue is left untouched.
|
|
const result: ?[]const u8 = if (submit_button) |s| blk: {
|
|
if (s.is(Element.Html.Form.Input)) |input| {
|
|
break :blk input.getValue();
|
|
}
|
|
// if not an Input, it has to be a Button (checked above by isSubmitButton)
|
|
break :blk s.as(Element.Html.Form.Button).getValue();
|
|
} else null;
|
|
|
|
var ancestor: ?*Element = form_element;
|
|
while (ancestor) |el| : (ancestor = el.parentElement()) {
|
|
const dialog = el.is(Element.Html.Dialog) orelse continue;
|
|
try dialog.close(result, self);
|
|
break;
|
|
}
|
|
return;
|
|
}
|
|
|
|
const is_post = std.mem.eql(u8, method, "post");
|
|
|
|
const arena = try self._session.getArena(.medium, "submitForm");
|
|
errdefer arena.release();
|
|
|
|
// Get charset from accept-charset attribute or fall back to document charset
|
|
const charset: []const u8 = blk: {
|
|
if (form_element.getAttributeSafe(.wrap("accept-charset"))) |ac| {
|
|
// Normalize to canonical encoding name
|
|
const info = h5e.encoding_for_label(ac.ptr, ac.len);
|
|
if (info.isValid()) {
|
|
break :blk info.name();
|
|
}
|
|
}
|
|
break :blk self.charset;
|
|
};
|
|
|
|
var boundary_buf: [36]u8 = undefined;
|
|
// GET ignores enctype per HTML spec; only resolve the union for POST.
|
|
const encoding: FormData.EncType = blk: {
|
|
if (!is_post) break :blk .urlencode;
|
|
const canonical = Element.Html.Form.normalizeEnctype(enctype_attr, "application/x-www-form-urlencoded");
|
|
if (std.mem.eql(u8, canonical, "multipart/form-data")) {
|
|
@import("../id.zig").uuidv4(&boundary_buf);
|
|
break :blk .{ .formdata = &boundary_buf };
|
|
}
|
|
if (std.mem.eql(u8, canonical, "text/plain")) {
|
|
break :blk .plaintext;
|
|
}
|
|
break :blk .urlencode;
|
|
};
|
|
|
|
var buf = std.Io.Writer.Allocating.init(arena.allocator());
|
|
try form_data.write(.{ .encoding = encoding, .charset = charset, .allocator = arena.allocator() }, &buf.writer);
|
|
|
|
var action = blk: {
|
|
if (submit_button) |s| {
|
|
if (s.getAttributeSafe(comptime .wrap("formaction"))) |fa| break :blk fa;
|
|
}
|
|
break :blk form_element.getAttributeSafe(comptime .wrap("action")) orelse self.url;
|
|
};
|
|
|
|
var opts = NavigateOpts{
|
|
.reason = .form,
|
|
.kind = .{ .push = null },
|
|
};
|
|
if (is_post) {
|
|
opts.method = .POST;
|
|
opts.body = buf.written();
|
|
opts.header = switch (encoding) {
|
|
.urlencode => "Content-Type: application/x-www-form-urlencoded",
|
|
.formdata => |b| try std.fmt.allocPrintSentinel(arena.allocator(), "Content-Type: multipart/form-data; boundary={s}", .{b}, 0),
|
|
// Per WHATWG HTML §4.10.21.6, text/plain submissions include the form's
|
|
// resolved encoding (accept-charset or document charset).
|
|
.plaintext => try std.fmt.allocPrintSentinel(arena.allocator(), "Content-Type: text/plain; charset={s}", .{charset}, 0),
|
|
};
|
|
} else {
|
|
action = try URL.concatQueryString(arena.allocator(), action, buf.written());
|
|
}
|
|
|
|
return self.scheduleNavigationWithArena(arena, action, opts, .{ .form = target_frame });
|
|
}
|
|
|
|
const testing = @import("../testing.zig");
|
|
|
|
fn dispositionHeader(value: []const u8) HttpClient.Header {
|
|
return .{ .name = "content-disposition", .value = value };
|
|
}
|
|
|
|
test "Frame: dispositionFilename" {
|
|
try testing.expectEqualSlices(u8, "report.csv", dispositionFilename(dispositionHeader("attachment; filename=\"report.csv\"")).?);
|
|
try testing.expectEqualSlices(u8, "report.csv", dispositionFilename(dispositionHeader("attachment; filename=report.csv")).?);
|
|
try testing.expectEqualSlices(u8, "r e.csv", dispositionFilename(dispositionHeader("attachment; filename=\"r e.csv\"")).?);
|
|
// RFC 5987 extended form is preferred over the plain filename when present
|
|
// (the value is taken verbatim after the charset'lang' prefix).
|
|
try testing.expectEqualSlices(u8, "extended.txt", dispositionFilename(dispositionHeader("attachment; filename=\"fallback.txt\"; filename*=UTF-8''extended.txt")).?);
|
|
try testing.expect(dispositionFilename(dispositionHeader("attachment")) == null);
|
|
// Path components are stripped to guard against traversal.
|
|
try testing.expectEqualSlices(u8, "evil.sh", dispositionFilename(dispositionHeader("attachment; filename=\"../../evil.sh\"")).?);
|
|
try testing.expectEqualSlices(u8, "evil.sh", dispositionFilename(dispositionHeader("attachment; filename=\"..\\..\\evil.sh\"")).?);
|
|
try testing.expect(dispositionFilename(dispositionHeader("attachment; filename=\"..\"")) == null);
|
|
}
|
|
|
|
test "Frame: urlBasename" {
|
|
var arena = std.heap.ArenaAllocator.init(testing.allocator);
|
|
defer arena.deinit();
|
|
const a = arena.allocator();
|
|
|
|
try testing.expectEqualSlices(u8, "report.csv", (try urlBasename(a, "http://x.com/a/b/report.csv")).?);
|
|
try testing.expectEqualSlices(u8, "report.csv", (try urlBasename(a, "http://x.com/report.csv?v=1#x")).?);
|
|
try testing.expect((try urlBasename(a, "http://x.com/")) == null);
|
|
}
|
|
|
|
test "WebApi: Frame" {
|
|
testing.silenceLog(&.{.http});
|
|
try testing.htmlRunner("page", .{});
|
|
}
|
|
|
|
test "WebApi: Frames" {
|
|
try testing.htmlRunner("frames", .{});
|
|
}
|
|
|
|
test "WebApi: Frame Blob" {
|
|
testing.silenceLog(&.{ .frame, .browser, .js });
|
|
try testing.htmlRunner("frames/blob", .{});
|
|
}
|
|
|
|
test "WebApi: Integration" {
|
|
try testing.htmlRunner("integration", .{});
|
|
}
|
|
|
|
test "WebApi: inject_script" {
|
|
try testing.htmlRunner("inject_script.html", .{
|
|
.inject_script = "window.__injected = true; window.__injectValue = 42;",
|
|
});
|
|
}
|
|
|
|
test "Page: isSameOrigin" {
|
|
var arena = std.heap.ArenaAllocator.init(testing.allocator);
|
|
defer arena.deinit();
|
|
const allocator = arena.allocator();
|
|
|
|
var frame: Frame = undefined;
|
|
|
|
frame.origin = null;
|
|
try testing.expectEqual(false, frame.isSameOrigin("https://origin.com/"));
|
|
|
|
frame.origin = try URL.getOrigin(allocator, "https://origin.com/foo/bar") orelse unreachable;
|
|
try testing.expectEqual(true, frame.isSameOrigin("https://origin.com/foo/bar")); // exact same
|
|
try testing.expectEqual(true, frame.isSameOrigin("https://origin.com/bar/bar")); // path differ
|
|
try testing.expectEqual(true, frame.isSameOrigin("https://origin.com/")); // path differ
|
|
try testing.expectEqual(true, frame.isSameOrigin("https://origin.com")); // no path
|
|
try testing.expectEqual(true, frame.isSameOrigin("https://origin.com/foo?q=1"));
|
|
try testing.expectEqual(true, frame.isSameOrigin("https://origin.com/foo#hash"));
|
|
try testing.expectEqual(true, frame.isSameOrigin("https://origin.com/foo?q=1#hash"));
|
|
// FIXME try testing.expectEqual(true, frame.isSameOrigin("https://foo:bar@origin.com"));
|
|
// FIXME try testing.expectEqual(true, frame.isSameOrigin("https://origin.com:443/foo"));
|
|
|
|
try testing.expectEqual(false, frame.isSameOrigin("http://origin.com/")); // another proto
|
|
try testing.expectEqual(false, frame.isSameOrigin("https://origin.com:123/")); // another port
|
|
try testing.expectEqual(false, frame.isSameOrigin("https://sub.origin.com/")); // another subdomain
|
|
try testing.expectEqual(false, frame.isSameOrigin("https://target.com/")); // different domain
|
|
try testing.expectEqual(false, frame.isSameOrigin("https://origin.com.target.com/")); // different domain
|
|
try testing.expectEqual(false, frame.isSameOrigin("https://target.com/@origin.com"));
|
|
|
|
frame.origin = try URL.getOrigin(allocator, "https://origin.com:8443/foo") orelse unreachable;
|
|
try testing.expectEqual(true, frame.isSameOrigin("https://origin.com:8443/bar"));
|
|
try testing.expectEqual(false, frame.isSameOrigin("https://origin.com/bar")); // missing port
|
|
try testing.expectEqual(false, frame.isSameOrigin("https://origin.com:9999/bar")); // wrong port
|
|
|
|
try testing.expectEqual(false, frame.isSameOrigin(""));
|
|
try testing.expectEqual(false, frame.isSameOrigin("not-a-url"));
|
|
try testing.expectEqual(false, frame.isSameOrigin("//origin.com/foo"));
|
|
}
|
|
|
|
test "Frame: httpMetadata after navigation" {
|
|
testing.expectLog(&.{.http});
|
|
|
|
const page = try testing.pageTest("page/meta.html", .{});
|
|
defer page.close();
|
|
|
|
const meta = page.frame().?.httpMetadata();
|
|
try testing.expect(meta.status != null);
|
|
try std.testing.expectEqual(@as(u16, 200), meta.status.?);
|
|
try testing.expect(meta.headers.len > 0);
|
|
try testing.expect(meta.url.len > 0);
|
|
}
|
|
|
|
test "Frame: httpMetadata 404" {
|
|
const page = try testing.pageTest("nonexistent_page_xyz.html", .{});
|
|
defer page.close();
|
|
|
|
const meta = page.frame().?.httpMetadata();
|
|
try testing.expect(meta.status != null);
|
|
try testing.expectEqual(404, meta.status.?);
|
|
}
|
|
|
|
test "Frame: 401" {
|
|
var page = try testing.pageTest("401", .{});
|
|
defer page.close();
|
|
|
|
const frame = page.frame().?;
|
|
|
|
var buf = std.Io.Writer.Allocating.init(testing.allocator);
|
|
defer buf.deinit();
|
|
try @import("dump.zig").root(frame.document, .{}, &buf.writer, frame);
|
|
try testing.expectEqual("<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head><body><pre>No</pre></body></html>", buf.written());
|
|
}
|