diff --git a/src/server/Handshake.zig b/src/server/Handshake.zig index 581e275090..1414b64f65 100644 --- a/src/server/Handshake.zig +++ b/src/server/Handshake.zig @@ -32,6 +32,7 @@ const bidi_session = @import("bidi/session.zig"); const log = lp.log; const posix = std.posix; +const Allocator = std.mem.Allocator; const Handshake = @This(); @@ -53,7 +54,13 @@ pub const Protocols = struct { pub const Options = struct { protocols: Protocols, bidi_session_url: []const u8, - json_version_response: []const u8, + http_responses: HttpResponses, +}; + +pub const HttpResponses = struct { + version: []const u8, + list: []const u8, + new: []const u8, }; app: *App, @@ -147,51 +154,62 @@ fn handleHttpRequest(self: *Handshake, request: []u8, head_len: usize) !Result { return error.InvalidRequest; } - // The classic WebDriver session bootstrap is the only thing with a body. - if (std.mem.startsWith(u8, request, "POST ") or std.mem.startsWith(u8, request, "DELETE ")) { - if (!self.options.protocols.webdriver) { - return error.NotFound; - } - return self.handleWebDriverRequest(request, head_len); - } + const method, const target, _, _ = header_parser.parseRequest(request) catch { + return error.InvalidRequest; + }; - if (std.mem.eql(u8, request[0..4], "GET ") == false) { - return error.NotFound; + switch (method) { + // The classic WebDriver session bootstrap is the only thing with a body. + .post, .delete => { + if (!self.options.protocols.webdriver) { + return error.NotFound; + } + return self.handleWebDriverRequest(request, head_len); + }, + .get, .put => {}, + else => return error.NotFound, } + const is_get = method == .get; - // Everything else is a body-less GET: the header block is the request. - if (head_len != request.len) { + // GETs are body-less; a PUT body is ignored rather than waited on. + if (is_get and head_len != request.len) { return .more; } - const url_end = std.mem.indexOfScalarPos(u8, request, 4, ' ') orelse { - return error.InvalidRequest; - }; + // Routing ignores the query string, as Chrome does. + const path, const query = if (std.mem.indexOfScalar(u8, target, '?')) |i| + .{ target[0..i], target[i + 1 ..] } + else + .{ target, "" }; - const url = request[4..url_end]; + // Only /json/new takes PUT (required since Chrome 66; GET kept for + // older clients). + if (!is_get and !std.mem.startsWith(u8, path, "/json/new")) { + return error.NotFound; + } - if (std.mem.eql(u8, url, "/metrics") and self.app.config.metricsEndpointEnabled()) { + if (std.mem.eql(u8, path, "/metrics") and self.app.config.metricsEndpointEnabled()) { try self.sendMetrics(); self.shutdown(); return .close; } if (self.options.protocols.webdriver) { - if (std.mem.eql(u8, url, "/session")) { + if (std.mem.eql(u8, path, "/session")) { // /session is the path Firefox advertises its BiDi endpoint on try self.upgrade(request); return .{ .upgrade = .{ .bidi = null } }; } - if (std.mem.startsWith(u8, url, "/session/") and url.len == "/session/".len + 36) { + if (std.mem.startsWith(u8, path, "/session/") and path.len == "/session/".len + 36) { // The URL a POST /session handed out; the session id is the suffix. var session_id: [36]u8 = undefined; - @memcpy(&session_id, url["/session/".len..]); + @memcpy(&session_id, path["/session/".len..]); try self.upgrade(request); return .{ .upgrade = .{ .bidi = session_id } }; } - if (std.mem.eql(u8, url, "/status")) { + if (std.mem.eql(u8, path, "/status")) { // WebDriver's discovery endpoint; `ready` is whether a new session // can be created, which the bootstrap never refuses. return self.sendWebDriver("200 OK", .{ .ready = true, .message = "" }); @@ -202,40 +220,54 @@ fn handleHttpRequest(self: *Handshake, request: []u8, head_len: usize) !Result { return error.NotFound; } - if (std.mem.eql(u8, url, "/")) { + if (std.mem.eql(u8, path, "/")) { try self.upgrade(request); return .{ .upgrade = .cdp }; } - if (std.mem.eql(u8, url, "/json/version") or std.mem.eql(u8, url, "/json/version/")) { - try self.send(self.options.json_version_response); - // Chromedp (a Go driver) does an http request to /json/version - // then to / (websocket upgrade) using a different connection. - // Since we only allow 1 connection at a time, the 2nd one (the - // websocket upgrade) blocks until the first one times out. - // We can avoid that by closing the connection. json_version_response - // has a Connection: Close header too. - self.shutdown(); - return .close; + const route = if (path.len > 1 and path[path.len - 1] == '/') path[0 .. path.len - 1] else path; + + if (std.mem.eql(u8, route, "/json/version")) { + return self.sendAndClose(self.options.http_responses.version); } - if (std.mem.eql(u8, url, "/json/list") or std.mem.eql(u8, url, "/json/list/") or - std.mem.eql(u8, url, "/json") or std.mem.eql(u8, url, "/json/")) - { - try self.send(empty_json_list_response); - self.shutdown(); - return .close; + if (std.mem.eql(u8, route, "/json/list") or std.mem.eql(u8, route, "/json")) { + return self.sendAndClose(self.options.http_responses.list); } - if (std.mem.eql(u8, url, "/json/protocol") or std.mem.eql(u8, url, "/json/protocol/")) { - try self.send(protocol_response); - self.shutdown(); - return .close; + if (std.mem.eql(u8, route, "/json/new")) { + if (query.len > 0) { + log.warn(.not_implemented, "json new navigation", .{ .url = query }); + } + return self.sendAndClose(self.options.http_responses.new); + } + + if (std.mem.eql(u8, route, "/json/activate") or std.mem.startsWith(u8, route, "/json/activate/")) { + return self.sendAndClose(target_activated_response); + } + + if (std.mem.eql(u8, route, "/json/close") or std.mem.startsWith(u8, route, "/json/close/")) { + log.warn(.not_implemented, "json close target", .{}); + return self.sendAndClose(target_closing_response); + } + + if (std.mem.eql(u8, route, "/json/protocol")) { + return self.sendAndClose(protocol_response); } return error.NotFound; } +// Every HTTP response carries Connection: Close and we really do close: +// chromedp requests /json/version and then upgrades on a different +// connection, and with a single connection allowed the upgrade would +// otherwise wait for the first one to time out. +fn sendAndClose(self: *Handshake, response: []const u8) !Result { + try self.send(response); + self.shutdown(); + return .close; +} + // TODO: Temporary solution that provides the bare minimum for Selenium to // connect. Serve a few of the (classic) WebDriver HTTP API. It's obvious that // Handshake.zig needs to become a more generic HTTP server/router, but that @@ -342,15 +374,9 @@ fn sendWebDriver(self: *Handshake, comptime status: []const u8, value: anytype) try std.json.Stringify.value(.{ .value = value }, .{}, &aw.writer); const body = aw.written(); - const response = try std.fmt.allocPrint(allocator, "HTTP/1.1 " ++ status ++ "\r\n" ++ - "Content-Length: {d}\r\n" ++ - "Connection: Close\r\n" ++ - "Content-Type: application/json; charset=UTF-8\r\n\r\n" ++ - "{s}", .{ body.len, body }); + const response = try buildResponse(allocator, status, "application/json; charset=UTF-8", "{s}", .{body}); defer allocator.free(response); - try self.send(response); - self.shutdown(); - return .close; + return self.sendAndClose(response); } fn upgrade(self: *Handshake, request: []u8) !void { @@ -480,11 +506,7 @@ fn sendMetrics(self: *Handshake) !void { lp.metrics.write(&aw.writer); const body = aw.written(); - const response = try std.fmt.allocPrint(allocator, "HTTP/1.1 200 OK\r\n" ++ - "Content-Length: {d}\r\n" ++ - "Connection: Close\r\n" ++ - "Content-Type: text/plain; version=0.0.4; charset=utf-8\r\n\r\n" ++ - "{s}", .{ body.len, body }); + const response = try buildResponse(allocator, "200 OK", "text/plain; version=0.0.4; charset=utf-8", "{s}", .{body}); defer allocator.free(response); try self.send(response); } @@ -533,19 +555,23 @@ fn shutdown(self: *Handshake) void { sys_net.shutdown(self.socket, .recv) catch {}; } -const empty_json_list_response = - "HTTP/1.1 200 OK\r\n" ++ - "Content-Length: 2\r\n" ++ +const response_head_format = + "HTTP/1.1 {s}\r\n" ++ + "Content-Length: {d}\r\n" ++ "Connection: Close\r\n" ++ - "Content-Type: application/json; charset=UTF-8\r\n\r\n" ++ - "[]"; + "Content-Type: {s}\r\n\r\n"; -const protocol_json = @embedFile("../data/protocol.json"); +pub fn buildResponse(allocator: Allocator, comptime status: []const u8, comptime content_type: []const u8, comptime body_format: []const u8, args: anytype) ![]const u8 { + const body_len = std.fmt.count(body_format, args); + return std.fmt.allocPrint(allocator, response_head_format ++ body_format, .{ status, body_len, content_type } ++ args); +} + +fn staticResponse(comptime content_type: []const u8, comptime body: []const u8) []const u8 { + return std.fmt.comptimePrint(response_head_format, .{ "200 OK", body.len, content_type ++ "; charset=UTF-8" }) ++ body; +} -const protocol_response = std.fmt.comptimePrint( - "HTTP/1.1 200 OK\r\n" ++ - "Content-Length: {d}\r\n" ++ - "Connection: Close\r\n" ++ - "Content-Type: application/json; charset=UTF-8\r\n\r\n", - .{protocol_json.len}, -) ++ protocol_json; +const target_activated_response = staticResponse("text/plain", "Target activated"); +const target_closing_response = staticResponse("text/plain", "Target is closing"); + +const protocol_json = @embedFile("../data/protocol.json"); +const protocol_response = staticResponse("application/json", protocol_json); diff --git a/src/server/Server.zig b/src/server/Server.zig index f054863505..e415b9dc33 100644 --- a/src/server/Server.zig +++ b/src/server/Server.zig @@ -74,7 +74,7 @@ app: *App, max_connections: usize, protocols: Handshake.Protocols, bidi_session_url: []const u8, -json_version_response: []const u8, +http_responses: Handshake.HttpResponses, driver_mutex: std.Io.Mutex = .init, drivers: std.ArrayList(Driver) = .empty, @@ -156,6 +156,10 @@ pub fn init(app: *App, address: sys_net.IpAddress) !*Server { const port = bound_address.getPort(); const json_version_response = try buildJSONVersionResponse(app, port); errdefer allocator.free(json_version_response); + const json_list_response = try buildJSONHttpResponse(allocator, "[ " ++ target_json_format ++ " ]", .{ app.config.advertiseHost(), port }); + errdefer allocator.free(json_list_response); + const json_new_response = try buildJSONHttpResponse(allocator, target_json_format, .{ app.config.advertiseHost(), port }); + errdefer allocator.free(json_new_response); const bidi_session_url = try std.fmt.allocPrint(allocator, "ws://{s}:{d}/session/", .{ app.config.advertiseHost(), port }); errdefer allocator.free(bidi_session_url); @@ -169,7 +173,11 @@ pub fn init(app: *App, address: sys_net.IpAddress) !*Server { self.* = .{ .app = app, .cdp_pool = .empty, - .json_version_response = json_version_response, + .http_responses = .{ + .version = json_version_response, + .list = json_list_response, + .new = json_new_response, + }, .bidi_session_url = bidi_session_url, .max_connections = max_connections, .listener = listener, @@ -233,7 +241,9 @@ pub fn deinit(self: *Server) void { self.drivers.deinit(allocator); self.handshakes.deinit(allocator); self.cdp_pool.deinit(allocator); - allocator.free(self.json_version_response); + allocator.free(self.http_responses.version); + allocator.free(self.http_responses.list); + allocator.free(self.http_responses.new); allocator.free(self.bidi_session_url); allocator.free(self.pollfds); allocator.free(self.poll_snapshot); @@ -672,7 +682,7 @@ fn handshake(self: *Server, socket: posix.socket_t) ?Handshake.Route { } return Handshake.run(self.app, socket, &.{ .protocols = self.protocols, - .json_version_response = self.json_version_response, + .http_responses = self.http_responses, .bidi_session_url = self.bidi_session_url, }); } @@ -813,7 +823,10 @@ fn getClientAddress(socket: posix.socket_t) !sys_net.IpAddress { return sys_net.addressFromSockaddr(@ptrCast(&storage)); } -// The pointed-to driver is owned by its worker thread +fn buildJSONHttpResponse(allocator: Allocator, comptime body_format: []const u8, args: anytype) ![]const u8 { + return Handshake.buildResponse(allocator, "200 OK", "application/json; charset=UTF-8", body_format, args); +} + fn buildJSONVersionResponse(app: *const App, port: u16) ![]const u8 { const host = app.config.advertiseHost(); if (app.config.bindIsWildcard()) { @@ -833,23 +846,22 @@ fn buildJSONVersionResponse(app: *const App, port: u16) ![]const u8 { "\"Lightpanda-Version\": \"" ++ lp.build_config.version ++ "\", " ++ "\"webSocketDebuggerUrl\": \"ws://{s}:{d}/\"" ++ "}}"; - const body_len = std.fmt.count(body_format, .{ host, port }); - - // We send a Connection: Close (and actually close the connection) - // because chromedp (Go driver) sends a request to /json/version and then - // does an upgrade request, on a different connection. Since we only allow - // 1 connection at a time, the upgrade connection doesn't proceed until we - // timeout the /json/version. So, instead of waiting for that, we just - // always close HTTP requests. - const response_format = - "HTTP/1.1 200 OK\r\n" ++ - "Content-Length: {d}\r\n" ++ - "Connection: Close\r\n" ++ - "Content-Type: application/json; charset=UTF-8\r\n\r\n" ++ - body_format; - return try std.fmt.allocPrint(app.allocator, response_format, .{ body_len, host, port }); + return try buildJSONHttpResponse(app.allocator, body_format, .{ host, port }); } +// Synthetic: /json is answered before any browser context exists, so the id +// won't match Target.getTargets and title/url never reflect a live page. +const target_json_format = + "{{" ++ + "\"description\": \"\", " ++ + "\"devtoolsFrontendUrl\": \"\", " ++ + "\"id\": \"1\", " ++ + "\"title\": \"\", " ++ + "\"type\": \"page\", " ++ + "\"url\": \"about:blank\", " ++ + "\"webSocketDebuggerUrl\": \"ws://{s}:{d}/\"" ++ + "}}"; + const testing = @import("../testing.zig"); test "server: buildJSONVersionResponse" { const res = try buildJSONVersionResponse(testing.test_app, testing.test_app.config.port()); @@ -1536,6 +1548,64 @@ test "server: get /json/version" { } } +test "server: get /json and /json/list" { + for ([_][]const u8{ + "GET /json HTTP/1.1\r\n\r\n", + "GET /json/ HTTP/1.1\r\n\r\n", + "GET /json?t=1 HTTP/1.1\r\n\r\n", + "GET /json/list HTTP/1.1\r\n\r\n", + "GET /json/list/ HTTP/1.1\r\n\r\n", + "GET /json/list?t=1 HTTP/1.1\r\n\r\n", + }) |req| { + var c = try createTestClient(); + defer c.deinit(); + + const res = try c.httpRequest(req); + try testing.expect(std.mem.startsWith(u8, res, "HTTP/1.1 200 OK\r\n")); + try testing.expect(std.mem.indexOf(u8, res, "Content-Type: application/json") != null); + try testing.expect(std.mem.indexOf(u8, res, "\"id\": \"1\"") != null); + try testing.expect(std.mem.indexOf(u8, res, "\"type\": \"page\"") != null); + try testing.expect(std.mem.indexOf(u8, res, "\"url\": \"about:blank\"") != null); + try testing.expect(std.mem.indexOf(u8, res, "\"webSocketDebuggerUrl\": \"ws://127.0.0.1:9583/\"") != null); + } +} + +test "server: get and put /json/new" { + for ([_][]const u8{ + "GET /json/new HTTP/1.1\r\n\r\n", + "GET /json/new/ HTTP/1.1\r\n\r\n", + "GET /json/new?https://example.com HTTP/1.1\r\n\r\n", + "PUT /json/new HTTP/1.1\r\n\r\n", + "PUT /json/new?https://example.com HTTP/1.1\r\n\r\n", + "PUT /json/new HTTP/1.1\r\nContent-Length: 3\r\n\r\nabc", + }) |req| { + var c = try createTestClient(); + defer c.deinit(); + + const res = try c.httpRequest(req); + try testing.expect(std.mem.startsWith(u8, res, "HTTP/1.1 200 OK\r\n")); + try testing.expect(std.mem.indexOf(u8, res, "Content-Type: application/json") != null); + try testing.expect(std.mem.indexOf(u8, res, "\"id\": \"1\"") != null); + try testing.expect(std.mem.indexOf(u8, res, "\"type\": \"page\"") != null); + try testing.expect(std.mem.indexOf(u8, res, "\"webSocketDebuggerUrl\": \"ws://127.0.0.1:9583/\"") != null); + } +} + +test "server: /json/activate and /json/close" { + for ([_]struct { req: []const u8, body: []const u8 }{ + .{ .req = "GET /json/activate/1 HTTP/1.1\r\n\r\n", .body = "Target activated" }, + .{ .req = "GET /json/close/1 HTTP/1.1\r\n\r\n", .body = "Target is closing" }, + }) |case| { + var c = try createTestClient(); + defer c.deinit(); + + const res = try c.httpRequest(case.req); + try testing.expect(std.mem.startsWith(u8, res, "HTTP/1.1 200 OK\r\n")); + try testing.expect(std.mem.indexOf(u8, res, "Content-Type: text/plain") != null); + try testing.expect(std.mem.endsWith(u8, res, case.body)); + } +} + test "server: get /json/protocol" { var c = try createTestClient(); defer c.deinit(); diff --git a/src/server/cdp/CDP.zig b/src/server/cdp/CDP.zig index 65816abb6f..8a26cffe99 100644 --- a/src/server/cdp/CDP.zig +++ b/src/server/cdp/CDP.zig @@ -1307,6 +1307,27 @@ pub const Command = struct { return self.browser_context.?; } + // Flat-protocol clients skip the Target handshake and expect the /json + // target to exist. A session id without a context is the browser + // session, which must not get a page implicitly. Not for handlers that + // resolve ids minted by an earlier command: a fresh context can never + // satisfy those. + pub fn requireBrowserContext(self: *Command) !*BrowserContext { + if (self.browser_context) |bc| return bc; + if (self.input.session_id != null) return error.BrowserContextNotLoaded; + try @import("domains/target.zig").createImplicitTarget(self); + return self.browser_context.?; + } + + // Teardown (X.disable, Page.close): with no context there is nothing to + // act on — sends the ok result and returns null. + pub fn teardownBrowserContext(self: *Command) !?*BrowserContext { + return self.browser_context orelse { + try self.sendResult(null, .{}); + return null; + }; + } + const SendResultOpts = struct { include_session_id: bool = true, }; diff --git a/src/server/cdp/domains/accessibility.zig b/src/server/cdp/domains/accessibility.zig index 2bdda6aa5e..36db228ac8 100644 --- a/src/server/cdp/domains/accessibility.zig +++ b/src/server/cdp/domains/accessibility.zig @@ -57,7 +57,7 @@ fn getFullAXTree(cmd: *CDP.Command) !void { frameId: ?[]const u8 = null, })) orelse return error.InvalidParams; - const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const bc = try cmd.requireBrowserContext(); const session = bc.session; const frame = blk: { diff --git a/src/server/cdp/domains/console.zig b/src/server/cdp/domains/console.zig index 3dae850909..51c3a2ccea 100644 --- a/src/server/cdp/domains/console.zig +++ b/src/server/cdp/domains/console.zig @@ -38,13 +38,13 @@ pub fn processMessage(cmd: *CDP.Command) !void { } fn enable(cmd: *CDP.Command) !void { - const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const bc = try cmd.requireBrowserContext(); try bc.consoleEnable(); return cmd.sendResult(null, .{}); } fn disable(cmd: *CDP.Command) !void { - const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const bc = try cmd.teardownBrowserContext() orelse return; bc.consoleDisable(); return cmd.sendResult(null, .{}); } diff --git a/src/server/cdp/domains/dom.zig b/src/server/cdp/domains/dom.zig index 238f0be56e..82839cc249 100644 --- a/src/server/cdp/domains/dom.zig +++ b/src/server/cdp/domains/dom.zig @@ -92,7 +92,7 @@ fn getDocument(cmd: *CDP.Command) !void { log.warn(.not_implemented, "DOM.getDocument", .{ .param = "pierce" }); } - const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const bc = try cmd.requireBrowserContext(); const frame = bc.mainFrame() orelse return error.FrameNotLoaded; const node = try bc.node_registry.register(frame.window._document.asNode()); @@ -156,7 +156,7 @@ fn performSearch(cmd: *CDP.Command) !void { includeUserAgentShadowDOM: ?bool = null, })) orelse return error.InvalidParams; - const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const bc = try cmd.requireBrowserContext(); const frame = bc.mainFrame() orelse return error.FrameNotLoaded; const root = frame.window._document.asNode(); @@ -187,7 +187,7 @@ fn finishSearch(cmd: *CDP.Command, bc: *CDP.BrowserContext, nodes: []const *DOMN // We should dispatch a node only if it has never been sent. fn dispatchSetChildNodes(cmd: *CDP.Command, dom_nodes: []const *DOMNode) !void { const arena = cmd.arena; - const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const bc = try cmd.requireBrowserContext(); const session_id = bc.session_id orelse return error.SessionIdNotLoaded; var parents: std.ArrayList(*NodeRegistry.Node) = .empty; @@ -571,7 +571,7 @@ fn getFrameOwner(cmd: *CDP.Command) !void { frameId: []const u8, })) orelse return error.InvalidParams; - const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const bc = try cmd.requireBrowserContext(); const frame_id = try id.parseFrameId(params.frameId); const frame = bc.session.findFrameByFrameId(frame_id) orelse { @@ -715,12 +715,14 @@ test "cdp.dom: getSearchResults unknown search id" { var ctx = try testing.context(); defer ctx.deinit(); + _ = try ctx.loadBrowserContext(.{ .id = "BID-A", .url = "cdp/dom1.html" }); + try ctx.processMessage(.{ .id = 8, .method = "DOM.getSearchResults", .params = .{ .searchId = "Nope", .fromIndex = 0, .toIndex = 10 }, }); - try ctx.expectSentError(-31998, "BrowserContextNotLoaded", .{ .id = 8 }); + try ctx.expectSentError(-31998, "SearchResultNotFound", .{ .id = 8 }); } test "cdp.dom: search flow" { diff --git a/src/server/cdp/domains/emulation.zig b/src/server/cdp/domains/emulation.zig index 93ae674fc9..53c58a56b4 100644 --- a/src/server/cdp/domains/emulation.zig +++ b/src/server/cdp/domains/emulation.zig @@ -173,7 +173,7 @@ pub fn setUserAgentOverride(cmd: *CDP.Command) !void { }, }; - const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const bc = try cmd.requireBrowserContext(); const http_client = &cmd.cdp.browser.http_client; try http_client.setUserAgentOverride(ua); bc.user_agent_changed = true; diff --git a/src/server/cdp/domains/fetch.zig b/src/server/cdp/domains/fetch.zig index 15b9d6c277..37d3266512 100644 --- a/src/server/cdp/domains/fetch.zig +++ b/src/server/cdp/domains/fetch.zig @@ -114,7 +114,7 @@ fn commandSessionId(cmd: *CDP.Command, bc: *CDP.BrowserContext) ![]const u8 { } fn disable(cmd: *CDP.Command) !void { - const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const bc = try cmd.teardownBrowserContext() orelse return; bc.fetchDisableForSession(try commandSessionId(cmd, bc)); return cmd.sendResult(null, .{}); } @@ -126,7 +126,7 @@ fn enable(cmd: *CDP.Command) !void { return cmd.sendError(-32602, "Can't specify empty patterns with handleAuth set", .{}); } } - const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const bc = try cmd.requireBrowserContext(); try bc.fetchEnable(params.patterns, params.handleAuthRequests, try commandSessionId(cmd, bc)); return cmd.sendResult(null, .{}); } diff --git a/src/server/cdp/domains/network.zig b/src/server/cdp/domains/network.zig index 2bfe84c823..f750371f93 100644 --- a/src/server/cdp/domains/network.zig +++ b/src/server/cdp/domains/network.zig @@ -118,7 +118,7 @@ fn enable(cmd: *CDP.Command) !void { maxPostDataSize: ?u32 = null, }; const params = (try cmd.params(Params)) orelse Params{}; - const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const bc = try cmd.requireBrowserContext(); var limits: BufferLimits = .{}; if (params.maxTotalBufferSize) |max| { @@ -136,7 +136,7 @@ fn enable(cmd: *CDP.Command) !void { } fn disable(cmd: *CDP.Command) !void { - const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const bc = try cmd.teardownBrowserContext() orelse return; bc.networkDisable(); return cmd.sendResult(null, .{}); } @@ -146,7 +146,7 @@ fn setCacheDisabled(cmd: *CDP.Command) !void { cacheDisabled: bool, })) orelse return error.InvalidParams; - const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const bc = try cmd.requireBrowserContext(); const client = &bc.cdp.browser.http_client; if (!bc.cdp.disable_set_cache_disabled) { client.disableCache(params.cacheDisabled); @@ -159,7 +159,7 @@ fn setBlockedURLs(cmd: *CDP.Command) !void { urlPatterns: []const HttpClient.BlockPattern, })) orelse return error.InvalidParams; - const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const bc = try cmd.requireBrowserContext(); try bc.cdp.browser.http_client.setBlockedUrlPatterns(params.urlPatterns); return cmd.sendResult(null, .{}); } @@ -169,7 +169,7 @@ fn setExtraHTTPHeaders(cmd: *CDP.Command) !void { headers: std.json.ArrayHashMap([]const u8), })) orelse return error.InvalidParams; - const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const bc = try cmd.requireBrowserContext(); // Copy the headers onto the browser context arena const arena = bc.arena; @@ -240,7 +240,7 @@ fn deleteCookies(cmd: *CDP.Command) !void { log.warn(.not_implemented, "partition key", .{ .src = "deleteCookies" }); } - const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const bc = try cmd.requireBrowserContext(); const cookies = &bc.session.cookie_jar.cookies; var index = cookies.items.len; @@ -264,14 +264,14 @@ fn clearBrowserCache(cmd: *CDP.Command) !void { // CDP clients (chrome-remote-interface, chromedp, custom websocket clients) // include an empty `"params":{}` object on every command for ergonomics. // Chrome accepts that and clears the jar; reject only on truly malformed JSON. - const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const bc = try cmd.requireBrowserContext(); const network = bc.cdp.browser.http_client.network; try network.cache.clear(); return cmd.sendResult(null, .{}); } fn canClearBrowserCache(cmd: *CDP.Command) !void { - const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const bc = try cmd.requireBrowserContext(); const network = bc.cdp.browser.http_client.network; return cmd.sendResult(.{ .result = network.cache.active() != null }, .{}); } @@ -281,7 +281,7 @@ fn clearBrowserCookies(cmd: *CDP.Command) !void { // CDP clients (chrome-remote-interface, chromedp, custom websocket clients) // include an empty `"params":{}` object on every command for ergonomics. // Chrome accepts that and clears the jar; reject only on truly malformed JSON. - const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const bc = try cmd.requireBrowserContext(); bc.session.cookie_jar.clearRetainingCapacity(); return cmd.sendResult(null, .{}); } @@ -291,7 +291,7 @@ fn setCookie(cmd: *CDP.Command) !void { CdpStorage.CdpCookie, )) orelse return error.InvalidParams; - const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const bc = try cmd.requireBrowserContext(); try CdpStorage.setCdpCookie(&bc.session.cookie_jar, params); try cmd.sendResult(.{ .success = true }, .{}); @@ -302,7 +302,7 @@ fn setCookies(cmd: *CDP.Command) !void { cookies: []const CdpStorage.CdpCookie, })) orelse return error.InvalidParams; - const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const bc = try cmd.requireBrowserContext(); for (params.cookies) |param| { try CdpStorage.setCdpCookie(&bc.session.cookie_jar, param); } @@ -314,8 +314,8 @@ const GetCookiesParam = struct { urls: ?[]const [:0]const u8 = null, }; fn getCookies(cmd: *CDP.Command) !void { - const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; const params = (try cmd.params(GetCookiesParam)) orelse GetCookiesParam{}; + const bc = try cmd.requireBrowserContext(); // If not specified, use the URLs of the page and all of its subframes. TODO subframes const frame_url = if (bc.mainFrame()) |frame| frame.url else null; @@ -337,7 +337,7 @@ fn getAllCookies(cmd: *CDP.Command) !void { // Mirrors Chrome's Network.getAllCookies and Storage.getCookies (without // the latter's browserContextId filter, since Network commands are scoped // to the current browser context already). - const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const bc = try cmd.requireBrowserContext(); var jar = &bc.session.cookie_jar; jar.removeExpired(null); const writer = CdpStorage.CookieWriter{ .cookies = jar.cookies.items }; diff --git a/src/server/cdp/domains/page.zig b/src/server/cdp/domains/page.zig index 7ea711e4a9..8f9adccd2c 100644 --- a/src/server/cdp/domains/page.zig +++ b/src/server/cdp/domains/page.zig @@ -107,7 +107,7 @@ fn setLifecycleEventsEnabled(cmd: *CDP.Command) !void { enabled: bool, })) orelse return error.InvalidParams; - const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const bc = try cmd.requireBrowserContext(); if (params.enabled == false) { bc.lifecycleEventsDisable(); @@ -152,7 +152,7 @@ fn addScriptToEvaluateOnNewDocument(cmd: *CDP.Command) !void { runImmediately: bool = false, })) orelse return error.InvalidParams; - const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const bc = try cmd.requireBrowserContext(); if (params.runImmediately) { log.warn(.not_implemented, "addScriptOnNewDocument", .{ .param = "runImmediately" }); @@ -179,7 +179,7 @@ fn removeScriptToEvaluateOnNewDocument(cmd: *CDP.Command) !void { identifier: []const u8, })) orelse return error.InvalidParams; - const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const bc = try cmd.requireBrowserContext(); const target_id = std.fmt.parseInt(u32, params.identifier, 10) catch return cmd.sendResult(null, .{}); @@ -194,7 +194,7 @@ fn removeScriptToEvaluateOnNewDocument(cmd: *CDP.Command) !void { } fn close(cmd: *CDP.Command) !void { - const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const bc = try cmd.teardownBrowserContext() orelse return; const target_id = bc.target_id orelse return error.TargetNotLoaded; @@ -244,7 +244,7 @@ fn createIsolatedWorld(cmd: *CDP.Command) !void { // When grantUniveralAccess == false and the client attempts to resolve // or otherwise access a DOM or other JS Object from another context that should fail. } - const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const bc = try cmd.requireBrowserContext(); const world = try bc.createIsolatedWorld(params.worldName, params.grantUniveralAccess); @@ -288,7 +288,7 @@ fn navigate(cmd: *CDP.Command) !void { // referrerPolicy: ?[]const u8 = null, // TODO: enum })) orelse return error.InvalidParams; - const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const bc = try cmd.requireBrowserContext(); // didn't create? // const target_id = bc.target_id orelse return error.TargetIdNotLoaded; @@ -335,7 +335,7 @@ fn doReload(cmd: *CDP.Command) !void { scriptToEvaluateOnLoad: ?[]const u8 = null, }); - const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const bc = try cmd.requireBrowserContext(); if (bc.session_id == null) { return error.SessionIdNotLoaded; @@ -378,7 +378,7 @@ const NavigationEntry = struct { }; fn getNavigationHistory(cmd: *CDP.Command) !void { - const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const bc = try cmd.requireBrowserContext(); if (bc.session_id == null) { return error.SessionIdNotLoaded; } @@ -412,7 +412,7 @@ fn navigateToHistoryEntry(cmd: *CDP.Command) !void { entryId: i64, })) orelse return error.InvalidParams; - const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const bc = try cmd.requireBrowserContext(); if (bc.session_id == null) { return error.SessionIdNotLoaded; } @@ -996,7 +996,7 @@ fn captureScreenshot(cmd: *CDP.Command) !void { log.warn(.not_implemented, "Page.captureScreenshot params", .{ .quality = params.quality }); } - const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const bc = try cmd.requireBrowserContext(); const frame = bc.mainFrame() orelse return error.FrameNotLoaded; const viewport = cmd.cdp.browser.getViewport(); @@ -1037,7 +1037,7 @@ fn printToPDF(cmd: *CDP.Command) !void { log.warn(.not_implemented, "Page.printToPDF params", .{ .displayHeaderFooter = true }); } - const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const bc = try cmd.requireBrowserContext(); const frame = bc.mainFrame() orelse return error.FrameNotLoaded; const paper_w = if (params.landscape) params.paperHeight else params.paperWidth; @@ -1588,12 +1588,6 @@ test "cdp.frame: reload" { var ctx = try testing.context(); defer ctx.deinit(); - { - // reload without browser context — should error - try ctx.processMessage(.{ .id = 30, .method = "Page.reload" }); - try ctx.expectSentError(-31998, "BrowserContextNotLoaded", .{ .id = 30 }); - } - const bc = try ctx.loadBrowserContext(.{ .id = "BID-9", .url = "hi.html", .target_id = "FID-000000000X".* }); { @@ -2157,16 +2151,6 @@ test "cdp.frame: getNavigationHistory + navigateToHistoryEntry" { var ctx = try testing.context(); defer ctx.deinit(); - { - // No browser context — should error. - try ctx.processMessage(.{ .id = 10, .method = "Page.getNavigationHistory" }); - try ctx.expectSentError(-31998, "BrowserContextNotLoaded", .{ .id = 10 }); - } - { - try ctx.processMessage(.{ .id = 11, .method = "Page.navigateToHistoryEntry", .params = .{ .entryId = 0 } }); - try ctx.expectSentError(-31998, "BrowserContextNotLoaded", .{ .id = 11 }); - } - var bc = try ctx.loadBrowserContext(.{ .id = "BID-B2", .url = "cdp/dom1.html", .target_id = "TID-B2-0000000".* }); // Build up history: dom1.html (from loadBrowserContext) → dom2.html → dom3.html. diff --git a/src/server/cdp/domains/runtime.zig b/src/server/cdp/domains/runtime.zig index 15570bbcb2..88c1ac1586 100644 --- a/src/server/cdp/domains/runtime.zig +++ b/src/server/cdp/domains/runtime.zig @@ -56,30 +56,33 @@ pub fn processMessage(cmd: *CDP.Command) !void { .enable => return enable(cmd), .disable => return disable(cmd), // Bookkeeping that can neither observe nor change the page's global. - .releaseObjectGroup, .discardConsoleEntries, .getHeapUsage, .getIsolateId, .setCustomObjectFormatterEnabled, .setMaxCallStackSizeToCapture => return sendInspector(cmd), + .releaseObjectGroup, .discardConsoleEntries, .setCustomObjectFormatterEnabled, .setMaxCallStackSizeToCapture => { + const bc = try cmd.teardownBrowserContext() orelse return; + return sendInspector(cmd, bc); + }, + // These have no honest answer without a live isolate. + .getHeapUsage, .getIsolateId => return sendInspector(cmd, try cmd.requireBrowserContext()), else => { - const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const bc = try cmd.requireBrowserContext(); bc.main_world_touched = true; - return sendInspector(cmd); + return sendInspector(cmd, bc); }, } } fn enable(cmd: *CDP.Command) !void { - const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const bc = try cmd.requireBrowserContext(); try bc.runtimeEnable(); - return sendInspector(cmd); + return sendInspector(cmd, bc); } fn disable(cmd: *CDP.Command) !void { - const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const bc = try cmd.teardownBrowserContext() orelse return; bc.runtimeDisable(); - return sendInspector(cmd); + return sendInspector(cmd, bc); } -fn sendInspector(cmd: *CDP.Command) !void { - const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; - +fn sendInspector(cmd: *CDP.Command, bc: *CDP.BrowserContext) void { // the result to return is handled directly by the inspector. bc.callInspector(cmd.input.json); } diff --git a/src/server/cdp/domains/storage.zig b/src/server/cdp/domains/storage.zig index 5cbe674eee..c2f04b83ad 100644 --- a/src/server/cdp/domains/storage.zig +++ b/src/server/cdp/domains/storage.zig @@ -44,8 +44,8 @@ pub fn processMessage(cmd: *CDP.Command) !void { const BrowserContextParam = struct { browserContextId: ?[]const u8 = null }; fn clearCookies(cmd: *CDP.Command) !void { - const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; const params = (try cmd.params(BrowserContextParam)) orelse BrowserContextParam{}; + const bc = try cmd.requireBrowserContext(); if (params.browserContextId) |browser_context_id| { if (std.mem.eql(u8, browser_context_id, bc.id) == false) { @@ -59,8 +59,8 @@ fn clearCookies(cmd: *CDP.Command) !void { } fn getCookies(cmd: *CDP.Command) !void { - const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; const params = (try cmd.params(BrowserContextParam)) orelse BrowserContextParam{}; + const bc = try cmd.requireBrowserContext(); if (params.browserContextId) |browser_context_id| { if (std.mem.eql(u8, browser_context_id, bc.id) == false) { @@ -73,11 +73,11 @@ fn getCookies(cmd: *CDP.Command) !void { } fn setCookies(cmd: *CDP.Command) !void { - const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; const params = (try cmd.params(struct { cookies: []const CdpCookie, browserContextId: ?[]const u8 = null, })) orelse return error.InvalidParams; + const bc = try cmd.requireBrowserContext(); if (params.browserContextId) |browser_context_id| { if (std.mem.eql(u8, browser_context_id, bc.id) == false) { diff --git a/src/server/cdp/domains/target.zig b/src/server/cdp/domains/target.zig index 11470a5d23..1d73f08089 100644 --- a/src/server/cdp/domains/target.zig +++ b/src/server/cdp/domains/target.zig @@ -170,11 +170,40 @@ fn createTarget(cmd: *CDP.Command) !void { } } + const frame = try setupTarget(cmd, bc); + const target_id = &bc.target_id.?; + + if (cmd.cdp.target_auto_attach) { + try doAttachtoTarget(cmd, target_id); + } + + if (!std.mem.eql(u8, "about:blank", params.url)) { + const encoded_url = try URL.resolveNavigation(frame.call_arena, params.url, .{}); + try frame.navigate( + encoded_url, + .{ .reason = .address_bar, .kind = .{ .push = null } }, + ); + } + + try cmd.sendResult(.{ + .targetId = target_id, + }, .{}); +} + +// Attaching is required even though flat-protocol clients never use the +// session id: page events are only emitted to an attached session. +pub fn createImplicitTarget(cmd: *CDP.Command) !void { + const bc = try cmd.createBrowserContext(); + _ = try setupTarget(cmd, bc); + try doAttachtoTarget(cmd, &bc.target_id.?); +} + +fn setupTarget(cmd: *CDP.Command, bc: *CDP.BrowserContext) !*lp.Frame { // if target_id is null, we should never have a blank frame - lp.assert(!bc.session.hasPage(), "CDP.target.createTarget not null page", .{}); + lp.assert(!bc.session.hasPage(), "CDP.target.setupTarget not null page", .{}); // if target_id is null, we should never have a session_id - lp.assert(bc.session_id == null, "CDP.target.createTarget not null session_id", .{}); + lp.assert(bc.session_id == null, "CDP.target.setupTarget not null session_id", .{}); const page = try bc.session.createPage(); const frame = page.frame().?; @@ -216,22 +245,7 @@ fn createTarget(cmd: *CDP.Command) !void { }, }, .{}); - // attach to the target only if auto attach is set. - if (cmd.cdp.target_auto_attach) { - try doAttachtoTarget(cmd, target_id); - } - - if (!std.mem.eql(u8, "about:blank", params.url)) { - const encoded_url = try URL.resolveNavigation(frame.call_arena, params.url, .{}); - try frame.navigate( - encoded_url, - .{ .reason = .address_bar, .kind = .{ .push = null } }, - ); - } - - try cmd.sendResult(.{ - .targetId = target_id, - }, .{}); + return frame; } fn attachToTarget(cmd: *CDP.Command) !void { @@ -993,3 +1007,30 @@ test "cdp.target: setAutoAttach false sends detachedFromTarget" { try testing.expectEqual(null, bc.session_id); try ctx.expectSentResult(null, .{ .id = 12 }); } + +test "cdp.target: sessionless page command creates an implicit target" { + var ctx = try testing.context(); + defer ctx.deinit(); + + // only handlers that need a context create one + try ctx.processMessage(.{ .id = 1, .method = "Target.getTargets" }); + try ctx.expectSentResult(.{ .targetInfos = [_]TargetInfo{} }, .{ .id = 1 }); + try ctx.processMessage(.{ .id = 3, .method = "Page.enable" }); + try ctx.expectSentResult(null, .{ .id = 3 }); + try testing.expectEqual(null, ctx.cdp().browser_context); + + // teardown succeeds as a no-op rather than spawning a page + try ctx.processMessage(.{ .id = 4, .method = "Network.disable" }); + try ctx.expectSentResult(null, .{ .id = 4 }); + try ctx.processMessage(.{ .id = 5, .method = "Runtime.disable" }); + try ctx.expectSentResult(null, .{ .id = 5 }); + try ctx.processMessage(.{ .id = 6, .method = "Page.close" }); + try ctx.expectSentResult(null, .{ .id = 6 }); + try testing.expectEqual(null, ctx.cdp().browser_context); + + try ctx.processMessage(.{ .id = 2, .method = "Page.setLifecycleEventsEnabled", .params = .{ .enabled = true } }); + const bc = ctx.cdp().browser_context.?; + try ctx.expectSentEvent("Target.targetCreated", .{ .targetInfo = .{ .url = "about:blank", .title = "", .attached = false, .type = "page", .canAccessOpener = false, .browserContextId = bc.id, .targetId = bc.target_id.? } }, .{}); + try ctx.expectSentEvent("Target.attachedToTarget", .{ .sessionId = bc.session_id.?, .targetInfo = .{ .url = "about:blank", .title = "", .attached = true, .type = "page", .canAccessOpener = false, .browserContextId = bc.id, .targetId = bc.target_id.? } }, .{}); + try ctx.expectSentResult(null, .{ .id = 2 }); +}