Files
browser/src/server/cdp/CDP.zig
T

1634 lines
65 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 lp = @import("lightpanda");
const App = @import("../../App.zig");
const Inbox = @import("../../Inbox.zig");
const Notification = @import("../../Notification.zig");
const sys_net = @import("../../sys/net.zig");
const http = @import("../../network/http.zig");
const HttpClient = @import("../../network/HttpClient.zig");
const Page = @import("../../browser/Page.zig");
const Mime = @import("../../browser/Mime.zig");
const Frame = @import("../../browser/Frame.zig");
const Browser = @import("../../browser/Browser.zig");
const Session = @import("../../browser/Session.zig");
const Label = @import("../../browser/webapi/element/html/Label.zig");
const WS = @import("../WS.zig");
const Link = @import("../Link.zig");
const Incrementing = @import("id.zig").Incrementing;
const fetch = @import("domains/fetch.zig");
const network_domain = @import("domains/network.zig");
const js = lp.js;
const log = lp.log;
const json = std.json;
const posix = std.posix;
const Allocator = std.mem.Allocator;
const InterceptState = fetch.InterceptState;
pub const URL_BASE = "chrome://newtab/";
const SessionIdGen = Incrementing(u32, "SID");
const BrowserSessionIdGen = Incrementing(u32, "BSID");
const BrowserContextIdGen = Incrementing(u32, "BID");
// webmcp tool invocation
pub const InvocationIdGen = Incrementing(u32, "INV");
// Generic so that we can inject mocks into it.
const CDP = @This();
app: *App,
link: Link,
browser: Browser,
allocator: Allocator,
// when true, any target creation must be attached.
target_auto_attach: bool = false,
session_id_gen: SessionIdGen = .{},
browser_session_id_gen: BrowserSessionIdGen = .{},
browser_context_id_gen: BrowserContextIdGen = .{},
// Session from Target.attachToBrowserTarget. Distinct from the page
// session (BrowserContext.session_id) as it targets the browser itself and
// outlives any browser context.
browser_session_id: ?[]const u8 = null,
browser_context: ?BrowserContext,
disable_set_cache_disabled: bool = false,
// Re-used arena for processing a message. Works because we strictly process
// one message at a time.
message_arena: std.heap.ArenaAllocator,
// Retained until the outermost notification handler returns.
notification_arena: std.heap.ArenaAllocator,
notification_depth: u32 = 0,
// Valid for 1 frame navigation (what CDP calls a "renderer")
frame_arena: std.heap.ArenaAllocator,
// Valid for the entire lifetime of the BrowserContext. Should minimize
// (or altogether eliminate) our use of this.
browser_context_arena: std.heap.ArenaAllocator,
// Files handed out as IO stream handles (Page.printToPDF ReturnAsStream).
streams: @import("domains/io.zig").Streams,
pub fn init(self: *CDP, app: *App, socket: posix.socket_t, inbox: *Inbox) !void {
const allocator = app.allocator;
{
// this is documentation, and future-proofing, to show exactly where
// the socket's ownership is
errdefer sys_net.close(socket);
self.* = .{
.app = app,
.link = undefined,
.browser = undefined,
.allocator = allocator,
.browser_context = null,
.frame_arena = std.heap.ArenaAllocator.init(allocator),
.message_arena = std.heap.ArenaAllocator.init(allocator),
.notification_arena = std.heap.ArenaAllocator.init(allocator),
.browser_context_arena = std.heap.ArenaAllocator.init(allocator),
.streams = .{ .allocator = allocator },
};
}
// takes ownership of the socket
try self.link.init(app, socket, .cdp, inbox);
errdefer self.link.deinit();
try self.browser.init(app, .{ .env = .{ .with_inspector = true } });
}
pub fn deinit(self: *CDP) void {
if (self.browser_context) |*bc| {
bc.deinit();
}
self.browser.deinit();
self.frame_arena.deinit();
self.message_arena.deinit();
self.notification_arena.deinit();
self.browser_context_arena.deinit();
self.streams.deinit();
self.link.deinit();
}
// Called by the Server run loop when readable bytes arrive on the CDP
// socket. Feeds them through the WS framer and pushes each parsed frame
// into the worker's inbox. Returns false if a close frame was seen (or a
// fatal frame error) so the loop drops the link from its poll set.
//
// Called in the Worker to dispatch a single CDP message bubbled up by
// HttpClient.drainInbox. The Server run loop already parsed the JSON
// when it pushed the message to the inbox, so we skip straight to
// dispatchParsed without re-parsing. `c.raw` and `c.arena` outlive
// this call (they're owned by the inbox Message which drainInbox
// frees right after we return), so `c.input`'s string slices stay
// valid for the duration of dispatch.
pub fn onMessage(self: *CDP, c: *Inbox.Message.Cdp) anyerror!void {
// Once a terminate is pending, don't dispatch
if (self.browser.env.terminatePending()) {
return;
}
const arena = &self.message_arena;
defer _ = arena.reset(.{ .retain_with_limit = 1024 * 16 });
return self.dispatchParsed(arena.allocator(), .{ .cdp = self }, c.raw, c.input);
}
// Parse + dispatch a raw JSON CDP message. Used by tests (which
// don't go through the Network thread / inbox pipeline) and by any
// caller that has bytes rather than a pre-parsed InputMessage.
pub fn processMessage(self: *CDP, msg: []const u8) !void {
const arena = &self.message_arena;
defer _ = arena.reset(.{ .retain_with_limit = 1024 * 16 });
return self.dispatch(arena.allocator(), .{ .cdp = self }, msg);
}
pub fn sendJSON(self: *CDP, message: anytype) !void {
try self.link.sendJSON(message, .{ .emit_null_optional_fields = false });
}
// Parse-then-dispatch entry point. Used by:
// - CDP.processMessage (tests, and any other caller that hands us
// raw JSON bytes).
// - Target.sendMessageToTarget (a CDP command that wraps another
// CDP message as a string parameter and forwards it through the
// dispatch table).
// The normal Network-thread path doesn't go through here — it parses
// once on the Network thread and reaches dispatchParsed directly via
// onMessage.
pub fn dispatch(self: *CDP, arena: Allocator, sender: Command.Sender, str: []const u8) !void {
const input = json.parseFromSliceLeaky(InputMessage, arena, str, .{
.ignore_unknown_fields = true,
}) catch return error.InvalidJSON;
return self.dispatchParsed(arena, sender, str, input);
}
// Dispatch a pre-parsed CDP message. The caller is responsible for
// keeping `str` and the backing storage for `input`'s string slices
// alive for the duration of the call.
fn dispatchParsed(self: *CDP, arena: Allocator, sender: Command.Sender, str: []const u8, input: InputMessage) !void {
lp.metrics.serve_commands.incr(.cdp);
var command = Command{
.input = .{
.json = str,
.id = input.id,
.action = "",
.params = input.params,
.session_id = input.sessionId,
},
.cdp = self,
.arena = arena,
.sender = sender,
.browser_context = if (self.browser_context) |*bc| bc else null,
};
// See dispatchStartupCommand for more info on this.
var is_startup = false;
if (input.sessionId) |input_session_id| {
if (std.mem.eql(u8, input_session_id, "STARTUP")) {
is_startup = true;
} else if (self.isValidSessionId(input_session_id) == false) {
return command.sendError(-32001, "Unknown sessionId", .{});
}
}
if (is_startup) {
dispatchStartupCommand(&command, input.method) catch |err| {
command.sendError(-31999, @errorName(err), .{}) catch return err;
};
} else {
dispatchCommand(&command, input.method) catch |err| {
switch (err) {
error.InvalidMethod, error.UnknownDomain, error.UnknownMethod => {
lp.metrics.serve_unknown_commands.incr(.cdp);
// Chrome's code and wording; drivers feature-detect on it.
const message = std.fmt.allocPrint(command.arena, "'{s}' wasn't found", .{input.method}) catch return err;
command.sendError(-32601, message, .{}) catch return err;
},
else => command.sendError(-31998, @errorName(err), .{}) catch return err,
}
};
}
}
// A CDP session isn't 100% fully driven by the driver. There's are
// independent actions that the browser is expected to take. For example
// Puppeteer expects the browser to startup a tab and thus have existing
// targets.
// To this end, we create a [very] dummy BrowserContext, Target and
// Session. There isn't actually a BrowserContext, just a special id.
// When messages are received with the "STARTUP" sessionId, we do
// "special" handling - the bare minimum we need to do until the driver
// switches to a real BrowserContext.
// (I can imagine this logic will become driver-specific)
fn dispatchStartupCommand(command: *Command, method: []const u8) !void {
// Stagehand parses the response and error if we don't return a
// correct one for Page.getFrameTree on startup call.
if (std.mem.eql(u8, method, "Page.getFrameTree")) {
// The Page.getFrameTree handles startup response gracefully.
return dispatchCommand(command, method);
}
return command.sendResult(null, .{});
}
fn dispatchCommand(command: *Command, method: []const u8) !void {
const domain = blk: {
const i = std.mem.indexOfScalarPos(u8, method, 0, '.') orelse {
return error.InvalidMethod;
};
command.input.action = method[i + 1 ..];
break :blk method[0..i];
};
switch (domain.len) {
2 => switch (@as(u16, @bitCast(domain[0..2].*))) {
asUint(u16, "LP") => return @import("domains/lp.zig").processMessage(command),
asUint(u16, "IO") => return @import("domains/io.zig").processMessage(command),
else => {},
},
3 => switch (@as(u24, @bitCast(domain[0..3].*))) {
asUint(u24, "DOM") => return @import("domains/dom.zig").processMessage(command),
asUint(u24, "Log") => return @import("domains/log.zig").processMessage(command),
asUint(u24, "CSS") => return @import("domains/css.zig").processMessage(command),
else => {},
},
4 => switch (@as(u32, @bitCast(domain[0..4].*))) {
asUint(u32, "Page") => return @import("domains/page.zig").processMessage(command),
else => {},
},
5 => switch (@as(u40, @bitCast(domain[0..5].*))) {
asUint(u40, "Fetch") => return @import("domains/fetch.zig").processMessage(command),
asUint(u40, "Input") => return @import("domains/input.zig").processMessage(command),
else => {},
},
6 => switch (@as(u48, @bitCast(domain[0..6].*))) {
asUint(u48, "Target") => return @import("domains/target.zig").processMessage(command),
asUint(u48, "Audits") => return @import("domains/audits.zig").processMessage(command),
asUint(u48, "WebMCP") => return @import("domains/webmcp.zig").processMessage(command),
else => {},
},
7 => switch (@as(u56, @bitCast(domain[0..7].*))) {
asUint(u56, "Browser") => return @import("domains/browser.zig").processMessage(command),
asUint(u56, "Runtime") => return @import("domains/runtime.zig").processMessage(command),
asUint(u56, "Network") => return network_domain.processMessage(command),
asUint(u56, "Storage") => return @import("domains/storage.zig").processMessage(command),
asUint(u56, "Console") => return @import("domains/console.zig").processMessage(command),
else => {},
},
8 => switch (@as(u64, @bitCast(domain[0..8].*))) {
asUint(u64, "Security") => return @import("domains/security.zig").processMessage(command),
else => {},
},
9 => switch (@as(u72, @bitCast(domain[0..9].*))) {
asUint(u72, "Emulation") => return @import("domains/emulation.zig").processMessage(command),
asUint(u72, "Inspector") => return @import("domains/inspector.zig").processMessage(command),
else => {},
},
11 => switch (@as(u88, @bitCast(domain[0..11].*))) {
asUint(u88, "Performance") => return @import("domains/performance.zig").processMessage(command),
else => {},
},
13 => switch (@as(u104, @bitCast(domain[0..13].*))) {
asUint(u104, "Accessibility") => return @import("domains/accessibility.zig").processMessage(command),
else => {},
},
else => {},
}
return error.UnknownDomain;
}
pub fn resolveSessionId(self: *const CDP, input_session_id: []const u8) ?[]const u8 {
if (self.browser_session_id) |browser_session_id| {
if (std.mem.eql(u8, browser_session_id, input_session_id)) {
return browser_session_id;
}
}
const browser_context = &(self.browser_context orelse return null);
if (browser_context.session_id) |session_id| {
if (std.mem.eql(u8, session_id, input_session_id)) {
return session_id;
}
}
for (browser_context.attached_sessions.items) |session| {
if (std.mem.eql(u8, session.id, input_session_id)) {
return session.id;
}
}
return null;
}
fn isValidSessionId(self: *const CDP, input_session_id: []const u8) bool {
return self.resolveSessionId(input_session_id) != null;
}
pub fn createBrowserContext(self: *CDP) ![]const u8 {
if (self.browser_context != null) {
return error.AlreadyExists;
}
const id = self.browser_context_id_gen.next();
self.browser_context = @as(BrowserContext, undefined);
const browser_context = &self.browser_context.?;
try BrowserContext.init(browser_context, id, self);
return id;
}
pub fn disposeBrowserContext(self: *CDP, browser_context_id: []const u8) bool {
const bc = &(self.browser_context orelse return false);
if (std.mem.eql(u8, bc.id, browser_context_id) == false) {
return false;
}
bc.deinit();
self.browser.closeSession();
self.browser_context = null;
_ = self.browser_context_arena.reset(.{ .retain_with_limit = 1024 * 16 });
return true;
}
const SendEventOpts = struct {
session_id: ?[]const u8 = null,
};
pub fn sendEvent(self: *CDP, method: []const u8, p: anytype, opts: SendEventOpts) !void {
return self.sendJSON(.{
.method = method,
.params = if (comptime @typeInfo(@TypeOf(p)) == .null) struct {}{} else p,
.sessionId = opts.session_id,
});
}
pub const BrowserContext = struct {
const Node = @import("Node.zig");
const AXNode = @import("AXNode.zig");
const NodeRegistry = @import("../../NodeRegistry.zig");
const CapturedResponse = struct {
must_encode: bool,
// null once evicted (size limit). We keep the map entry so that we
// can give an "evicted" error instead of "unknown"
data: ?std.ArrayList(u8),
};
// Key for `captured_responses` / `captured_requests`. Documents are
// keyed by `loader_id`, everything else by `request_id` — the two
// id-spaces are independent counters and overlap numerically (loader 1
// / request 1, loader 2 / request 2, ...), so the map key has to carry
// the namespace or entries collide. The wire-format prefix (`LID-` /
// `REQ-`) provides the same disambiguation on lookup; see
// `idFromRequestId` in domains/network.zig.
pub const CapturedKey = struct {
kind: enum { request, loader },
id: u32,
};
const AttachedSession = struct {
id: []const u8,
parent_id: ?[]const u8,
};
id: []const u8,
cdp: *CDP,
// Represents the browser session. There is no equivalent in CDP. For
// all intents and purpose, from CDP's point of view our Browser and
// our Session more or less maps to a BrowserContext. THIS HAS ZERO
// RELATION TO SESSION_ID
session: *Session,
// Tied to the lifetime of the BrowserContext
arena: Allocator,
// Tied to the lifetime of 1 page rendered in the BrowserContext.
frame_arena: Allocator,
// From the parent's notification_arena.allocator(). Most of the CDP
// code paths deal with a cmd which has its own arena (from the
// message_arena). But notifications happen outside of the typical CDP
// request->response, and thus don't have a cmd and don't have an arena.
notification_arena: Allocator,
// 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: ?[14]u8,
// Navigation-safe handle to this context's page, set when `frameCreated`
// fires. A BrowserContext is single-target by protocol, so it resolves its
// (live) page/frame through this handle — see `mainFrame` / `mainPage`. The
// handle's frame_id is stable across navigations (the replacement reuses
// it). null until the first page is created.
page_handle: ?Session.PageHandle = null,
// The CDP session_id. After the target/page is created, the client
// "attaches" to it (either explicitly or automatically). We return a
// "sessionId" which identifies this link. `sessionId` is the how
// the CDP client informs us what it's trying to manipulate. Because we
// only support 1 BrowserContext at a time, and 1 page at a time, this
// is all pretty straightforward, but it still needs to be enforced, i.e.
// if we get a request with a sessionId that doesn't match the current one
// we should reject it.
session_id: ?[]const u8,
attached_sessions: std.ArrayList(AttachedSession) = .empty,
// A cancelled text-less keyDown drops the char message that follows it
// (chromedp's keyDown/char/keyUp split), as Chrome does.
suppress_next_char: bool = false,
security_origin: []const u8,
page_life_cycle_events: bool,
secure_context_type: []const u8,
node_registry: NodeRegistry,
node_search_list: Node.Search.List,
// Node ids a DOM.setChildNodes event was already sent for. CDP protocol
// bookkeeping; ids are never reused within a registry's lifetime, so
// entries evicted by resetFrame can linger harmlessly until reset.
set_child_nodes_sent: std.AutoHashMapUnmanaged(NodeRegistry.Id, void) = .empty,
inspector_session: *js.Inspector.Session,
isolated_worlds: std.ArrayList(*IsolatedWorld),
// True when Runtime.evaluate has been run in the main world. Optimization
// so that, if it has _not_ and the page is on its initial about:blank, we
// can use the existing Frame/js.Context as-is.
main_world_touched: bool = false,
// Scripts registered via Page.addScriptToEvaluateOnNewDocument.
// Evaluated in each new document after navigation completes.
scripts_on_new_document: std.ArrayList(ScriptOnNewDocument) = .empty,
next_script_id: u32 = 1,
http_proxy_changed: bool = false,
user_agent_changed: bool = false,
// Extra headers to add to all requests.
extra_headers: std.ArrayList(http.Header) = .empty,
intercept_state: InterceptState,
fetch_session_id: ?[]const u8 = null,
// Request bodies retained for Network.getRequestPostData, which can be
// called after the transfer is gone. Capped at max_post_data_size.
captured_requests: std.AutoHashMapUnmanaged(CapturedKey, []const u8),
// When network is enabled, we'll capture the transfer.id -> body
// This is awfully memory intensive, but our underlying http client and
// its users (script manager and frame) correctly do not hold the body
// memory longer than they have to. In fact, the main request is only
// ever streamed. So if CDP is the only thing that needs bodies in
// memory for an arbitrary amount of time, then that's where we're going
// to store them.
captured_responses_size: usize = 0,
captured_responses: std.AutoArrayHashMapUnmanaged(CapturedKey, CapturedResponse),
network_limits: network_domain.BufferLimits = .{},
notification: *Notification,
// Pre-armed response for the next JS dialog (alert/confirm/prompt).
// Set by Page.handleJavaScriptDialog; consumed (and cleared) when the
// next javascript_dialog_opening notification is dispatched. Strings
// are duplicated into self.arena so they outlive the CDP command's
// own message arena.
pending_dialog_response: ?Notification.DialogResponse = null,
// webmcp tool invocation
invocation_id_gen: InvocationIdGen = .{},
// WebMCP domain state. Populated when `WebMCP.enable` is received.
webmcp_invocations: std.AutoHashMapUnmanaged(u32, *@import("domains/webmcp.zig").Invocation) = .empty,
fn init(self: *BrowserContext, id: []const u8, cdp: *CDP) !void {
const allocator = cdp.allocator;
// Create notification for this BrowserContext
const notification = try Notification.init(allocator);
errdefer notification.deinit();
const session = try cdp.browser.newSession(notification);
if (cdp.app.config.cookieFile()) |cookie_path| {
lp.cookies.loadFromFile(session, cookie_path);
}
const browser = &cdp.browser;
const inspector_session = browser.env.inspector.?.startSession(self);
errdefer browser.env.inspector.?.stopSession();
var registry = NodeRegistry.init(allocator);
errdefer registry.deinit();
self.* = .{
.id = id,
.cdp = cdp,
.target_id = null,
.session_id = null,
.session = session,
.security_origin = URL_BASE,
.secure_context_type = "Secure", // TODO = enum
.page_life_cycle_events = false, // TODO; Target based value
.node_registry = registry,
.node_search_list = undefined,
.isolated_worlds = .empty,
.inspector_session = inspector_session,
.frame_arena = cdp.frame_arena.allocator(),
.arena = cdp.browser_context_arena.allocator(),
.notification_arena = cdp.notification_arena.allocator(),
.intercept_state = try InterceptState.init(allocator),
.captured_requests = .empty,
.captured_responses = .empty,
.notification = notification,
};
self.node_search_list = Node.Search.List.init(allocator, &self.node_registry);
errdefer self.deinit();
try notification.register(.frame_remove, self, onFrameRemove);
try notification.register(.frame_created, self, onFrameCreated);
try notification.register(.frame_navigate, self, onFrameNavigate);
try notification.register(.frame_navigated, self, onFrameNavigated);
try notification.register(.frame_navigated_within_document, self, onFrameNavigatedWithinDocument);
try notification.register(.frame_navigate_failed, self, onFrameNavigateFailed);
try notification.register(.frame_child_frame_created, self, onFrameChildFrameCreated);
try notification.register(.frame_destroyed, self, onFrameDestroyed);
try notification.register(.frame_dom_content_loaded, self, onFrameDOMContentLoaded);
try notification.register(.frame_loaded, self, onFrameLoaded);
try notification.register(.javascript_dialog_opening, self, onJavascriptDialogOpening);
}
pub fn deinit(self: *BrowserContext) void {
const browser = &self.cdp.browser;
const env = &browser.env;
// resetContextGroup detach the inspector from all contexts.
// It appends async tasks, so we make sure we run the message loop
// before deinit it.
env.inspector.?.resetContextGroup();
env.inspector.?.stopSession();
// abort all intercepted requests before closing the session/page
// since some of these might callback into the page/scriptmanager.
// intercept_state stores ids.
const http_client = &browser.http_client;
for (self.intercept_state.pendingIntercepts()) |transfer_id| {
if (http_client.findTransfer(transfer_id)) |transfer| {
transfer.abortParked(error.ClientDisconnect);
}
}
// Notify any CDP client waiting on an in-flight WebMCP invocation
// before the V8 context (and its promise callbacks) get torn down
// by browser.closeSession below.
@import("domains/webmcp.zig").cancelAllPending(self);
for (self.isolated_worlds.items) |world| {
world.deinit();
}
self.isolated_worlds.clearRetainingCapacity();
// do this before closeSession, since we don't want to process any
// new notification (Or maybe, instead of the deinit above, we just
// rely on those notifications to do our normal cleanup?)
self.notification.unregisterAll(self);
self.clearCapturedResponses();
// If the session has a frame, we need to clear it first. The page
// context is always nested inside of the isolated world context,
// so we need to shutdown the page one first.
browser.closeSession();
self.node_registry.deinit();
self.node_search_list.deinit();
self.set_child_nodes_sent.deinit(self.cdp.allocator);
// Session.deinit (called via closeSession above) already cleared this
// notification off any ownerless CorsGate/RobotsGate transfers.
self.notification.deinit();
if (self.http_proxy_changed) {
// has to be called after browser.closeSession, since it won't
// work if there are active connections.
browser.http_client.changeProxy(null) catch |err| {
log.warn(.http, "changeProxy", .{ .err = err });
};
}
if (self.user_agent_changed) {
browser.http_client.clearUserAgentOverride();
}
browser.http_client.clearAcceptLanguageOverride();
self.intercept_state.deinit();
}
pub fn reset(self: *BrowserContext) void {
self.node_registry.reset();
self.node_search_list.reset();
self.set_child_nodes_sent.clearRetainingCapacity();
}
const GetOrPutIsolatedWorld = struct {
world: *IsolatedWorld,
found_existing: bool,
};
pub fn findIsolatedWorld(self: *const BrowserContext, world_name: []const u8) ?*IsolatedWorld {
for (self.isolated_worlds.items) |world| {
if (std.mem.eql(u8, world.name, world_name)) {
return world;
}
}
return null;
}
pub fn createIsolatedWorld(self: *BrowserContext, world_name: []const u8, grant_universal_access: bool) !GetOrPutIsolatedWorld {
if (self.findIsolatedWorld(world_name)) |world| {
if (world.grant_universal_access != grant_universal_access) {
log.warn(.cdp, "isolated world mismatch", .{ .name = world_name, .gua = grant_universal_access });
}
return .{ .world = world, .found_existing = true };
}
const browser = &self.cdp.browser;
const arena = try browser.arena_pool.acquire(.small, "IsolatedWorld");
errdefer arena.release();
const world = try arena.create(IsolatedWorld);
world.* = .{
.arena = arena,
.browser = browser,
.name = try arena.dupe(u8, world_name),
.grant_universal_access = grant_universal_access,
};
try self.isolated_worlds.append(self.arena, world);
return .{ .world = world, .found_existing = false };
}
// only called when we fail to fully create a world (e.g. errdefer in
// Page.createIsolatedWorld).
pub fn removeIsolatedWorld(self: *BrowserContext, world: *IsolatedWorld) void {
for (self.isolated_worlds.items, 0..) |w, i| {
if (w == world) {
_ = self.isolated_worlds.swapRemove(i);
world.deinit();
return;
}
}
}
pub fn nodeWriter(self: *BrowserContext, root: *const NodeRegistry.Node, opts: Node.Writer.Opts) Node.Writer {
return .{
.root = root,
.depth = opts.depth,
.exclude_root = opts.exclude_root,
.registry = &self.node_registry,
};
}
pub fn axnodeWriter(self: *BrowserContext, temp_arena: *lp.Arena, root: *const NodeRegistry.Node, opts: AXNode.Writer.Opts) !AXNode.Writer {
// Bind the writer to the frame that owns the root node. Name resolution
// (`Label.findLabelByFor` against `ownerDocument`) and visibility
// checks (`frame._style_manager`) are per-frame; getting this wrong on
// cross-frame queries produces names/visibility from the wrong document.
const main_frame = self.mainFrame() orelse return error.FrameNotLoaded;
const frame = root.dom.ownerFrame(main_frame) orelse return error.InvalidNodeId;
const label_index = try frame.call_arena.create(Label.LabelByForIndex);
label_index.* = .{};
return .{
.frame = frame,
.root = root,
.registry = &self.node_registry,
.label_index = label_index,
.temp_arena = temp_arena,
.filter = opts.filter,
};
}
// The root frame of this context's live Page.
pub fn mainFrame(self: *const BrowserContext) ?*Frame {
return &(self.mainPage() orelse return null).frame;
}
// True while this context's page is mid-commit (a root navigation's
// replacement is being promoted).
pub fn inCommit(self: *const BrowserContext) bool {
return (self.page_handle orelse return false).inCommit();
}
// The live Page for this single-target context, or null if no page is
// loaded.
pub fn mainPage(self: *const BrowserContext) ?*Page {
return (self.page_handle orelse return null).page();
}
pub fn getURL(self: *const BrowserContext) ?[:0]const u8 {
const frame = self.mainFrame() orelse return null;
const url = frame.url;
return if (url.len == 0) null else url;
}
pub fn getTitle(self: *const BrowserContext) ?[]const u8 {
const frame = self.mainFrame() orelse return null;
return frame.getTitle() catch |err| {
log.err(.cdp, "page title", .{ .err = err });
return null;
};
}
pub fn networkEnable(self: *BrowserContext, limits: network_domain.BufferLimits) !void {
const previous = self.network_limits;
self.network_limits = limits;
if (limits.resource < previous.resource) {
// evict responses which are over the new small limit
for (self.captured_responses.values()) |*resp| {
const data = resp.data orelse continue;
if (data.items.len > limits.resource) {
self.evictCapturedResponse(resp);
}
}
}
if (limits.total < previous.total) {
self.ensureCapturedSpace(0);
}
try self.notification.register(.http_request_fail, self, onHttpRequestFail);
try self.notification.register(.http_request_start, self, onHttpRequestStart);
try self.notification.register(.http_request_done, self, onHttpRequestDone);
try self.notification.register(.http_response_data, self, onHttpResponseData);
try self.notification.register(.http_response_header_done, self, onHttpResponseHeadersDone);
try self.notification.register(.http_request_served_from_cache, self, onHttpRequestServedFromCache);
}
pub fn networkDisable(self: *BrowserContext) void {
self.notification.unregister(.http_request_fail, self);
self.notification.unregister(.http_request_start, self);
self.notification.unregister(.http_request_done, self);
self.notification.unregister(.http_response_data, self);
self.notification.unregister(.http_response_header_done, self);
self.notification.unregister(.http_request_served_from_cache, self);
self.clearCapturedResponses();
}
pub fn clearCapturedResponses(self: *BrowserContext) void {
const allocator = self.cdp.allocator;
for (self.captured_responses.values()) |*resp| {
if (resp.data) |*data| {
data.deinit(allocator);
}
}
self.captured_responses.deinit(allocator);
self.captured_responses = .empty;
self.captured_responses_size = 0;
}
fn evictCapturedResponse(self: *BrowserContext, resp: *CapturedResponse) void {
if (resp.data) |*data| {
self.captured_responses_size -= data.items.len;
data.deinit(self.cdp.allocator);
resp.data = null;
}
}
// Evict captured responses, ordered by age, until we can fit `size` more bytes
fn ensureCapturedSpace(self: *BrowserContext, size: usize) void {
for (self.captured_responses.values()) |*resp| {
if (self.captured_responses_size + size <= self.network_limits.total) {
return;
}
const data = resp.data orelse continue;
if (data.items.len == 0) {
continue;
}
self.evictCapturedResponse(resp);
}
}
pub fn fetchEnable(self: *BrowserContext, patterns: ?[]const fetch.RequestPattern, authRequests: bool, session_id: []const u8) !void {
self.fetchDisable(); //in case of multiple calls
errdefer self.fetchDisable();
try self.intercept_state.setPatterns(patterns);
self.fetch_session_id = session_id;
try self.notification.register(.http_request_intercept, self, onHttpRequestIntercept);
if (authRequests) {
try self.notification.register(.http_request_auth_required, self, onHttpRequestAuthRequired);
}
}
pub fn fetchDisableForSession(self: *BrowserContext, session_id: []const u8) void {
const active_session_id = self.fetch_session_id orelse return;
if (std.mem.eql(u8, active_session_id, session_id)) {
self.fetchDisable();
}
}
fn fetchDisable(self: *BrowserContext) void {
self.notification.unregister(.http_request_intercept, self);
self.notification.unregister(.http_request_auth_required, self);
self.intercept_state.clearPatterns();
self.fetch_session_id = null;
}
pub fn lifecycleEventsEnable(self: *BrowserContext) !void {
self.page_life_cycle_events = true;
try self.notification.register(.frame_network_idle, self, onFrameNetworkIdle);
try self.notification.register(.frame_network_almost_idle, self, onFrameNetworkAlmostIdle);
}
pub fn lifecycleEventsDisable(self: *BrowserContext) void {
self.page_life_cycle_events = false;
self.notification.unregister(.frame_network_idle, self);
self.notification.unregister(.frame_network_almost_idle, self);
}
pub fn consoleEnable(self: *BrowserContext) !void {
try self.notification.register(.console_message, self, onConsoleMessage);
}
pub fn consoleDisable(self: *BrowserContext) void {
self.notification.unregister(.console_message, self);
}
pub fn runtimeEnable(self: *BrowserContext) !void {
try self.notification.register(.runtime_console_message, self, onRuntimeConsoleMessage);
}
pub fn runtimeDisable(self: *BrowserContext) void {
self.notification.unregister(.runtime_console_message, self);
}
// Registers for the download notifications dispatched by Frame when a
// navigation is treated as a file download. Idempotent. See issue #2701.
pub fn downloadEventsEnable(self: *BrowserContext) !void {
try self.notification.register(.download_will_begin, self, onDownloadWillBegin);
try self.notification.register(.download_progress, self, onDownloadProgress);
}
pub fn downloadEventsDisable(self: *BrowserContext) void {
self.notification.unregister(.download_will_begin, self);
self.notification.unregister(.download_progress, self);
}
fn onDownloadWillBegin(ctx: *anyopaque, msg: *const Notification.DownloadWillBegin) !void {
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
return @import("domains/browser.zig").downloadWillBegin(self, msg);
}
fn onDownloadProgress(ctx: *anyopaque, msg: *const Notification.DownloadProgress) !void {
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
return @import("domains/browser.zig").downloadProgress(self, msg);
}
pub fn webmcpEnable(self: *BrowserContext) !void {
try self.notification.register(.model_context_tool_added, self, onModelContextToolAdded);
try self.notification.register(.model_context_tool_removed, self, onModelContextToolRemoved);
}
pub fn webmcpDisable(self: *BrowserContext) void {
self.notification.unregister(.model_context_tool_added, self);
self.notification.unregister(.model_context_tool_removed, self);
}
fn onModelContextToolAdded(ctx: *anyopaque, event: *const Notification.ModelContextToolEvent) !void {
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
return @import("domains/webmcp.zig").onToolAdded(self, event);
}
fn onModelContextToolRemoved(ctx: *anyopaque, event: *const Notification.ModelContextToolEvent) !void {
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
return @import("domains/webmcp.zig").onToolRemoved(self, event);
}
fn onFrameRemove(ctx: *anyopaque, _: Notification.FrameRemove) !void {
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
@import("domains/page.zig").frameRemove(self);
}
fn onFrameCreated(ctx: *anyopaque, frame: *Frame) !void {
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
return @import("domains/page.zig").frameCreated(self, frame);
}
fn onFrameNavigate(ctx: *anyopaque, msg: *const Notification.FrameNavigate) !void {
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
return @import("domains/page.zig").frameNavigate(self, msg);
}
fn onFrameNavigated(ctx: *anyopaque, msg: *const Notification.FrameNavigated) !void {
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
const arena = self.acquireNotificationArena();
defer self.releaseNotificationArena();
return @import("domains/page.zig").frameNavigated(arena, self, msg);
}
fn onFrameNavigateFailed(ctx: *anyopaque, msg: *const Notification.FrameNavigateFailed) !void {
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
return @import("domains/page.zig").frameNavigateFailed(self, msg);
}
fn onFrameNavigatedWithinDocument(ctx: *anyopaque, msg: *const Notification.FrameNavigatedWithinDocument) !void {
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
return @import("domains/page.zig").frameNavigatedWithinDocument(self, msg);
}
fn onFrameDestroyed(ctx: *anyopaque, frame: *const Frame) !void {
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
@import("domains/page.zig").frameDestroyed(self, frame);
}
fn onFrameChildFrameCreated(ctx: *anyopaque, msg: *const Notification.FrameChildFrameCreated) !void {
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
return @import("domains/page.zig").frameChildFrameCreated(self, msg);
}
fn onFrameNetworkIdle(ctx: *anyopaque, msg: *const Notification.FrameNetworkIdle) !void {
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
return @import("domains/page.zig").frameNetworkIdle(self, msg);
}
fn onFrameNetworkAlmostIdle(ctx: *anyopaque, msg: *const Notification.FrameNetworkAlmostIdle) !void {
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
return @import("domains/page.zig").frameNetworkAlmostIdle(self, msg);
}
fn onHttpRequestStart(ctx: *anyopaque, msg: *const Notification.RequestStart) !void {
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
{
// capture the request
const transfer = msg.transfer;
const key = keyFromTransfer(transfer);
const body = transfer.req.body orelse "";
if (body.len == 0 or body.len > network_domain.max_post_data_size) {
_ = self.captured_requests.remove(key);
} else {
const owned_body = try self.frame_arena.dupe(u8, body);
try self.captured_requests.put(self.frame_arena, key, owned_body);
}
}
const arena = self.acquireNotificationArena();
defer self.releaseNotificationArena();
try network_domain.httpRequestStart(arena, self, msg);
}
fn onHttpRequestIntercept(ctx: *anyopaque, msg: *const Notification.RequestIntercept) !void {
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
const arena = self.acquireNotificationArena();
defer self.releaseNotificationArena();
try @import("domains/fetch.zig").requestIntercept(arena, self, msg);
}
fn onHttpRequestFail(ctx: *anyopaque, msg: *const Notification.RequestFail) !void {
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
return network_domain.httpRequestFail(self, msg);
}
fn onFrameDOMContentLoaded(ctx: *anyopaque, msg: *const Notification.FrameDOMContentLoaded) !void {
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
return @import("domains/page.zig").frameDOMContentLoaded(self, msg);
}
fn onFrameLoaded(ctx: *anyopaque, msg: *const Notification.FrameLoaded) !void {
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
return @import("domains/page.zig").frameLoaded(self, msg);
}
fn onJavascriptDialogOpening(ctx: *anyopaque, msg: *const Notification.JavascriptDialogOpening) !void {
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
return @import("domains/page.zig").javascriptDialogOpening(self, msg);
}
fn keyFromTransfer(transfer: *const HttpClient.Transfer) CDP.BrowserContext.CapturedKey {
return if (transfer.req.resource_type == .document)
.{ .kind = .loader, .id = transfer.req.loader_id }
else
.{ .kind = .request, .id = transfer.id };
}
fn onHttpResponseHeadersDone(ctx: *anyopaque, msg: *const Notification.ResponseHeaderDone) !void {
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
const arena = self.acquireNotificationArena();
defer self.releaseNotificationArena();
// Prepare the captured response value.
const key = keyFromTransfer(msg.transfer);
const gop = try self.captured_responses.getOrPut(self.cdp.allocator, key);
if (!gop.found_existing) {
gop.value_ptr.* = .{
.data = blk: {
const body_len = msg.transfer.bodyLen();
if (body_len > self.network_limits.resource) {
break :blk null;
}
break :blk try std.ArrayList(u8).initCapacity(self.cdp.allocator, body_len);
},
// Encode the data in base64 by default, but don't encode
// for well known content-type.
.must_encode = blk: {
if (msg.transfer.contentType()) |ct| {
const mime = try Mime.parse(ct);
if (!mime.isText()) {
break :blk true;
}
if (std.ascii.eqlIgnoreCase("UTF-8", mime.charsetString())) {
break :blk false;
}
}
break :blk true;
},
};
}
return network_domain.httpResponseHeaderDone(arena, self, msg);
}
fn onHttpRequestDone(ctx: *anyopaque, msg: *const Notification.RequestDone) !void {
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
return network_domain.httpRequestDone(self, msg);
}
pub fn onHttpResponseData(ctx: *anyopaque, msg: *const Notification.ResponseData) !void {
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
const key = keyFromTransfer(msg.transfer);
const resp = self.captured_responses.getPtr(key) orelse lp.assert(false, "onHttpResponseData missing captured response", .{});
const data = &(resp.data orelse return);
const chunk = msg.data;
const limits = &self.network_limits;
if (data.items.len + chunk.len > limits.resource or chunk.len > limits.total) {
return self.evictCapturedResponse(resp);
}
// Can evict `resp` itself when it's the oldest, hence the re-check.
self.ensureCapturedSpace(chunk.len);
if (resp.data == null) {
return;
}
try data.appendSlice(self.cdp.allocator, chunk);
self.captured_responses_size += chunk.len;
}
fn onHttpRequestAuthRequired(ctx: *anyopaque, data: *const Notification.RequestAuthRequired) !void {
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
const arena = self.acquireNotificationArena();
defer self.releaseNotificationArena();
try @import("domains/fetch.zig").requestAuthRequired(arena, self, data);
}
fn onHttpRequestServedFromCache(ctx: *anyopaque, msg: *const Notification.RequestServedFromCache) !void {
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
return network_domain.httpServedFromCache(self, msg);
}
fn onConsoleMessage(ctx: *anyopaque, msg: *const Notification.ConsoleMessage) !void {
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
const arena = self.acquireNotificationArena();
defer self.releaseNotificationArena();
return @import("domains/console.zig").consoleMessage(arena, self, msg);
}
fn onRuntimeConsoleMessage(ctx: *anyopaque, msg: *const Notification.ConsoleMessage) !void {
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
const arena = self.acquireNotificationArena();
defer self.releaseNotificationArena();
return @import("domains/runtime.zig").consoleMessage(arena, self, msg);
}
fn acquireNotificationArena(self: *BrowserContext) Allocator {
self.cdp.notification_depth += 1;
return self.notification_arena;
}
fn releaseNotificationArena(self: *BrowserContext) void {
self.cdp.notification_depth -= 1;
if (self.cdp.notification_depth == 0) {
_ = self.cdp.notification_arena.reset(.{ .retain_with_limit = 1024 * 64 });
}
}
pub fn callInspector(self: *const BrowserContext, msg: []const u8) void {
self.inspector_session.send(msg);
self.session.browser.env.runMicrotasks();
}
pub fn onInspectorResponse(ctx: *anyopaque, _: u32, msg: []const u8) void {
sendInspectorMessage(@ptrCast(@alignCast(ctx)), msg) catch |err| {
log.err(.cdp, "send inspector response", .{ .err = err });
};
}
pub fn onInspectorEvent(ctx: *anyopaque, msg: []const u8) void {
if (log.enabled(.cdp, .debug)) {
// msg should be {"method":<method>,...
lp.assert(std.mem.startsWith(u8, msg, "{\"method\":"), "onInspectorEvent prefix", .{});
const method_end = std.mem.indexOfScalar(u8, msg, ',') orelse {
log.err(.cdp, "invalid inspector event", .{ .msg = msg });
return;
};
const method = msg[10..method_end];
log.debug(.cdp, "inspector event", .{ .method = method });
}
sendInspectorMessage(@ptrCast(@alignCast(ctx)), msg) catch |err| {
log.err(.cdp, "send inspector event", .{ .err = err });
};
}
// This is hacky x 2. First, we create the JSON payload by gluing our
// session_id onto it. Second, we're much more client/websocket aware than
// we should be.
fn sendInspectorMessage(self: *BrowserContext, msg: []const u8) !void {
const session_id = self.session_id orelse {
// We no longer have an active session. What should we do
// in this case?
return;
};
const cdp = self.cdp;
const allocator = cdp.link.acquireSendArena();
defer cdp.link.releaseSendArena();
const field = ",\"sessionId\":\"";
// + 1 for the closing quote after the session id
// + 10 for the max websocket header
const message_len = msg.len + session_id.len + 1 + field.len + 10;
var buf: std.ArrayList(u8) = .empty;
buf.ensureTotalCapacityPrecise(allocator, message_len) catch |err| {
log.err(.cdp, "inspector buffer", .{ .err = err });
return;
};
// reserve 10 bytes for websocket header
buf.appendSliceAssumeCapacity(&.{ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 });
// -1 because we don't want the closing brace '}'
buf.appendSliceAssumeCapacity(msg[0 .. msg.len - 1]);
buf.appendSliceAssumeCapacity(field);
buf.appendSliceAssumeCapacity(session_id);
buf.appendSliceAssumeCapacity("\"}");
if (comptime lp.IS_DEBUG) {
std.debug.assert(buf.items.len == message_len);
}
try cdp.link.sendJSONRaw(buf);
}
};
/// see: https://chromium.googlesource.com/chromium/src/+/master/third_party/blink/renderer/bindings/core/v8/V8BindingDesign.md#world
/// The current understanding. An isolated world lives in the same isolate, but a separated context.
/// Clients create this to be able to create variables and run code without interfering with the
/// normal namespace and values of the webframe. Similar to the main context we need to pretend to recreate it after
/// a executionContextsCleared event which happens when navigating to a new frame. A client can have a command be executed
const ScriptOnNewDocument = struct {
identifier: u32,
source: []const u8,
// Page.addScriptToEvaluateOnNewDocument's worldName. null means the main
// world. A named world is seeded into every frame (see IsolatedWorld).
world_name: ?[]const u8,
};
/// An isolated world is identified by its name and has one V8::Context per
/// frame it has been seeded into. A world enters a frame on an explicit
/// trigger: Page.createIsolatedWorld or, or a preload script which is seeded
/// into every frame. Once seeded, the frame's context is rebuilt on every
/// navigation with no further client involvement.
/// Frame ids are stable across a child frame's re-navigation (the Frame is
/// torn down and re-initialized in place), so a per-frame context is removed
/// on frame_destroyed and created again on the frame's next frame_navigated.
pub const IsolatedWorld = struct {
arena: *lp.Arena,
browser: *Browser,
name: []const u8,
grant_universal_access: bool,
contexts: std.ArrayList(FrameContext) = .empty,
// Frames this world has been seeded into, by frame id.
seeded_frames: std.ArrayList(u32) = .empty,
// Identity tracking for this isolated world (separate from main world).
// Shared by all of the world's frame contexts, like the main world shares
// Page.identity across frames, and reset with them on root teardown.
identity: js.Identity = .{},
const FrameContext = struct {
frame: *const Frame,
context: *js.Context,
// Per-context, not per-world: the call_arena is reset when a context's
// call depth returns to 0, which would free the data of another frame's
// in-flight call if they shared one.
call_arena: *lp.Arena,
local_arena: *lp.Arena,
};
pub fn deinit(self: *IsolatedWorld) void {
self.removeAllContexts();
self.arena.release();
}
pub fn seed(self: *IsolatedWorld, frame_id: u32) !void {
if (self.isSeeded(frame_id)) {
return;
}
return self.seeded_frames.append(self.arena.allocator(), frame_id);
}
pub fn isSeeded(self: *const IsolatedWorld, frame_id: u32) bool {
return std.mem.indexOfScalar(u32, self.seeded_frames.items, frame_id) != null;
}
// Keyed by Frame, not frame id: a retired root Page keeps its frame id
// while its deferred teardown is pending, and that teardown must not
// touch the live page's context.
pub fn contextFor(self: *const IsolatedWorld, frame: *const Frame) ?*js.Context {
for (self.contexts.items) |fc| {
if (fc.frame == frame) {
return fc.context;
}
}
return null;
}
// Callers must register the returned context with the inspector
pub fn createContext(self: *IsolatedWorld, frame: *Frame) !*js.Context {
lp.assert(self.contextFor(frame) == null, "IsolatedWorld.createContext duplicate", .{ .frame_id = frame._frame_id });
const browser = self.browser;
const call_arena = try browser.arena_pool.acquire(.tiny, "IsolatedWorld.call_arena");
errdefer call_arena.release();
const local_arena = try browser.arena_pool.acquire(.tiny, "IsolatedWorld.local_arena");
errdefer local_arena.release();
const ctx = try browser.env.createContext(frame, .{
.identity = &self.identity,
.identity_arena = self.arena.allocator(),
.call_arena = call_arena.allocator(),
.local_arena = local_arena.allocator(),
.debug_name = "IsolatedContext",
});
errdefer browser.env.destroyContext(ctx);
try ctx.setOrigin(frame.origin);
try self.contexts.append(self.arena.allocator(), .{
.frame = frame,
.context = ctx,
.call_arena = call_arena,
.local_arena = local_arena,
});
return ctx;
}
pub fn removeContext(self: *IsolatedWorld, frame: *const Frame) void {
for (self.contexts.items, 0..) |fc, i| {
if (fc.frame == frame) {
self.destroyFrameContext(fc);
_ = self.contexts.swapRemove(i);
return;
}
}
}
pub fn removeAllContexts(self: *IsolatedWorld) void {
for (self.contexts.items) |fc| {
self.destroyFrameContext(fc);
}
self.contexts.clearRetainingCapacity();
// The page's objects are going away with the root frame; wrappers
// keyed by their addresses must not survive to alias a new page's.
self.identity.deinit();
self.identity = .{};
}
fn destroyFrameContext(self: *IsolatedWorld, fc: FrameContext) void {
// A re-navigating child frame keeps its Window, and the identity map
// keeps the window's global proxy; detach it from this context so the
// frame's next context can reattach it (as the main world does).
fc.context.detachGlobal();
self.browser.env.destroyContext(fc.context);
fc.call_arena.release();
fc.local_arena.release();
}
};
// This is a generic because when we send a result we have two different
// behaviors. Normally, we're sending the result to the client. But in some cases
// we want to capture the result. So we want the command.sendResult to be
// generic.
pub const Command = struct {
// A misc arena that can be used for any allocation for processing
// the message
arena: Allocator,
// reference to our CDP instance
cdp: *CDP,
// The browser context this command targets
browser_context: ?*BrowserContext,
// The command input (the id, optional session_id, params, ...)
input: Input,
// In most cases, Sender is going to be cdp itself. We'll call
// sender.sendJSON() and CDP will send it to the client. But some
// commands are dispatched internally, in which cases the Sender will
// be code to capture the data that we were "sending".
sender: Sender,
const Sender = union(enum) {
cdp: *CDP,
capture: *std.Io.Writer,
pub fn sendJSON(self: Sender, message: anytype) !void {
switch (self) {
.cdp => |cdp| return cdp.sendJSON(message),
.capture => |writer| {
return std.json.Stringify.value(message, .{
.emit_null_optional_fields = false,
}, writer);
},
}
}
};
pub fn params(self: *const Command, comptime T: type) !?T {
if (self.input.params) |p| {
return try json.parseFromSliceLeaky(
T,
self.arena,
p.raw,
.{ .ignore_unknown_fields = true },
);
}
return null;
}
pub fn createBrowserContext(self: *Command) !*BrowserContext {
_ = try self.cdp.createBrowserContext();
self.browser_context = &(self.cdp.browser_context.?);
return self.browser_context.?;
}
const SendResultOpts = struct {};
pub fn sendResult(self: *Command, result: anytype, _: SendResultOpts) !void {
return self.sender.sendJSON(.{
.id = self.input.id,
.result = if (comptime @typeInfo(@TypeOf(result)) == .null) struct {}{} else result,
.sessionId = self.input.session_id,
});
}
pub fn sendEvent(self: *Command, method: []const u8, p: anytype, opts: SendEventOpts) !void {
// Events ALWAYS go to the client. self.sender should not be used
return self.cdp.sendEvent(method, p, opts);
}
const SendErrorOpts = struct {};
pub fn sendError(self: *Command, code: i32, message: []const u8, _: SendErrorOpts) !void {
return self.sender.sendJSON(.{
.id = self.input.id,
.@"error" = .{ .code = code, .message = message },
.sessionId = self.input.session_id,
});
}
const Input = struct {
// When we reply to a message, we echo back the message id
id: ?i64,
// The "action" of the message.Given a method of "LOG.enable", the
// action is "enable"
action: []const u8,
// See notes in BrowserContext about session_id
session_id: ?[]const u8,
// Unparsed / untyped input.params.
params: ?InputParams,
// The full raw json input
json: []const u8,
};
};
// When we parse a JSON message from the client, this is the structure
// we always expect. Parsed on the Network thread inside
// Link.handleMessage; the slices reference the raw JSON bytes
// (or arena allocations for fields that needed unescaping). Both
// outlive the InputMessage for the inbox message's lifetime.
pub const InputMessage = struct {
id: ?i64 = null,
method: []const u8,
params: ?InputParams = null,
sessionId: ?[]const u8 = null,
};
// The JSON "params" field changes based on the "method". Initially, we just
// capture the raw json object (including the opening and closing braces).
// Then, when we're processing the message, and we know what type it is, we
// can parse it (in Disaptch(T).params).
const InputParams = struct {
raw: []const u8,
pub fn jsonParse(
_: Allocator,
scanner: *json.Scanner,
_: json.ParseOptions,
) !InputParams {
const height = scanner.stackHeight();
const start = scanner.cursor;
if (try scanner.next() != .object_begin) {
return error.UnexpectedToken;
}
try scanner.skipUntilStackHeight(height);
const end = scanner.cursor;
return .{ .raw = scanner.input[start..end] };
}
};
fn asUint(comptime T: type, comptime string: []const u8) T {
return @bitCast(string[0..string.len].*);
}
const testing = @import("testing.zig");
test "cdp: invalid json" {
var ctx = try testing.context();
defer ctx.deinit();
try testing.expectError(error.InvalidJSON, ctx.processMessage("invalid"));
// method is required
try testing.expectError(error.InvalidJSON, ctx.processMessage(.{}));
try ctx.processMessage(.{
.method = "Target",
});
try ctx.expectSentError(-32601, "'Target' wasn't found", .{});
try ctx.processMessage(.{
.method = "Unknown.domain",
});
try ctx.expectSentError(-32601, "'Unknown.domain' wasn't found", .{});
try ctx.processMessage(.{
.method = "Target.over9000",
});
try ctx.expectSentError(-32601, "'Target.over9000' wasn't found", .{});
}
test "cdp: invalid sessionId" {
var ctx = try testing.context();
defer ctx.deinit();
{
// we have no browser context
try ctx.processMessage(.{ .method = "Hi", .sessionId = "nope" });
try ctx.expectSentError(-32001, "Unknown sessionId", .{});
}
{
// we have a browser context but no session_id
_ = try ctx.loadBrowserContext(.{});
try ctx.processMessage(.{ .method = "Hi", .sessionId = "BC-Has-No-SessionId" });
try ctx.expectSentError(-32001, "Unknown sessionId", .{});
}
{
// we have a browser context with a different session_id
_ = try ctx.loadBrowserContext(.{ .session_id = "SESS-2" });
try ctx.processMessage(.{ .method = "Hi", .sessionId = "SESS-1" });
try ctx.expectSentError(-32001, "Unknown sessionId", .{});
}
}
test "cdp: STARTUP sessionId" {
var ctx = try testing.context();
defer ctx.deinit();
{
// we have no browser context
try ctx.processMessage(.{ .id = 2, .method = "Hi", .sessionId = "STARTUP" });
try ctx.expectSentResult(null, .{ .id = 2, .index = 0, .session_id = "STARTUP" });
}
{
// we have a browser context but no session_id
_ = try ctx.loadBrowserContext(.{});
try ctx.processMessage(.{ .id = 3, .method = "Hi", .sessionId = "STARTUP" });
try ctx.expectSentResult(null, .{ .id = 3, .index = 1, .session_id = "STARTUP" });
}
{
// we have a browser context with a different session_id
_ = try ctx.loadBrowserContext(.{ .session_id = "SESS-2" });
try ctx.processMessage(.{ .id = 4, .method = "Hi", .sessionId = "STARTUP" });
try ctx.expectSentResult(null, .{ .id = 4, .index = 2, .session_id = "STARTUP" });
}
}
test "cdp: disconnect latches so the worker keeps exiting" {
var ctx = try testing.context();
defer ctx.deinit();
const client = &ctx.cdp().browser.http_client;
// Simulate the Server run loop delivering a peer disconnect into the
// worker's inbox — the dropLink(notify=true) path used on peer EOF and,
// since #2510, on shutdown via shutdownLinks.
{
const arena = try client.arena_pool.acquire(.tiny, "test disconnect");
ctx.inbox.push(arena, .{ .disconnect = null });
}
// First tick drains the .disconnect and tears the link down.
try testing.expectError(error.ClientDisconnected, client.tick(0));
// The inbox is now empty. Without the latch this second tick would fall
// through to perform/poll with no producer left to wake it, so the worker
// would never exit and Server.deinit() would spin on active_threads
// (#2510). The latch keeps the terminal state sticky so the worker exits.
try testing.expectError(error.ClientDisconnected, client.tick(0));
}
test "cdp: run sends a close frame on pending terminate" {
testing.expectLog(&.{.cdp});
var ctx = try testing.context();
defer ctx.deinit();
const cdp = ctx.cdp();
cdp.browser.env.requestTerminate();
// Clear the pending terminate so deinit's V8 calls don't trip over the
// terminating-state asserts.
defer cdp.browser.env.cancelTerminate();
// The pending terminate makes the first tick the last one, so run returns.
ctx.driver.run();
// The client should receive a close frame (code 1001, going away), not
// just an abrupt socket close.
var buf: [WS.CLOSE_GOING_AWAY.len]u8 = undefined;
const n = try posix.read(ctx.socket, &buf);
try testing.expectEqualSlices(u8, &WS.CLOSE_GOING_AWAY, buf[0..n]);
}
test "cdp: syncRequest short-circuits after disconnect" {
var ctx = try testing.context();
defer ctx.deinit();
const client = &ctx.cdp().browser.http_client;
// Latch disconnected via a drained disconnect (as above).
{
const arena = try client.arena_pool.acquire(.tiny, "test disconnect");
ctx.inbox.push(arena, .{ .disconnect = null });
}
try testing.expectError(error.ClientDisconnected, client.tick(0));
// A synchronous fetch attempted after the latch returns ClientDisconnected
// without starting the request: syncRequest consumes (deinits) the
// transfer on the early-return path, before any of the callbacks are
// installed. The latch check runs before any req field is read, so the
// rest are placeholders.
const transfer = try client.newRequest(.{
.method = .GET,
.url = "http://127.0.0.1:9582/",
.origin = null,
.credentials_mode = .omit,
.request_mode = .no_cors,
.resource_type = .fetch,
.shutdown_callback = HttpClient.noopShutdown,
}, null);
try testing.expectError(error.ClientDisconnected, transfer.submitSync());
}