mirror of
https://github.com/lightpanda-io/browser.git
synced 2025-10-29 07:03:29 +00:00
- Add 2 internal notifications 1 - http_request_start 2 - http_request_complete - When Network.enable CDP message is received, browser context registers for these 2 events (when Network.disable is called, it unregisters) - On http_request_start, CDP will emit a Network.requestWillBeSent message. This _does not_ include all the fields, but what we have appears to be enough for puppeteer.waitForNetworkIdle. - On http_request_complete, CDP will emit a Network.responseReceived message. This _does not_ include all the fields, bu what we have appears to be enough for puppeteer.waitForNetworkIdle. We currently don't emit any other new events, including any network-specific lifecycleEvent (i.e. Chrome will emit an networkIdle and networkAlmostIdle). To support this, the following other things were done: - CDP now has a `notification_arena` which is re-used between browser contexts. Normally, CDP code runs based on a "cmd" which has its own message_arena, but these notifications happen out-of-band, so we needed a new arena which is valid for handling 1 notification. - HTTP Client is notification-aware. The SessionState no longer includes the *http.Client directly. It instead includes an http.RequestFactory which is the combination fo the client + a specific configuration (i.e. *Notification). This ensures that all requests made from that factory have the same settings. - However, despite the above, _some_ requests do not appear to emit CDP events, such as loading a <script src="X">. So the page still deals directly with the *http.Client. - Playwright and Puppeteer (but Playwright in particular) are very sensitive to event ordering. These new events have introduced additional sensitivity. The result sent to Page.navigate had to be moved to inside the navigate event handler, which meant passing some cdp-specific data (the input.id) into the NavigateOpts. This is the only way I found to keep both happy - the sequence of events is closer (but still pretty far) from what Chrome does.
389 lines
14 KiB
Zig
389 lines
14 KiB
Zig
// Copyright (C) 2023-2024 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 URL = @import("../../url.zig").URL;
|
|
const Page = @import("../../browser/page.zig").Page;
|
|
const Notification = @import("../../notification.zig").Notification;
|
|
|
|
const Allocator = std.mem.Allocator;
|
|
|
|
pub fn processMessage(cmd: anytype) !void {
|
|
const action = std.meta.stringToEnum(enum {
|
|
enable,
|
|
getFrameTree,
|
|
setLifecycleEventsEnabled,
|
|
addScriptToEvaluateOnNewDocument,
|
|
createIsolatedWorld,
|
|
navigate,
|
|
}, cmd.input.action) orelse return error.UnknownMethod;
|
|
|
|
switch (action) {
|
|
.enable => return cmd.sendResult(null, .{}),
|
|
.getFrameTree => return getFrameTree(cmd),
|
|
.setLifecycleEventsEnabled => return setLifecycleEventsEnabled(cmd),
|
|
.addScriptToEvaluateOnNewDocument => return addScriptToEvaluateOnNewDocument(cmd),
|
|
.createIsolatedWorld => return createIsolatedWorld(cmd),
|
|
.navigate => return navigate(cmd),
|
|
}
|
|
}
|
|
|
|
const Frame = struct {
|
|
id: []const u8,
|
|
loaderId: []const u8,
|
|
url: []const u8,
|
|
domainAndRegistry: []const u8 = "",
|
|
securityOrigin: []const u8,
|
|
mimeType: []const u8 = "text/html",
|
|
adFrameStatus: struct {
|
|
adFrameType: []const u8 = "none",
|
|
} = .{},
|
|
secureContextType: []const u8,
|
|
crossOriginIsolatedContextType: []const u8 = "NotIsolated",
|
|
gatedAPIFeatures: [][]const u8 = &[0][]const u8{},
|
|
};
|
|
|
|
fn getFrameTree(cmd: anytype) !void {
|
|
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
|
|
const target_id = bc.target_id orelse return error.TargetNotLoaded;
|
|
|
|
return cmd.sendResult(.{
|
|
.frameTree = .{
|
|
.frame = Frame{
|
|
.id = target_id,
|
|
.loaderId = bc.loader_id,
|
|
.securityOrigin = bc.security_origin,
|
|
.url = bc.getURL() orelse "about:blank",
|
|
.secureContextType = bc.secure_context_type,
|
|
},
|
|
},
|
|
}, .{});
|
|
}
|
|
|
|
fn setLifecycleEventsEnabled(cmd: anytype) !void {
|
|
// const params = (try cmd.params(struct {
|
|
// enabled: bool,
|
|
// })) orelse return error.InvalidParams;
|
|
|
|
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
|
|
bc.page_life_cycle_events = true;
|
|
return cmd.sendResult(null, .{});
|
|
}
|
|
|
|
// TODO: hard coded method
|
|
// With the command we receive a script we need to store and run for each new document.
|
|
// Note that the worldName refers to the name given to the isolated world.
|
|
fn addScriptToEvaluateOnNewDocument(cmd: anytype) !void {
|
|
// const params = (try cmd.params(struct {
|
|
// source: []const u8,
|
|
// worldName: ?[]const u8 = null,
|
|
// includeCommandLineAPI: bool = false,
|
|
// runImmediately: bool = false,
|
|
// })) orelse return error.InvalidParams;
|
|
|
|
return cmd.sendResult(.{
|
|
.identifier = "1",
|
|
}, .{});
|
|
}
|
|
|
|
fn createIsolatedWorld(cmd: anytype) !void {
|
|
const params = (try cmd.params(struct {
|
|
frameId: []const u8,
|
|
worldName: []const u8,
|
|
grantUniveralAccess: bool,
|
|
})) orelse return error.InvalidParams;
|
|
if (!params.grantUniveralAccess) {
|
|
std.debug.print("grantUniveralAccess == false is not yet implemented", .{});
|
|
// When grantUniveralAccess == false and the client attempts to resolve
|
|
// or otherwise access a DOM or other JS Object from another context that should fail.
|
|
}
|
|
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
|
|
|
|
const world = try bc.createIsolatedWorld(params.worldName, params.grantUniveralAccess);
|
|
const page = bc.session.currentPage() orelse return error.PageNotLoaded;
|
|
try pageCreated(bc, page);
|
|
const scope = &world.executor.scope.?;
|
|
|
|
// Create the auxdata json for the contextCreated event
|
|
// Calling contextCreated will assign a Id to the context and send the contextCreated event
|
|
const aux_data = try std.fmt.allocPrint(cmd.arena, "{{\"isDefault\":false,\"type\":\"isolated\",\"frameId\":\"{s}\"}}", .{params.frameId});
|
|
bc.inspector.contextCreated(scope, world.name, "", aux_data, false);
|
|
|
|
return cmd.sendResult(.{ .executionContextId = scope.context.debugContextId() }, .{});
|
|
}
|
|
|
|
fn navigate(cmd: anytype) !void {
|
|
const params = (try cmd.params(struct {
|
|
url: []const u8,
|
|
// referrer: ?[]const u8 = null,
|
|
// transitionType: ?[]const u8 = null, // TODO: enum
|
|
// frameId: ?[]const u8 = null,
|
|
// referrerPolicy: ?[]const u8 = null, // TODO: enum
|
|
})) orelse return error.InvalidParams;
|
|
|
|
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
|
|
|
|
// didn't create?
|
|
// const target_id = bc.target_id orelse return error.TargetIdNotLoaded;
|
|
|
|
// didn't attach?
|
|
if (bc.session_id == null) {
|
|
return error.SessionIdNotLoaded;
|
|
}
|
|
|
|
const url = try URL.parse(params.url, "https");
|
|
|
|
var page = bc.session.currentPage() orelse return error.PageNotLoaded;
|
|
bc.loader_id = bc.cdp.loader_id_gen.next();
|
|
|
|
try page.navigate(url, .{
|
|
.reason = .address_bar,
|
|
.cdp_id = cmd.input.id,
|
|
});
|
|
}
|
|
|
|
pub fn pageNavigate(arena: Allocator, bc: anytype, event: *const Notification.PageNavigate) !void {
|
|
// I don't think it's possible that we get these notifications and don't
|
|
// have these things setup.
|
|
std.debug.assert(bc.session.page != null);
|
|
|
|
var cdp = bc.cdp;
|
|
const loader_id = bc.loader_id;
|
|
const target_id = bc.target_id orelse unreachable;
|
|
const session_id = bc.session_id orelse unreachable;
|
|
|
|
bc.reset();
|
|
|
|
const is_anchor = event.opts.reason == .anchor;
|
|
if (is_anchor) {
|
|
try cdp.sendEvent("Page.frameScheduledNavigation", .{
|
|
.frameId = target_id,
|
|
.delay = 0,
|
|
.reason = "anchorClick",
|
|
.url = event.url.raw,
|
|
}, .{ .session_id = session_id });
|
|
|
|
try cdp.sendEvent("Page.frameRequestedNavigation", .{
|
|
.frameId = target_id,
|
|
.reason = "anchorClick",
|
|
.url = event.url.raw,
|
|
.disposition = "currentTab",
|
|
}, .{ .session_id = session_id });
|
|
}
|
|
|
|
// frameStartedNavigating event
|
|
try cdp.sendEvent("Page.frameStartedNavigating", .{
|
|
.frameId = target_id,
|
|
.url = event.url.raw,
|
|
.loaderId = loader_id,
|
|
.navigationType = "differentDocument",
|
|
}, .{ .session_id = session_id });
|
|
|
|
// frameStartedLoading event
|
|
try cdp.sendEvent("Page.frameStartedLoading", .{
|
|
.frameId = target_id,
|
|
}, .{ .session_id = session_id });
|
|
|
|
// Drivers are sensitive to the order of events. Some more than others.
|
|
// The result for the Page.navigate seems like it _must_ come after
|
|
// the frameStartedLoading, but before any lifecycleEvent. So we
|
|
// unfortunately have to put the input_id ito the NavigateOpts which gets
|
|
// passed back into the notification.
|
|
if (event.opts.cdp_id) |input_id| {
|
|
try cdp.sendJSON(.{
|
|
.id = input_id,
|
|
.result = .{
|
|
.frameId = target_id,
|
|
.loaderId = loader_id,
|
|
},
|
|
.sessionId = session_id,
|
|
});
|
|
}
|
|
|
|
if (bc.page_life_cycle_events) {
|
|
try cdp.sendEvent("Page.lifecycleEvent", LifecycleEvent{
|
|
.name = "init",
|
|
.frameId = target_id,
|
|
.loaderId = loader_id,
|
|
.timestamp = event.timestamp,
|
|
}, .{ .session_id = session_id });
|
|
}
|
|
|
|
if (is_anchor) {
|
|
try cdp.sendEvent("Page.frameClearedScheduledNavigation", .{
|
|
.frameId = target_id,
|
|
}, .{ .session_id = session_id });
|
|
}
|
|
|
|
// When we actually recreated the context we should have the inspector send this event, see: resetContextGroup
|
|
// Sending this event will tell the client that the context ids they had are invalid and the context shouls be dropped
|
|
// The client will expect us to send new contextCreated events, such that the client has new id's for the active contexts.
|
|
try cdp.sendEvent("Runtime.executionContextsCleared", null, .{ .session_id = session_id });
|
|
|
|
{
|
|
const page = bc.session.currentPage() orelse return error.PageNotLoaded;
|
|
const aux_data = try std.fmt.allocPrint(arena, "{{\"isDefault\":true,\"type\":\"default\",\"frameId\":\"{s}\"}}", .{target_id});
|
|
bc.inspector.contextCreated(
|
|
page.scope,
|
|
"",
|
|
try page.origin(arena),
|
|
aux_data,
|
|
true,
|
|
);
|
|
}
|
|
if (bc.isolated_world) |*isolated_world| {
|
|
const aux_json = try std.fmt.allocPrint(arena, "{{\"isDefault\":false,\"type\":\"isolated\",\"frameId\":\"{s}\"}}", .{target_id});
|
|
// Calling contextCreated will assign a new Id to the context and send the contextCreated event
|
|
bc.inspector.contextCreated(
|
|
&isolated_world.executor.scope.?,
|
|
isolated_world.name,
|
|
"://",
|
|
aux_json,
|
|
false,
|
|
);
|
|
}
|
|
}
|
|
|
|
pub fn pageRemove(bc: anytype) !void {
|
|
// The main page is going to be removed, we need to remove contexts from other worlds first.
|
|
if (bc.isolated_world) |*isolated_world| {
|
|
try isolated_world.removeContext();
|
|
}
|
|
}
|
|
|
|
pub fn pageCreated(bc: anytype, page: *Page) !void {
|
|
if (bc.isolated_world) |*isolated_world| {
|
|
// We need to recreate the isolated world context
|
|
try isolated_world.createContext(page);
|
|
|
|
const polyfill = @import("../../browser/polyfill/polyfill.zig");
|
|
try polyfill.load(bc.arena, &isolated_world.executor.scope.?);
|
|
}
|
|
}
|
|
|
|
pub fn pageNavigated(bc: anytype, event: *const Notification.PageNavigated) !void {
|
|
// I don't think it's possible that we get these notifications and don't
|
|
// have these things setup.
|
|
std.debug.assert(bc.session.page != null);
|
|
|
|
var cdp = bc.cdp;
|
|
const timestamp = event.timestamp;
|
|
const loader_id = bc.loader_id;
|
|
const target_id = bc.target_id orelse unreachable;
|
|
const session_id = bc.session_id orelse unreachable;
|
|
|
|
// frameNavigated event
|
|
try cdp.sendEvent("Page.frameNavigated", .{
|
|
.type = "Navigation",
|
|
.frame = Frame{
|
|
.id = target_id,
|
|
.url = event.url.raw,
|
|
.loaderId = bc.loader_id,
|
|
.securityOrigin = bc.security_origin,
|
|
.secureContextType = bc.secure_context_type,
|
|
},
|
|
}, .{ .session_id = session_id });
|
|
|
|
// The DOM.documentUpdated event must be send after the frameNavigated one.
|
|
// chromedp client expects to receive the events is this order.
|
|
// see https://github.com/chromedp/chromedp/issues/1558
|
|
try cdp.sendEvent("DOM.documentUpdated", null, .{ .session_id = session_id });
|
|
|
|
// domContentEventFired event
|
|
// TODO: partially hard coded
|
|
try cdp.sendEvent(
|
|
"Page.domContentEventFired",
|
|
.{ .timestamp = timestamp },
|
|
.{ .session_id = session_id },
|
|
);
|
|
|
|
// lifecycle DOMContentLoaded event
|
|
// TODO: partially hard coded
|
|
if (bc.page_life_cycle_events) {
|
|
try cdp.sendEvent("Page.lifecycleEvent", LifecycleEvent{
|
|
.timestamp = timestamp,
|
|
.name = "DOMContentLoaded",
|
|
.frameId = target_id,
|
|
.loaderId = loader_id,
|
|
}, .{ .session_id = session_id });
|
|
}
|
|
|
|
// loadEventFired event
|
|
try cdp.sendEvent(
|
|
"Page.loadEventFired",
|
|
.{ .timestamp = timestamp },
|
|
.{ .session_id = session_id },
|
|
);
|
|
|
|
// lifecycle DOMContentLoaded event
|
|
if (bc.page_life_cycle_events) {
|
|
try cdp.sendEvent("Page.lifecycleEvent", LifecycleEvent{
|
|
.timestamp = timestamp,
|
|
.name = "load",
|
|
.frameId = target_id,
|
|
.loaderId = loader_id,
|
|
}, .{ .session_id = session_id });
|
|
}
|
|
|
|
// frameStoppedLoading
|
|
return cdp.sendEvent("Page.frameStoppedLoading", .{
|
|
.frameId = target_id,
|
|
}, .{ .session_id = session_id });
|
|
}
|
|
|
|
const LifecycleEvent = struct {
|
|
frameId: []const u8,
|
|
loaderId: ?[]const u8,
|
|
name: []const u8,
|
|
timestamp: u32,
|
|
};
|
|
|
|
const testing = @import("../testing.zig");
|
|
test "cdp.page: getFrameTree" {
|
|
var ctx = testing.context();
|
|
defer ctx.deinit();
|
|
|
|
{
|
|
try testing.expectError(error.BrowserContextNotLoaded, ctx.processMessage(.{ .id = 10, .method = "Page.getFrameTree", .params = .{ .targetId = "X" } }));
|
|
try ctx.expectSentError(-31998, "BrowserContextNotLoaded", .{ .id = 10 });
|
|
}
|
|
|
|
const bc = try ctx.loadBrowserContext(.{ .id = "BID-9", .target_id = "TID-3" });
|
|
{
|
|
try ctx.processMessage(.{ .id = 11, .method = "Page.getFrameTree" });
|
|
try ctx.expectSentResult(.{
|
|
.frameTree = .{
|
|
.frame = .{
|
|
.id = "TID-3",
|
|
.loaderId = bc.loader_id,
|
|
.url = "about:blank",
|
|
.domainAndRegistry = "",
|
|
.securityOrigin = bc.security_origin,
|
|
.mimeType = "text/html",
|
|
.adFrameStatus = .{
|
|
.adFrameType = "none",
|
|
},
|
|
.secureContextType = bc.secure_context_type,
|
|
.crossOriginIsolatedContextType = "NotIsolated",
|
|
.gatedAPIFeatures = [_][]const u8{},
|
|
},
|
|
},
|
|
}, .{ .id = 11 });
|
|
}
|
|
}
|