Merge pull request #1582 from lightpanda-io/cdp_per_page_frame_id

Rework CDP frameIds (and loaderIds and requestIds and interceptorIds)
This commit is contained in:
Karl Seguin
2026-02-19 22:16:52 +08:00
committed by GitHub
18 changed files with 409 additions and 296 deletions

View File

@@ -30,12 +30,11 @@ const Browser = @import("../browser/Browser.zig");
const Session = @import("../browser/Session.zig");
const HttpClient = @import("../http/Client.zig");
const Page = @import("../browser/Page.zig");
const Incrementing = @import("../id.zig").Incrementing;
const Incrementing = @import("id.zig").Incrementing;
const Notification = @import("../Notification.zig");
const InterceptState = @import("domains/fetch.zig").InterceptState;
pub const URL_BASE = "chrome://newtab/";
pub const LOADER_ID = "LOADERID24DD2FD56CF1EF33C965C79C";
const IS_DEBUG = @import("builtin").mode == .Debug;
@@ -45,7 +44,6 @@ pub const CDP = CDPT(struct {
const SessionIdGen = Incrementing(u32, "SID");
const TargetIdGen = Incrementing(u32, "TID");
const LoaderIdGen = Incrementing(u32, "LID");
const BrowserContextIdGen = Incrementing(u32, "BID");
// Generic so that we can inject mocks into it.
@@ -63,7 +61,6 @@ pub fn CDPT(comptime TypeProvider: type) type {
target_auto_attach: bool = false,
target_id_gen: TargetIdGen = .{},
loader_id_gen: LoaderIdGen = .{},
session_id_gen: SessionIdGen = .{},
browser_context_id_gen: BrowserContextIdGen = .{},
@@ -200,7 +197,7 @@ pub fn CDPT(comptime TypeProvider: type) type {
.frameTree = .{
.frame = .{
.id = "TID-STARTUP-B",
.loaderId = LOADER_ID,
.loaderId = "LOADERID24DD2FD56CF1EF33C965C79C",
.securityOrigin = URL_BASE,
.url = "about:blank",
.secureContextType = "Secure",
@@ -350,7 +347,7 @@ pub fn BrowserContext(comptime CDP_T: type) type {
// Maps to our Page. (There are other types of targets, but we only
// deal with "pages" for now). Since we only allow 1 open page at a
// time, we only have 1 target_id.
target_id: ?[]const u8,
target_id: ?[14]u8,
// The CDP session_id. After the target/page is created, the client
// "attaches" to it (either explicitly or automatically). We return a
@@ -362,7 +359,6 @@ pub fn BrowserContext(comptime CDP_T: type) type {
// we should reject it.
session_id: ?[]const u8,
loader_id: []const u8,
security_origin: []const u8,
page_life_cycle_events: bool,
secure_context_type: []const u8,
@@ -416,7 +412,6 @@ pub fn BrowserContext(comptime CDP_T: type) type {
.session = session,
.security_origin = URL_BASE,
.secure_context_type = "Secure", // TODO = enum
.loader_id = LOADER_ID,
.page_life_cycle_events = false, // TODO; Target based value
.node_registry = registry,
.node_search_list = undefined,
@@ -593,7 +588,7 @@ pub fn BrowserContext(comptime CDP_T: type) type {
pub fn onPageNavigate(ctx: *anyopaque, msg: *const Notification.PageNavigate) !void {
const self: *Self = @ptrCast(@alignCast(ctx));
defer self.resetNotificationArena();
return @import("domains/page.zig").pageNavigate(self.notification_arena, self, msg);
return @import("domains/page.zig").pageNavigate(self, msg);
}
pub fn onPageNavigated(ctx: *anyopaque, msg: *const Notification.PageNavigated) !void {
@@ -615,19 +610,19 @@ pub fn BrowserContext(comptime CDP_T: type) type {
pub fn onHttpRequestStart(ctx: *anyopaque, msg: *const Notification.RequestStart) !void {
const self: *Self = @ptrCast(@alignCast(ctx));
defer self.resetNotificationArena();
try @import("domains/network.zig").httpRequestStart(self.notification_arena, self, msg);
try @import("domains/network.zig").httpRequestStart(self, msg);
}
pub fn onHttpRequestIntercept(ctx: *anyopaque, msg: *const Notification.RequestIntercept) !void {
const self: *Self = @ptrCast(@alignCast(ctx));
defer self.resetNotificationArena();
try @import("domains/fetch.zig").requestIntercept(self.notification_arena, self, msg);
try @import("domains/fetch.zig").requestIntercept(self, msg);
}
pub fn onHttpRequestFail(ctx: *anyopaque, msg: *const Notification.RequestFail) !void {
const self: *Self = @ptrCast(@alignCast(ctx));
defer self.resetNotificationArena();
return @import("domains/network.zig").httpRequestFail(self.notification_arena, self, msg);
return @import("domains/network.zig").httpRequestFail(self, msg);
}
pub fn onHttpResponseHeadersDone(ctx: *anyopaque, msg: *const Notification.ResponseHeaderDone) !void {
@@ -639,7 +634,7 @@ pub fn BrowserContext(comptime CDP_T: type) type {
pub fn onHttpRequestDone(ctx: *anyopaque, msg: *const Notification.RequestDone) !void {
const self: *Self = @ptrCast(@alignCast(ctx));
defer self.resetNotificationArena();
return @import("domains/network.zig").httpRequestDone(self.notification_arena, self, msg);
return @import("domains/network.zig").httpRequestDone(self, msg);
}
pub fn onHttpResponseData(ctx: *anyopaque, msg: *const Notification.ResponseData) !void {
@@ -657,7 +652,7 @@ pub fn BrowserContext(comptime CDP_T: type) type {
pub fn onHttpRequestAuthRequired(ctx: *anyopaque, data: *const Notification.RequestAuthRequired) !void {
const self: *Self = @ptrCast(@alignCast(ctx));
defer self.resetNotificationArena();
try @import("domains/fetch.zig").requestAuthRequired(self.notification_arena, self, data);
try @import("domains/fetch.zig").requestAuthRequired(self, data);
}
fn resetNotificationArena(self: *Self) void {

View File

@@ -17,6 +17,7 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const id = @import("../id.zig");
pub fn processMessage(cmd: anytype) !void {
const action = std.meta.stringToEnum(enum {
@@ -46,15 +47,18 @@ fn getFullAXTree(cmd: anytype) !void {
})) orelse return error.InvalidParams;
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
const session = bc.session;
if (params.frameId) |frameId| {
const target_id = bc.target_id orelse return error.TargetNotLoaded;
if (std.mem.eql(u8, target_id, frameId) == false) {
const page = blk: {
const frame_id = params.frameId orelse {
break :blk session.currentPage() orelse return error.PageNotLoaded;
};
const page_id = try id.toPageId(.frame_id, frame_id);
break :blk session.findPage(page_id) orelse {
return cmd.sendError(-32000, "Frame with the given id does not belong to the target.", .{});
}
}
};
};
const page = bc.session.currentPage() orelse return error.PageNotLoaded;
const doc = page.window._document.asNode();
const node = try bc.node_registry.register(doc);

View File

@@ -17,6 +17,7 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const id = @import("../id.zig");
const log = @import("../../log.zig");
const Node = @import("../Node.zig");
const DOMNode = @import("../../browser/webapi/Node.zig");
@@ -497,12 +498,11 @@ fn getFrameOwner(cmd: anytype) !void {
})) orelse return error.InvalidParams;
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
const target_id = bc.target_id orelse return error.TargetNotLoaded;
if (std.mem.eql(u8, target_id, params.frameId) == false) {
return cmd.sendError(-32000, "Frame with the given id does not belong to the target.", .{});
}
const page_id = try id.toPageId(.frame_id, params.frameId);
const page = bc.session.currentPage() orelse return error.PageNotLoaded;
const page = bc.session.findPage(page_id) orelse {
return cmd.sendError(-32000, "Frame with the given id does not belong to the target.", .{});
};
const node = try bc.node_registry.register(page.window._document.asNode());
return cmd.sendResult(.{ .nodeId = node.id, .backendNodeId = node.id }, .{});

View File

@@ -19,6 +19,7 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const id = @import("../id.zig");
const log = @import("../../log.zig");
const network = @import("network.zig");
@@ -48,7 +49,7 @@ pub fn processMessage(cmd: anytype) !void {
// Stored in CDP
pub const InterceptState = struct {
allocator: Allocator,
waiting: std.AutoArrayHashMapUnmanaged(u64, *Http.Transfer),
waiting: std.AutoArrayHashMapUnmanaged(u32, *Http.Transfer),
pub fn init(allocator: Allocator) !InterceptState {
return .{
@@ -65,8 +66,8 @@ pub const InterceptState = struct {
return self.waiting.put(self.allocator, transfer.id, transfer);
}
pub fn remove(self: *InterceptState, id: u64) ?*Http.Transfer {
const entry = self.waiting.fetchSwapRemove(id) orelse return null;
pub fn remove(self: *InterceptState, request_id: u32) ?*Http.Transfer {
const entry = self.waiting.fetchSwapRemove(request_id) orelse return null;
return entry.value;
}
@@ -178,13 +179,11 @@ fn arePatternsSupported(patterns: []RequestPattern) bool {
return true;
}
pub fn requestIntercept(arena: Allocator, bc: anytype, intercept: *const Notification.RequestIntercept) !void {
pub fn requestIntercept(bc: anytype, intercept: *const Notification.RequestIntercept) !void {
// detachTarget could be called, in which case, we still have a page doing
// things, but no session.
const session_id = bc.session_id orelse return;
const target_id = bc.target_id orelse unreachable;
// We keep it around to wait for modifications to the request.
// NOTE: we assume whomever created the request created it with a lifetime of the Page.
// TODO: What to do when receiving replies for a previous page's requests?
@@ -193,16 +192,16 @@ pub fn requestIntercept(arena: Allocator, bc: anytype, intercept: *const Notific
try bc.intercept_state.put(transfer);
try bc.cdp.sendEvent("Fetch.requestPaused", .{
.requestId = try std.fmt.allocPrint(arena, "INTERCEPT-{d}", .{transfer.id}),
.requestId = &id.toInterceptId(transfer.id),
.frameId = &id.toFrameId(transfer.req.page_id),
.request = network.TransferAsRequestWriter.init(transfer),
.frameId = target_id,
.resourceType = switch (transfer.req.resource_type) {
.script => "Script",
.xhr => "XHR",
.document => "Document",
.fetch => "Fetch",
},
.networkId = try std.fmt.allocPrint(arena, "REQ-{d}", .{transfer.id}),
.networkId = &id.toRequestId(transfer.id), // matches the Network REQ-ID
}, .{ .session_id = session_id });
log.debug(.cdp, "request intercept", .{
@@ -218,7 +217,7 @@ pub fn requestIntercept(arena: Allocator, bc: anytype, intercept: *const Notific
fn continueRequest(cmd: anytype) !void {
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
const params = (try cmd.params(struct {
requestId: []const u8, // "INTERCEPT-{d}"
requestId: []const u8, // INT-{d}"
url: ?[]const u8 = null,
method: ?[]const u8 = null,
postData: ?[]const u8 = null,
@@ -278,7 +277,7 @@ const AuthChallengeResponse = enum {
fn continueWithAuth(cmd: anytype) !void {
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
const params = (try cmd.params(struct {
requestId: []const u8, // "INTERCEPT-{d}"
requestId: []const u8, // "INT-{d}"
authChallengeResponse: struct {
response: AuthChallengeResponse,
username: []const u8 = "",
@@ -322,7 +321,7 @@ fn fulfillRequest(cmd: anytype) !void {
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
const params = (try cmd.params(struct {
requestId: []const u8, // "INTERCEPT-{d}"
requestId: []const u8, // "INT-{d}"
responseCode: u16,
responseHeaders: ?[]const Http.Header = null,
binaryResponseHeaders: ?[]const u8 = null,
@@ -363,7 +362,7 @@ fn fulfillRequest(cmd: anytype) !void {
fn failRequest(cmd: anytype) !void {
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
const params = (try cmd.params(struct {
requestId: []const u8, // "INTERCEPT-{d}"
requestId: []const u8, // "INT-{d}"
errorReason: ErrorReason,
})) orelse return error.InvalidParams;
@@ -382,13 +381,11 @@ fn failRequest(cmd: anytype) !void {
return cmd.sendResult(null, .{});
}
pub fn requestAuthRequired(arena: Allocator, bc: anytype, intercept: *const Notification.RequestAuthRequired) !void {
pub fn requestAuthRequired(bc: anytype, intercept: *const Notification.RequestAuthRequired) !void {
// detachTarget could be called, in which case, we still have a page doing
// things, but no session.
const session_id = bc.session_id orelse return;
const target_id = bc.target_id orelse unreachable;
// We keep it around to wait for modifications to the request.
// NOTE: we assume whomever created the request created it with a lifetime of the Page.
// TODO: What to do when receiving replies for a previous page's requests?
@@ -399,9 +396,9 @@ pub fn requestAuthRequired(arena: Allocator, bc: anytype, intercept: *const Noti
const challenge = transfer._auth_challenge orelse return error.NullAuthChallenge;
try bc.cdp.sendEvent("Fetch.authRequired", .{
.requestId = try std.fmt.allocPrint(arena, "INTERCEPT-{d}", .{transfer.id}),
.requestId = &id.toInterceptId(transfer.id),
.frameId = &id.toFrameId(transfer.req.page_id),
.request = network.TransferAsRequestWriter.init(transfer),
.frameId = target_id,
.resourceType = switch (transfer.req.resource_type) {
.script => "Script",
.xhr => "XHR",
@@ -414,7 +411,7 @@ pub fn requestAuthRequired(arena: Allocator, bc: anytype, intercept: *const Noti
.scheme = if (challenge.scheme == .digest) "digest" else "basic",
.realm = challenge.realm,
},
.networkId = try std.fmt.allocPrint(arena, "REQ-{d}", .{transfer.id}),
.networkId = &id.toRequestId(transfer.id),
}, .{ .session_id = session_id });
log.debug(.cdp, "request auth required", .{
@@ -427,10 +424,10 @@ pub fn requestAuthRequired(arena: Allocator, bc: anytype, intercept: *const Noti
intercept.wait_for_interception.* = true;
}
// Get u64 from requestId which is formatted as: "INTERCEPT-{d}"
fn idFromRequestId(request_id: []const u8) !u64 {
if (!std.mem.startsWith(u8, request_id, "INTERCEPT-")) {
// Get u32 from requestId which is formatted as: "INT-{d}"
fn idFromRequestId(request_id: []const u8) !u32 {
if (!std.mem.startsWith(u8, request_id, "INT-")) {
return error.InvalidParams;
}
return std.fmt.parseInt(u64, request_id[10..], 10) catch return error.InvalidParams;
return std.fmt.parseInt(u32, request_id[4..], 10) catch return error.InvalidParams;
}

View File

@@ -116,30 +116,3 @@ fn insertText(cmd: anytype) !void {
try cmd.sendResult(null, .{});
}
fn clickNavigate(cmd: anytype, uri: std.Uri) !void {
const bc = cmd.browser_context.?;
var url_buf: std.ArrayList(u8) = .{};
try uri.writeToStream(.{
.scheme = true,
.authentication = true,
.authority = true,
.port = true,
.path = true,
.query = true,
}, url_buf.writer(cmd.arena));
const url = url_buf.items;
try cmd.sendEvent("Page.frameRequestedNavigation", .{
.url = url,
.frameId = bc.target_id.?,
.reason = "anchorClick",
.disposition = "currentTab",
}, .{ .session_id = bc.session_id.? });
try bc.session.removePage();
_ = try bc.session.createPage(null);
try @import("page.zig").navigateToUrl(cmd, url, false);
}

View File

@@ -21,6 +21,8 @@ const lp = @import("lightpanda");
const Allocator = std.mem.Allocator;
const CdpStorage = @import("storage.zig");
const id = @import("../id.zig");
const URL = @import("../../browser/URL.zig");
const Transfer = @import("../../http/Client.zig").Transfer;
const Notification = @import("../../Notification.zig");
@@ -208,7 +210,7 @@ fn getResponseBody(cmd: anytype) !void {
}, .{});
}
pub fn httpRequestFail(arena: Allocator, bc: anytype, msg: *const Notification.RequestFail) !void {
pub fn httpRequestFail(bc: anytype, msg: *const Notification.RequestFail) !void {
// It's possible that the request failed because we aborted when the client
// sent Target.closeTarget. In that case, bc.session_id will be cleared
// already, and we can skip sending these messages to the client.
@@ -220,7 +222,7 @@ pub fn httpRequestFail(arena: Allocator, bc: anytype, msg: *const Notification.R
// We're missing a bunch of fields, but, for now, this seems like enough
try bc.cdp.sendEvent("Network.loadingFailed", .{
.requestId = try std.fmt.allocPrint(arena, "REQ-{d}", .{msg.transfer.id}),
.requestId = &id.toRequestId(msg.transfer.id),
// Seems to be what chrome answers with. I assume it depends on the type of error?
.type = "Ping",
.errorText = msg.err,
@@ -228,28 +230,27 @@ pub fn httpRequestFail(arena: Allocator, bc: anytype, msg: *const Notification.R
}, .{ .session_id = session_id });
}
pub fn httpRequestStart(arena: Allocator, bc: anytype, msg: *const Notification.RequestStart) !void {
pub fn httpRequestStart(bc: anytype, msg: *const Notification.RequestStart) !void {
// detachTarget could be called, in which case, we still have a page doing
// things, but no session.
const session_id = bc.session_id orelse return;
const target_id = bc.target_id orelse unreachable;
const page = bc.session.currentPage() orelse unreachable;
const transfer = msg.transfer;
const req = &transfer.req;
const page_id = req.page_id;
const page = bc.session.findPage(page_id) orelse return;
// Modify request with extra CDP headers
for (bc.extra_headers.items) |extra| {
try msg.transfer.req.headers.add(extra);
try req.headers.add(extra);
}
const transfer = msg.transfer;
const loader_id = try std.fmt.allocPrint(arena, "REQ-{d}", .{transfer.id});
// We're missing a bunch of fields, but, for now, this seems like enough
try bc.cdp.sendEvent("Network.requestWillBeSent", .{
.requestId = loader_id,
.frameId = target_id,
.loaderId = loader_id,
.type = msg.transfer.req.resource_type.string(),
.loaderId = &id.toLoaderId(transfer.id),
.requestId = &id.toRequestId(transfer.id),
.frameId = &id.toFrameId(page_id),
.type = req.resource_type.string(),
.documentURL = page.url,
.request = TransferAsRequestWriter.init(transfer),
.initiator = .{ .type = "other" },
@@ -262,29 +263,27 @@ pub fn httpResponseHeaderDone(arena: Allocator, bc: anytype, msg: *const Notific
// detachTarget could be called, in which case, we still have a page doing
// things, but no session.
const session_id = bc.session_id orelse return;
const target_id = bc.target_id orelse unreachable;
const transfer = msg.transfer;
const loader_id = try std.fmt.allocPrint(arena, "REQ-{d}", .{transfer.id});
// We're missing a bunch of fields, but, for now, this seems like enough
try bc.cdp.sendEvent("Network.responseReceived", .{
.requestId = loader_id,
.frameId = target_id,
.loaderId = loader_id,
.loaderId = &id.toLoaderId(transfer.id),
.requestId = &id.toRequestId(transfer.id),
.frameId = &id.toFrameId(transfer.req.page_id),
.response = TransferAsResponseWriter.init(arena, msg.transfer),
.hasExtraInfo = false, // TODO change after adding Network.responseReceivedExtraInfo
}, .{ .session_id = session_id });
}
pub fn httpRequestDone(arena: Allocator, bc: anytype, msg: *const Notification.RequestDone) !void {
pub fn httpRequestDone(bc: anytype, msg: *const Notification.RequestDone) !void {
// detachTarget could be called, in which case, we still have a page doing
// things, but no session.
const session_id = bc.session_id orelse return;
const transfer = msg.transfer;
try bc.cdp.sendEvent("Network.loadingFinished", .{
.requestId = try std.fmt.allocPrint(arena, "REQ-{d}", .{msg.transfer.id}),
.encodedDataLength = msg.transfer.bytes_received,
.requestId = &id.toRequestId(transfer.id),
.encodedDataLength = transfer.bytes_received,
}, .{ .session_id = session_id });
}

View File

@@ -19,6 +19,7 @@
const std = @import("std");
const lp = @import("lightpanda");
const id = @import("../id.zig");
const log = @import("../../log.zig");
const js = @import("../../browser/js/js.zig");
const Page = @import("../../browser/Page.zig");
@@ -73,9 +74,9 @@ fn getFrameTree(cmd: anytype) !void {
return cmd.sendResult(.{
.frameTree = .{
.frame = Frame{
.id = target_id,
.loaderId = bc.loader_id,
.id = &target_id,
.securityOrigin = bc.security_origin,
.loaderId = "LID-0000000001",
.url = bc.getURL() orelse "about:blank",
.secureContextType = bc.secure_context_type,
},
@@ -103,18 +104,21 @@ fn setLifecycleEventsEnabled(cmd: anytype) !void {
const page = bc.session.currentPage() orelse return error.PageNotLoaded;
if (page._load_state == .complete) {
const frame_id = &id.toFrameId(page.id);
const loader_id = &id.toLoaderId(page._req_id);
const now = timestampF(.monotonic);
try sendPageLifecycle(bc, "DOMContentLoaded", now);
try sendPageLifecycle(bc, "load", now);
try sendPageLifecycle(bc, "DOMContentLoaded", now, frame_id, loader_id);
try sendPageLifecycle(bc, "load", now, frame_id, loader_id);
const http_client = page._session.browser.http_client;
const http_active = http_client.active;
const total_network_activity = http_active + http_client.intercepted;
if (page._notified_network_almost_idle.check(total_network_activity <= 2)) {
try sendPageLifecycle(bc, "networkAlmostIdle", now);
try sendPageLifecycle(bc, "networkAlmostIdle", now, frame_id, loader_id);
}
if (page._notified_network_idle.check(total_network_activity == 0)) {
try sendPageLifecycle(bc, "networkIdle", now);
try sendPageLifecycle(bc, "networkIdle", now, frame_id, loader_id);
}
}
@@ -227,16 +231,15 @@ fn navigate(cmd: anytype) !void {
});
}
pub fn pageNavigate(arena: Allocator, bc: anytype, event: *const Notification.PageNavigate) !void {
pub fn pageNavigate(bc: anytype, event: *const Notification.PageNavigate) !void {
// detachTarget could be called, in which case, we still have a page doing
// things, but no session.
const session_id = bc.session_id orelse return;
const loader_id = try std.fmt.allocPrint(arena, "REQ-{d}", .{event.req_id});
const target_id = bc.target_id orelse unreachable;
bc.reset();
const frame_id = &id.toFrameId(event.page_id);
const loader_id = &id.toLoaderId(event.req_id);
var cdp = bc.cdp;
const reason_: ?[]const u8 = switch (event.opts.reason) {
.anchor => "anchorClick",
@@ -250,14 +253,14 @@ pub fn pageNavigate(arena: Allocator, bc: anytype, event: *const Notification.Pa
};
if (reason_) |reason| {
try cdp.sendEvent("Page.frameScheduledNavigation", .{
.frameId = target_id,
.frameId = frame_id,
.delay = 0,
.reason = reason,
.url = event.url,
}, .{ .session_id = session_id });
try cdp.sendEvent("Page.frameRequestedNavigation", .{
.frameId = target_id,
.frameId = frame_id,
.reason = reason,
.url = event.url,
.disposition = "currentTab",
@@ -266,7 +269,7 @@ pub fn pageNavigate(arena: Allocator, bc: anytype, event: *const Notification.Pa
// frameStartedNavigating event
try cdp.sendEvent("Page.frameStartedNavigating", .{
.frameId = target_id,
.frameId = frame_id,
.url = event.url,
.loaderId = loader_id,
.navigationType = "differentDocument",
@@ -274,7 +277,7 @@ pub fn pageNavigate(arena: Allocator, bc: anytype, event: *const Notification.Pa
// frameStartedLoading event
try cdp.sendEvent("Page.frameStartedLoading", .{
.frameId = target_id,
.frameId = frame_id,
}, .{ .session_id = session_id });
}
@@ -301,9 +304,10 @@ pub fn pageNavigated(arena: Allocator, bc: anytype, event: *const Notification.P
// detachTarget could be called, in which case, we still have a page doing
// things, but no session.
const session_id = bc.session_id orelse return;
const loader_id = try std.fmt.allocPrint(arena, "REQ-{d}", .{event.req_id});
const target_id = bc.target_id orelse unreachable;
const timestamp = event.timestamp;
const frame_id = &id.toFrameId(event.page_id);
const loader_id = &id.toLoaderId(event.req_id);
var cdp = bc.cdp;
@@ -316,7 +320,7 @@ pub fn pageNavigated(arena: Allocator, bc: anytype, event: *const Notification.P
try cdp.sendJSON(.{
.id = input_id,
.result = .{
.frameId = target_id,
.frameId = frame_id,
.loaderId = loader_id,
},
.sessionId = session_id,
@@ -326,7 +330,7 @@ pub fn pageNavigated(arena: Allocator, bc: anytype, event: *const Notification.P
if (bc.page_life_cycle_events) {
try cdp.sendEvent("Page.lifecycleEvent", LifecycleEvent{
.name = "init",
.frameId = target_id,
.frameId = frame_id,
.loaderId = loader_id,
.timestamp = event.timestamp,
}, .{ .session_id = session_id });
@@ -345,7 +349,7 @@ pub fn pageNavigated(arena: Allocator, bc: anytype, event: *const Notification.P
if (reason_ != null) {
try cdp.sendEvent("Page.frameClearedScheduledNavigation", .{
.frameId = target_id,
.frameId = frame_id,
}, .{ .session_id = session_id });
}
@@ -356,7 +360,7 @@ pub fn pageNavigated(arena: Allocator, bc: anytype, event: *const Notification.P
{
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});
const aux_data = try std.fmt.allocPrint(arena, "{{\"isDefault\":true,\"type\":\"default\",\"frameId\":\"{s}\",\"loaderId\":\"{s}\"}}", .{ frame_id, loader_id });
var ls: js.Local.Scope = undefined;
page.js.localScope(&ls);
@@ -371,7 +375,7 @@ pub fn pageNavigated(arena: Allocator, bc: anytype, event: *const Notification.P
);
}
for (bc.isolated_worlds.items) |isolated_world| {
const aux_json = try std.fmt.allocPrint(arena, "{{\"isDefault\":false,\"type\":\"isolated\",\"frameId\":\"{s}\"}}", .{target_id});
const aux_json = try std.fmt.allocPrint(arena, "{{\"isDefault\":false,\"type\":\"isolated\",\"frameId\":\"{s}\",\"loaderId\":\"{s}\"}}", .{ frame_id, loader_id });
// Calling contextCreated will assign a new Id to the context and send the contextCreated event
@@ -392,7 +396,7 @@ pub fn pageNavigated(arena: Allocator, bc: anytype, event: *const Notification.P
try cdp.sendEvent("Page.frameNavigated", .{
.type = "Navigation",
.frame = Frame{
.id = target_id,
.id = frame_id,
.url = event.url,
.loaderId = loader_id,
.securityOrigin = bc.security_origin,
@@ -419,7 +423,7 @@ pub fn pageNavigated(arena: Allocator, bc: anytype, event: *const Notification.P
try cdp.sendEvent("Page.lifecycleEvent", LifecycleEvent{
.timestamp = timestamp,
.name = "DOMContentLoaded",
.frameId = target_id,
.frameId = frame_id,
.loaderId = loader_id,
}, .{ .session_id = session_id });
}
@@ -436,35 +440,33 @@ pub fn pageNavigated(arena: Allocator, bc: anytype, event: *const Notification.P
try cdp.sendEvent("Page.lifecycleEvent", LifecycleEvent{
.timestamp = timestamp,
.name = "load",
.frameId = target_id,
.frameId = frame_id,
.loaderId = loader_id,
}, .{ .session_id = session_id });
}
// frameStoppedLoading
return cdp.sendEvent("Page.frameStoppedLoading", .{
.frameId = target_id,
.frameId = frame_id,
}, .{ .session_id = session_id });
}
pub fn pageNetworkIdle(bc: anytype, event: *const Notification.PageNetworkIdle) !void {
return sendPageLifecycle(bc, "networkIdle", event.timestamp);
return sendPageLifecycle(bc, "networkIdle", event.timestamp, &id.toFrameId(event.page_id), &id.toLoaderId(event.req_id));
}
pub fn pageNetworkAlmostIdle(bc: anytype, event: *const Notification.PageNetworkAlmostIdle) !void {
return sendPageLifecycle(bc, "networkAlmostIdle", event.timestamp);
return sendPageLifecycle(bc, "networkAlmostIdle", event.timestamp, &id.toFrameId(event.page_id), &id.toLoaderId(event.req_id));
}
fn sendPageLifecycle(bc: anytype, name: []const u8, timestamp: u64) !void {
fn sendPageLifecycle(bc: anytype, name: []const u8, timestamp: u64, frame_id: []const u8, loader_id: []const u8) !void {
// detachTarget could be called, in which case, we still have a page doing
// things, but no session.
const session_id = bc.session_id orelse return;
const loader_id = bc.loader_id;
const target_id = bc.target_id orelse unreachable;
return bc.cdp.sendEvent("Page.lifecycleEvent", LifecycleEvent{
.name = name,
.frameId = target_id,
.frameId = frame_id,
.loaderId = loader_id,
.timestamp = timestamp,
}, .{ .session_id = session_id });
@@ -487,15 +489,15 @@ test "cdp.page: getFrameTree" {
try ctx.expectSentError(-31998, "BrowserContextNotLoaded", .{ .id = 10 });
}
const bc = try ctx.loadBrowserContext(.{ .id = "BID-9", .target_id = "TID-3" });
const bc = try ctx.loadBrowserContext(.{ .id = "BID-9", .url = "hi.html", .target_id = "FID-000000000X".* });
{
try ctx.processMessage(.{ .id = 11, .method = "Page.getFrameTree" });
try ctx.expectSentResult(.{
.frameTree = .{
.frame = .{
.id = "TID-3",
.loaderId = bc.loader_id,
.url = "about:blank",
.id = "FID-000000000X",
.loaderId = "LID-0000000001",
.url = "http://127.0.0.1:9582/src/browser/tests/hi.html",
.domainAndRegistry = "",
.securityOrigin = bc.security_origin,
.mimeType = "text/html",

View File

@@ -18,6 +18,8 @@
const std = @import("std");
const lp = @import("lightpanda");
const id = @import("../id.zig");
const log = @import("../../log.zig");
const js = @import("../../browser/js/js.zig");
@@ -66,11 +68,11 @@ fn getTargets(cmd: anytype) !void {
}, .{ .include_session_id = false });
};
const target_id = bc.target_id orelse {
const target_id = &(bc.target_id orelse {
return cmd.sendResult(.{
.targetInfos = [_]TargetInfo{},
}, .{ .include_session_id = false });
};
});
return cmd.sendResult(.{
.targetInfos = [_]TargetInfo{.{
@@ -171,11 +173,12 @@ fn createTarget(cmd: anytype) !void {
// if target_id is null, we should never have a session_id
lp.assert(bc.session_id == null, "CDP.target.createTarget not null session_id", .{});
const target_id = cmd.cdp.target_id_gen.next();
bc.target_id = target_id;
const page = try bc.session.createPage();
// the target_id == the frame_id of the "root" page
const frame_id = id.toFrameId(page.id);
bc.target_id = frame_id;
const target_id = &bc.target_id.?;
{
var ls: js.Local.Scope = undefined;
page.js.localScope(&ls);
@@ -195,7 +198,6 @@ fn createTarget(cmd: anytype) !void {
// change CDP state
bc.security_origin = "://";
bc.secure_context_type = "InsecureScheme";
bc.loader_id = LOADER_ID;
// send targetCreated event
// TODO: should this only be sent when Target.setDiscoverTargets
@@ -234,7 +236,7 @@ fn attachToTarget(cmd: anytype) !void {
})) orelse return error.InvalidParams;
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
const target_id = bc.target_id orelse return error.TargetNotLoaded;
const target_id = &(bc.target_id orelse return error.TargetNotLoaded);
if (std.mem.eql(u8, target_id, params.targetId) == false) {
return error.UnknownTargetId;
}
@@ -255,7 +257,7 @@ fn closeTarget(cmd: anytype) !void {
})) orelse return error.InvalidParams;
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
const target_id = bc.target_id orelse return error.TargetNotLoaded;
const target_id = &(bc.target_id orelse return error.TargetNotLoaded);
if (std.mem.eql(u8, target_id, params.targetId) == false) {
return error.UnknownTargetId;
}
@@ -298,7 +300,7 @@ fn getTargetInfo(cmd: anytype) !void {
if (params.targetId) |param_target_id| {
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
const target_id = bc.target_id orelse return error.TargetNotLoaded;
const target_id = &(bc.target_id orelse return error.TargetNotLoaded);
if (std.mem.eql(u8, target_id, param_target_id) == false) {
return error.UnknownTargetId;
}
@@ -415,10 +417,11 @@ fn setAutoAttach(cmd: anytype) !void {
// autoAttach is set to true, we must attach to all existing targets.
if (cmd.browser_context) |bc| {
if (bc.target_id == null) {
// hasn't attached yet
const target_id = cmd.cdp.target_id_gen.next();
try doAttachtoTarget(cmd, target_id);
bc.target_id = target_id;
if (bc.session.currentPage()) |page| {
// the target_id == the frame_id of the "root" page
bc.target_id = id.toFrameId(page.id);
try doAttachtoTarget(cmd, &bc.target_id.?);
}
}
// should we send something here?
return;
@@ -612,14 +615,14 @@ test "cdp.target: closeTarget" {
// pretend we createdTarget first
_ = try bc.session.createPage();
bc.target_id = "TID-A";
bc.target_id = "TID-000000000A".*;
{
try testing.expectError(error.UnknownTargetId, ctx.processMessage(.{ .id = 10, .method = "Target.closeTarget", .params = .{ .targetId = "TID-8" } }));
try ctx.expectSentError(-31998, "UnknownTargetId", .{ .id = 10 });
}
{
try ctx.processMessage(.{ .id = 11, .method = "Target.closeTarget", .params = .{ .targetId = "TID-A" } });
try ctx.processMessage(.{ .id = 11, .method = "Target.closeTarget", .params = .{ .targetId = "TID-000000000A" } });
try ctx.expectSentResult(.{ .success = true }, .{ .id = 11 });
try testing.expectEqual(null, bc.session.page);
try testing.expectEqual(null, bc.target_id);
@@ -643,14 +646,14 @@ test "cdp.target: attachToTarget" {
// pretend we createdTarget first
_ = try bc.session.createPage();
bc.target_id = "TID-B";
bc.target_id = "TID-000000000B".*;
{
try testing.expectError(error.UnknownTargetId, ctx.processMessage(.{ .id = 10, .method = "Target.attachToTarget", .params = .{ .targetId = "TID-8" } }));
try ctx.expectSentError(-31998, "UnknownTargetId", .{ .id = 10 });
}
{
try ctx.processMessage(.{ .id = 11, .method = "Target.attachToTarget", .params = .{ .targetId = "TID-B" } });
try ctx.processMessage(.{ .id = 11, .method = "Target.attachToTarget", .params = .{ .targetId = "TID-000000000B" } });
const session_id = bc.session_id.?;
try ctx.expectSentResult(.{ .sessionId = session_id }, .{ .id = 11 });
try ctx.expectSentEvent("Target.attachedToTarget", .{ .sessionId = session_id, .targetInfo = .{ .url = "chrome://newtab/", .title = "about:blank", .attached = true, .type = "page", .canAccessOpener = false, .browserContextId = "BID-9", .targetId = bc.target_id.? } }, .{});
@@ -687,17 +690,17 @@ test "cdp.target: getTargetInfo" {
// pretend we createdTarget first
_ = try bc.session.createPage();
bc.target_id = "TID-A";
bc.target_id = "TID-000000000C".*;
{
try testing.expectError(error.UnknownTargetId, ctx.processMessage(.{ .id = 10, .method = "Target.getTargetInfo", .params = .{ .targetId = "TID-8" } }));
try ctx.expectSentError(-31998, "UnknownTargetId", .{ .id = 10 });
}
{
try ctx.processMessage(.{ .id = 11, .method = "Target.getTargetInfo", .params = .{ .targetId = "TID-A" } });
try ctx.processMessage(.{ .id = 11, .method = "Target.getTargetInfo", .params = .{ .targetId = "TID-000000000C" } });
try ctx.expectSentResult(.{
.targetInfo = .{
.targetId = "TID-A",
.targetId = "TID-000000000C",
.type = "page",
.title = "",
.url = "about:blank",

184
src/cdp/id.zig Normal file
View File

@@ -0,0 +1,184 @@
// Copyright (C) 2023-2026 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 IS_DEBUG = @import("builtin").mode == .Debug;
pub fn toPageId(comptime id_type: enum { frame_id, loader_id }, input: []const u8) !u32 {
const err = switch (comptime id_type) {
.frame_id => error.InvalidFrameId,
.loader_id => error.InvalidLoaderId,
};
if (input.len < 4) {
return err;
}
return std.fmt.parseInt(u32, input[4..], 10) catch err;
}
pub fn toFrameId(page_id: u32) [14]u8 {
var buf: [14]u8 = undefined;
_ = std.fmt.bufPrint(&buf, "FID-{d:0>10}", .{page_id}) catch unreachable;
return buf;
}
pub fn toLoaderId(page_id: u32) [14]u8 {
var buf: [14]u8 = undefined;
_ = std.fmt.bufPrint(&buf, "LID-{d:0>10}", .{page_id}) catch unreachable;
return buf;
}
pub fn toRequestId(page_id: u32) [14]u8 {
var buf: [14]u8 = undefined;
_ = std.fmt.bufPrint(&buf, "RID-{d:0>10}", .{page_id}) catch unreachable;
return buf;
}
pub fn toInterceptId(page_id: u32) [14]u8 {
var buf: [14]u8 = undefined;
_ = std.fmt.bufPrint(&buf, "INT-{d:0>10}", .{page_id}) catch unreachable;
return buf;
}
// Generates incrementing prefixed integers, i.e. CTX-1, CTX-2, CTX-3.
// Wraps to 0 on overflow.
// Many caveats for using this:
// - Not thread-safe.
// - Information leaking
// - The slice returned by next() is only valid:
// - while incrementor is valid
// - until the next call to next()
// On the positive, it's zero allocation
pub fn Incrementing(comptime T: type, comptime prefix: []const u8) type {
// +1 for the '-' separator
const NUMERIC_START = prefix.len + 1;
const MAX_BYTES = NUMERIC_START + switch (T) {
u8 => 3,
u16 => 5,
u32 => 10,
u64 => 20,
else => @compileError("Incrementing must be given an unsigned int type, got: " ++ @typeName(T)),
};
const buffer = blk: {
var b = [_]u8{0} ** MAX_BYTES;
@memcpy(b[0..prefix.len], prefix);
b[prefix.len] = '-';
break :blk b;
};
const PrefixIntType = @Type(.{ .int = .{
.bits = NUMERIC_START * 8,
.signedness = .unsigned,
} });
const PREFIX_INT_CODE: PrefixIntType = @bitCast(buffer[0..NUMERIC_START].*);
return struct {
counter: T = 0,
buffer: [MAX_BYTES]u8 = buffer,
const Self = @This();
pub fn next(self: *Self) []const u8 {
const counter = self.counter;
const n = counter +% 1;
defer self.counter = n;
const size = std.fmt.printInt(self.buffer[NUMERIC_START..], n, 10, .lower, .{});
return self.buffer[0 .. NUMERIC_START + size];
}
// extracts the numeric portion from an ID
pub fn parse(str: []const u8) !T {
if (str.len <= NUMERIC_START) {
return error.InvalidId;
}
if (@as(PrefixIntType, @bitCast(str[0..NUMERIC_START].*)) != PREFIX_INT_CODE) {
return error.InvalidId;
}
return std.fmt.parseInt(T, str[NUMERIC_START..], 10) catch {
return error.InvalidId;
};
}
};
}
const testing = @import("../testing.zig");
test "id: Incrementing.next" {
var id = Incrementing(u16, "IDX"){};
try testing.expectEqual("IDX-1", id.next());
try testing.expectEqual("IDX-2", id.next());
try testing.expectEqual("IDX-3", id.next());
// force a wrap
id.counter = 65533;
try testing.expectEqual("IDX-65534", id.next());
try testing.expectEqual("IDX-65535", id.next());
try testing.expectEqual("IDX-0", id.next());
}
test "id: Incrementing.parse" {
const ReqId = Incrementing(u32, "REQ");
try testing.expectError(error.InvalidId, ReqId.parse(""));
try testing.expectError(error.InvalidId, ReqId.parse("R"));
try testing.expectError(error.InvalidId, ReqId.parse("RE"));
try testing.expectError(error.InvalidId, ReqId.parse("REQ"));
try testing.expectError(error.InvalidId, ReqId.parse("REQ-"));
try testing.expectError(error.InvalidId, ReqId.parse("REQ--1"));
try testing.expectError(error.InvalidId, ReqId.parse("REQ--"));
try testing.expectError(error.InvalidId, ReqId.parse("REQ-Nope"));
try testing.expectError(error.InvalidId, ReqId.parse("REQ-4294967296"));
try testing.expectEqual(0, try ReqId.parse("REQ-0"));
try testing.expectEqual(99, try ReqId.parse("REQ-99"));
try testing.expectEqual(4294967295, try ReqId.parse("REQ-4294967295"));
}
test "id: toPageId" {
try testing.expectEqual(0, toPageId(.frame_id, "FID-0"));
try testing.expectEqual(0, toPageId(.loader_id, "LID-0"));
try testing.expectEqual(4294967295, toPageId(.frame_id, "FID-4294967295"));
try testing.expectEqual(4294967295, toPageId(.loader_id, "LID-4294967295"));
try testing.expectError(error.InvalidFrameId, toPageId(.frame_id, ""));
try testing.expectError(error.InvalidLoaderId, toPageId(.loader_id, "LID-NOPE"));
}
test "id: toFrameId" {
try testing.expectEqual("FID-0000000000", toFrameId(0));
try testing.expectEqual("FID-4294967295", toFrameId(4294967295));
}
test "id: toLoaderId" {
try testing.expectEqual("LID-0000000000", toLoaderId(0));
try testing.expectEqual("LID-4294967295", toLoaderId(4294967295));
}
test "id: toRequestId" {
try testing.expectEqual("RID-0000000000", toRequestId(0));
try testing.expectEqual("RID-4294967295", toRequestId(4294967295));
}
test "id: toInterceptId" {
try testing.expectEqual("INT-0000000000", toInterceptId(0));
try testing.expectEqual("INT-4294967295", toInterceptId(4294967295));
}

View File

@@ -92,7 +92,7 @@ const TestContext = struct {
const BrowserContextOpts = struct {
id: ?[]const u8 = null,
target_id: ?[]const u8 = null,
target_id: ?[14]u8 = null,
session_id: ?[]const u8 = null,
url: ?[:0]const u8 = null,
};
@@ -122,7 +122,7 @@ const TestContext = struct {
bc.session_id = "SID-X";
}
if (bc.target_id == null) {
bc.target_id = "TID-X";
bc.target_id = "TID-000000000Z".*;
}
const page = try bc.session.createPage();
const full_url = try std.fmt.allocPrintSentinel(