// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . const std = @import("std"); const lp = @import("lightpanda"); const 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":,... 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()); }