mirror of
				https://github.com/lightpanda-io/browser.git
				synced 2025-10-30 07:31:47 +00:00 
			
		
		
		
	
		
			
				
	
	
		
			938 lines
		
	
	
		
			39 KiB
		
	
	
	
		
			Zig
		
	
	
	
	
	
			
		
		
	
	
			938 lines
		
	
	
		
			39 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 Allocator = std.mem.Allocator;
 | |
| const json = std.json;
 | |
| 
 | |
| const log = @import("../log.zig");
 | |
| const js = @import("../browser/js/js.zig");
 | |
| const polyfill = @import("../browser/polyfill/polyfill.zig");
 | |
| 
 | |
| const App = @import("../app.zig").App;
 | |
| const Browser = @import("../browser/browser.zig").Browser;
 | |
| const Session = @import("../browser/session.zig").Session;
 | |
| const Page = @import("../browser/page.zig").Page;
 | |
| const Incrementing = @import("../id.zig").Incrementing;
 | |
| const Notification = @import("../notification.zig").Notification;
 | |
| const LogInterceptor = @import("domains/log.zig").LogInterceptor;
 | |
| const InterceptState = @import("domains/fetch.zig").InterceptState;
 | |
| 
 | |
| pub const URL_BASE = "chrome://newtab/";
 | |
| pub const LOADER_ID = "LOADERID24DD2FD56CF1EF33C965C79C";
 | |
| 
 | |
| pub const CDP = CDPT(struct {
 | |
|     const Client = *@import("../server.zig").Client;
 | |
| });
 | |
| 
 | |
| const SessionIdGen = Incrementing(u32, "SID");
 | |
| const TargetIdGen = Incrementing(u32, "TID");
 | |
| const LoaderIdGen = Incrementing(u32, "LID");
 | |
| const BrowserContextIdGen = Incrementing(u32, "BID");
 | |
| 
 | |
| // Generic so that we can inject mocks into it.
 | |
