diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 8e2a70b4d..7494f5e22 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -116,7 +116,8 @@ jobs: echo "value=$args" >> "$GITHUB_OUTPUT" - run: | - ./lightpanda serve --port 9222 ${{ steps.args.outputs.value }} & + ./lightpanda serve --port 9222 --protocol cdp ${{ steps.args.outputs.value }} & + ./lightpanda serve --port 9223 --protocol webdriver ${{ steps.args.outputs.value }} & - run: | go run runner/main.go diff --git a/src/Config.zig b/src/Config.zig index df3ccfbb0..6d206e2ba 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -386,6 +386,7 @@ const Commands = cli.Builder(.{ .{ .name = "host", .type = []const u8, .default = "127.0.0.1" }, .{ .name = "port", .type = u16, .default = 9222 }, .{ .name = "advertise_host", .type = ?[]const u8 }, + .{ .name = "protocol", .type = Protocol, .default = Protocol.cdp }, .{ .name = "cdp_max_connections", .type = u16, .default = 16 }, .{ .name = "cdp_max_pending_connections", .type = u16, .default = 128 }, .{ .name = "cdp_max_message_size", .type = u32, .default = 1024 * 1024 }, @@ -821,6 +822,19 @@ pub fn adblockLists(self: *const Config) ?std.mem.SplitIterator(u8, .scalar) { return std.mem.splitScalar(u8, paths, ','); } +pub const Protocol = enum { + cdp, + webdriver, +}; + +pub fn protocol(self: *const Config) Protocol { + return switch (self.mode) { + .serve => |opts| opts.protocol, + .mcp => .cdp, + else => unreachable, + }; +} + pub fn maxConnections(self: *const Config) u16 { return switch (self.mode) { .serve => |opts| opts.cdp_max_connections, diff --git a/src/server/Handshake.zig b/src/server/Handshake.zig index 76ad05e6b..581e27509 100644 --- a/src/server/Handshake.zig +++ b/src/server/Handshake.zig @@ -26,7 +26,9 @@ const lp = @import("lightpanda"); const App = @import("../App.zig"); const sys_net = @import("../sys/net.zig"); +const uuidv4 = @import("../id.zig").uuidv4; const header_parser = @import("../network/header_parser.zig"); +const bidi_session = @import("bidi/session.zig"); const log = lp.log; const posix = std.posix; @@ -35,27 +37,46 @@ const Handshake = @This(); pub const Driver = enum { cdp, bidi }; +// Which driver an upgraded socket is handed to. +pub const Route = union(Driver) { + cdp, + bidi: ?[36]u8, // the sessionId +}; + +// Which route families are served. +pub const Protocols = struct { + cdp: bool = false, + webdriver: bool = false, +}; + +// What every handshake on a server needs; built once by Server. +pub const Options = struct { + protocols: Protocols, + bidi_session_url: []const u8, + json_version_response: []const u8, +}; + app: *App, len: usize = 0, socket: posix.socket_t, // cdpMaxHTTPMessageSize is a u14, so this covers any configured limit. buf: [std.math.maxInt(u14) + 1]u8 = undefined, -json_version_response: []const u8, +options: *const Options, const Result = union(enum) { more, close, - upgrade: Driver, + upgrade: Route, }; // Runs the HTTP phase to completion. Returns the route to hand the // upgraded socket to, or null if the connection is done (plain HTTP // request served, error, timeout or disconnect). -pub fn run(app: *App, socket: posix.socket_t, json_version_response: []const u8) ?Driver { +pub fn run(app: *App, socket: posix.socket_t, options: *const Options) ?Route { var self = Handshake{ .app = app, .socket = socket, - .json_version_response = json_version_response, + .options = options, }; while (true) { @@ -97,13 +118,10 @@ fn processHttpRequest(self: *Handshake) !Result { return error.RequestTooLarge; } - // we're only expecting [body-less] GET requests. - if (std.mem.endsWith(u8, request, "\r\n\r\n") == false) { - // we need more data, put any more data here - return .more; - } + // Wait for the whole header block; put any more data here. + const head_len = (std.mem.indexOf(u8, request, "\r\n\r\n") orelse return .more) + 4; - return self.handleHttpRequest(request) catch |err| { + return self.handleHttpRequest(request, head_len) catch |err| { switch (err) { error.NotFound => self.sendHttpError(404, "Not found"), error.ForbiddenOrigin => self.sendHttpError(403, "Origin not allowed"), @@ -123,35 +141,74 @@ fn processHttpRequest(self: *Handshake) !Result { }; } -fn handleHttpRequest(self: *Handshake, request: []u8) !Result { +fn handleHttpRequest(self: *Handshake, request: []u8, head_len: usize) !Result { if (request.len < 18) { // 18 is [generously] the smallest acceptable HTTP request 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); + } + if (std.mem.eql(u8, request[0..4], "GET ") == false) { return error.NotFound; } + // Everything else is a body-less GET: the header block is the request. + if (head_len != request.len) { + return .more; + } + const url_end = std.mem.indexOfScalarPos(u8, request, 4, ' ') orelse { return error.InvalidRequest; }; const url = request[4..url_end]; + if (std.mem.eql(u8, url, "/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")) { + // /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) { + // 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..]); + try self.upgrade(request); + return .{ .upgrade = .{ .bidi = session_id } }; + } + + if (std.mem.eql(u8, url, "/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 = "" }); + } + } + + if (!self.options.protocols.cdp) { + return error.NotFound; + } + if (std.mem.eql(u8, url, "/")) { try self.upgrade(request); return .{ .upgrade = .cdp }; } - if (std.mem.eql(u8, url, "/session")) { - // /session is the path Firefox advertises its BiDi endpoint on - try self.upgrade(request); - return .{ .upgrade = .bidi }; - } - if (std.mem.eql(u8, url, "/json/version") or std.mem.eql(u8, url, "/json/version/")) { - try self.send(self.json_version_response); + 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 @@ -176,15 +233,126 @@ fn handleHttpRequest(self: *Handshake, request: []u8) !Result { return .close; } - if (std.mem.eql(u8, url, "/metrics") and self.app.config.metricsEndpointEnabled()) { - try self.sendMetrics(); - self.shutdown(); - return .close; + return error.NotFound; +} + +// 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 +// can be done after the experimental BiDi code lands. +fn handleWebDriverRequest(self: *Handshake, request: []const u8, head_len: usize) !Result { + // A malformed request line or header maps to a 400 in processHttpRequest. + const method, const path, _, var header_iterator = header_parser.parseRequest(request) catch { + return error.InvalidProtocol; + }; + + var content_length: usize = 0; + while (header_iterator.next() catch return error.InvalidRequest) |header| { + if (std.ascii.eqlIgnoreCase(header.key, "content-length")) { + content_length = std.fmt.parseInt(usize, header.value, 10) catch return error.InvalidRequest; + } } + const total_len = head_len + content_length; + if (request.len < total_len) { + return .more; + } + if (request.len > total_len) { + return error.InvalidRequest; + } + const body = request[head_len..total_len]; + + switch (method) { + .post => if (std.mem.eql(u8, path, "/session")) { + return self.newSession(body); + }, + .delete => if (std.mem.startsWith(u8, path, "/session/")) { + return self.sendWebDriver("200 OK", null); + }, + else => {}, + } return error.NotFound; } +fn newSession(self: *Handshake, body: []const u8) !Result { + const allocator = self.app.allocator; + + const Capability = struct { webSocketUrl: ?bool = null }; + const parsed = std.json.parseFromSlice(struct { + capabilities: ?struct { + alwaysMatch: ?Capability = null, + firstMatch: ?[]const Capability = null, + } = null, + }, allocator, body, .{ .ignore_unknown_fields = true }) catch { + return self.sendWebDriver("400 Bad Request", .{ + .@"error" = "invalid argument", + .message = "invalid JSON body", + .stacktrace = "", + }); + }; + defer parsed.deinit(); + + // Without the capability the client intends to drive the session over + // HTTP, which this server doesn't serve: tell it now rather than 404 + // its first real command. + if (!requestsWebSocketUrl(parsed.value.capabilities)) { + return self.sendWebDriver("500 Internal Server Error", .{ + .@"error" = "session not created", + .message = "only WebDriver BiDi sessions are supported; request the webSocketUrl capability", + .stacktrace = "", + }); + } + + var session_id: [36]u8 = undefined; + uuidv4(&session_id); + + const url = try std.fmt.allocPrint(allocator, "{s}{s}", .{ self.options.bidi_session_url, &session_id }); + defer allocator.free(url); + + return self.sendWebDriver("200 OK", .{ + .sessionId = &session_id, + .capabilities = bidi_session.Capabilities{ + .userAgent = self.app.config.http_headers.user_agent, + .webSocketUrl = url, + }, + }); +} + +fn requestsWebSocketUrl(capabilities: anytype) bool { + const caps = capabilities orelse return false; + if (caps.alwaysMatch) |always| { + if (always.webSocketUrl == true) { + return true; + } + } + for (caps.firstMatch orelse &.{}) |first| { + if (first.webSocketUrl == true) { + return true; + } + } + return false; +} + +// Answers a classic WebDriver request with {"value": value} and closes. +fn sendWebDriver(self: *Handshake, comptime status: []const u8, value: anytype) !Result { + const allocator = self.app.allocator; + + var aw = try std.Io.Writer.Allocating.initCapacity(allocator, 512); + defer aw.deinit(); + 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 }); + defer allocator.free(response); + try self.send(response); + self.shutdown(); + return .close; +} + fn upgrade(self: *Handshake, request: []u8) !void { // We need to make sure that we got all the necessary headers + values; // a bit per required header. diff --git a/src/server/Server.zig b/src/server/Server.zig index 460eeba51..86a05e8ad 100644 --- a/src/server/Server.zig +++ b/src/server/Server.zig @@ -72,6 +72,8 @@ const PSEUDO_POLLFDS = 2; app: *App, max_connections: usize, +protocols: Handshake.Protocols, +bidi_session_url: []const u8, json_version_response: []const u8, driver_mutex: std.Io.Mutex = .init, @@ -151,17 +153,27 @@ pub fn init(app: *App, address: sys_net.IpAddress) !*Server { pollfds[1] = .{ .fd = listener, .events = posix.POLL.IN, .revents = 0 }; log.note(.app, "server running", .{ .address = bound_address }); - const json_version_response = try buildJSONVersionResponse(app, bound_address.getPort()); + const port = bound_address.getPort(); + const json_version_response = try buildJSONVersionResponse(app, port); + errdefer allocator.free(json_version_response); + + const bidi_session_url = try std.fmt.allocPrint(allocator, "ws://{s}:{d}/session/", .{ app.config.advertiseHost(), port }); + errdefer allocator.free(bidi_session_url); self.* = .{ .app = app, .cdp_pool = .empty, .json_version_response = json_version_response, + .bidi_session_url = bidi_session_url, .max_connections = max_connections, .listener = listener, .pollfds = pollfds, .wakeup_pipe = pipe, .poll_snapshot = poll_snapshot, + .protocols = switch (app.config.protocol()) { + .cdp => .{ .cdp = true }, + .webdriver => .{ .webdriver = true }, + }, }; return self; } @@ -219,6 +231,7 @@ pub fn deinit(self: *Server) void { self.handshakes.deinit(allocator); self.cdp_pool.deinit(allocator); allocator.free(self.json_version_response); + allocator.free(self.bidi_session_url); allocator.free(self.pollfds); allocator.free(self.poll_snapshot); for (self.wakeup_pipe) |fd| { @@ -634,11 +647,11 @@ fn handleConnection(self: *Server, socket: posix.socket_t) void { const route = self.handshake(socket) orelse return; switch (route) { .cdp => self.serveCDP(socket, &active_conns_early_release), - .bidi => self.serveBiDi(socket, &active_conns_early_release), + .bidi => |session_id| self.serveBiDi(socket, session_id, &active_conns_early_release), } } -fn handshake(self: *Server, socket: posix.socket_t) ?Handshake.Driver { +fn handshake(self: *Server, socket: posix.socket_t) ?Handshake.Route { { self.driver_mutex.lockUncancelable(lp.io); defer self.driver_mutex.unlock(lp.io); @@ -654,7 +667,11 @@ fn handshake(self: *Server, socket: posix.socket_t) ?Handshake.Driver { } } } - return Handshake.run(self.app, socket, self.json_version_response); + return Handshake.run(self.app, socket, &.{ + .protocols = self.protocols, + .json_version_response = self.json_version_response, + .bidi_session_url = self.bidi_session_url, + }); } // The socket is an upgraded websocket speaking CDP. @@ -684,15 +701,17 @@ fn serveCDP(self: *Server, socket: posix.socket_t, active_conns_early_release: * self.serve(.init(.{ .cdp = cdp }), active_conns_early_release); } -// The socket is an upgraded websocket speaking WebDriver BiDi. -fn serveBiDi(self: *Server, socket: posix.socket_t, active_conns_early_release: *bool) void { +// The socket is an upgraded websocket speaking WebDriver BiDi. session_id is +// set when the client came through a classic POST /session, which already +// created the session it's about to use. +fn serveBiDi(self: *Server, socket: posix.socket_t, session_id: ?[36]u8, active_conns_early_release: *bool) void { const allocator = self.app.allocator; // heap-allocated: BiDi embeds a Browser const bidi = allocator.create(BiDi) catch @panic("OOM"); defer allocator.destroy(bidi); - bidi.init(self.app, socket) catch |err| { + bidi.init(self.app, socket, session_id) catch |err| { log.err(.app, "BiDi init", .{ .err = err }); return; }; @@ -1357,6 +1376,139 @@ test "server: 404" { "Not found", res); } +test "server: classic session bootstrap" { + // What Selenium does before it speaks BiDi: a classic POST /session + // that hands back the websocket URL, then a DELETE on quit. + const session_id = blk: { + var c = try createTestClient(); + defer c.deinit(); + + const body = "{\"capabilities\":{\"firstMatch\":[{}],\"alwaysMatch\":{\"browserName\":\"firefox\",\"webSocketUrl\":true}}}"; + const res = try c.httpRequest(std.fmt.comptimePrint("POST /session HTTP/1.1\r\n" ++ + "Content-Type: application/json;charset=UTF-8\r\n" ++ + "Content-Length: {d}\r\n\r\n" ++ + "{s}", .{ body.len, body })); + try testing.expect(std.mem.startsWith(u8, res, "HTTP/1.1 200 OK\r\n")); + try testing.expect(std.mem.indexOf(u8, res, "\r\nConnection: Close\r\n") != null); + + const json = res[std.mem.indexOf(u8, res, "\r\n\r\n").? + 4 ..]; + const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{}); + defer parsed.deinit(); + + const value = parsed.value.object.get("value").?.object; + const id = value.get("sessionId").?.string; + try testing.expectEqual(36, id.len); + + const capabilities = value.get("capabilities").?.object; + try testing.expectEqual("Lightpanda", capabilities.get("browserName").?.string); + try testing.expectEqual(false, capabilities.get("acceptInsecureCerts").?.bool); + const ws_url = capabilities.get("webSocketUrl").?.string; + try testing.expectEqual("ws://127.0.0.1:9583/session/", ws_url[0 .. ws_url.len - 36]); + try testing.expectEqual(id, ws_url[ws_url.len - 36 ..]); + + break :blk id[0..36].*; + }; + + { + // The session already exists on the advertised URL: no session.new + // needed (or possible), everything else works as usual. + var c = try createTestClient(); + defer c.deinit(); + var path_buf: [64]u8 = undefined; + try c.handshake(try std.fmt.bufPrint(&path_buf, "/session/{s}", .{&session_id})); + + try c.bidiCommand("{\"id\":1,\"method\":\"session.status\"}"); + try assertBidiMessage(&c, .{ .type = "success", .id = 1, .result = .{ .ready = false, .message = "session already started" } }); + + try c.bidiCommand("{\"id\":2,\"method\":\"session.new\",\"params\":{\"capabilities\":{}}}"); + try assertBidiMessage(&c, .{ .type = "error", .id = 2, .@"error" = "session not created", .message = "session already exists" }); + + try c.bidiCommand("{\"id\":3,\"method\":\"browsingContext.getTree\"}"); + try assertBidiMessage(&c, .{ .type = "success", .id = 3, .result = .{ .contexts = .{} } }); + } + + { + var c = try createTestClient(); + defer c.deinit(); + var request_buf: [128]u8 = undefined; + const res = try c.httpRequest(try std.fmt.bufPrint(&request_buf, "DELETE /session/{s} HTTP/1.1\r\nContent-Length: 0\r\n\r\n", .{&session_id})); + try testing.expectEqual("HTTP/1.1 200 OK\r\n" ++ + "Content-Length: 14\r\n" ++ + "Connection: Close\r\n" ++ + "Content-Type: application/json; charset=UTF-8\r\n\r\n" ++ + "{\"value\":null}", res); + } +} + +test "server: classic session bootstrap errors" { + { + // the body can arrive after the headers + var c = try createTestClient(); + defer c.deinit(); + const body = "{\"capabilities\":{\"alwaysMatch\":{\"browserName\":\"firefox\"}}}"; + try sys_net.writeAll(c.socket, std.fmt.comptimePrint("POST /session HTTP/1.1\r\nContent-Length: {d}\r\n\r\n", .{body.len})); + lp.io.sleep(.fromMilliseconds(20), .awake) catch {}; + const res = try c.httpRequest(body); + try testing.expect(std.mem.startsWith(u8, res, "HTTP/1.1 500 Internal Server Error\r\n")); + try testing.expect(std.mem.endsWith(u8, res, "{\"value\":{\"error\":\"session not created\",\"message\":\"only WebDriver BiDi sessions are supported; request the webSocketUrl capability\",\"stacktrace\":\"\"}}")); + } + + { + var c = try createTestClient(); + defer c.deinit(); + const res = try c.httpRequest("POST /session HTTP/1.1\r\nContent-Length: 8\r\n\r\nnot json"); + try testing.expect(std.mem.startsWith(u8, res, "HTTP/1.1 400 Bad Request\r\n")); + try testing.expect(std.mem.endsWith(u8, res, "{\"value\":{\"error\":\"invalid argument\",\"message\":\"invalid JSON body\",\"stacktrace\":\"\"}}")); + } + + try assertHTTPError(404, "Not found", "POST /session/abc HTTP/1.1\r\nContent-Length: 0\r\n\r\n"); + try assertHTTPError(404, "Not found", "DELETE /session HTTP/1.1\r\nContent-Length: 0\r\n\r\n"); + // a websocket upgrade on /session/ needs a real session id + try assertHTTPError(404, "Not found", "GET /session/abc HTTP/1.1\r\n\r\n"); +} + +test "server: protocol gate" { + // The test server serves both; the CLI only ever enables one. + const protocols = &testing.test_cdp_server.?.protocols; + defer protocols.* = .{ .cdp = true, .webdriver = true }; + + protocols.* = .{ .cdp = true }; + try assertHTTPError(404, "Not found", "GET /status HTTP/1.1\r\n\r\n"); + try assertHTTPError(404, "Not found", "POST /session HTTP/1.1\r\nContent-Length: 2\r\n\r\n{}"); + try assertHTTPError(404, "Not found", "DELETE /session/x HTTP/1.1\r\nContent-Length: 0\r\n\r\n"); + try assertHTTPError(404, "Not found", "GET /session HTTP/1.1\r\n" ++ + "Connection: upgrade\r\nUpgrade: websocket\r\nsec-websocket-version:13\r\nsec-websocket-key: k\r\n\r\n"); + { + var c = try createTestClient(); + defer c.deinit(); + const res = try c.httpRequest("GET /json/version HTTP/1.1\r\n\r\n"); + try testing.expect(std.mem.startsWith(u8, res, "HTTP/1.1 200 OK\r\n")); + } + + protocols.* = .{ .webdriver = true }; + try assertHTTPError(404, "Not found", "GET /json/version HTTP/1.1\r\n\r\n"); + try assertHTTPError(404, "Not found", "GET /json/list HTTP/1.1\r\n\r\n"); + try assertHTTPError(404, "Not found", "GET / HTTP/1.1\r\n" ++ + "Connection: upgrade\r\nUpgrade: websocket\r\nsec-websocket-version:13\r\nsec-websocket-key: k\r\n\r\n"); + { + var c = try createTestClient(); + defer c.deinit(); + const res = try c.httpRequest("GET /status HTTP/1.1\r\n\r\n"); + try testing.expectEqual("HTTP/1.1 200 OK\r\n" ++ + "Content-Length: 37\r\n" ++ + "Connection: Close\r\n" ++ + "Content-Type: application/json; charset=UTF-8\r\n\r\n" ++ + "{\"value\":{\"ready\":true,\"message\":\"\"}}", res); + } + { + // /metrics is protocol-neutral + var c = try createTestClient(); + defer c.deinit(); + const res = try c.httpRequestAlloc("GET /metrics HTTP/1.1\r\n\r\n"); + try testing.expect(std.mem.startsWith(u8, res, "HTTP/1.1 200 OK\r\n")); + } +} + test "server: get /json/version" { { // twice on the same connection @@ -1386,7 +1538,6 @@ test "server: get /json/protocol" { defer c.deinit(); const res = try c.httpRequestAlloc("GET /json/protocol HTTP/1.1\r\n\r\n"); - defer testing.allocator.free(res); 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); @@ -1578,24 +1729,23 @@ const TestClient = struct { try sys_net.writeAll(self.socket, req); var response: std.ArrayList(u8) = .empty; - defer response.deinit(testing.allocator); while (true) { const n = try posix.read(self.socket, &self.buf); if (n == 0) { - return response.toOwnedSlice(testing.allocator); + return response.items; } - try response.appendSlice(testing.allocator, self.buf[0..n]); + try response.appendSlice(testing.arena_allocator, self.buf[0..n]); } } - fn handshake(self: *TestClient, comptime path: []const u8) !void { - const request = - "GET " ++ path ++ " HTTP/1.1\r\n" ++ + fn handshake(self: *TestClient, path: []const u8) !void { + var request_buf: [256]u8 = undefined; + const request = try std.fmt.bufPrint(&request_buf, "GET {s} HTTP/1.1\r\n" ++ "Connection: upgrade\r\n" ++ "Upgrade: websocket\r\n" ++ "sec-websocket-version:13\r\n" ++ "sec-websocket-key: this is my key\r\n" ++ - "Custom: Header-Value\r\n\r\n"; + "Custom: Header-Value\r\n\r\n", .{path}); const res = try self.httpRequest(request); try testing.expectEqual("HTTP/1.1 101 Switching Protocols\r\n" ++ diff --git a/src/server/bidi/BiDi.zig b/src/server/bidi/BiDi.zig index 1caaa0e29..6a7d25e8f 100644 --- a/src/server/bidi/BiDi.zig +++ b/src/server/bidi/BiDi.zig @@ -53,8 +53,8 @@ message_arena: std.heap.ArenaAllocator, // Allocations that live for the whole session (subscriptions). session_arena: std.heap.ArenaAllocator, -// The active session, created by session.new -session_id: ?[36]u8 = null, +// The active session +session_id: ?[36]u8, browser: Browser, notification: *Notification, @@ -89,7 +89,7 @@ const InputMessage = struct { method: ?[]const u8 = null, }; -pub fn init(self: *BiDi, app: *App, socket: posix.socket_t) !void { +pub fn init(self: *BiDi, app: *App, socket: posix.socket_t, session_id: ?[36]u8) !void { const allocator = app.allocator; self.* = .{ .app = app, @@ -98,6 +98,7 @@ pub fn init(self: *BiDi, app: *App, socket: posix.socket_t) !void { .browser = undefined, .user_context = undefined, .notification = undefined, + .session_id = session_id, .node_registry = .init(allocator), .handles = .{ .allocator = allocator }, .message_arena = std.heap.ArenaAllocator.init(allocator), diff --git a/src/server/bidi/session.zig b/src/server/bidi/session.zig index 99af6fcce..bb93e0b22 100644 --- a/src/server/bidi/session.zig +++ b/src/server/bidi/session.zig @@ -67,18 +67,42 @@ fn new(cmd: *const BiDi.Command) !void { return cmd.sendResult(.{ .sessionId = &bidi.session_id.?, - .capabilities = .{ - .acceptInsecureCerts = false, - .browserName = "Lightpanda", - .browserVersion = lp.build_config.version, - .platformName = platform_name, - .setWindowRect = false, + .capabilities = Capabilities{ .userAgent = cmd.bidi.app.config.http_headers.user_agent, - .proxy = struct {}{}, }, }); } +pub const Capabilities = struct { + acceptInsecureCerts: bool = false, + browserName: []const u8 = "Lightpanda", + browserVersion: []const u8 = lp.build_config.version, + platformName: []const u8 = platform_name, + setWindowRect: bool = false, + userAgent: []const u8, + proxy: struct {} = .{}, + webSocketUrl: ?[]const u8 = null, // only reported for the classic handshake + + // ugh, are you kidding me? All this so we don't emit the webSocketUrl + // when it's null. + pub fn jsonStringify(self: *const Capabilities, jws: anytype) !void { + try jws.beginObject(); + inline for (std.meta.fields(Capabilities)) |field| { + const value = @field(self, field.name); + if (@typeInfo(field.type) == .optional) { + if (value) |v| { + try jws.objectField(field.name); + try jws.write(v); + } + } else { + try jws.objectField(field.name); + try jws.write(value); + } + } + try jws.endObject(); + } +}; + fn end(cmd: *const BiDi.Command) !void { try cmd.sendResult(struct {}{}); diff --git a/src/server/bidi/testing.zig b/src/server/bidi/testing.zig index 2db75fa84..68cfe0ce8 100644 --- a/src/server/bidi/testing.zig +++ b/src/server/bidi/testing.zig @@ -60,7 +60,7 @@ pub const TestContext = struct { pub fn bidi(self: *TestContext) *BiDi { if (!self.bidi_initialized) { - self.bidi_.init(base.test_app, self.bidi_socket) catch |err| @panic(@errorName(err)); + self.bidi_.init(base.test_app, self.bidi_socket, null) catch |err| @panic(@errorName(err)); self.bidi_initialized = true; } return &self.bidi_; diff --git a/src/testing.zig b/src/testing.zig index 8df8e8b4c..c1706cb36 100644 --- a/src/testing.zig +++ b/src/testing.zig @@ -511,7 +511,7 @@ const Server = @import("server/Server.zig"); const TestWSServer = @import("TestWSServer.zig"); const TestHTTPServer = @import("TestHTTPServer.zig"); -var test_cdp_server: ?*Server = null; +pub var test_cdp_server: ?*Server = null; var test_cdp_server_thread: ?std.Thread = null; var test_http_server: ?TestHTTPServer = null; var test_http_server_thread: ?std.Thread = null; @@ -613,6 +613,7 @@ fn serveCDP(wg: *lp.WaitGroup) !void { std.debug.print("CDP server error: {}", .{err}); return err; }; + test_cdp_server.?.protocols = .{ .cdp = true, .webdriver = true }; wg.finish(); test_cdp_server.?.run();