| pub fn CDPT(comptime TypeProvider: type) type {
 | |
|     return struct {
 | |
|         // Used for sending message to the client and closing on error
 | |
|         client: TypeProvider.Client,
 | |
| 
 | |
|         allocator: Allocator,
 | |
| 
 | |
|         // The active browser
 | |
|         browser: Browser,
 | |
| 
 | |
|         // when true, any target creation must be attached.
 | |
|         target_auto_attach: bool = false,
 | |
| 
 | |
|         target_id_gen: TargetIdGen = .{},
 | |
|         loader_id_gen: LoaderIdGen = .{},
 | |
|         session_id_gen: SessionIdGen = .{},
 | |
|         browser_context_id_gen: BrowserContextIdGen = .{},
 | |
| 
 | |
|         browser_context: ?BrowserContext(Self),
 | |
| 
 | |
|         // Re-used arena for processing a message. We're assuming that we're getting
 | |
|         // 1 message at a time.
 | |
|         message_arena: std.heap.ArenaAllocator,
 | |
| 
 | |
|         // Used for processing notifications within a browser context.
 | |
|         notification_arena: std.heap.ArenaAllocator,
 | |
| 
 | |
|         const Self = @This();
 | |
| 
 | |
|         pub fn init(app: *App, client: TypeProvider.Client) !Self {
 | |
|             const allocator = app.allocator;
 | |
|             const browser = try Browser.init(app);
 | |
|             errdefer browser.deinit();
 | |
| 
 | |
|             return .{
 | |
|                 .client = client,
 | |
|                 .browser = browser,
 | |
|                 .allocator = allocator,
 | |
|                 .browser_context = null,
 | |
|                 .message_arena = std.heap.ArenaAllocator.init(allocator),
 | |
|                 .notification_arena = std.heap.ArenaAllocator.init(allocator),
 | |
|             };
 | |
|         }
 | |
| 
 | |
|         pub fn deinit(self: *Self) void {
 | |
|             if (self.browser_context) |*bc| {
 | |
|                 bc.deinit();
 | |
|             }
 | |
|             self.browser.deinit();
 | |
|             self.message_arena.deinit();
 | |
|             self.notification_arena.deinit();
 | |
|         }
 | |
| 
 | |
|         pub fn handleMessage(self: *Self, msg: []const u8) bool {
 | |
|             // if there's an error, it's already been logged
 | |
|             self.processMessage(msg) catch return false;
 | |
|             return true;
 | |
|         }
 | |
| 
 | |
|         pub fn processMessage(self: *Self, msg: []const u8) !void {
 | |
|             const arena = &self.message_arena;
 | |
|             defer _ = arena.reset(.{ .retain_with_limit = 1024 * 16 });
 | |
|             return self.dispatch(arena.allocator(), self, msg);
 | |
|         }
 | |
| 
 | |
|         // @newhttp
 | |
|         // A bit hacky right now. The main server loop doesn't unblock for
 | |
|         // scheduled task. So we run this directly in order to process any
 | |
|         // timeouts (or http events) which are ready to be processed.
 | |
| 
 | |
|         pub fn hasPage() bool {}
 | |
|         pub fn pageWait(self: *Self, ms: i32) Session.WaitResult {
 | |
|             const session = &(self.browser.session orelse return .no_page);
 | |
|             return session.wait(ms);
 | |
|         }
 | |
| 
 | |
|         // Called from above, in processMessage which handles client messages
 | |
|         // but can also be called internally. For example, Target.sendMessageToTarget
 | |
|         // calls back into dispatch to capture the response.
 | |
|         pub fn dispatch(self: *Self, arena: Allocator, sender: anytype, str: []const u8) !void {
 | |
|             const input = json.parseFromSliceLeaky(InputMessage, arena, str, .{
 | |
|                 .ignore_unknown_fields = true,
 | |
|             }) catch return error.InvalidJSON;
 | |
| 
 | |
|             var command = Command(Self, @TypeOf(sender)){
 | |
|                 .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) catch |err| {
 | |
|                     command.sendError(-31999, @errorName(err), .{}) catch {};
 | |
|                     return err;
 | |
|                 };
 | |
|             } else {
 | |
|                 dispatchCommand(&command, input.method) catch |err| {
 | |
|                     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: anytype) !void {
 | |
|             return command.sendResult(null, .{});
 | |
|         }
 | |
| 
 | |
|         fn dispatchCommand(command: anytype, 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) {
 | |
|                 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),
 | |
|                     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 @import("domains/network.zig").processMessage(command),
 | |
|                     asUint(u56, "Storage") => return @import("domains/storage.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 => {},
 | |
|                 },
 | |
|                 else => {},
 | |
|             }
 | |
| 
 | |
|             return error.UnknownDomain;
 | |
|         }
 | |
| 
 | |
|         fn isValidSessionId(self: *const Self, input_session_id: []const u8) bool {
 | |
|             const browser_context = &(self.browser_context orelse return false);
 | |
|             const session_id = browser_context.session_id orelse return false;
 | |
|             return std.mem.eql(u8, session_id, input_session_id);
 | |
|         }
 | |
| 
 | |
|         pub fn createBrowserContext(self: *Self) ![]const u8 {
 | |
|             if (self.browser_context != null) {
 | |
|                 return error.AlreadyExists;
 | |
|             }
 | |
|             const id = self.browser_context_id_gen.next();
 | |
| 
 | |
|             self.browser_context = @as(BrowserContext(Self), undefined);
 | |
|             const browser_context = &self.browser_context.?;
 | |
| 
 | |
|             try BrowserContext(Self).init(browser_context, id, self);
 | |
|             return id;
 | |
|         }
 | |
| 
 | |
|         pub fn disposeBrowserContext(self: *Self, 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;
 | |
|             return true;
 | |
|         }
 | |
| 
 | |
|         const SendEventOpts = struct {
 | |
|             session_id: ?[]const u8 = null,
 | |
|         };
 | |
|         pub fn sendEvent(self: *Self, 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 fn sendJSON(self: *Self, message: anytype) !void {
 | |
|             return self.client.sendJSON(message, .{
 | |
|                 .emit_null_optional_fields = false,
 | |
|             });
 | |
|         }
 | |
|     };
 | |
| }
 | |
| 
 | |
| pub fn BrowserContext(comptime CDP_T: type) type {
 | |
|     const Node = @import("Node.zig");
 | |
| 
 | |
|     return struct {
 | |
|         id: []const u8,
 | |
|         cdp: *CDP_T,
 | |
| 
 | |
|         // 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,
 | |
| 
 | |
|         // Points to the session arena
 | |
|         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: ?[]const u8,
 | |
| 
 | |
|         // 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,
 | |
| 
 | |
|         loader_id: []const u8,
 | |
|         security_origin: []const u8,
 | |
|         page_life_cycle_events: bool,
 | |
|         secure_context_type: []const u8,
 | |
|         node_registry: Node.Registry,
 | |
|         node_search_list: Node.Search.List,
 | |
| 
 | |
|         inspector: js.Inspector,
 | |
|         isolated_worlds: std.ArrayListUnmanaged(IsolatedWorld),
 | |
| 
 | |
|         http_proxy_changed: bool = false,
 | |
| 
 | |
|         // Extra headers to add to all requests.
 | |
|         extra_headers: std.ArrayListUnmanaged([*c]const u8) = .empty,
 | |
| 
 | |
|         intercept_state: InterceptState,
 | |
| 
 | |
|         log_interceptor: LogInterceptor(Self),
 | |
| 
 | |
|         // 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 page) 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 the,
 | |
|         captured_responses: std.AutoHashMapUnmanaged(usize, std.ArrayListUnmanaged(u8)),
 | |
| 
 | |
|         const Self = @This();
 | |
| 
 | |
|         fn init(self: *Self, id: []const u8, cdp: *CDP_T) !void {
 | |
|             const allocator = cdp.allocator;
 | |
| 
 | |
|             const session = try cdp.browser.newSession();
 | |
|             const arena = session.arena;
 | |
| 
 | |
|             const inspector = try cdp.browser.env.newInspector(arena, self);
 | |
| 
 | |
|             var registry = Node.Registry.init(allocator);
 | |
|             errdefer registry.deinit();
 | |
| 
 | |
|             self.* = .{
 | |
|                 .id = id,
 | |
|                 .cdp = cdp,
 | |
|                 .arena = arena,
 | |
|                 .target_id = null,
 | |
|                 .session_id = null,
 | |
|                 .session = session,
 | |
|                 .security_origin = URL_BASE,
 | |
|                 .secure_context_type = "Secure", // TODO = enum
 | |
|                 .loader_id = LOADER_ID,
 | |
|                 .page_life_cycle_events = false, // TODO; Target based value
 | |
|                 .node_registry = registry,
 | |
|                 .node_search_list = undefined,
 | |
|                 .isolated_worlds = .empty,
 | |
|                 .inspector = inspector,
 | |
|                 .notification_arena = cdp.notification_arena.allocator(),
 | |
|                 .intercept_state = try InterceptState.init(allocator),
 | |
|                 .captured_responses = .empty,
 | |
|                 .log_interceptor = LogInterceptor(Self).init(allocator, self),
 | |
|             };
 | |
|             self.node_search_list = Node.Search.List.init(allocator, &self.node_registry);
 | |
|             errdefer self.deinit();
 | |
| 
 | |
|             try cdp.browser.notification.register(.page_remove, self, onPageRemove);
 | |
|             try cdp.browser.notification.register(.page_created, self, onPageCreated);
 | |
|             try cdp.browser.notification.register(.page_navigate, self, onPageNavigate);
 | |
|             try cdp.browser.notification.register(.page_navigated, self, onPageNavigated);
 | |
|         }
 | |
| 
 | |
|         pub fn deinit(self: *Self) void {
 | |
|             // safe to call even if never registered
 | |
|             log.unregisterInterceptor();
 | |
|             self.log_interceptor.deinit();
 | |
| 
 | |
|             // Drain microtasks makes sure we don't have inspector's callback
 | |
|             // in progress before deinit.
 | |
|             self.cdp.browser.env.runMicrotasks();
 | |
| 
 | |
|             self.inspector.deinit();
 | |
| 
 | |
|             // abort all intercepted requests before closing the sesion/page
 | |
|             // since some of these might callback into the page/scriptmanager
 | |
|             for (self.intercept_state.pendingTransfers()) |transfer| {
 | |
|                 transfer.abort();
 | |
|             }
 | |
| 
 | |
|             // If the session has a page, 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.
 | |
|             self.cdp.browser.closeSession();
 | |
| 
 | |
|             for (self.isolated_worlds.items) |*world| {
 | |
|                 world.deinit();
 | |
|             }
 | |
|             self.isolated_worlds.clearRetainingCapacity();
 | |
|             self.node_registry.deinit();
 | |
|             self.node_search_list.deinit();
 | |
|             self.cdp.browser.notification.unregisterAll(self);
 | |
| 
 | |
|             if (self.http_proxy_changed) {
 | |
|                 // has to be called after browser.closeSession, since it won't
 | |
|                 // work if there are active connections.
 | |
|                 self.cdp.browser.http_client.restoreOriginalProxy() catch |err| {
 | |
|                     log.warn(.http, "restoreOriginalProxy", .{ .err = err });
 | |
|                 };
 | |
|             }
 | |
|             self.intercept_state.deinit();
 | |
|         }
 | |
| 
 | |
|         pub fn reset(self: *Self) void {
 | |
|             self.node_registry.reset();
 | |
|             self.node_search_list.reset();
 | |
|         }
 | |
| 
 | |
|         pub fn createIsolatedWorld(self: *Self, world_name: []const u8, grant_universal_access: bool) !*IsolatedWorld {
 | |
|             var executor = try self.cdp.browser.env.newExecutionWorld();
 | |
|             errdefer executor.deinit();
 | |
| 
 | |
|             const owned_name = try self.arena.dupe(u8, world_name);
 | |
|             const world = try self.isolated_worlds.addOne(self.arena);
 | |
| 
 | |
|             world.* = .{
 | |
|                 .name = owned_name,
 | |
|                 .executor = executor,
 | |
|                 .grant_universal_access = grant_universal_access,
 | |
|             };
 | |
| 
 | |
|             return world;
 | |
|         }
 | |
| 
 | |
|         pub fn nodeWriter(self: *Self, root: *const Node, opts: Node.Writer.Opts) Node.Writer {
 | |
|             return .{
 | |
|                 .root = root,
 | |
|                 .depth = opts.depth,
 | |
|                 .exclude_root = opts.exclude_root,
 | |
|                 .registry = &self.node_registry,
 | |
|             };
 | |
|         }
 | |
| 
 | |
|         pub fn getURL(self: *const Self) ?[]const u8 {
 | |
|             const page = self.session.currentPage() orelse return null;
 | |
|             const raw_url = page.url.raw;
 | |
|             return if (raw_url.len == 0) null else raw_url;
 | |
|         }
 | |
| 
 | |
|         pub fn networkEnable(self: *Self) !void {
 | |
|             try self.cdp.browser.notification.register(.http_request_fail, self, onHttpRequestFail);
 | |
|             try self.cdp.browser.notification.register(.http_request_start, self, onHttpRequestStart);
 | |
|             try self.cdp.browser.notification.register(.http_request_done, self, onHttpRequestDone);
 | |
|             try self.cdp.browser.notification.register(.http_response_data, self, onHttpResponseData);
 | |
|             try self.cdp.browser.notification.register(.http_response_header_done, self, onHttpResponseHeadersDone);
 | |
|         }
 | |
| 
 | |
|         pub fn networkDisable(self: *Self) void {
 | |
|             self.cdp.browser.notification.unregister(.http_request_fail, self);
 | |
|             self.cdp.browser.notification.unregister(.http_request_start, self);
 | |
|             self.cdp.browser.notification.unregister(.http_request_done, self);
 | |
|             self.cdp.browser.notification.unregister(.http_response_data, self);
 | |
|             self.cdp.browser.notification.unregister(.http_response_header_done, self);
 | |
|         }
 | |
| 
 | |
|         pub fn fetchEnable(self: *Self, authRequests: bool) !void {
 | |
|             try self.cdp.browser.notification.register(.http_request_intercept, self, onHttpRequestIntercept);
 | |
|             if (authRequests) {
 | |
|                 try self.cdp.browser.notification.register(.http_request_auth_required, self, onHttpRequestAuthRequired);
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         pub fn fetchDisable(self: *Self) void {
 | |
|             self.cdp.browser.notification.unregister(.http_request_intercept, self);
 | |
|             self.cdp.browser.notification.unregister(.http_request_auth_required, self);
 | |
|         }
 | |
| 
 | |
|         pub fn lifecycleEventsEnable(self: *Self) !void {
 | |
|             self.page_life_cycle_events = true;
 | |
|             try self.cdp.browser.notification.register(.page_network_idle, self, onPageNetworkIdle);
 | |
|             try self.cdp.browser.notification.register(.page_network_almost_idle, self, onPageNetworkAlmostIdle);
 | |
|         }
 | |
| 
 | |
|         pub fn lifecycleEventsDisable(self: *Self) void {
 | |
|             self.page_life_cycle_events = false;
 | |
|             self.cdp.browser.notification.unregister(.page_network_idle, self);
 | |
|             self.cdp.browser.notification.unregister(.page_network_almost_idle, self);
 | |
|         }
 | |
| 
 | |
|         pub fn logEnable(self: *Self) void {
 | |
|             log.registerInterceptor(.{
 | |
|                 .ctx = &self.log_interceptor,
 | |
|                 .done = LogInterceptor(Self).done,
 | |
|                 .writer = LogInterceptor(Self).writer,
 | |
|             });
 | |
|         }
 | |
| 
 | |
|         pub fn logDisable(_: *const Self) void {
 | |
|             log.unregisterInterceptor();
 | |
|         }
 | |
| 
 | |
|         pub fn onPageRemove(ctx: *anyopaque, _: Notification.PageRemove) !void {
 | |
|             const self: *Self = @ptrCast(@alignCast(ctx));
 | |
|             try @import("domains/page.zig").pageRemove(self);
 | |
|         }
 | |
| 
 | |
|         pub fn onPageCreated(ctx: *anyopaque, page: *Page) !void {
 | |
|             const self: *Self = @ptrCast(@alignCast(ctx));
 | |
|             return @import("domains/page.zig").pageCreated(self, page);
 | |
|         }
 | |
| 
 | |
|         pub fn onPageNavigate(ctx: *anyopaque, msg: *const Notification.PageNavigate) !void {
 | |
|             const self: *Self = @ptrCast(@alignCast(ctx));
 | |
|             defer self.resetNotificationArena();
 | |
|             return @import("domains/page.zig").pageNavigate(self.notification_arena, self, msg);
 | |
|         }
 | |
| 
 | |
|         pub fn onPageNavigated(ctx: *anyopaque, msg: *const Notification.PageNavigated) !void {
 | |
|             const self: *Self = @ptrCast(@alignCast(ctx));
 | |
|             return @import("domains/page.zig").pageNavigated(self, msg);
 | |
|         }
 | |
| 
 | |
|         pub fn onPageNetworkIdle(ctx: *anyopaque, msg: *const Notification.PageNetworkIdle) !void {
 | |
|             const self: *Self = @ptrCast(@alignCast(ctx));
 | |
|             return @import("domains/page.zig").pageNetworkIdle(self, msg);
 | |
|         }
 | |
| 
 | |
|         pub fn onPageNetworkAlmostIdle(ctx: *anyopaque, msg: *const Notification.PageNetworkAlmostIdle) !void {
 | |
|             const self: *Self = @ptrCast(@alignCast(ctx));
 | |
|             return @import("domains/page.zig").pageNetworkAlmostIdle(self, msg);
 | |
|         }
 | |
| 
 | |
|         pub fn onHttpRequestStart(ctx: *anyopaque, msg: *const Notification.RequestStart) !void {
 | |
|             const self: *Self = @ptrCast(@alignCast(ctx));
 | |
|             defer self.resetNotificationArena();
 | |
|             try @import("domains/network.zig").httpRequestStart(self.notification_arena, self, msg);
 | |
|         }
 | |
| 
 | |
|         pub fn onHttpRequestIntercept(ctx: *anyopaque, msg: *const Notification.RequestIntercept) !void {
 | |
|             const self: *Self = @ptrCast(@alignCast(ctx));
 | |
|             defer self.resetNotificationArena();
 | |
|             try @import("domains/fetch.zig").requestIntercept(self.notification_arena, self, msg);
 | |
|         }
 | |
| 
 | |
|         pub fn onHttpRequestFail(ctx: *anyopaque, msg: *const Notification.RequestFail) !void {
 | |
|             const self: *Self = @ptrCast(@alignCast(ctx));
 | |
|             defer self.resetNotificationArena();
 | |
|             return @import("domains/network.zig").httpRequestFail(self.notification_arena, self, msg);
 | |
|         }
 | |
| 
 | |
|         pub fn onHttpResponseHeadersDone(ctx: *anyopaque, msg: *const Notification.ResponseHeaderDone) !void {
 | |
|             const self: *Self = @ptrCast(@alignCast(ctx));
 | |
|             defer self.resetNotificationArena();
 | |
|             return @import("domains/network.zig").httpResponseHeaderDone(self.notification_arena, self, msg);
 | |
|         }
 | |
| 
 | |
|         pub fn onHttpRequestDone(ctx: *anyopaque, msg: *const Notification.RequestDone) !void {
 | |
|             const self: *Self = @ptrCast(@alignCast(ctx));
 | |
|             defer self.resetNotificationArena();
 | |
|             return @import("domains/network.zig").httpRequestDone(self.notification_arena, self, msg);
 | |
|         }
 | |
| 
 | |
|         pub fn onHttpResponseData(ctx: *anyopaque, msg: *const Notification.ResponseData) !void {
 | |
|             const self: *Self = @ptrCast(@alignCast(ctx));
 | |
|             const arena = self.arena;
 | |
| 
 | |
|             const id = msg.transfer.id;
 | |
|             const gop = try self.captured_responses.getOrPut(arena, id);
 | |
|             if (!gop.found_existing) {
 | |
|                 gop.value_ptr.* = .{};
 | |
|             }
 | |
|             try gop.value_ptr.appendSlice(arena, try arena.dupe(u8, msg.data));
 | |
|         }
 | |
| 
 | |
|         pub fn onHttpRequestAuthRequired(ctx: *anyopaque, data: *const Notification.RequestAuthRequired) !void {
 | |
|             const self: *Self = @ptrCast(@alignCast(ctx));
 | |
|             defer self.resetNotificationArena();
 | |
|             try @import("domains/fetch.zig").requestAuthRequired(self.notification_arena, self, data);
 | |
|         }
 | |
| 
 | |
|         fn resetNotificationArena(self: *Self) void {
 | |
|             defer _ = self.cdp.notification_arena.reset(.{ .retain_with_limit = 1024 * 64 });
 | |
|         }
 | |
| 
 | |
|         pub fn callInspector(self: *const Self, msg: []const u8) void {
 | |
|             self.inspector.send(msg);
 | |
|             // force running micro tasks after send input to the inspector.
 | |
|             self.cdp.browser.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>,...
 | |
|                 std.debug.assert(std.mem.startsWith(u8, msg, "{\"method\":"));
 | |
|                 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: *Self, 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.client.send_arena.allocator();
 | |
| 
 | |
|             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.ArrayListUnmanaged(u8) = .{};
 | |
|             buf.ensureTotalCapacity(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 dont' want the closing brace '}'
 | |
|             buf.appendSliceAssumeCapacity(msg[0 .. msg.len - 1]);
 | |
|             buf.appendSliceAssumeCapacity(field);
 | |
|             buf.appendSliceAssumeCapacity(session_id);
 | |
|             buf.appendSliceAssumeCapacity("\"}");
 | |
|             std.debug.assert(buf.items.len == message_len);
 | |
| 
 | |
|             try cdp.client.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 webpage. Similar to the main context we need to pretend to recreate it after
 | |
| /// a executionContextsCleared event which happens when navigating to a new page. A client can have a command be executed
 | |
| /// in the isolated world by using its Context ID or the worldName.
 | |
| /// grantUniveralAccess Indecated whether the isolated world can reference objects like the DOM or other JS Objects.
 | |
| /// An isolated world has it's own instance of globals like Window.
 | |
| /// Generally the client needs to resolve a node into the isolated world to be able to work with it.
 | |
| /// An object id is unique across all contexts, different object ids can refer to the same Node in different contexts.
 | |
| const IsolatedWorld = struct {
 | |
|     name: []const u8,
 | |
|     executor: js.ExecutionWorld,
 | |
|     grant_universal_access: bool,
 | |
| 
 | |
|     // Polyfill loader for the isolated world.
 | |
|     // We want to load polyfill in the world's context.
 | |
|     polyfill_loader: polyfill.Loader = .{},
 | |
| 
 | |
|     pub fn deinit(self: *IsolatedWorld) void {
 | |
|         self.executor.deinit();
 | |
|     }
 | |
|     pub fn removeContext(self: *IsolatedWorld) !void {
 | |
|         if (self.executor.context == null) return error.NoIsolatedContextToRemove;
 | |
|         self.executor.removeContext();
 | |
|     }
 | |
| 
 | |
|     // The isolate world must share at least some of the state with the related page, specifically the DocumentHTML
 | |
|     // (assuming grantUniveralAccess will be set to True!).
 | |
|     // We just created the world and the page. The page's state lives in the session, but is update on navigation.
 | |
|     // This also means this pointer becomes invalid after removePage untill a new page is created.
 | |
|     // Currently we have only 1 page/frame and thus also only 1 state in the isolate world.
 | |
|     pub fn createContext(self: *IsolatedWorld, page: *Page) !void {
 | |
|         // if (self.executor.context != null) return error.Only1IsolatedContextSupported;
 | |
|         if (self.executor.context != null) {
 | |
|             log.warn(.cdp, "not implemented", .{
 | |
|                 .feature = "createContext: Not implemented second isolated context creation",
 | |
|                 .info = "reuse existing context",
 | |
|             });
 | |
|             return;
 | |
|         }
 | |
|         _ = try self.executor.createContext(
 | |
|             page,
 | |
|             false,
 | |
|             js.GlobalMissingCallback.init(&self.polyfill_loader),
 | |
|         );
 | |
|     }
 | |
| 
 | |
|     pub fn createContextAndLoadPolyfills(self: *IsolatedWorld, arena: Allocator, page: *Page) !void {
 | |
|         // We need to recreate the isolated world context
 | |
|         try self.createContext(page);
 | |
| 
 | |
|         const loader = @import("../browser/polyfill/polyfill.zig");
 | |
|         try loader.preload(arena, &self.executor.context.?);
 | |
|     }
 | |
| };
 | |
| 
 | |
| // 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 fn Command(comptime CDP_T: type, comptime Sender: type) type {
 | |
|     return struct {
 | |
|         // A misc arena that can be used for any allocation for processing
 | |
|         // the message
 | |
|         arena: Allocator,
 | |
| 
 | |
|         // reference to our CDP instance
 | |
|         cdp: *CDP_T,
 | |
| 
 | |
|         // The browser context this command targets
 | |
|         browser_context: ?*BrowserContext(CDP_T),
 | |
| 
 | |
|         // 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
 | |
|         // comamnds are dispatched internally, in which cases the Sender will
 | |
|         // be code to capture the data that we were "sending".
 | |
|         sender: Sender,
 | |
| 
 | |
|         const Self = @This();
 | |
| 
 | |
|         pub fn params(self: *const Self, 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: *Self) !*BrowserContext(CDP_T) {
 | |
|             _ = try self.cdp.createBrowserContext();
 | |
|             self.browser_context = &(self.cdp.browser_context.?);
 | |
|             return self.browser_context.?;
 | |
|         }
 | |
| 
 | |
|         const SendResultOpts = struct {
 | |
|             include_session_id: bool = true,
 | |
|         };
 | |
|         pub fn sendResult(self: *Self, result: anytype, opts: SendResultOpts) !void {
 | |
|             return self.sender.sendJSON(.{
 | |
|                 .id = self.input.id,
 | |
|                 .result = if (comptime @typeInfo(@TypeOf(result)) == .null) struct {}{} else result,
 | |
|                 .sessionId = if (opts.include_session_id) self.input.session_id else null,
 | |
|             });
 | |
|         }
 | |
| 
 | |
|         const SendEventOpts = struct {
 | |
|             session_id: ?[]const u8 = null,
 | |
|         };
 | |
|         pub fn sendEvent(self: *Self, method: []const u8, p: anytype, opts: CDP_T.SendEventOpts) !void {
 | |
|             // Events ALWAYS go to the client. self.sender should not be used
 | |
|             return self.cdp.sendEvent(method, p, opts);
 | |
|         }
 | |
| 
 | |
|         const SendErrorOpts = struct {
 | |
|             include_session_id: bool = true,
 | |
|         };
 | |
|         pub fn sendError(self: *Self, code: i32, message: []const u8, opts: SendErrorOpts) !void {
 | |
|             return self.sender.sendJSON(.{
 | |
|                 .id = self.input.id,
 | |
|                 .@"error" = .{ .code = code, .message = message },
 | |
|                 .sessionId = if (opts.include_session_id) self.input.session_id else null,
 | |
|             });
 | |
|         }
 | |
| 
 | |
|         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
 | |
| 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 = testing.context();
 | |
|     defer ctx.deinit();
 | |
| 
 | |
|     try testing.expectError(error.InvalidJSON, ctx.processMessage("invalid"));
 | |
| 
 | |
|     // method is required
 | |
|     try testing.expectError(error.InvalidJSON, ctx.processMessage(.{}));
 | |
| 
 | |
|     try testing.expectError(error.InvalidMethod, ctx.processMessage(.{
 | |
|         .method = "Target",
 | |
|     }));
 | |
|     try ctx.expectSentError(-31998, "InvalidMethod", .{});
 | |
| 
 | |
|     try testing.expectError(error.UnknownDomain, ctx.processMessage(.{
 | |
|         .method = "Unknown.domain",
 | |
|     }));
 | |
| 
 | |
|     try testing.expectError(error.UnknownMethod, ctx.processMessage(.{
 | |
|         .method = "Target.over9000",
 | |
|     }));
 | |
| }
 | |
| 
 | |
| test "cdp: invalid sessionId" {
 | |
|     var ctx = 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 brower 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 brower 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 = 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 brower context but no session_id
 | |
|         _ = try ctx.loadBrowserContext(.{});
 | |
|         try ctx.processMessage(.{ .id = 3, .method = "Hi", .sessionId = "STARTUP" });
 | |
|         try ctx.expectSentResult(null, .{ .id = 3, .index = 0, .session_id = "STARTUP" });
 | |
|     }
 | |
| 
 | |
|     {
 | |
|         // we have a brower 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 = 0, .session_id = "STARTUP" });
 | |
|     }
 | |
| }
 | 
