From c0c0694fcc9d56101347d7ba9b8f4b66d1d1d176 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 6 Feb 2025 22:05:01 +0800 Subject: [PATCH 01/15] Make TCP server websocket-aware Adding HTTP & websocket awareness to the TCP server. HTTP server handles `GET /json/version` and websocket upgrade requests. Conceptually, websocket handling is the same code as before, but receiving data will parse the websocket frames and writing data will wrap it in a websocket frame. The previous `Ctx` was split into a `Server` and a `Client`. This was largely done to make it easy to write unit tests, since the `Client` is a generic, all its dependencies (i.e. the server) can be mocked out. This also makes it a bit nicer to know if there is or isn't a client (via the server's client optional). Added a MemoryPool for the Send object (I thought that was a nice touch!) Removed MacOS hack on accept/conn completion usage. Known issues: - When framing an outgoing message, the entire message has to be duped. This is no worse than how it was before, but it should be possible to eliminate this in the future. Probably not part of this PR. - Websocket parsing will reject continuation frames. I don't know of a single client that will send a fragmented message (websocket has its own message fragmentation), but we should probably still support this just in case. - I don't think the receive, timeout and close completions can safely be re-used like we're doing. I believe they need to be associated with a specific client socket. - A new connection creates a new browser session. I think this is right (??), but for the very first, we're throwing out a perfectly usable session. I'm thinking this might be a change to how Browser/Sessions work. - zig build test won't compile. This branch reproduces the issue with none of these changes: https://github.com/karlseguin/browser/tree/broken_test_build (or, as a diff to main): https://github.com/lightpanda-io/browser/compare/main...karlseguin:broken_test_build --- .gitmodules | 4 - src/cdp/runtime.zig | 4 +- src/handler.zig | 95 --- src/main.zig | 71 +- src/main_tests.zig | 4 +- src/msg.zig | 166 ----- src/server.zig | 1676 ++++++++++++++++++++++++++++++++++--------- 7 files changed, 1350 insertions(+), 670 deletions(-) delete mode 100644 src/handler.zig delete mode 100644 src/msg.zig diff --git a/.gitmodules b/.gitmodules index 184dd2025..5743ca29f 100644 --- a/.gitmodules +++ b/.gitmodules @@ -28,7 +28,3 @@ [submodule "vendor/zig-async-io"] path = vendor/zig-async-io url = https://github.com/lightpanda-io/zig-async-io.git/ -[submodule "vendor/websocket.zig"] - path = vendor/websocket.zig - url = https://github.com/lightpanda-io/websocket.zig.git/ - branch = lightpanda diff --git a/src/cdp/runtime.zig b/src/cdp/runtime.zig index 44c1a9074..054d5a786 100644 --- a/src/cdp/runtime.zig +++ b/src/cdp/runtime.zig @@ -131,12 +131,12 @@ fn sendInspector( const buf = try alloc.alloc(u8, msg.json.len + 1); defer alloc.free(buf); _ = std.mem.replace(u8, msg.json, "\"awaitPromise\":true", "\"awaitPromise\":false", buf); - ctx.sendInspector(buf); + try ctx.sendInspector(buf); return ""; } } - ctx.sendInspector(msg.json); + try ctx.sendInspector(msg.json); if (msg.id == null) return ""; diff --git a/src/handler.zig b/src/handler.zig deleted file mode 100644 index 0decb3f78..000000000 --- a/src/handler.zig +++ /dev/null @@ -1,95 +0,0 @@ -// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) -// -// Francis Bouvier -// Pierre Tachoire -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as -// published by the Free Software Foundation, either version 3 of the -// License, or (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -const std = @import("std"); - -const ws = @import("websocket"); -const Msg = @import("msg.zig").Msg; - -const log = std.log.scoped(.handler); - -pub const Stream = struct { - addr: std.net.Address, - socket: std.posix.socket_t = undefined, - - ws_host: []const u8, - ws_port: u16, - ws_conn: *ws.Conn = undefined, - - fn connectCDP(self: *Stream) !void { - const flags: u32 = std.posix.SOCK.STREAM; - const proto = blk: { - if (self.addr.any.family == std.posix.AF.UNIX) break :blk @as(u32, 0); - break :blk std.posix.IPPROTO.TCP; - }; - const socket = try std.posix.socket(self.addr.any.family, flags, proto); - - try std.posix.connect( - socket, - &self.addr.any, - self.addr.getOsSockLen(), - ); - log.debug("connected to Stream server", .{}); - self.socket = socket; - } - - fn closeCDP(self: *const Stream) void { - const close_msg: []const u8 = .{ 5, 0, 0, 0 } ++ "close"; - self.recv(close_msg) catch |err| { - log.err("stream close error: {any}", .{err}); - }; - std.posix.close(self.socket); - } - - fn start(self: *Stream, ws_conn: *ws.Conn) !void { - try self.connectCDP(); - self.ws_conn = ws_conn; - } - - pub fn recv(self: *const Stream, data: []const u8) !void { - var pos: usize = 0; - while (pos < data.len) { - const len = try std.posix.write(self.socket, data[pos..]); - pos += len; - } - } - - pub fn send(self: *const Stream, data: []const u8) !void { - return self.ws_conn.write(data); - } -}; - -pub const Handler = struct { - stream: *Stream, - - pub fn init(_: ws.Handshake, ws_conn: *ws.Conn, stream: *Stream) !Handler { - try stream.start(ws_conn); - return .{ .stream = stream }; - } - - pub fn close(self: *Handler) void { - self.stream.closeCDP(); - } - - pub fn clientMessage(self: *Handler, data: []const u8) !void { - var header: [4]u8 = undefined; - Msg.setSize(data.len, &header); - try self.stream.recv(&header); - try self.stream.recv(data); - } -}; diff --git a/src/main.zig b/src/main.zig index e4da1df2f..c5c04996c 100644 --- a/src/main.zig +++ b/src/main.zig @@ -20,12 +20,9 @@ const std = @import("std"); const builtin = @import("builtin"); const jsruntime = @import("jsruntime"); -const websocket = @import("websocket"); const Browser = @import("browser/browser.zig").Browser; const server = @import("server.zig"); -const handler = @import("handler.zig"); -const MaxSize = @import("msg.zig").MaxSize; const parser = @import("netsurf"); const apiweb = @import("apiweb.zig"); @@ -86,11 +83,9 @@ const CliMode = union(CliModeTag) { const Server = struct { execname: []const u8 = undefined, args: *std.process.ArgIterator = undefined, - addr: std.net.Address = undefined, host: []const u8 = Host, port: u16 = Port, timeout: u8 = Timeout, - tcp: bool = false, // undocumented TCP mode // default options const Host = "127.0.0.1"; @@ -160,10 +155,6 @@ const CliMode = union(CliModeTag) { return printUsageExit(execname, 1); } } - if (std.mem.eql(u8, "--tcp", opt)) { - _server.tcp = true; - continue; - } // unknown option if (std.mem.startsWith(u8, opt, "--")) { @@ -186,10 +177,6 @@ const CliMode = union(CliModeTag) { if (default_mode == .server) { // server mode - _server.addr = std.net.Address.parseIp4(_server.host, _server.port) catch |err| { - log.err("address (host:port) {any}\n", .{err}); - return printUsageExit(execname, 1); - }; _server.execname = execname; _server.args = args; return CliMode{ .server = _server }; @@ -247,65 +234,19 @@ pub fn main() !void { switch (cli_mode) { .server => |opts| { - - // Stream server - const addr = blk: { - if (opts.tcp) { - break :blk opts.addr; - } else { - const unix_path = "/tmp/lightpanda"; - std.fs.deleteFileAbsolute(unix_path) catch {}; // file could not exists - break :blk try std.net.Address.initUnix(unix_path); - } - }; - const socket = server.listen(addr) catch |err| { - log.err("Server listen error: {any}\n", .{err}); + const address = std.net.Address.parseIp4(opts.host, opts.port) catch |err| { + log.err("address (host:port) {any}\n", .{err}); return printUsageExit(opts.execname, 1); }; - defer std.posix.close(socket); - log.debug("Server opts: listening internally on {any}...", .{addr}); - - const timeout = std.time.ns_per_s * @as(u64, opts.timeout); - // loop var loop = try jsruntime.Loop.init(alloc); defer loop.deinit(); - // TCP server mode - if (opts.tcp) { - return server.handle(alloc, &loop, socket, null, timeout); - } - - // start stream server in separate thread - var stream = handler.Stream{ - .ws_host = opts.host, - .ws_port = opts.port, - .addr = addr, + const timeout = std.time.ns_per_s * @as(u64, opts.timeout); + server.run(alloc, address, timeout, &loop) catch |err| { + log.err("Server error", .{}); + return err; }; - const cdp_thread = try std.Thread.spawn( - .{ .allocator = alloc }, - server.handle, - .{ alloc, &loop, socket, &stream, timeout }, - ); - - // Websocket server - var ws = try websocket.Server(handler.Handler).init(alloc, .{ - .port = opts.port, - .address = opts.host, - .max_message_size = MaxSize + 14, // overhead websocket - .max_conn = 1, - .handshake = .{ - .timeout = 3, - .max_size = 1024, - // since we aren't using hanshake.headers - // we can set this to 0 to save a few bytes. - .max_headers = 0, - }, - }); - defer ws.deinit(); - - try ws.listen(&stream); - cdp_thread.join(); }, .fetch => |opts| { diff --git a/src/main_tests.zig b/src/main_tests.zig index 3544cc6ae..88acd1a52 100644 --- a/src/main_tests.zig +++ b/src/main_tests.zig @@ -314,9 +314,6 @@ const kb = 1024; const ms = std.time.ns_per_ms; test { - const msgTest = @import("msg.zig"); - std.testing.refAllDecls(msgTest); - const dumpTest = @import("browser/dump.zig"); std.testing.refAllDecls(dumpTest); @@ -340,6 +337,7 @@ test { std.testing.refAllDecls(@import("generate.zig")); std.testing.refAllDecls(@import("cdp/msg.zig")); + std.testing.refAllDecls(@import("server.zig")); } fn testJSRuntime(alloc: std.mem.Allocator) !void { diff --git a/src/msg.zig b/src/msg.zig deleted file mode 100644 index 13b7a62e9..000000000 --- a/src/msg.zig +++ /dev/null @@ -1,166 +0,0 @@ -// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) -// -// Francis Bouvier -// Pierre Tachoire -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as -// published by the Free Software Foundation, either version 3 of the -// License, or (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -const std = @import("std"); - -pub const HeaderSize = 4; -pub const MsgSize = 256 * 1204; // 256KB -// NOTE: Theorically we could go up to 4GB with a 4 bytes binary encoding -// but we prefer to put a lower hard limit for obvious memory size reasons. - -pub const MaxSize = HeaderSize + MsgSize; - -pub const Msg = struct { - pub fn getSize(data: []const u8) usize { - return std.mem.readInt(u32, data[0..HeaderSize], .little); - } - - pub fn setSize(len: usize, header: *[4]u8) void { - std.mem.writeInt(u32, header, @intCast(len), .little); - } -}; - -/// Buffer returns messages from a raw text read stream, -/// with the message size being encoded on the 2 first bytes (little endian) -/// It handles both: -/// - combined messages in one read -/// - single message in several reads (multipart) -/// It's safe (and a good practice) to reuse the same Buffer -/// on several reads of the same stream. -pub const Buffer = struct { - buf: []u8, - size: usize = 0, - pos: usize = 0, - - fn isFinished(self: *const Buffer) bool { - return self.pos >= self.size; - } - - fn isEmpty(self: *const Buffer) bool { - return self.size == 0 and self.pos == 0; - } - - fn reset(self: *Buffer) void { - self.size = 0; - self.pos = 0; - } - - // read input - pub fn read(self: *Buffer, input: []const u8) !struct { - msg: []const u8, - left: []const u8, - } { - var _input = input; // make input writable - - // msg size - var msg_size: usize = undefined; - if (self.isEmpty()) { - // decode msg size header - msg_size = Msg.getSize(_input); - _input = _input[HeaderSize..]; - } else { - msg_size = self.size; - } - - // multipart - const is_multipart = !self.isEmpty() or _input.len < msg_size; - if (is_multipart) { - - // set msg size on empty Buffer - if (self.isEmpty()) { - self.size = msg_size; - } - - // get the new position of the cursor - const new_pos = self.pos + _input.len; - - // check max limit size - if (new_pos > MaxSize) { - return error.MsgTooBig; - } - - // copy the current input into Buffer - // NOTE: we could use @memcpy but it's not Thread-safe (alias problem) - // see https://www.openmymind.net/Zigs-memcpy-copyForwards-and-copyBackwards/ - // Intead we just use std.mem.copyForwards - std.mem.copyForwards(u8, self.buf[self.pos..new_pos], _input[0..]); - - // set the new cursor position - self.pos = new_pos; - - // if multipart is not finished, go fetch the next input - if (!self.isFinished()) return error.MsgMultipart; - - // otherwhise multipart is finished, use its buffer as input - _input = self.buf[0..self.pos]; - self.reset(); - } - - // handle several JSON msg in 1 read - return .{ .msg = _input[0..msg_size], .left = _input[msg_size..] }; - } -}; - -test "Buffer" { - const Case = struct { - input: []const u8, - nb: u8, - }; - - const cases = [_]Case{ - // simple - .{ .input = .{ 2, 0, 0, 0 } ++ "ok", .nb = 1 }, - // combined - .{ .input = .{ 2, 0, 0, 0 } ++ "ok" ++ .{ 3, 0, 0, 0 } ++ "foo", .nb = 2 }, - // multipart - .{ .input = .{ 9, 0, 0, 0 } ++ "multi", .nb = 0 }, - .{ .input = "part", .nb = 1 }, - // multipart & combined - .{ .input = .{ 9, 0, 0, 0 } ++ "multi", .nb = 0 }, - .{ .input = "part" ++ .{ 2, 0, 0, 0 } ++ "ok", .nb = 2 }, - // multipart & combined with other multipart - .{ .input = .{ 9, 0, 0, 0 } ++ "multi", .nb = 0 }, - .{ .input = "part" ++ .{ 8, 0, 0, 0 } ++ "co", .nb = 1 }, - .{ .input = "mbined", .nb = 1 }, - // several multipart - .{ .input = .{ 23, 0, 0, 0 } ++ "multi", .nb = 0 }, - .{ .input = "several", .nb = 0 }, - .{ .input = "complex", .nb = 0 }, - .{ .input = "part", .nb = 1 }, - // combined & multipart - .{ .input = .{ 2, 0, 0, 0 } ++ "ok" ++ .{ 9, 0, 0, 0 } ++ "multi", .nb = 1 }, - .{ .input = "part", .nb = 1 }, - }; - - var b: [MaxSize]u8 = undefined; - var buf = Buffer{ .buf = &b }; - - for (cases) |case| { - var nb: u8 = 0; - var input = case.input; - while (input.len > 0) { - const parts = buf.read(input) catch |err| { - if (err == error.MsgMultipart) break; // go to the next case input - return err; - }; - nb += 1; - input = parts.left; - } - try std.testing.expect(nb == case.nb); - } -} diff --git a/src/server.zig b/src/server.zig index cbe16e129..5f18b6e97 100644 --- a/src/server.zig +++ b/src/server.zig @@ -19,7 +19,10 @@ const std = @import("std"); const builtin = @import("builtin"); -const Stream = @import("handler.zig").Stream; +const net = std.net; +const posix = std.posix; + +const Allocator = std.mem.Allocator; const jsruntime = @import("jsruntime"); const Completion = jsruntime.IO.Completion; @@ -30,237 +33,232 @@ const CloseError = jsruntime.IO.CloseError; const CancelError = jsruntime.IO.CancelError; const TimeoutError = jsruntime.IO.TimeoutError; -const MsgBuffer = @import("msg.zig").Buffer; -const MaxSize = @import("msg.zig").MaxSize; const Browser = @import("browser/browser.zig").Browser; const cdp = @import("cdp/cdp.zig"); -const NoError = error{NoError}; const IOError = AcceptError || RecvError || SendError || CloseError || TimeoutError || CancelError; -const Error = IOError || std.fmt.ParseIntError || cdp.Error || NoError; +const HTTPError = error{ + OutOfMemory, + RequestTooLarge, + NotFound, + InvalidRequest, + MissingHeaders, + InvalidProtocol, + InvalidUpgradeHeader, + InvalidVersionHeader, + InvalidConnectionHeader, +}; +const WebSocketError = error{ + OutOfMemory, + ReservedFlags, + NotMasked, + TooLarge, + InvalidMessageType, + ContinuationNotSupported, +}; +const Error = IOError || cdp.Error || HTTPError || WebSocketError; const TimeoutCheck = std.time.ns_per_ms * 100; const log = std.log.scoped(.server); -const isLinux = builtin.target.os.tag == .linux; -// I/O Main -// -------- +const MAX_HTTP_REQUEST_SIZE = 2048; -const BufReadSize = 1024; // 1KB -const MaxStdOutSize = 512; // ensure debug msg are not too long +// max message size, +14 for max websocket payload overhead +const MAX_MESSAGE_SIZE = 256 * 1024 + 14; -pub const Ctx = struct { +// For now, cdp does @import("server.zig").Ctx. Could change cdp to use "Server" +// but I rather try to decouple the CDP code from the server, so a quick +// stopgap is fine. TODO: Decouple cdp from the server +pub const Ctx = Server; + +const Server = struct { + allocator: Allocator, loop: *jsruntime.Loop, - stream: ?*Stream, // internal fields - accept_socket: std.posix.socket_t, - conn_socket: std.posix.socket_t = undefined, - read_buf: []u8, // only for read operations - msg_buf: *MsgBuffer, - err: ?Error = null, + listener: posix.socket_t, + client: ?Client(*Server) = null, + timeout: u64, + + // a memory poor for our Send objects + send_pool: std.heap.MemoryPool(Send), // I/O fields - accept_completion: *Completion, - conn_completion: *Completion, - timeout_completion: *Completion, - timeout: u64, - last_active: ?std.time.Instant = null, + conn_completion: Completion, + close_completion: Completion, + accept_completion: Completion, + timeout_completion: Completion, + + // used when gluing the session id to the inspector message + scrap: std.ArrayListUnmanaged(u8) = .{}, + + // The response to send on a GET /json/version request + json_version_response: []const u8, // CDP state: cdp.State = .{}, // JS fields browser: *Browser, // TODO: is pointer mandatory here? - sessionNew: bool, - // try_catch: jsruntime.TryCatch, // TODO - // callbacks - // --------- + fn deinit(self: *Server) void { + self.send_pool.deinit(); + self.allocator.free(self.json_version_response); + } - fn acceptCbk( - self: *Ctx, + fn queueAccept(self: *Server) void { + log.info("accepting new conn...", .{}); + self.loop.io.accept( + *Server, + self, + callbackAccept, + &self.accept_completion, + self.listener, + ); + } + + fn callbackAccept( + self: *Server, completion: *Completion, - result: AcceptError!std.posix.socket_t, + result: AcceptError!posix.socket_t, ) void { - std.debug.assert(completion == self.acceptCompletion()); + std.debug.assert(completion == &self.accept_completion); - self.conn_socket = result catch |err| { + const socket = result catch |err| { log.err("accept error: {any}", .{err}); - self.err = err; + self.queueAccept(); return; }; - log.info("client connected", .{}); - // set connection timestamp and timeout - self.last_active = std.time.Instant.now() catch |err| { - log.err("accept timestamp error: {any}", .{err}); + self.newSession() catch |err| { + log.err("new session error: {any}", .{err}); + self.queueClose(socket); return; }; + + log.info("client connected", .{}); + self.client = Client(*Server).init(socket, self); + self.queueRead(); + self.queueTimeout(); + } + + fn queueTimeout(self: *Server) void { self.loop.io.timeout( - *Ctx, + *Server, self, - Ctx.timeoutCbk, - self.timeout_completion, + callbackTimeout, + &self.timeout_completion, TimeoutCheck, ); - - // receving incomming messages asynchronously - self.loop.io.recv( - *Ctx, - self, - Ctx.readCbk, - self.conn_completion, - self.conn_socket, - self.read_buf, - ); } - fn readCbk(self: *Ctx, completion: *Completion, result: RecvError!usize) void { - std.debug.assert(completion == self.conn_completion); + fn callbackTimeout( + self: *Server, + completion: *Completion, + result: TimeoutError!void, + ) void { + std.debug.assert(completion == &self.timeout_completion); - const size = result catch |err| { - if (self.isClosed() and err == error.FileDescriptorInvalid) { - log.debug("read has been canceled", .{}); + const client = &(self.client orelse return); + + if (result) |_| { + if (now().since(client.last_active) > self.timeout) { + // close current connection + log.debug("conn timeout, closing...", .{}); + client.close(.timeout); return; } - log.err("read error: {any}", .{err}); - self.err = err; - return; - }; + } else |err| { + log.err("timeout error: {any}", .{err}); + } - if (size == 0) { - // continue receving incomming messages asynchronously + // We re-queue this if the timeout hasn't been exceeded or on some + // very unlikely IO timeout error. + // AKA: we don't requeue this if the connection timed out and we + // closed the connection.s + self.queueTimeout(); + } + + fn queueRead(self: *Server) void { + if (self.client) |*client| { self.loop.io.recv( - *Ctx, + *Server, self, - Ctx.readCbk, - self.conn_completion, - self.conn_socket, - self.read_buf, + callbackRead, + &self.conn_completion, + client.socket, + client.readBuf(), ); - return; - } - - // set connection timestamp - self.last_active = std.time.Instant.now() catch |err| { - log.err("read timestamp error: {any}", .{err}); - return; - }; - - // continue receving incomming messages asynchronously - self.loop.io.recv( - *Ctx, - self, - Ctx.readCbk, - self.conn_completion, - self.conn_socket, - self.read_buf, - ); - - // read and execute input - var input: []const u8 = self.read_buf[0..size]; - while (input.len > 0) { - const parts = self.msg_buf.read(input) catch |err| { - if (err == error.MsgMultipart) { - return; - } else { - log.err("msg read error: {any}", .{err}); - return; - } - }; - input = parts.left; - // execute - self.do(parts.msg) catch |err| { - if (err != error.Closed) { - log.err("do error: {any}", .{err}); - log.debug("last msg: {s}", .{parts.msg}); - } - }; } } - fn timeoutCbk(self: *Ctx, completion: *Completion, result: TimeoutError!void) void { - std.debug.assert(completion == self.timeout_completion); + fn callbackRead( + self: *Server, + completion: *Completion, + result: RecvError!usize, + ) void { + std.debug.assert(completion == &self.conn_completion); - _ = result catch |err| { - log.err("timeout error: {any}", .{err}); - self.err = err; - return; - }; + var client = &(self.client orelse return); - if (self.isClosed()) { - // conn is already closed, ignore timeout + const size = result catch |err| { + log.err("read error: {any}", .{err}); + self.queueClose(client.socket); return; - } + }; - // check time since last read - const now = std.time.Instant.now() catch |err| { - log.err("timeout timestamp error: {any}", .{err}); + const more = client.processData(size) catch |err| { + std.debug.print("Client Processing Error: {}\n", .{err}); return; }; - if (now.since(self.last_active.?) > self.timeout) { - // close current connection - log.debug("conn timeout, closing...", .{}); - self.close(); - return; + // if more == false, the client is disconnecting + if (more) { + self.queueRead(); } - - // continue checking timeout - self.loop.io.timeout( - *Ctx, - self, - Ctx.timeoutCbk, - self.timeout_completion, - TimeoutCheck, - ); } - // shortcuts - // --------- - - inline fn isClosed(self: *Ctx) bool { - // last_active is first saved on acceptCbk - return self.last_active == null; - } - - // allocator of the current session - inline fn alloc(self: *Ctx) std.mem.Allocator { - return self.browser.session.alloc; - } - - // JS env of the current session - inline fn env(self: Ctx) jsruntime.Env { - return self.browser.session.env; + fn queueSend( + self: *Server, + socket: posix.socket_t, + data: []const u8, + free_when_done: bool, + ) !void { + const sd = try self.send_pool.create(); + errdefer self.send_pool.destroy(sd); + + sd.* = .{ + .data = data, + .unsent = data, + .server = self, + .socket = socket, + .completion = undefined, + .free_when_done = free_when_done, + }; + sd.queueSend(); } - inline fn acceptCompletion(self: *Ctx) *Completion { - // NOTE: the logical completion to use here is the accept_completion - // as the pipe_connection can be used simulteanously by a recv I/O operation. - // But on MacOS (kqueue) the recv I/O operation on a closed socket leads to a panic - // so we use the pipe_connection to avoid this problem - if (isLinux) return self.accept_completion; - return self.conn_completion; + fn queueClose(self: *Server, socket: posix.socket_t) void { + self.loop.io.close( + *Server, + self, + callbackClose, + &self.close_completion, + socket, + ); } - // actions - // ------- - - fn do(self: *Ctx, cmd: []const u8) anyerror!void { - - // close cmd - if (std.mem.eql(u8, cmd, "close")) { - // close connection - log.info("close cmd, closing conn...", .{}); - self.close(); - return error.Closed; + fn callbackClose(self: *Server, completion: *Completion, _: CloseError!void) void { + std.debug.assert(completion == &self.close_completion); + if (self.client != null) { + self.client = null; } + self.queueAccept(); + } - if (self.sessionNew) self.sessionNew = false; - - const res = cdp.do(self.alloc(), cmd, self) catch |err| { + fn handleCDP(self: *Server, cmd: []const u8) !void { + const res = cdp.do(self.allocator, cmd, self) catch |err| { // cdp end cmd if (err == error.DisposeBrowserContext) { @@ -274,106 +272,106 @@ pub const Ctx = struct { }; // send result - if (!std.mem.eql(u8, res, "")) { + if (res.len != 0) { return self.send(res); } } - pub fn send(self: *Ctx, msg: []const u8) !void { - if (self.stream) |stream| { - // if we have a stream connection, just write on it - defer self.alloc().free(msg); - try stream.send(msg); - } else { - // otherwise write asynchronously on the socket connection - return sendAsync(self, msg); - } - } - - fn close(self: *Ctx) void { - - // conn is closed - self.last_active = null; - std.posix.close(self.conn_socket); - log.debug("connection closed", .{}); - - // restart a new browser session in case of re-connect - if (!self.sessionNew) { - self.newSession() catch |err| { - log.err("new session error: {any}", .{err}); - return; - }; + // called from CDP + pub fn send(self: *Server, data: []const u8) !void { + if (self.client) |*client| { + try client.sendWS(data); } - - log.info("accepting new conn...", .{}); - - // continue accepting incoming requests - self.loop.io.accept( - *Ctx, - self, - Ctx.acceptCbk, - self.acceptCompletion(), - self.accept_socket, - ); } - fn newSession(self: *Ctx) !void { - try self.browser.newSession(self.alloc(), self.loop); + fn newSession(self: *Server) !void { + try self.browser.newSession(self.allocator, self.loop); try self.browser.session.initInspector( self, - Ctx.onInspectorResp, - Ctx.onInspectorNotif, + inspectorResponse, + inspectorEvent, ); - self.sessionNew = true; - } - - // inspector - // --------- - - pub fn sendInspector(self: *Ctx, msg: []const u8) void { - if (self.env().getInspector()) |inspector| { - inspector.send(self.env(), msg); - } else @panic("Inspector has not been set"); } - inline fn inspectorCtx(ctx_opaque: *anyopaque) *Ctx { - const aligned = @as(*align(@alignOf(Ctx)) anyopaque, @alignCast(ctx_opaque)); - return @as(*Ctx, @ptrCast(aligned)); - } + // // inspector + // // --------- - fn inspectorMsg(allocator: std.mem.Allocator, ctx: *Ctx, msg: []const u8) !void { - // inject sessionID in cdp msg - const tpl = "{s},\"sessionId\":\"{s}\"}}"; - const msg_open = msg[0 .. msg.len - 1]; // remove closing bracket - const s = try std.fmt.allocPrint( - allocator, - tpl, - .{ msg_open, @tagName(ctx.state.sessionID) }, - ); - - try ctx.send(s); + // called by cdp + pub fn sendInspector(self: *Server, msg: []const u8) !void { + const env = self.browser.session.env; + if (env.getInspector()) |inspector| { + inspector.send(env, msg); + return; + } + return error.InspectNotSet; } - pub fn onInspectorResp(ctx_opaque: *anyopaque, _: u32, msg: []const u8) void { + fn inspectorResponse(ctx: *anyopaque, _: u32, msg: []const u8) void { if (std.log.defaultLogEnabled(.debug)) { // msg should be {"id":,... - const id_end = std.mem.indexOfScalar(u8, msg, ',') orelse unreachable; + std.debug.assert(std.mem.startsWith(u8, msg, "{\"id\":")); + + const id_end = std.mem.indexOfScalar(u8, msg, ',') orelse { + log.warn("invalid inspector response message: {s}", .{msg}); + return; + }; + const id = msg[6..id_end]; std.log.scoped(.cdp).debug("Res (inspector) > id {s}", .{id}); } - const ctx = inspectorCtx(ctx_opaque); - inspectorMsg(ctx.alloc(), ctx, msg) catch unreachable; + sendInspectorMessage(@alignCast(@ptrCast(ctx)), msg); } - pub fn onInspectorNotif(ctx_opaque: *anyopaque, msg: []const u8) void { + fn inspectorEvent(ctx: *anyopaque, msg: []const u8) void { if (std.log.defaultLogEnabled(.debug)) { // msg should be {"method":,... - const method_end = std.mem.indexOfScalar(u8, msg, ',') orelse unreachable; + std.debug.assert(std.mem.startsWith(u8, msg, "{\"method\":")); + const method_end = std.mem.indexOfScalar(u8, msg, ',') orelse { + log.warn("invalid inspector event message: {s}", .{msg}); + return; + }; const method = msg[10..method_end]; std.log.scoped(.cdp).debug("Event (inspector) > method {s}", .{method}); } - const ctx = inspectorCtx(ctx_opaque); - inspectorMsg(ctx.alloc(), ctx, msg) catch unreachable; + + sendInspectorMessage(@alignCast(@ptrCast(ctx)), msg); + } + + fn sendInspectorMessage(self: *Server, msg: []const u8) void { + var client = &(self.client orelse return); + + var scrap = &self.scrap; + scrap.clearRetainingCapacity(); + + const field = ",\"sessionId\":"; + const sessionID = @tagName(self.state.sessionID); + + // + 2 for the quotes around the session + const message_len = msg.len + sessionID.len + 2 + field.len; + + scrap.ensureTotalCapacity(self.allocator, message_len) catch |err| { + log.err("Failed to expand inspector buffer: {}", .{err}); + return; + }; + + // -1 because we dont' want the closing brace '}' + scrap.appendSliceAssumeCapacity(msg[0 .. msg.len - 1]); + scrap.appendSliceAssumeCapacity(field); + scrap.appendAssumeCapacity('"'); + scrap.appendSliceAssumeCapacity(sessionID); + scrap.appendSliceAssumeCapacity("\"}"); + std.debug.assert(scrap.items.len == message_len); + + // TODO: Remove when we clean up ownership of messages between + // CDD and sending. + const owned = self.allocator.dupe(u8, scrap.items) catch return; + + client.sendWS(owned) catch |err| { + log.debug("Failed to write inspector message to client: {}", .{err}); + // don't bother trying to cleanly close the client, if sendWS fails + // we're almost certainly in a non-recoverable state (i.e. OOM) + self.queueClose(client.socket); + }; } }; @@ -383,47 +381,560 @@ pub const Ctx = struct { // NOTE: to allow concurrent send we create each time a dedicated context // (with its own completion), allocated on the heap. // After the send (on the sendCbk) the dedicated context will be destroy -// and the msg slice will be free. +// and the data slice will be free. const Send = struct { - ctx: *Ctx, - msg: []const u8, - completion: Completion = undefined, + // The full data to be sent + data: []const u8, - fn init(ctx: *Ctx, msg: []const u8) !*Send { - const sd = try ctx.alloc().create(Send); - sd.* = .{ .ctx = ctx, .msg = msg }; - return sd; - } + // Whether or not to free the data once the message is sent (or fails to) + // send. This is false in cases where the message is comptime known + free_when_done: bool, + + // Any unsent data we have. Initially unsent == data, but as part of the + // message is succesfully sent, unsent becomes a smaller and smaller slice + // of data + unsent: []const u8, + + server: *Server, + completion: Completion, + socket: posix.socket_t, fn deinit(self: *Send) void { - self.ctx.alloc().free(self.msg); - self.ctx.alloc().destroy(self); + var server = self.server; + if (self.free_when_done) { + server.allocator.free(self.data); + } + server.send_pool.destroy(self); } - fn asyncCbk(self: *Send, _: *Completion, result: SendError!usize) void { - _ = result catch |err| { + fn queueSend(self: *Send) void { + self.server.loop.io.send( + *Send, + self, + sendCallback, + &self.completion, + self.socket, + self.unsent, + ); + } + + fn sendCallback( + self: *Send, + _: *Completion, + result: SendError!usize, + ) void { + const sent = result catch |err| { log.err("send error: {any}", .{err}); - self.ctx.err = err; + if (self.server.client) |*client| { + self.server.queueClose(client.socket); + } + self.deinit(); + return; }; - self.deinit(); + + if (sent == self.unsent.len) { + self.deinit(); + return; + } + + // partial send, re-queue a send for whatever we have left + self.unsent = self.unsent[sent..]; + self.queueSend(); } }; -pub fn sendAsync(ctx: *Ctx, msg: []const u8) !void { - const sd = try Send.init(ctx, msg); - ctx.loop.io.send(*Send, sd, Send.asyncCbk, &sd.completion, ctx.conn_socket, sd.msg); +// Client +// -------- + +// This is a generic only so that it can be unit tested. Normally, S == Server +// and when we send a message, we'll use server.send(...) to send via the server's +// IO loop. During tests, we can inject a simple mock to record (and then verify) +// the send message +fn Client(comptime S: type) type { + const EMPTY_PONG = [_]u8{ 138, 0 }; + + // CLOSE, 2 length, code + const CLOSE_NORMAL = [_]u8{ 136, 2, 3, 232 }; // code: 1000 + const CLOSE_TOO_BIG = [_]u8{ 136, 2, 3, 241 }; // 1009 + const CLOSE_PROTOCOL_ERROR = [_]u8{ 136, 2, 3, 234 }; //code: 1002 + // This should be removed once we support continuation frames + const CLOSE_UNSUPPORTED_ERROR = [_]u8{ 136, 2, 3, 235 }; //code: 1003 + const CLOSE_TIMEOUT = [_]u8{ 136, 2, 15, 160 }; // code: 4000 + + return struct { + // The client is initially serving HTTP requests but, under normal circumstances + // should eventually be upgraded to a websocket connections + mode: Mode, + server: S, + socket: posix.socket_t, + last_active: std.time.Instant, + + // the start of the message in our read_buf + read_pos: usize = 0, + // up to where do we have data in our read_buf + read_len: usize = 0, + read_buf: [MAX_MESSAGE_SIZE]u8 = undefined, + + const Mode = enum { + http, + websocket, + }; + + const Self = @This(); + + fn init(socket: posix.socket_t, server: S) Self { + return .{ + .mode = .http, + .socket = socket, + .server = server, + .last_active = now(), + }; + } + + fn close(self: *Self, close_code: CloseCode) void { + if (self.mode == .websocket) { + switch (close_code) { + .timeout => self.send(&CLOSE_TIMEOUT, false) catch {}, + } + } + self.server.queueClose(self.socket); + } + + fn readBuf(self: *Self) []u8 { + // We might have read a partial http or websocket message. + // Subsequent reads must read from where we left off. + std.debug.assert(self.read_pos < self.read_buf.len); + return self.read_buf[self.read_len..]; + } + + fn processData(self: *Self, len: usize) !bool { + const end = self.read_len + len; + std.debug.assert(end >= self.read_pos); + + self.last_active = now(); + const data = self.read_buf[self.read_pos..end]; + + switch (self.mode) { + .http => { + try self.processHTTPRequest(data); + return true; + }, + .websocket => return self.processWebsocketMessage(data), + } + } + + fn processHTTPRequest(self: *Self, request: []u8) HTTPError!void { + // We should never get pipelined HTTP requests + std.debug.assert(self.read_pos == 0); + + errdefer self.server.queueClose(self.socket); + + // we're only expecting [body-less] GET requests. + if (std.mem.endsWith(u8, request, "\r\n\r\n") == false) { + if (request.len > MAX_HTTP_REQUEST_SIZE) { + self.writeHTTPErrorResponse(413, "Request too large"); + return error.RequestTooLarge; + } + // we need more data, put any more data here + self.read_len = request.len; + return; + } + + self.handleHTTPRequest(request) catch |err| { + switch (err) { + error.NotFound => self.writeHTTPErrorResponse(404, "Not found"), + error.InvalidRequest => self.writeHTTPErrorResponse(400, "Invalid request"), + error.InvalidProtocol => self.writeHTTPErrorResponse(400, "Invalid HTTP protocol"), + error.MissingHeaders => self.writeHTTPErrorResponse(400, "Missing required header"), + error.InvalidUpgradeHeader => self.writeHTTPErrorResponse(400, "Unsupported upgrade type"), + error.InvalidVersionHeader => self.writeHTTPErrorResponse(400, "Invalid websocket version"), + error.InvalidConnectionHeader => self.writeHTTPErrorResponse(400, "Invalid connection header"), + else => { + log.err("error processing HTTP request: {}", .{err}); + self.writeHTTPErrorResponse(500, "Internal Server Error"); + }, + } + return err; + }; + + // the next incoming data can go to the front of our buffer + self.read_len = 0; + } + + fn handleHTTPRequest(self: *Self, request: []u8) !void { + if (request.len < 18) { + // 18 is [generously] the smallest acceptable HTTP request + return error.InvalidRequest; + } + + if (std.mem.eql(u8, request[0..4], "GET ") == false) { + return error.NotFound; + } + + 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, "/")) { + return self.upgradeConnection(request); + } + + if (std.mem.eql(u8, url, "/json/version")) { + return self.send(self.server.json_version_response, false); + } + + return error.NotFound; + } + + fn upgradeConnection(self: *Self, request: []u8) !void { + // our caller already confirmed that we have a trailing \r\n\r\n + const request_line_end = std.mem.indexOfScalar(u8, request, '\r') orelse unreachable; + const request_line = request[0..request_line_end]; + + if (!std.ascii.endsWithIgnoreCase(request_line, "http/1.1")) { + return error.InvalidProtocol; + } + + // we need to extract the sec-websocket-key value + var key: []const u8 = ""; + + // we need to make sure that we got all the necessary headers + values + var required_headers: u8 = 0; + + // can't std.mem.split because it forces the iterated value to be const + // (we could @constCast...) + + var buf = request[request_line_end + 2 ..]; + + while (buf.len > 4) { + const index = std.mem.indexOfScalar(u8, buf, '\r') orelse unreachable; + const separator = std.mem.indexOfScalar(u8, buf[0..index], ':') orelse return error.InvalidRequest; + + const name = std.mem.trim(u8, toLower(buf[0..separator]), &std.ascii.whitespace); + const value = std.mem.trim(u8, buf[(separator + 1)..index], &std.ascii.whitespace); + + if (std.mem.eql(u8, name, "upgrade")) { + if (!std.ascii.eqlIgnoreCase("websocket", value)) { + return error.InvalidUpgradeHeader; + } + required_headers |= 1; + } else if (std.mem.eql(u8, name, "sec-websocket-version")) { + if (value.len != 2 or value[0] != '1' or value[1] != '3') { + return error.InvalidVersionHeader; + } + required_headers |= 2; + } else if (std.mem.eql(u8, name, "connection")) { + // find if connection header has upgrade in it, example header: + // Connection: keep-alive, Upgrade + if (std.ascii.indexOfIgnoreCase(value, "upgrade") == null) { + return error.InvalidConnectionHeader; + } + required_headers |= 4; + } else if (std.mem.eql(u8, name, "sec-websocket-key")) { + key = value; + required_headers |= 8; + } + + const next = index + 2; + buf = buf[next..]; + } + + if (required_headers != 15) { + return error.MissingHeaders; + } + + // our caller has already made sure this request ended in \r\n\r\n + // so it isn't something we need to check again + + const response = blk: { + // Response to an ugprade request is always this, with + // the Sec-Websocket-Accept value a spacial sha1 hash of the + // request "sec-websocket-version" and a magic value. + + const template = + "HTTP/1.1 101 Switching Protocols\r\n" ++ + "Upgrade: websocket\r\n" ++ + "Connection: upgrade\r\n" ++ + "Sec-Websocket-Accept: 0000000000000000000000000000\r\n\r\n"; + + // The response will be sent via the IO Loop and thus has to have its + // own lifetime. + const res = try self.server.allocator.dupe(u8, template); + errdefer self.server.allocator.free(res); + + // magic response + const key_pos = res.len - 32; + var h: [20]u8 = undefined; + var hasher = std.crypto.hash.Sha1.init(.{}); + hasher.update(key); + // websocket spec always used this value + hasher.update("258EAFA5-E914-47DA-95CA-C5AB0DC85B11"); + hasher.final(&h); + + _ = std.base64.standard.Encoder.encode(res[key_pos .. key_pos + 28], h[0..]); + + break :blk res; + }; + + self.mode = .websocket; + return self.send(response, true); + } + + fn processWebsocketMessage(self: *Self, data: []u8) !bool { + errdefer self.server.queueClose(self.socket); + + var reader = Reader{ .data = data }; + while (true) { + const msg = reader.next() catch |err| { + switch (err) { + error.TooLarge => self.send(&CLOSE_TOO_BIG, false) catch {}, + error.NotMasked => self.send(&CLOSE_PROTOCOL_ERROR, false) catch {}, + error.ReservedFlags => self.send(&CLOSE_PROTOCOL_ERROR, false) catch {}, + error.InvalidMessageType => self.send(&CLOSE_PROTOCOL_ERROR, false) catch {}, + error.ContinuationNotSupported => self.send(&CLOSE_UNSUPPORTED_ERROR, false) catch {}, + } + return err; + } orelse break; + + switch (msg.type) { + .pong => {}, + .ping => try self.sendPong(msg.data), + .close => { + self.send(&CLOSE_NORMAL, false) catch {}; + self.server.queueClose(self.socket); + return false; + }, + .text, .binary => try self.server.handleCDP(msg.data), + } + } + + const incomplete = reader.data; + self.read_len = incomplete.len; + if (incomplete.len > 0) { + // we have part of the data for the next message + + // can't use @memset because incomplete is a slice of read_buf, + // so they could overlap + + // TODO: this can be skipped if we know that the next message will + // fit into whatever reamining space we have. + std.mem.copyForwards(u8, self.read_buf[0..incomplete.len], incomplete); + } + return true; + } + + fn sendPong(self: *Self, data: []const u8) !void { + if (data.len == 0) { + return self.send(&EMPTY_PONG, false); + } + + return self.sendFrame(data, .pong); + } + + fn sendWS(self: *Self, data: []const u8) !void { + std.debug.assert(data.len < 4294967296); + + // for now, we're going to dupe this before we send it, so we don't need + // to keep this around. + defer self.server.allocator.free(data); + return self.sendFrame(data, .text); + } + + // We need to append the websocket header to data. If our IO loop supported + // a writev call, this would be simple. + // For now, we'll just have to dupe data into a larger message. + // TODO: Remove this awful allocation (probably by passing a websocket-aware + // Writer into CDP) + fn sendFrame(self: *Self, data: []const u8, op_code: OpCode) !void { + if (comptime builtin.is_test == false) { + std.debug.assert(self.mode == .websocket); + } + + // 10 is the max possible length of our header + // server->client has no mask, so it's 4 fewer bytes than the reader overhead + var header_buf: [10]u8 = undefined; + + const header: []const u8 = blk: { + const len = data.len; + header_buf[0] = 128 | @intFromEnum(op_code); // fin | opcode + + if (len <= 125) { + header_buf[1] = @intCast(len); + break :blk header_buf[0..2]; + } + + if (len < 65536) { + header_buf[1] = 126; + header_buf[2] = @intCast((len >> 8) & 0xFF); + header_buf[3] = @intCast(len & 0xFF); + break :blk header_buf[0..4]; + } + + header_buf[1] = 127; + header_buf[2] = 0; + header_buf[3] = 0; + header_buf[4] = 0; + header_buf[5] = 0; + header_buf[6] = @intCast((len >> 24) & 0xFF); + header_buf[7] = @intCast((len >> 16) & 0xFF); + header_buf[8] = @intCast((len >> 8) & 0xFF); + header_buf[9] = @intCast(len & 0xFF); + break :blk header_buf[0..10]; + }; + + const allocator = self.server.allocator; + const full = try allocator.alloc(u8, header.len + data.len); + errdefer allocator.free(full); + @memcpy(full[0..header.len], header); + @memcpy(full[header.len..], data); + try self.send(full, true); + } + + fn writeHTTPErrorResponse(self: *Self, comptime status: u16, comptime body: []const u8) void { + const response = std.fmt.comptimePrint( + "HTTP/1.1 {d} \r\nConnection: Close\r\nContent-Length: {d}\r\n\r\n{s}", + .{ status, body.len, body }, + ); + + // we're going to close this connection anyways, swallowing any + // error seems safe + self.send(response, false) catch {}; + } + + fn send(self: *Self, data: []const u8, free_when_done: bool) !void { + return self.server.queueSend(self.socket, data, free_when_done); + } + }; } -// Listener and handler -// -------------------- +// WebSocket message reader. Given websocket message, acts as an iterator that +// can return zero or more Messages. When next returns null, any incomplete +// message will remain in reader.data +const Reader = struct { + data: []u8, -pub fn handle( - alloc: std.mem.Allocator, - loop: *jsruntime.Loop, - server_socket: std.posix.socket_t, - stream: ?*Stream, + fn next(self: *Reader) !?Message { + var data = self.data; + if (data.len < 2) { + return null; + } + + const byte1 = data[0]; + + if (byte1 & 112 != 0) { + return error.ReservedFlags; + } + + var message_type: Message.Type = undefined; + switch (byte1 & 15) { + 0 => return error.ContinuationNotSupported, // TODO?? + 1 => message_type = .text, + 2 => message_type = .binary, + 8 => message_type = .close, + 9 => message_type = .ping, + 10 => message_type = .pong, + else => return error.InvalidMessageType, + } + + if (byte1 & 128 != 128) { + // TODO?? + return error.ContinuationNotSupported; + } + + const byte2 = data[1]; + if (byte2 & 128 != 128) { + // client -> server messages _must_ be masked + return error.NotMasked; + } + + const length_of_len: usize = switch (byte2 & 127) { + 126 => 2, + 127 => 8, + else => 0, + }; + + if (data.len < length_of_len + 2) { + // we definitely don't have enough data yet + return null; + } + + const message_len = switch (length_of_len) { + 2 => @as(u16, @intCast(data[3])) | @as(u16, @intCast(data[2])) << 8, + 8 => @as(u64, @intCast(data[9])) | @as(u64, @intCast(data[8])) << 8 | @as(u64, @intCast(data[7])) << 16 | @as(u64, @intCast(data[6])) << 24 | @as(u64, @intCast(data[5])) << 32 | @as(u64, @intCast(data[4])) << 40 | @as(u64, @intCast(data[3])) << 48 | @as(u64, @intCast(data[2])) << 56, + else => data[1] & 127, + } + length_of_len + 2 + 4; // +2 for header prefix, +4 for mask + + if (message_len > MAX_MESSAGE_SIZE) { + return error.TooLarge; + } + + if (data.len < message_len) { + return null; + } + + // prefix + length_of_len + mask + const header_len = 2 + length_of_len + 4; + + const payload = data[header_len..message_len]; + mask(data[header_len - 4 .. header_len], payload); + + self.data = data[message_len..]; + return .{ + .type = message_type, + .data = payload, + }; + } +}; + +const Message = struct { + type: Type, + data: []const u8, + + const Type = enum { + text, + binary, + close, + ping, + pong, + }; +}; + +// These are the only websocket types that we're currently sending +const OpCode = enum(u8) { + text = 128 | 1, + close = 128 | 8, + pong = 128 | 10, +}; + +// "private-use" close codes must be from 4000-49999 +const CloseCode = enum { + timeout, +}; + +pub fn run( + allocator: Allocator, + address: net.Address, timeout: u64, -) anyerror!void { + loop: *jsruntime.Loop, +) !void { + // create socket + const flags = posix.SOCK.STREAM | posix.SOCK.CLOEXEC | posix.SOCK.NONBLOCK; + const listener = try posix.socket(address.any.family, flags, posix.IPPROTO.TCP); + defer posix.close(listener); + + try posix.setsockopt(listener, posix.SOL.SOCKET, posix.SO.REUSEADDR, &std.mem.toBytes(@as(c_int, 1))); + // TODO: Broken on darwin + // https://github.com/ziglang/zig/issues/17260 (fixed in Zig 0.14) + // if (@hasDecl(os.TCP, "NODELAY")) { + // try os.setsockopt(socket.sockfd.?, os.IPPROTO.TCP, os.TCP.NODELAY, &std.mem.toBytes(@as(c_int, 1))); + // } + try posix.setsockopt(listener, posix.IPPROTO.TCP, 1, &std.mem.toBytes(@as(c_int, 1))); + + // bind & listen + try posix.bind(listener, &address.any, address.getOsSockLen()); + try posix.listen(listener, 1); // create v8 vm const vm = jsruntime.VM.init(); @@ -431,43 +942,30 @@ pub fn handle( // browser var browser: Browser = undefined; - try Browser.init(&browser, alloc, loop, vm); + try Browser.init(&browser, allocator, loop, vm); defer browser.deinit(); - // create buffers - var read_buf: [BufReadSize]u8 = undefined; - var buf: [MaxSize]u8 = undefined; - var msg_buf = MsgBuffer{ .buf = &buf }; - - // create I/O completions - var accept_completion: Completion = undefined; - var conn_completion: Completion = undefined; - var timeout_completion: Completion = undefined; + const json_version_response = try buildJSONVersionResponse(allocator, address); - // create I/O contexts and callbacks - // for accepting connections and receving messages - var ctx = Ctx{ + var server = Server{ .loop = loop, - .stream = stream, - .browser = &browser, - .sessionNew = true, - .read_buf = &read_buf, - .msg_buf = &msg_buf, - .accept_socket = server_socket, .timeout = timeout, - .accept_completion = &accept_completion, - .conn_completion = &conn_completion, - .timeout_completion = &timeout_completion, + .browser = &browser, + .listener = listener, + .allocator = allocator, + .conn_completion = undefined, + .close_completion = undefined, + .accept_completion = undefined, + .timeout_completion = undefined, + .json_version_response = json_version_response, + .send_pool = std.heap.MemoryPool(Send).init(allocator), }; - try browser.session.initInspector( - &ctx, - Ctx.onInspectorResp, - Ctx.onInspectorNotif, - ); + defer server.deinit(); - // accepting connection asynchronously on internal server - log.info("accepting new conn...", .{}); - loop.io.accept(*Ctx, &ctx, Ctx.acceptCbk, ctx.acceptCompletion(), ctx.accept_socket); + try browser.session.initInspector(&server, Server.inspectorResponse, Server.inspectorEvent); + + // accept an connection + server.queueAccept(); // infinite loop on I/O events, either: // - cmd from incoming connection on server socket @@ -476,58 +974,566 @@ pub fn handle( try loop.io.run_for_ns(10 * std.time.ns_per_ms); if (loop.cbk_error) { log.err("JS error", .{}); - // if (try try_catch.exception(alloc, js_env.*)) |msg| { - // std.debug.print("\n\rUncaught {s}\n\r", .{msg}); - // alloc.free(msg); - // } - // loop.cbk_error = false; } - if (ctx.err) |err| { - if (err != error.NoError) log.err("Server error: {any}", .{err}); - break; + } +} + +// Utils +// -------- + +fn buildJSONVersionResponse( + allocator: Allocator, + address: net.Address, +) ![]const u8 { + const body_format = "{{\"webSocketDebuggerUrl\": \"ws://{}/\"}}"; + const body_len = std.fmt.count(body_format, .{address}); + + const response_format = + "HTTP/1.1 200 OK\r\n" ++ + "Content-Length: {d}\r\n" ++ + "Content-Type: application/json; charset=UTF-8\r\n\r\n" ++ + body_format; + return try std.fmt.allocPrint(allocator, response_format, .{ body_len, address }); +} + +fn now() std.time.Instant { + // can only fail on platforms we don't support + return std.time.Instant.now() catch unreachable; +} + +// In-place string lowercase +fn toLower(str: []u8) []u8 { + for (str, 0..) |c, i| { + str[i] = std.ascii.toLower(c); + } + return str; +} + +// Zig is in a weird backend transition right now. Need to determine if +// SIMD is even available. +const backend_supports_vectors = switch (builtin.zig_backend) { + .stage2_llvm, .stage2_c => true, + else => false, +}; + +// Websocket messages from client->server are masked using a 4 byte XOR mask +fn mask(m: []const u8, payload: []u8) void { + var data = payload; + + if (!comptime backend_supports_vectors) return simpleMask(m, data); + + const vector_size = std.simd.suggestVectorLength(u8) orelse @sizeOf(usize); + if (data.len >= vector_size) { + const mask_vector = std.simd.repeat(vector_size, @as(@Vector(4, u8), m[0..4].*)); + while (data.len >= vector_size) { + const slice = data[0..vector_size]; + const masked_data_slice: @Vector(vector_size, u8) = slice.*; + slice.* = masked_data_slice ^ mask_vector; + data = data[vector_size..]; } } + simpleMask(m, data); } -fn setSockOpt(fd: std.posix.socket_t, level: i32, option: u32, value: c_int) !void { - try std.posix.setsockopt(fd, level, option, &std.mem.toBytes(value)); +// Used when SIMD isn't available, or for any remaining part of the message +// which is too small to effectively use SIMD. +fn simpleMask(m: []const u8, payload: []u8) void { + for (payload, 0..) |b, i| { + payload[i] = b ^ m[i & 3]; + } } -fn isUnixSocket(addr: std.net.Address) bool { - return addr.any.family == std.posix.AF.UNIX; +const testing = std.testing; +test "server: buildJSONVersionResponse" { + const address = try net.Address.parseIp4("127.0.0.1", 9001); + const res = try buildJSONVersionResponse(testing.allocator, address); + defer testing.allocator.free(res); + + try testing.expectEqualStrings("HTTP/1.1 200 OK\r\n" ++ + "Content-Length: 48\r\n" ++ + "Content-Type: application/json; charset=UTF-8\r\n\r\n" ++ + "{\"webSocketDebuggerUrl\": \"ws://127.0.0.1:9001/\"}", res); } -pub fn listen(address: std.net.Address) !std.posix.socket_t { - const isunixsock = isUnixSocket(address); +test "Client: http invalid handshake" { + try assertHTTPError( + error.InvalidRequest, + 400, + "Invalid request", + "\r\n\r\n", + ); - // create socket - const flags = std.posix.SOCK.STREAM | std.posix.SOCK.CLOEXEC | std.posix.SOCK.NONBLOCK; - const proto = if (isunixsock) @as(u32, 0) else std.posix.IPPROTO.TCP; - const sockfd = try std.posix.socket(address.any.family, flags, proto); - errdefer std.posix.close(sockfd); + try assertHTTPError( + error.NotFound, + 404, + "Not found", + "GET /over/9000 HTTP/1.1\r\n\r\n", + ); + + try assertHTTPError( + error.NotFound, + 404, + "Not found", + "POST / HTTP/1.1\r\n\r\n", + ); + + try assertHTTPError( + error.InvalidProtocol, + 400, + "Invalid HTTP protocol", + "GET / HTTP/1.0\r\n\r\n", + ); + + try assertHTTPError( + error.MissingHeaders, + 400, + "Missing required header", + "GET / HTTP/1.1\r\n\r\n", + ); + + try assertHTTPError( + error.MissingHeaders, + 400, + "Missing required header", + "GET / HTTP/1.1\r\nConnection: upgrade\r\n\r\n", + ); + + try assertHTTPError( + error.MissingHeaders, + 400, + "Missing required header", + "GET / HTTP/1.1\r\nConnection: upgrade\r\nUpgrade: websocket\r\n\r\n", + ); + + try assertHTTPError( + error.MissingHeaders, + 400, + "Missing required header", + "GET / HTTP/1.1\r\nConnection: upgrade\r\nUpgrade: websocket\r\nsec-websocket-version:13\r\n\r\n", + ); +} + +test "Client: http valid handshake" { + var ms = MockServer{}; + defer ms.deinit(); + + var client = Client(*MockServer).init(0, &ms); + + const request = + "GET / 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"; + + @memcpy(client.read_buf[0..request.len], request); + try testing.expectEqual(true, try client.processData(request.len)); + + try testing.expectEqual(.websocket, client.mode); + try testing.expectEqualStrings( + "HTTP/1.1 101 Switching Protocols\r\n" ++ + "Upgrade: websocket\r\n" ++ + "Connection: upgrade\r\n" ++ + "Sec-Websocket-Accept: flzHu2DevQ2dSCSVqKSii5e9C2o=\r\n\r\n", + ms.sent.items[0], + ); +} + +test "Client: http get json version" { + var ms = MockServer{}; + defer ms.deinit(); + + var client = Client(*MockServer).init(0, &ms); + + const request = "GET /json/version HTTP/1.1\r\n\r\n"; + + @memcpy(client.read_buf[0..request.len], request); + try testing.expectEqual(true, try client.processData(request.len)); + + try testing.expectEqual(.http, client.mode); + + // this is the hardcoded string in our MockServer + try testing.expectEqualStrings("the json version response", ms.sent.items[0]); +} + +test "Client: write websocket message" { + var ms = MockServer{}; + defer ms.deinit(); + + var client = Client(*MockServer).init(0, &ms); + + const cases = [_]struct { expected: []const u8, message: []const u8 }{ + .{ .expected = &.{ 129, 0 }, .message = "" }, + .{ .expected = [_]u8{ 129, 12 } ++ "hello world!", .message = "hello world!" }, + .{ .expected = [_]u8{ 129, 126, 0, 130 } ++ ("A" ** 130), .message = "A" ** 130 }, + }; + + for (cases) |c| { + ms.sent.clearRetainingCapacity(); + try client.sendWS(try testing.allocator.dupe(u8, c.message)); + try testing.expectEqual(1, ms.sent.items.len); + try testing.expectEqualSlices(u8, c.expected, ms.sent.items[0]); + } +} + +test "Client: read invalid websocket message" { + try assertWebSocketError( + error.InvalidMessageType, + 1002, + "", + &.{ 131, 1 }, // 128 (fin) | 3 where 3 isn't a valid type + ); - // socket options - // - // REUSEPORT can't be set on unix socket anymore. - // see https://github.com/torvalds/linux/commit/5b0af621c3f6ef9261cf6067812f2fd9943acb4b - if (@hasDecl(std.posix.SO, "REUSEPORT") and !isunixsock) { - try setSockOpt(sockfd, std.posix.SOL.SOCKET, std.posix.SO.REUSEPORT, 1); + try assertWebSocketError( + error.ContinuationNotSupported, + 1003, + "", + &.{ 128, 1 }, // 128 (fin) | 0 where 0 is a continuation frame + ); + + try assertWebSocketError( + error.ContinuationNotSupported, + 1003, + "", + &.{ 1, 1 }, // 0 (non-fin) | 1 non-fin (contination) not supported + ); + + for ([_]u8{ 16, 32, 64 }) |rsv| { + // none of the reserve flags should be set + try assertWebSocketError( + error.ReservedFlags, + 1002, + "", + &.{ rsv, 0 }, + ); + + // as a bitmask + try assertWebSocketError( + error.ReservedFlags, + 1002, + "", + &.{ rsv + 4, 0 }, + ); } - try setSockOpt(sockfd, std.posix.SOL.SOCKET, std.posix.SO.REUSEADDR, 1); - if (!isUnixSocket(address)) { - if (builtin.target.os.tag == .linux) { // posix.TCP not available on MacOS - // WARNING: disable Nagle's alogrithm to avoid latency issues - try setSockOpt(sockfd, std.posix.IPPROTO.TCP, std.posix.TCP.NODELAY, 1); + + try assertWebSocketError( + error.NotMasked, + 1002, + "", + &.{ 129, 127 }, // client->server messages must be masked + ); + + try assertWebSocketError( + error.TooLarge, + 1009, + "", + &.{ 129, 255, 0, 0, 0, 0, 0, 4, 0, 1 }, // 1024 * 256 + 1 + ); +} + +test "Client: ping reply" { + try assertWebSocketMessage( + // fin | pong, len + &.{ 138, 0 }, + + // fin | ping, masked | len, 4-byte mask + &.{ 137, 128, 0, 0, 0, 0 }, + ); + + try assertWebSocketMessage( + // fin | pong, len, payload + &.{ 138, 5, 100, 96, 97, 109, 104 }, + + // fin | ping, masked | len, 4-byte mask, 5 byte payload + &.{ 137, 133, 0, 5, 7, 10, 100, 101, 102, 103, 104 }, + ); +} + +test "Client: close message" { + try assertWebSocketMessage( + // fin | close, len, close code (normal) + &.{ 136, 2, 3, 232 }, + + // fin | close, masked | len, 4-byte mask + &.{ 136, 128, 0, 0, 0, 0 }, + ); +} + +// Testing both HTTP and websocket messages broken up across multiple reads. +// We need to fuzz HTTP messages differently than websocket. HTTP are strictly +// req -> res with no pipelining. So there should only be 1 message at a time. +// So we can only "fuzz" on a per-message basis. +// But for websocket, we can fuzz _all_ the messages together. +test "Client: fuzz" { + var prng = std.rand.DefaultPrng.init(blk: { + var seed: u64 = undefined; + try std.posix.getrandom(std.mem.asBytes(&seed)); + break :blk seed; + }); + const random = prng.random(); + + const allocator = testing.allocator; + var websocket_messages: std.ArrayListUnmanaged(u8) = .{}; + defer websocket_messages.deinit(allocator); + + // ping with no payload + try websocket_messages.appendSlice( + allocator, + &.{ 137, 128, 0, 0, 0, 0 }, + ); + + // // 10 byte text message with a 0,0,0,0 mask + try websocket_messages.appendSlice( + allocator, + &.{ 129, 138, 0, 0, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }, + ); + + // ping with a payload + try websocket_messages.appendSlice( + allocator, + &.{ 137, 133, 0, 5, 7, 10, 100, 101, 102, 103, 104 }, + ); + + // pong with no payload (noop in the server) + try websocket_messages.appendSlice( + allocator, + &.{ 138, 128, 10, 10, 10, 10 }, + ); + + // 687 long message, with a mask + try websocket_messages.appendSlice( + allocator, + [_]u8{ 129, 254, 2, 175, 1, 2, 3, 4 } ++ "A" ** 687, + ); + + // close + try websocket_messages.appendSlice( + allocator, + &.{ 136, 130, 200, 103, 34, 22, 0, 1 }, + ); + + const SendRandom = struct { + fn send(c: anytype, r: std.Random, data: []const u8) !void { + var buf = data; + while (buf.len > 0) { + const to_send = r.intRangeAtMost(usize, 1, buf.len); + @memcpy(c.readBuf()[0..to_send], buf[0..to_send]); + if (try c.processData(to_send) == false) { + return; + } + buf = buf[to_send..]; + } } + }; + + for (0..1) |_| { + var ms = MockServer{}; + defer ms.deinit(); + + var client = Client(*MockServer).init(0, &ms); + + try SendRandom.send(&client, random, "GET /json/version HTTP/1.1\r\nContent-Length: 0\r\n\r\n"); + try SendRandom.send(&client, random, "GET / HTTP/1.1\r\n" ++ + "Connection: upgrade\r\n" ++ + "Upgrade: websocket\r\n" ++ + "sec-websocket-version:13\r\n" ++ + "sec-websocket-key: 1234aa93\r\n" ++ + "Custom: Header-Value\r\n\r\n"); + + // fuzz over all websocket messages + try SendRandom.send(&client, random, websocket_messages.items); + + try testing.expectEqual(5, ms.sent.items.len); + + try testing.expectEqualStrings( + "the json version response", + ms.sent.items[0], + ); + + try testing.expectEqualStrings( + "HTTP/1.1 101 Switching Protocols\r\n" ++ + "Upgrade: websocket\r\n" ++ + "Connection: upgrade\r\n" ++ + "Sec-Websocket-Accept: KnOKWrrjHS0nGFmtfmYFQoPIGKQ=\r\n\r\n", + ms.sent.items[1], + ); + + try testing.expectEqualSlices(u8, &.{ 138, 0 }, ms.sent.items[2]); + + try testing.expectEqualSlices( + u8, + &.{ 138, 5, 100, 96, 97, 109, 104 }, + ms.sent.items[3], + ); + + try testing.expectEqualSlices( + u8, + &.{ 136, 2, 3, 232 }, + ms.sent.items[4], + ); + + try testing.expectEqual(2, ms.cdp.items.len); + try testing.expectEqualSlices( + u8, + &.{ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }, + ms.cdp.items[0], + ); + + try testing.expectEqualSlices( + u8, + &([_]u8{ 64, 67, 66, 69 } ** 171 ++ [_]u8{ 64, 67, 66 }), + ms.cdp.items[1], + ); + + try testing.expectEqual(true, ms.closed); } +} - // bind & listen - var socklen = address.getOsSockLen(); - try std.posix.bind(sockfd, &address.any, socklen); - const kernel_backlog = 1; // default value is 128. Here we just want 1 connection - try std.posix.listen(sockfd, kernel_backlog); - var listen_address: std.net.Address = undefined; - try std.posix.getsockname(sockfd, &listen_address.any, &socklen); - - return sockfd; +test "server: mask" { + var buf: [4000]u8 = undefined; + const messages = [_][]const u8{ "1234", "1234" ** 99, "1234" ** 999 }; + for (messages) |message| { + // we need the message to be mutable since mask operates in-place + const payload = buf[0..message.len]; + @memcpy(payload, message); + + mask(&.{ 1, 2, 200, 240 }, payload); + try testing.expectEqual(false, std.mem.eql(u8, payload, message)); + + mask(&.{ 1, 2, 200, 240 }, payload); + try testing.expectEqual(true, std.mem.eql(u8, payload, message)); + } +} + +fn assertHTTPError( + expected_error: HTTPError, + comptime expected_status: u16, + comptime expected_body: []const u8, + input: []const u8, +) !void { + var ms = MockServer{}; + defer ms.deinit(); + + var client = Client(*MockServer).init(0, &ms); + @memcpy(client.read_buf[0..input.len], input); + try testing.expectError(expected_error, client.processData(input.len)); + + const expected_response = std.fmt.comptimePrint( + "HTTP/1.1 {d} \r\nConnection: Close\r\nContent-Length: {d}\r\n\r\n{s}", + .{ expected_status, expected_body.len, expected_body }, + ); + + try testing.expectEqual(1, ms.sent.items.len); + try testing.expectEqualStrings(expected_response, ms.sent.items[0]); +} + +fn assertWebSocketError( + expected_error: WebSocketError, + close_code: u16, + close_payload: []const u8, + input: []const u8, +) !void { + var ms = MockServer{}; + defer ms.deinit(); + + var client = Client(*MockServer).init(0, &ms); + client.mode = .websocket; // force websocket message processing + + @memcpy(client.read_buf[0..input.len], input); + try testing.expectError(expected_error, client.processData(input.len)); + + try testing.expectEqual(1, ms.sent.items.len); + + const actual = ms.sent.items[0]; + + // fin | close opcode + try testing.expectEqual(136, actual[0]); + + // message length (code + payload) + try testing.expectEqual(2 + close_payload.len, actual[1]); + + // close code + try testing.expectEqual(close_code, std.mem.readInt(u16, actual[2..4], .big)); + + // close payload (if any) + try testing.expectEqualStrings(close_payload, actual[4..]); +} + +fn assertWebSocketMessage( + expected: []const u8, + input: []const u8, +) !void { + var ms = MockServer{}; + defer ms.deinit(); + + var client = Client(*MockServer).init(0, &ms); + client.mode = .websocket; // force websocket message processing + + @memcpy(client.read_buf[0..input.len], input); + const more = try client.processData(input.len); + + try testing.expectEqual(1, ms.sent.items.len); + try testing.expectEqualSlices(u8, expected, ms.sent.items[0]); + + // if we sent a close message, then the serve should have been told + // to close the connection + if (expected[0] == 136) { + try testing.expectEqual(true, ms.closed); + try testing.expectEqual(false, more); + } else { + try testing.expectEqual(false, ms.closed); + try testing.expectEqual(true, more); + } } + +const MockServer = struct { + closed: bool = false, + + // record the messages we sent to the client + sent: std.ArrayListUnmanaged([]const u8) = .{}, + + // record the CDP messages we need to process + cdp: std.ArrayListUnmanaged([]const u8) = .{}, + + allocator: Allocator = testing.allocator, + + json_version_response: []const u8 = "the json version response", + + fn deinit(self: *MockServer) void { + const allocator = self.allocator; + + for (self.sent.items) |msg| { + allocator.free(msg); + } + self.sent.deinit(allocator); + + for (self.cdp.items) |msg| { + allocator.free(msg); + } + self.cdp.deinit(allocator); + } + + fn queueClose(self: *MockServer, _: anytype) void { + self.closed = true; + } + + fn handleCDP(self: *MockServer, message: []const u8) !void { + const owned = try self.allocator.dupe(u8, message); + try self.cdp.append(self.allocator, owned); + } + + fn queueSend( + self: *MockServer, + socket: posix.socket_t, + data: []const u8, + free_when_done: bool, + ) !void { + _ = socket; + const owned = try self.allocator.dupe(u8, data); + try self.sent.append(self.allocator, owned); + if (free_when_done) { + testing.allocator.free(data); + } + } +}; From 733c6b4c1747000f342b79a5e48beebcebb38fec Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 6 Feb 2025 22:29:15 +0800 Subject: [PATCH 02/15] remove websocket.zig dependency from build --- build.zig | 5 ----- vendor/websocket.zig | 1 - 2 files changed, 6 deletions(-) delete mode 160000 vendor/websocket.zig diff --git a/build.zig b/build.zig index 44e99222a..adf4e26f0 100644 --- a/build.zig +++ b/build.zig @@ -189,11 +189,6 @@ fn common( .root_source_file = b.path("vendor/tls.zig/src/main.zig"), }); step.root_module.addImport("tls", tlsmod); - - const wsmod = b.addModule("websocket", .{ - .root_source_file = b.path("vendor/websocket.zig/src/websocket.zig"), - }); - step.root_module.addImport("websocket", wsmod); } fn moduleNetSurf(b: *std.Build, target: std.Build.ResolvedTarget) !*std.Build.Module { diff --git a/vendor/websocket.zig b/vendor/websocket.zig deleted file mode 160000 index 1b49626c7..000000000 --- a/vendor/websocket.zig +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 1b49626c78bb5063cede4f22b30496fc66e7ba64 From 72eaab68be2cc65ecbe186210f54bf541975dce2 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 6 Feb 2025 22:33:54 +0800 Subject: [PATCH 03/15] zig fmt --- src/server.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server.zig b/src/server.zig index 5f18b6e97..acef658ea 100644 --- a/src/server.zig +++ b/src/server.zig @@ -251,7 +251,7 @@ const Server = struct { fn callbackClose(self: *Server, completion: *Completion, _: CloseError!void) void { std.debug.assert(completion == &self.close_completion); - if (self.client != null) { + if (self.client != null) { self.client = null; } self.queueAccept(); From f41c1cbfd0ac872382b48bbaaddc9de33e3baa73 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 7 Feb 2025 11:46:11 +0800 Subject: [PATCH 04/15] "fix" test compilation --- src/main_tests.zig | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main_tests.zig b/src/main_tests.zig index 88acd1a52..8028827c3 100644 --- a/src/main_tests.zig +++ b/src/main_tests.zig @@ -337,7 +337,12 @@ test { std.testing.refAllDecls(@import("generate.zig")); std.testing.refAllDecls(@import("cdp/msg.zig")); - std.testing.refAllDecls(@import("server.zig")); + + // Don't use refAllDecls, as this will pull in the entire project + // and break the test build. + // We should fix this. See this branch & the commit message for details: + // https://github.com/karlseguin/browser/commit/193ab5ceab3d3758ea06db04f7690460d79eb79e + _ = @import("server.zig"); } fn testJSRuntime(alloc: std.mem.Allocator) !void { From 4d9cc55a87fa37b8151235e01e153682242fd414 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 7 Feb 2025 12:33:47 +0800 Subject: [PATCH 05/15] Increase fuzz count. Add test for [too] large HTTP requests --- src/server.zig | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/server.zig b/src/server.zig index acef658ea..1ef5d01e9 100644 --- a/src/server.zig +++ b/src/server.zig @@ -529,12 +529,13 @@ fn Client(comptime S: type) type { errdefer self.server.queueClose(self.socket); + if (request.len > MAX_HTTP_REQUEST_SIZE) { + self.writeHTTPErrorResponse(413, "Request too large"); + return error.RequestTooLarge; + } + // we're only expecting [body-less] GET requests. if (std.mem.endsWith(u8, request, "\r\n\r\n") == false) { - if (request.len > MAX_HTTP_REQUEST_SIZE) { - self.writeHTTPErrorResponse(413, "Request too large"); - return error.RequestTooLarge; - } // we need more data, put any more data here self.read_len = request.len; return; @@ -1055,6 +1056,16 @@ test "server: buildJSONVersionResponse" { "{\"webSocketDebuggerUrl\": \"ws://127.0.0.1:9001/\"}", res); } +test "Client: http invalid request" { + try assertHTTPError( + error.RequestTooLarge, + 413, + "Request too large", + "GET /over/9000 HTTP/1.1\r\n" ++ "Header: " ++ ("a" ** 2050) ++ "\r\n\r\n", + ); + +} + test "Client: http invalid handshake" { try assertHTTPError( error.InvalidRequest, @@ -1327,7 +1338,7 @@ test "Client: fuzz" { } }; - for (0..1) |_| { + for (0..100) |_| { var ms = MockServer{}; defer ms.deinit(); From bdb70444d657f83030404c7d2293a136eaabde56 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 7 Feb 2025 15:57:02 +0800 Subject: [PATCH 06/15] Make websocket client reader stateful Move more logic into the reader. Avoid copying partial messages in cases where we know that the buffer is large enough. This is mostly groundwork for trying to add support for continuation frames. --- src/server.zig | 226 +++++++++++++++++++++++++++++++------------------ 1 file changed, 144 insertions(+), 82 deletions(-) diff --git a/src/server.zig b/src/server.zig index 1ef5d01e9..d5bc4856b 100644 --- a/src/server.zig +++ b/src/server.zig @@ -64,7 +64,9 @@ const log = std.log.scoped(.server); const MAX_HTTP_REQUEST_SIZE = 2048; -// max message size, +14 for max websocket payload overhead +// max message size +// +14 for max websocket payload overhead +// +140 for the max control packet that might be interleaved in a message const MAX_MESSAGE_SIZE = 256 * 1024 + 14; // For now, cdp does @import("server.zig").Ctx. Could change cdp to use "Server" @@ -466,15 +468,10 @@ fn Client(comptime S: type) type { // should eventually be upgraded to a websocket connections mode: Mode, server: S, + reader: Reader, socket: posix.socket_t, last_active: std.time.Instant, - // the start of the message in our read_buf - read_pos: usize = 0, - // up to where do we have data in our read_buf - read_len: usize = 0, - read_buf: [MAX_MESSAGE_SIZE]u8 = undefined, - const Mode = enum { http, websocket, @@ -485,6 +482,7 @@ fn Client(comptime S: type) type { fn init(socket: posix.socket_t, server: S) Self { return .{ .mode = .http, + .reader = .{}, .socket = socket, .server = server, .last_active = now(), @@ -501,31 +499,25 @@ fn Client(comptime S: type) type { } fn readBuf(self: *Self) []u8 { - // We might have read a partial http or websocket message. - // Subsequent reads must read from where we left off. - std.debug.assert(self.read_pos < self.read_buf.len); - return self.read_buf[self.read_len..]; + return self.reader.readBuf(); } fn processData(self: *Self, len: usize) !bool { - const end = self.read_len + len; - std.debug.assert(end >= self.read_pos); - self.last_active = now(); - const data = self.read_buf[self.read_pos..end]; + self.reader.len += len; switch (self.mode) { .http => { - try self.processHTTPRequest(data); + try self.processHTTPRequest(); return true; }, - .websocket => return self.processWebsocketMessage(data), + .websocket => return self.processWebsocketMessage(), } } - fn processHTTPRequest(self: *Self, request: []u8) HTTPError!void { - // We should never get pipelined HTTP requests - std.debug.assert(self.read_pos == 0); + fn processHTTPRequest(self: *Self) HTTPError!void { + std.debug.assert(self.reader.pos == 0); + const request = self.reader.buf[0..self.reader.len]; errdefer self.server.queueClose(self.socket); @@ -537,7 +529,6 @@ fn Client(comptime S: type) type { // 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 - self.read_len = request.len; return; } @@ -559,7 +550,7 @@ fn Client(comptime S: type) type { }; // the next incoming data can go to the front of our buffer - self.read_len = 0; + self.reader.len = 0; } fn handleHTTPRequest(self: *Self, request: []u8) !void { @@ -683,10 +674,10 @@ fn Client(comptime S: type) type { return self.send(response, true); } - fn processWebsocketMessage(self: *Self, data: []u8) !bool { + fn processWebsocketMessage(self: *Self) !bool { errdefer self.server.queueClose(self.socket); - var reader = Reader{ .data = data }; + var reader = &self.reader; while (true) { const msg = reader.next() catch |err| { switch (err) { @@ -711,18 +702,9 @@ fn Client(comptime S: type) type { } } - const incomplete = reader.data; - self.read_len = incomplete.len; - if (incomplete.len > 0) { - // we have part of the data for the next message - - // can't use @memset because incomplete is a slice of read_buf, - // so they could overlap - - // TODO: this can be skipped if we know that the next message will - // fit into whatever reamining space we have. - std.mem.copyForwards(u8, self.read_buf[0..incomplete.len], incomplete); - } + // We might have read part of the next message. Our reader potentially + // has to move data around in its buffer to make space. + reader.compact(); return true; } @@ -814,23 +796,52 @@ fn Client(comptime S: type) type { // can return zero or more Messages. When next returns null, any incomplete // message will remain in reader.data const Reader = struct { - data: []u8, + // position in buf of the start of the next message + pos: usize = 0, + + // position in buf up until where we have valid data + // (any new reads must be placed after this) + len: usize = 0, + + // we add 140 to allow 1 control message (ping/pong/close) to be + // fragmented into a normal message. + buf: [MAX_MESSAGE_SIZE + 140]u8 = undefined, + + fn readBuf(self: *Reader) []u8 { + // We might have read a partial http or websocket message. + // Subsequent reads must read from where we left off. + return self.buf[self.len..]; + } fn next(self: *Reader) !?Message { - var data = self.data; - if (data.len < 2) { + var buf = self.buf[self.pos..self.len]; + + const length_of_len, const message_len = extractLengths(buf) orelse { + // we don't have enough bytes return null; - } + }; - const byte1 = data[0]; + const byte1 = buf[0]; if (byte1 & 112 != 0) { return error.ReservedFlags; } + const fin = byte1 & 128 == 128; + if (!fin) { + return error.ContinuationNotSupported; + } + + if (buf[1] & 128 != 128) { + // client -> server messages _must_ be masked + return error.NotMasked; + } + + // var is_continuation = false; var message_type: Message.Type = undefined; switch (byte1 & 15) { - 0 => return error.ContinuationNotSupported, // TODO?? + // 0 => is_continuation = true, + 0 => return error.ContinuationNotSupported, 1 => message_type = .text, 2 => message_type = .binary, 8 => message_type = .close, @@ -839,53 +850,104 @@ const Reader = struct { else => return error.InvalidMessageType, } - if (byte1 & 128 != 128) { - // TODO?? - return error.ContinuationNotSupported; + if (message_len > MAX_MESSAGE_SIZE) { + return error.TooLarge; } - const byte2 = data[1]; - if (byte2 & 128 != 128) { - // client -> server messages _must_ be masked - return error.NotMasked; + if (buf.len < message_len) { + return null; + } + + // prefix + length_of_len + mask + const header_len = 2 + length_of_len + 4; + + const payload = buf[header_len..message_len]; + mask(buf[header_len - 4 .. header_len], payload); + + self.pos += message_len; + + return .{ + .type = message_type, + .data = payload, + }; + } + + fn extractLengths(buf: []const u8) ?struct{usize, usize} { + if (buf.len < 2) { + return null; } - const length_of_len: usize = switch (byte2 & 127) { + const length_of_len: usize = switch (buf[1] & 127) { 126 => 2, 127 => 8, else => 0, }; - if (data.len < length_of_len + 2) { - // we definitely don't have enough data yet + if (buf.len < length_of_len + 2) { + // we definitely don't have enough buf yet return null; } const message_len = switch (length_of_len) { - 2 => @as(u16, @intCast(data[3])) | @as(u16, @intCast(data[2])) << 8, - 8 => @as(u64, @intCast(data[9])) | @as(u64, @intCast(data[8])) << 8 | @as(u64, @intCast(data[7])) << 16 | @as(u64, @intCast(data[6])) << 24 | @as(u64, @intCast(data[5])) << 32 | @as(u64, @intCast(data[4])) << 40 | @as(u64, @intCast(data[3])) << 48 | @as(u64, @intCast(data[2])) << 56, - else => data[1] & 127, - } + length_of_len + 2 + 4; // +2 for header prefix, +4 for mask + 2 => @as(u16, @intCast(buf[3])) | @as(u16, @intCast(buf[2])) << 8, + 8 => @as(u64, @intCast(buf[9])) | @as(u64, @intCast(buf[8])) << 8 | @as(u64, @intCast(buf[7])) << 16 | @as(u64, @intCast(buf[6])) << 24 | @as(u64, @intCast(buf[5])) << 32 | @as(u64, @intCast(buf[4])) << 40 | @as(u64, @intCast(buf[3])) << 48 | @as(u64, @intCast(buf[2])) << 56, + else => buf[1] & 127, + } + length_of_len + 2 + 4; // +2 for header prefix, +4 for mask; - if (message_len > MAX_MESSAGE_SIZE) { - return error.TooLarge; - } + return .{length_of_len, message_len}; + } - if (data.len < message_len) { - return null; + // This is called after we've processed complete websocket messages (this + // only applies to websocket messages). + // There are three cases: + // 1 - We don't have any incomplete data (for a subsequent message) in buf. + // This is the easier to handle, we can set pos & len to 0. + // 2 - We have part of the next message, but we know it'll fit in the + // remaining buf. We don't need to do anything + // 3 - We have part of the next message, but either it won't fight into the + // remaining buffer, or we don't know (because we don't have enough + // of the header to tell the length). We need to "compact" the buffer + fn compact(self: *Reader) void { + const pos = self.pos; + const len = self.len; + + std.debug.assert(pos <= len); + + // how many (if any) partial bytes do we have + const partial_bytes = len - pos; + + if (partial_bytes == 0) { + // We have no partial bytes. Setting these to 0 ensures that we + // get the best utilization of our buffer + self.pos = 0; + self.len = 0; + return; } - // prefix + length_of_len + mask - const header_len = 2 + length_of_len + 4; + const partial = self.buf[pos..len]; - const payload = data[header_len..message_len]; - mask(data[header_len - 4 .. header_len], payload); + // If we have enough bytes of the next message to tell its length + // we'll be able to figure out whether we need to do anything or not. + if (extractLengths(partial)) |length_meta| { + const next_message_len = length_meta.@"1"; + // if this isn't true, then we have a full message and it + // should have been processed. + std.debug.assert(next_message_len > partial_bytes); - self.data = data[message_len..]; - return .{ - .type = message_type, - .data = payload, - }; + const missing_bytes = next_message_len - partial_bytes; + + const free_space = self.buf.len - len; + if (missing_bytes < free_space) { + // we have enough space in our buffer, as is, + return; + } + } + + // We're here because we either don't have enough bytes of the next + // message, or we know that it won't fit in our buffer as-is. + std.mem.copyForwards(u8, &self.buf, partial); + self.pos = 0; + self.len = partial_bytes; } }; @@ -1138,7 +1200,7 @@ test "Client: http valid handshake" { "sec-websocket-key: this is my key\r\n" ++ "Custom: Header-Value\r\n\r\n"; - @memcpy(client.read_buf[0..request.len], request); + @memcpy(client.reader.buf[0..request.len], request); try testing.expectEqual(true, try client.processData(request.len)); try testing.expectEqual(.websocket, client.mode); @@ -1159,7 +1221,7 @@ test "Client: http get json version" { const request = "GET /json/version HTTP/1.1\r\n\r\n"; - @memcpy(client.read_buf[0..request.len], request); + @memcpy(client.reader.buf[0..request.len], request); try testing.expectEqual(true, try client.processData(request.len)); try testing.expectEqual(.http, client.mode); @@ -1193,21 +1255,21 @@ test "Client: read invalid websocket message" { error.InvalidMessageType, 1002, "", - &.{ 131, 1 }, // 128 (fin) | 3 where 3 isn't a valid type + &.{ 131, 128, 'm', 'a', 's', 'k' }, // 128 (fin) | 3 where 3 isn't a valid type ); try assertWebSocketError( error.ContinuationNotSupported, 1003, "", - &.{ 128, 1 }, // 128 (fin) | 0 where 0 is a continuation frame + &.{ 128, 128, 'm', 'a', 's', 'k' }, // 128 (fin) | 0 where 0 is a continuation frame ); try assertWebSocketError( error.ContinuationNotSupported, 1003, "", - &.{ 1, 1 }, // 0 (non-fin) | 1 non-fin (contination) not supported + &.{ 1, 128, 'm', 'a', 's', 'k' }, // 0 (non-fin) | 1 non-fin (contination) not supported ); for ([_]u8{ 16, 32, 64 }) |rsv| { @@ -1216,7 +1278,7 @@ test "Client: read invalid websocket message" { error.ReservedFlags, 1002, "", - &.{ rsv, 0 }, + &.{ rsv, 128, 'm', 'a', 's', 'k' }, ); // as a bitmask @@ -1224,7 +1286,7 @@ test "Client: read invalid websocket message" { error.ReservedFlags, 1002, "", - &.{ rsv + 4, 0 }, + &.{ rsv + 4, 128, 'm', 'a', 's', 'k' }, ); } @@ -1232,14 +1294,14 @@ test "Client: read invalid websocket message" { error.NotMasked, 1002, "", - &.{ 129, 127 }, // client->server messages must be masked + &.{ 129, 1, 'a' }, // client->server messages must be masked ); try assertWebSocketError( error.TooLarge, 1009, "", - &.{ 129, 255, 0, 0, 0, 0, 0, 4, 0, 1 }, // 1024 * 256 + 1 + &.{ 129, 255, 0, 0, 0, 0, 0, 4, 0, 1, 'm', 'a', 's', 'k' }, // 1024 * 256 + 1 ); } @@ -1427,7 +1489,7 @@ fn assertHTTPError( defer ms.deinit(); var client = Client(*MockServer).init(0, &ms); - @memcpy(client.read_buf[0..input.len], input); + @memcpy(client.reader.buf[0..input.len], input); try testing.expectError(expected_error, client.processData(input.len)); const expected_response = std.fmt.comptimePrint( @@ -1451,7 +1513,7 @@ fn assertWebSocketError( var client = Client(*MockServer).init(0, &ms); client.mode = .websocket; // force websocket message processing - @memcpy(client.read_buf[0..input.len], input); + @memcpy(client.reader.buf[0..input.len], input); try testing.expectError(expected_error, client.processData(input.len)); try testing.expectEqual(1, ms.sent.items.len); @@ -1481,7 +1543,7 @@ fn assertWebSocketMessage( var client = Client(*MockServer).init(0, &ms); client.mode = .websocket; // force websocket message processing - @memcpy(client.read_buf[0..input.len], input); + @memcpy(client.reader.buf[0..input.len], input); const more = try client.processData(input.len); try testing.expectEqual(1, ms.sent.items.len); From 14fe4f65e164bd1cdfcd7a3a4ea39836e2ff7cef Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 7 Feb 2025 18:18:53 +0800 Subject: [PATCH 07/15] support continuation frames --- src/server.zig | 281 ++++++++++++++++++++++++++++++++++++------------- 1 file changed, 210 insertions(+), 71 deletions(-) diff --git a/src/server.zig b/src/server.zig index d5bc4856b..91653e452 100644 --- a/src/server.zig +++ b/src/server.zig @@ -54,7 +54,8 @@ const WebSocketError = error{ NotMasked, TooLarge, InvalidMessageType, - ContinuationNotSupported, + InvalidContinuation, + NestedFragementation, }; const Error = IOError || cdp.Error || HTTPError || WebSocketError; @@ -211,7 +212,7 @@ const Server = struct { }; const more = client.processData(size) catch |err| { - std.debug.print("Client Processing Error: {}\n", .{err}); + log.err("Client Processing Error: {}\n", .{err}); return; }; @@ -459,8 +460,6 @@ fn Client(comptime S: type) type { const CLOSE_NORMAL = [_]u8{ 136, 2, 3, 232 }; // code: 1000 const CLOSE_TOO_BIG = [_]u8{ 136, 2, 3, 241 }; // 1009 const CLOSE_PROTOCOL_ERROR = [_]u8{ 136, 2, 3, 234 }; //code: 1002 - // This should be removed once we support continuation frames - const CLOSE_UNSUPPORTED_ERROR = [_]u8{ 136, 2, 3, 235 }; //code: 1003 const CLOSE_TIMEOUT = [_]u8{ 136, 2, 15, 160 }; // code: 4000 return struct { @@ -482,10 +481,10 @@ fn Client(comptime S: type) type { fn init(socket: posix.socket_t, server: S) Self { return .{ .mode = .http, - .reader = .{}, .socket = socket, .server = server, .last_active = now(), + .reader = .{ .allocator = server.allocator }, }; } @@ -496,6 +495,7 @@ fn Client(comptime S: type) type { } } self.server.queueClose(self.socket); + self.reader.deinit(); } fn readBuf(self: *Self) []u8 { @@ -685,7 +685,9 @@ fn Client(comptime S: type) type { error.NotMasked => self.send(&CLOSE_PROTOCOL_ERROR, false) catch {}, error.ReservedFlags => self.send(&CLOSE_PROTOCOL_ERROR, false) catch {}, error.InvalidMessageType => self.send(&CLOSE_PROTOCOL_ERROR, false) catch {}, - error.ContinuationNotSupported => self.send(&CLOSE_UNSUPPORTED_ERROR, false) catch {}, + error.InvalidContinuation => self.send(&CLOSE_PROTOCOL_ERROR, false) catch {}, + error.NestedFragementation => self.send(&CLOSE_PROTOCOL_ERROR, false) catch {}, + error.OutOfMemory => {}, // don't borther trying to send an error in this case } return err; } orelse break; @@ -700,6 +702,9 @@ fn Client(comptime S: type) type { }, .text, .binary => try self.server.handleCDP(msg.data), } + if (msg.cleanup_fragment) { + reader.cleanup(); + } } // We might have read part of the next message. Our reader potentially @@ -796,6 +801,8 @@ fn Client(comptime S: type) type { // can return zero or more Messages. When next returns null, any incomplete // message will remain in reader.data const Reader = struct { + allocator: Allocator, + // position in buf of the start of the next message pos: usize = 0, @@ -807,6 +814,19 @@ const Reader = struct { // fragmented into a normal message. buf: [MAX_MESSAGE_SIZE + 140]u8 = undefined, + fragments: ?Fragments = null, + + fn deinit(self: *Reader) void { + self.cleanup(); + } + + fn cleanup(self: *Reader) void { + if (self.fragments) |*f| { + f.message.deinit(self.allocator); + self.fragments = null; + } + } + fn readBuf(self: *Reader) []u8 { // We might have read a partial http or websocket message. // Subsequent reads must read from where we left off. @@ -814,65 +834,105 @@ const Reader = struct { } fn next(self: *Reader) !?Message { - var buf = self.buf[self.pos..self.len]; + LOOP: while (true) { + var buf = self.buf[self.pos..self.len]; - const length_of_len, const message_len = extractLengths(buf) orelse { - // we don't have enough bytes - return null; - }; + const length_of_len, const message_len = extractLengths(buf) orelse { + // we don't have enough bytes + return null; + }; - const byte1 = buf[0]; + const byte1 = buf[0]; - if (byte1 & 112 != 0) { - return error.ReservedFlags; - } + if (byte1 & 112 != 0) { + return error.ReservedFlags; + } - const fin = byte1 & 128 == 128; - if (!fin) { - return error.ContinuationNotSupported; - } + if (buf[1] & 128 != 128) { + // client -> server messages _must_ be masked + return error.NotMasked; + } - if (buf[1] & 128 != 128) { - // client -> server messages _must_ be masked - return error.NotMasked; - } + var is_continuation = false; + var message_type: Message.Type = undefined; + switch (byte1 & 15) { + 0 => is_continuation = true, + 1 => message_type = .text, + 2 => message_type = .binary, + 8 => message_type = .close, + 9 => message_type = .ping, + 10 => message_type = .pong, + else => return error.InvalidMessageType, + } - // var is_continuation = false; - var message_type: Message.Type = undefined; - switch (byte1 & 15) { - // 0 => is_continuation = true, - 0 => return error.ContinuationNotSupported, - 1 => message_type = .text, - 2 => message_type = .binary, - 8 => message_type = .close, - 9 => message_type = .ping, - 10 => message_type = .pong, - else => return error.InvalidMessageType, - } + if (message_len > MAX_MESSAGE_SIZE) { + return error.TooLarge; + } - if (message_len > MAX_MESSAGE_SIZE) { - return error.TooLarge; - } + if (buf.len < message_len) { + return null; + } - if (buf.len < message_len) { - return null; - } + // prefix + length_of_len + mask + const header_len = 2 + length_of_len + 4; - // prefix + length_of_len + mask - const header_len = 2 + length_of_len + 4; + const payload = buf[header_len..message_len]; + mask(buf[header_len - 4 .. header_len], payload); - const payload = buf[header_len..message_len]; - mask(buf[header_len - 4 .. header_len], payload); + // whatever happens after this, we know where the next message starts + self.pos += message_len; - self.pos += message_len; + const fin = byte1 & 128 == 128; - return .{ - .type = message_type, - .data = payload, - }; + if (is_continuation) { + const fragments = &(self.fragments orelse return error.InvalidContinuation); + if (fragments.message.items.len + message_len > MAX_MESSAGE_SIZE) { + return error.TooLarge; + } + + try fragments.message.appendSlice(self.allocator, payload); + + if (fin == false) { + // maybe we have more parts of the message waiting + continue :LOOP; + } + + // this continuation is done! + return .{ + .type = fragments.type, + .data = fragments.message.items, + .cleanup_fragment = true, + }; + } + + const can_be_fragmented = message_type == .text or message_type == .binary; + if (self.fragments != null and can_be_fragmented) { + // if this isn't a continuation, then we can't have fragements + return error.NestedFragementation; + } + + if (fin == false) { + if (can_be_fragmented == false) { + return error.InvalidContinuation; + } + + // not continuation, and not fin. It has to be the first message + // in a fragemented message. + var fragments = Fragments{ .message = .{}, .type = message_type }; + try fragments.message.appendSlice(self.allocator, payload); + self.fragments = fragments; + continue :LOOP; + } + + return .{ + .data = payload, + .type = message_type, + .cleanup_fragment = false, + }; + } } - fn extractLengths(buf: []const u8) ?struct{usize, usize} { + fn extractLengths(buf: []const u8) ?struct { usize, usize } { if (buf.len < 2) { return null; } @@ -894,7 +954,7 @@ const Reader = struct { else => buf[1] & 127, } + length_of_len + 2 + 4; // +2 for header prefix, +4 for mask; - return .{length_of_len, message_len}; + return .{ length_of_len, message_len }; } // This is called after we've processed complete websocket messages (this @@ -951,9 +1011,15 @@ const Reader = struct { } }; +const Fragments = struct { + type: Message.Type, + message: std.ArrayListUnmanaged(u8), +}; + const Message = struct { type: Type, data: []const u8, + cleanup_fragment: bool, const Type = enum { text, @@ -1125,7 +1191,6 @@ test "Client: http invalid request" { "Request too large", "GET /over/9000 HTTP/1.1\r\n" ++ "Header: " ++ ("a" ** 2050) ++ "\r\n\r\n", ); - } test "Client: http invalid handshake" { @@ -1251,25 +1316,12 @@ test "Client: write websocket message" { } test "Client: read invalid websocket message" { + // 131 = 128 (fin) | 3 where 3 isn't a valid type try assertWebSocketError( error.InvalidMessageType, 1002, "", - &.{ 131, 128, 'm', 'a', 's', 'k' }, // 128 (fin) | 3 where 3 isn't a valid type - ); - - try assertWebSocketError( - error.ContinuationNotSupported, - 1003, - "", - &.{ 128, 128, 'm', 'a', 's', 'k' }, // 128 (fin) | 0 where 0 is a continuation frame - ); - - try assertWebSocketError( - error.ContinuationNotSupported, - 1003, - "", - &.{ 1, 128, 'm', 'a', 's', 'k' }, // 0 (non-fin) | 1 non-fin (contination) not supported + &.{ 131, 128, 'm', 'a', 's', 'k' }, ); for ([_]u8{ 16, 32, 64 }) |rsv| { @@ -1290,18 +1342,84 @@ test "Client: read invalid websocket message" { ); } + // client->server messages must be masked try assertWebSocketError( error.NotMasked, 1002, "", - &.{ 129, 1, 'a' }, // client->server messages must be masked + &.{ 129, 1, 'a' }, ); + // length of message is 0000 0401, i.e: 1024 * 256 + 1 try assertWebSocketError( error.TooLarge, 1009, "", - &.{ 129, 255, 0, 0, 0, 0, 0, 4, 0, 1, 'm', 'a', 's', 'k' }, // 1024 * 256 + 1 + &.{ 129, 255, 0, 0, 0, 0, 0, 4, 0, 1, 'm', 'a', 's', 'k' }, + ); + + // continuation type message must come after a normal message + // even when not a fin frame + try assertWebSocketError( + error.InvalidContinuation, + 1002, + "", + &.{ 0, 129, 'm', 'a', 's', 'k', 'd' }, + ); + + // continuation type message must come after a normal message + // even as a fin frame + try assertWebSocketError( + error.InvalidContinuation, + 1002, + "", + &.{ 128, 129, 'm', 'a', 's', 'k', 'd' }, + ); + + // text (non-fin) - text (non-fin) + try assertWebSocketError( + error.NestedFragementation, + 1002, + "", + &.{ 1, 129, 'm', 'a', 's', 'k', 'd', 1, 128, 'k', 's', 'a', 'm' }, + ); + + // text (non-fin) - text (fin) should always been continuation after non-fin + try assertWebSocketError( + error.NestedFragementation, + 1002, + "", + &.{ 1, 129, 'm', 'a', 's', 'k', 'd', 129, 128, 'k', 's', 'a', 'm' }, + ); + + // close must be fin + try assertWebSocketError( + error.InvalidContinuation, + 1002, + "", + &.{ + 8, 129, 'm', 'a', 's', 'k', 'd', + }, + ); + + // ping must be fin + try assertWebSocketError( + error.InvalidContinuation, + 1002, + "", + &.{ + 9, 129, 'm', 'a', 's', 'k', 'd', + }, + ); + + // pong must be fin + try assertWebSocketError( + error.InvalidContinuation, + 1002, + "", + &.{ + 10, 129, 'm', 'a', 's', 'k', 'd', + }, ); } @@ -1380,6 +1498,21 @@ test "Client: fuzz" { [_]u8{ 129, 254, 2, 175, 1, 2, 3, 4 } ++ "A" ** 687, ); + // non-fin text message + try websocket_messages.appendSlice(allocator, &.{ 1, 130, 0, 0, 0, 0, 1, 2 }); + + // continuation + try websocket_messages.appendSlice(allocator, &.{ 0, 131, 0, 0, 0, 0, 3, 4, 5 }); + + // pong happening in fragement + try websocket_messages.appendSlice(allocator, &.{ 138, 128, 0, 0, 0, 0 }); + + // more continuation + try websocket_messages.appendSlice(allocator, &.{ 0, 130, 0, 0, 0, 0, 6, 7 }); + + // fin + try websocket_messages.appendSlice(allocator, &.{ 128, 133, 0, 0, 0, 0, 8, 9, 10, 11, 12 }); + // close try websocket_messages.appendSlice( allocator, @@ -1446,7 +1579,7 @@ test "Client: fuzz" { ms.sent.items[4], ); - try testing.expectEqual(2, ms.cdp.items.len); + try testing.expectEqual(3, ms.cdp.items.len); try testing.expectEqualSlices( u8, &.{ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }, @@ -1459,6 +1592,12 @@ test "Client: fuzz" { ms.cdp.items[1], ); + try testing.expectEqualSlices( + u8, + &.{ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 }, + ms.cdp.items[2], + ); + try testing.expectEqual(true, ms.closed); } } From 6ab64d155b0513737f90dda01b77c6233f8c2d95 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 10 Feb 2025 17:34:54 +0800 Subject: [PATCH 08/15] Refactor CDP CDP is now an struct which contains its own state a browser and a session. When a client connection is made and successfully upgrades, the client creates the CDP instance. There is now a cleaner separation betwen Server, Client and CDP. Removed a number of allocations, especially when writing results/events from CDP to the client. Improved input message parsing. Tried to remove some usage of undefined. --- src/browser/browser.zig | 189 +++++++------ src/cdp/browser.zig | 164 ++++------- src/cdp/cdp.zig | 553 +++++++++++++++++++++++------------- src/cdp/css.zig | 43 +-- src/cdp/dom.zig | 43 +-- src/cdp/emulation.zig | 119 +++----- src/cdp/fetch.zig | 43 +-- src/cdp/inspector.zig | 43 +-- src/cdp/log.zig | 43 +-- src/cdp/msg.zig | 293 ------------------- src/cdp/network.zig | 61 +--- src/cdp/page.zig | 402 +++++++++----------------- src/cdp/performance.zig | 44 +-- src/cdp/runtime.zig | 227 +++++---------- src/cdp/security.zig | 43 +-- src/cdp/target.zig | 581 ++++++++++++++------------------------ src/main.zig | 7 +- src/main_tests.zig | 1 - src/server.zig | 606 +++++++++++++++++++--------------------- src/unit_tests.zig | 4 +- 20 files changed, 1306 insertions(+), 2203 deletions(-) delete mode 100644 src/cdp/msg.zig diff --git a/src/browser/browser.zig b/src/browser/browser.zig index e7e2445c4..a9bc24252 100644 --- a/src/browser/browser.zig +++ b/src/browser/browser.zig @@ -19,6 +19,8 @@ const std = @import("std"); const builtin = @import("builtin"); +const Allocator = std.mem.Allocator; + const Types = @import("root").Types; const parser = @import("netsurf"); @@ -57,30 +59,44 @@ pub const user_agent = "Lightpanda/1.0"; // A browser contains only one session. // TODO allow multiple sessions per browser. pub const Browser = struct { - session: Session = undefined, - agent: []const u8 = user_agent, + loop: *Loop, + session: ?*Session, + allocator: Allocator, + session_pool: SessionPool, - const uri = "about:blank"; + const SessionPool = std.heap.MemoryPool(Session); - pub fn init(self: *Browser, alloc: std.mem.Allocator, loop: *Loop, vm: jsruntime.VM) !void { - // We want to ensure the caller initialised a VM, but the browser - // doesn't use it directly... - _ = vm; + const uri = "about:blank"; - try Session.init(&self.session, alloc, loop, uri); + pub fn init(allocator: Allocator, loop: *Loop) Browser { + return .{ + .loop = loop, + .session = null, + .allocator = allocator, + .session_pool = SessionPool.init(allocator), + }; } pub fn deinit(self: *Browser) void { - self.session.deinit(); + self.closeSession(); + self.session_pool.deinit(); } - pub fn newSession( - self: *Browser, - alloc: std.mem.Allocator, - loop: *jsruntime.Loop, - ) !void { - self.session.deinit(); - try Session.init(&self.session, alloc, loop, uri); + pub fn newSession(self: *Browser, ctx: anytype) !*Session { + self.closeSession(); + + const session = try self.session_pool.create(); + try Session.init(session, self.allocator, ctx, self.loop, uri); + self.session = session; + return session; + } + + fn closeSession(self: *Browser) void { + if (self.session) |session| { + session.deinit(); + self.session_pool.destroy(session); + self.session = null; + } } }; @@ -90,7 +106,7 @@ pub const Browser = struct { // deinit a page before running another one. pub const Session = struct { // allocator used to init the arena. - alloc: std.mem.Allocator, + allocator: Allocator, // The arena is used only to bound the js env init b/c it leaks memory. // see https://github.com/lightpanda-io/jsruntime-lib/issues/181 @@ -103,8 +119,9 @@ pub const Session = struct { // TODO handle proxy loader: Loader, - env: Env = undefined, - inspector: ?jsruntime.Inspector = null, + + env: Env, + inspector: jsruntime.Inspector, window: Window, @@ -115,20 +132,54 @@ pub const Session = struct { jstypes: [Types.len]usize = undefined, - fn init(self: *Session, alloc: std.mem.Allocator, loop: *Loop, uri: []const u8) !void { - self.* = Session{ + fn init(self: *Session, allocator: Allocator, ctx: anytype, loop: *Loop, uri: []const u8) !void { + self.* = .{ .uri = uri, - .alloc = alloc, - .arena = std.heap.ArenaAllocator.init(alloc), + .env = undefined, + .inspector = undefined, + .allocator = allocator, + .loader = Loader.init(allocator), + .httpClient = .{ .allocator = allocator }, + .storageShed = storage.Shed.init(allocator), + .arena = std.heap.ArenaAllocator.init(allocator), .window = Window.create(null, .{ .agent = user_agent }), - .loader = Loader.init(alloc), - .storageShed = storage.Shed.init(alloc), - .httpClient = undefined, }; - Env.init(&self.env, self.arena.allocator(), loop, null); - self.httpClient = .{ .allocator = alloc }; + const arena = self.arena.allocator(); + + Env.init(&self.env, arena, loop, null); + errdefer self.env.deinit(); try self.env.load(&self.jstypes); + + const ContextT = @TypeOf(ctx); + const InspectorContainer = switch (@typeInfo(ContextT)) { + .Struct => ContextT, + .Pointer => |ptr| ptr.child, + .Void => NoopInspector, + else => @compileError("invalid context type"), + }; + + // const ctx_opaque = @as(*anyopaque, @ptrCast(ctx)); + self.inspector = try jsruntime.Inspector.init( + arena, + self.env, + if (@TypeOf(ctx) == void) @constCast(@ptrCast(&{})) else ctx, + InspectorContainer.onInspectorResponse, + InspectorContainer.onInspectorEvent, + ); + self.env.setInspector(self.inspector); + } + + fn deinit(self: *Session) void { + if (self.page) |*p| { + p.end(); + } + + self.env.deinit(); + self.arena.deinit(); + self.httpClient.deinit(); + self.loader.deinit(); + self.storageShed.deinit(); } fn fetchModule(ctx: *anyopaque, referrer: ?jsruntime.Module, specifier: []const u8) !jsruntime.Module { @@ -146,47 +197,15 @@ pub const Session = struct { return self.env.compileModule(body, specifier); } - fn deinit(self: *Session) void { - if (self.page) |*p| p.end(); - - if (self.inspector) |inspector| { - inspector.deinit(self.alloc); - } - - self.env.deinit(); - self.arena.deinit(); - - self.httpClient.deinit(); - self.loader.deinit(); - self.storageShed.deinit(); - } - - pub fn initInspector( - self: *Session, - ctx: anytype, - onResp: jsruntime.InspectorOnResponseFn, - onEvent: jsruntime.InspectorOnEventFn, - ) !void { - const ctx_opaque = @as(*anyopaque, @ptrCast(ctx)); - self.inspector = try jsruntime.Inspector.init(self.alloc, self.env, ctx_opaque, onResp, onEvent); - self.env.setInspector(self.inspector.?); - } - pub fn callInspector(self: *Session, msg: []const u8) void { - if (self.inspector) |inspector| { - inspector.send(msg, self.env); - } else { - @panic("No Inspector"); - } + self.inspector.send(self.env, msg); } // NOTE: the caller is not the owner of the returned value, // the pointer on Page is just returned as a convenience pub fn createPage(self: *Session) !*Page { if (self.page != null) return error.SessionPageExists; - const p: Page = undefined; - self.page = p; - Page.init(&self.page.?, self.alloc, self); + self.page = Page.init(self.allocator, self); return &self.page.?; } }; @@ -197,8 +216,8 @@ pub const Session = struct { // The page handle all its memory in an arena allocator. The arena is reseted // when end() is called. pub const Page = struct { - arena: std.heap.ArenaAllocator, session: *Session, + arena: std.heap.ArenaAllocator, doc: ?*parser.Document = null, // handle url @@ -212,17 +231,18 @@ pub const Page = struct { raw_data: ?[]const u8 = null, - fn init( - self: *Page, - alloc: std.mem.Allocator, - session: *Session, - ) void { - self.* = .{ - .arena = std.heap.ArenaAllocator.init(alloc), + fn init(allocator: Allocator, session: *Session) Page { + return .{ .session = session, + .arena = std.heap.ArenaAllocator.init(allocator), }; } + pub fn deinit(self: *Page) void { + self.arena.deinit(); + self.session.page = null; + } + // start js env. // - auxData: extra data forwarded to the Inspector // see Inspector.contextCreated @@ -242,10 +262,8 @@ pub const Page = struct { try polyfill.load(self.arena.allocator(), self.session.env); // inspector - if (self.session.inspector) |inspector| { - log.debug("inspector context created", .{}); - inspector.contextCreated(self.session.env, "", self.origin orelse "://", auxData); - } + log.debug("inspector context created", .{}); + self.session.inspector.contextCreated(self.session.env, "", self.origin orelse "://", auxData); } // reset js env and mem arena. @@ -253,7 +271,6 @@ pub const Page = struct { self.session.env.stop(); // TODO unload document: https://html.spec.whatwg.org/#unloading-documents - if (self.url) |*u| u.deinit(self.arena.allocator()); self.url = null; self.location.url = null; self.session.window.replaceLocation(&self.location) catch |e| { @@ -266,13 +283,8 @@ pub const Page = struct { _ = self.arena.reset(.free_all); } - pub fn deinit(self: *Page) void { - self.arena.deinit(); - self.session.page = null; - } - // dump writes the page content into the given file. - pub fn dump(self: *Page, out: std.fs.File) !void { + pub fn dump(self: *const Page, out: std.fs.File) !void { // if no HTML document pointer available, dump the data content only. if (self.doc == null) { @@ -320,11 +332,9 @@ pub const Page = struct { } // own the url - if (self.rawuri) |prev| alloc.free(prev); self.rawuri = try alloc.dupe(u8, uri); self.uri = std.Uri.parse(self.rawuri.?) catch try std.Uri.parseAfterScheme("", self.rawuri.?); - if (self.url) |*prev| prev.deinit(alloc); self.url = try URL.constructor(alloc, self.rawuri.?, null); self.location.url = &self.url.?; try self.session.window.replaceLocation(&self.location); @@ -422,9 +432,7 @@ pub const Page = struct { // https://html.spec.whatwg.org/#read-html // inspector - if (self.session.inspector) |inspector| { - inspector.contextCreated(self.session.env, "", self.origin.?, auxData); - } + self.session.inspector.contextCreated(self.session.env, "", self.origin.?, auxData); // replace the user context document with the new one. try self.session.env.setUserContext(.{ @@ -583,7 +591,7 @@ pub const Page = struct { }; // the caller owns the returned string - fn fetchData(self: *Page, alloc: std.mem.Allocator, src: []const u8) ![]const u8 { + fn fetchData(self: *Page, alloc: Allocator, src: []const u8) ![]const u8 { log.debug("starting fetch {s}", .{src}); var buffer: [1024]u8 = undefined; @@ -658,7 +666,7 @@ pub const Page = struct { return .unknown; } - fn eval(self: Script, alloc: std.mem.Allocator, env: Env, body: []const u8) !void { + fn eval(self: Script, alloc: Allocator, env: Env, body: []const u8) !void { var try_catch: jsruntime.TryCatch = undefined; try_catch.init(env); defer try_catch.deinit(); @@ -683,3 +691,8 @@ pub const Page = struct { } }; }; + +const NoopInspector = struct { + pub fn onInspectorResponse(_: *anyopaque, _: u32, _: []const u8) void {} + pub fn onInspectorEvent(_: *anyopaque, _: []const u8) void {} +}; diff --git a/src/cdp/browser.zig b/src/cdp/browser.zig index 12f882e2c..72b0f44d0 100644 --- a/src/cdp/browser.zig +++ b/src/cdp/browser.zig @@ -17,132 +17,66 @@ // along with this program. If not, see . const std = @import("std"); - -const server = @import("../server.zig"); -const Ctx = server.Ctx; const cdp = @import("cdp.zig"); -const result = cdp.result; -const IncomingMessage = @import("msg.zig").IncomingMessage; -const Input = @import("msg.zig").Input; - -const log = std.log.scoped(.cdp); - -const Methods = enum { - getVersion, - setDownloadBehavior, - getWindowForTarget, - setWindowBounds, -}; - -pub fn browser( - alloc: std.mem.Allocator, - msg: *IncomingMessage, - action: []const u8, - ctx: *Ctx, -) ![]const u8 { - const method = std.meta.stringToEnum(Methods, action) orelse - return error.UnknownMethod; - return switch (method) { - .getVersion => getVersion(alloc, msg, ctx), - .setDownloadBehavior => setDownloadBehavior(alloc, msg, ctx), - .getWindowForTarget => getWindowForTarget(alloc, msg, ctx), - .setWindowBounds => setWindowBounds(alloc, msg, ctx), - }; -} // TODO: hard coded data -const ProtocolVersion = "1.3"; -const Product = "Chrome/124.0.6367.29"; -const Revision = "@9e6ded5ac1ff5e38d930ae52bd9aec09bd1a68e4"; -const UserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"; -const JsVersion = "12.4.254.8"; - -fn getVersion( - alloc: std.mem.Allocator, - msg: *IncomingMessage, - _: *Ctx, -) ![]const u8 { - // input - const input = try Input(void).get(alloc, msg); - defer input.deinit(); - log.debug("Req > id {d}, method {s}", .{ input.id, "browser.getVersion" }); +const PROTOCOL_VERSION = "1.3"; +const PRODUCT = "Chrome/124.0.6367.29"; +const REVISION = "@9e6ded5ac1ff5e38d930ae52bd9aec09bd1a68e4"; +const USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"; +const JS_VERSION = "12.4.254.8"; +const DEV_TOOLS_WINDOW_ID = 1923710101; + +pub fn processMessage(cmd: anytype) !void { + const action = std.meta.stringToEnum(enum { + getVersion, + setDownloadBehavior, + getWindowForTarget, + setWindowBounds, + }, cmd.action) orelse return error.UnknownMethod; + + switch (action) { + .getVersion => return getVersion(cmd), + .setDownloadBehavior => return setDownloadBehavior(cmd), + .getWindowForTarget => return getWindowForTarget(cmd), + .setWindowBounds => return setWindowBounds(cmd), + } +} - // ouput - const Res = struct { - protocolVersion: []const u8 = ProtocolVersion, - product: []const u8 = Product, - revision: []const u8 = Revision, - userAgent: []const u8 = UserAgent, - jsVersion: []const u8 = JsVersion, - }; - return result(alloc, input.id, Res, .{}, null); +fn getVersion(cmd: anytype) !void { + // TODO: pre-serialize? + return cmd.sendResult(.{ + .protocolVersion = PROTOCOL_VERSION, + .product = PRODUCT, + .revision = REVISION, + .userAgent = USER_AGENT, + .jsVersion = JS_VERSION, + }, .{ .include_session_id = false }); } // TODO: noop method -fn setDownloadBehavior( - alloc: std.mem.Allocator, - msg: *IncomingMessage, - _: *Ctx, -) ![]const u8 { - // input - const Params = struct { - behavior: []const u8, - browserContextId: ?[]const u8 = null, - downloadPath: ?[]const u8 = null, - eventsEnabled: ?bool = null, - }; - const input = try Input(Params).get(alloc, msg); - defer input.deinit(); - log.debug("REQ > id {d}, method {s}", .{ input.id, "browser.setDownloadBehavior" }); - - // output - return result(alloc, input.id, null, null, null); +fn setDownloadBehavior(cmd: anytype) !void { + // const params = (try cmd.params(struct { + // behavior: []const u8, + // browserContextId: ?[]const u8 = null, + // downloadPath: ?[]const u8 = null, + // eventsEnabled: ?bool = null, + // })) orelse return error.InvalidParams; + + return cmd.sendResult(null, .{ .include_session_id = false }); } -// TODO: hard coded ID -const DevToolsWindowID = 1923710101; - -fn getWindowForTarget( - alloc: std.mem.Allocator, - msg: *IncomingMessage, - _: *Ctx, -) ![]const u8 { +fn getWindowForTarget(cmd: anytype) !void { + // const params = (try cmd.params(struct { + // targetId: ?[]const u8 = null, + // })) orelse return error.InvalidParams; - // input - const Params = struct { - targetId: ?[]const u8 = null, - }; - const input = try Input(?Params).get(alloc, msg); - defer input.deinit(); - std.debug.assert(input.sessionId != null); - log.debug("Req > id {d}, method {s}", .{ input.id, "browser.getWindowForTarget" }); - - // output - const Resp = struct { - windowId: u64 = DevToolsWindowID, - bounds: struct { - left: ?u64 = null, - top: ?u64 = null, - width: ?u64 = null, - height: ?u64 = null, - windowState: []const u8 = "normal", - } = .{}, - }; - return result(alloc, input.id, Resp, Resp{}, input.sessionId); + return cmd.sendResult(.{ .windowId = DEV_TOOLS_WINDOW_ID, .bounds = .{ + .windowState = "normal", + } }, .{}); } // TODO: noop method -fn setWindowBounds( - alloc: std.mem.Allocator, - msg: *IncomingMessage, - _: *Ctx, -) ![]const u8 { - - // input - const input = try Input(void).get(alloc, msg); - defer input.deinit(); - log.debug("Req > id {d}, method {s}", .{ input.id, "browser.setWindowBounds" }); - - // output - return result(alloc, input.id, null, null, input.sessionId); +fn setWindowBounds(cmd: anytype) !void { + return cmd.sendResult(null, .{}); } diff --git a/src/cdp/cdp.zig b/src/cdp/cdp.zig index 65e2cc85b..10b837f94 100644 --- a/src/cdp/cdp.zig +++ b/src/cdp/cdp.zig @@ -17,209 +17,387 @@ // along with this program. If not, see . const std = @import("std"); +const Allocator = std.mem.Allocator; +const json = std.json; -const server = @import("../server.zig"); -const Ctx = server.Ctx; - -const browser = @import("browser.zig").browser; -const target = @import("target.zig").target; -const page = @import("page.zig").page; -const log = @import("log.zig").log; -const runtime = @import("runtime.zig").runtime; -const network = @import("network.zig").network; -const emulation = @import("emulation.zig").emulation; -const fetch = @import("fetch.zig").fetch; -const performance = @import("performance.zig").performance; -const IncomingMessage = @import("msg.zig").IncomingMessage; -const Input = @import("msg.zig").Input; -const inspector = @import("inspector.zig").inspector; -const dom = @import("dom.zig").dom; -const css = @import("css.zig").css; -const security = @import("security.zig").security; - -const log_cdp = std.log.scoped(.cdp); - -pub const Error = error{ - UnknonwDomain, - UnknownMethod, - NoResponse, - RequestWithoutID, +const Loop = @import("jsruntime").Loop; +const Client = @import("../server.zig").Client; +const asUint = @import("../str/parser.zig").asUint; +const Browser = @import("../browser/browser.zig").Browser; +const Session = @import("../browser/browser.zig").Session; + +const log = std.log.scoped(.cdp); + +pub const URL_BASE = "chrome://newtab/"; +pub const LOADER_ID = "LOADERID24DD2FD56CF1EF33C965C79C"; +pub const FRAME_ID = "FRAMEIDD8AED408A0467AC93100BCDBE"; +pub const BROWSER_SESSION_ID = @tagName(SessionID.BROWSERSESSIONID597D9875C664CAC0); +pub const CONTEXT_SESSION_ID = @tagName(SessionID.CONTEXTSESSIONID0497A05C95417CF4); + +pub const TimestampEvent = struct { + timestamp: f64, }; -pub fn isCdpError(err: anyerror) ?Error { - // see https://github.com/ziglang/zig/issues/2473 - const errors = @typeInfo(Error).ErrorSet.?; - inline for (errors) |e| { - if (std.mem.eql(u8, e.name, @errorName(err))) { - return @errorCast(err); +pub const CDP = struct { + // Used for sending message to the client and closing on error + client: *Client, + + // The active browser + browser: Browser, + + // The active browser session + session: ?*Session, + + allocator: Allocator, + + // Re-used arena for processing a message. We're assuming that we're getting + // 1 message at a time. + message_arena: std.heap.ArenaAllocator, + + // State + url: []const u8, + frame_id: []const u8, + loader_id: []const u8, + session_id: SessionID, + context_id: ?[]const u8, + execution_context_id: u32, + security_origin: []const u8, + page_life_cycle_events: bool, + secure_context_type: []const u8, + + pub fn init(allocator: Allocator, client: *Client, loop: *Loop) CDP { + return .{ + .client = client, + .browser = Browser.init(allocator, loop), + .session = null, + .allocator = allocator, + .url = URL_BASE, + .execution_context_id = 0, + .context_id = null, + .frame_id = FRAME_ID, + .session_id = .CONTEXTSESSIONID0497A05C95417CF4, + .security_origin = URL_BASE, + .secure_context_type = "Secure", // TODO = enum + .loader_id = LOADER_ID, + .message_arena = std.heap.ArenaAllocator.init(allocator), + .page_life_cycle_events = false, // TODO; Target based value + }; + } + + pub fn deinit(self: *CDP) void { + self.browser.deinit(); + self.message_arena.deinit(); + } + + pub fn newSession(self: *CDP) !void { + self.session = try self.browser.newSession(self); + } + + pub fn processMessage(self: *CDP, msg: []const u8) void { + const arena = &self.message_arena; + defer _ = arena.reset(.{ .retain_with_limit = 1024 * 16 }); + + self.dispatch(arena.allocator(), self, msg) catch |err| { + log.err("failed to process message: {}\n{s}", .{ err, msg }); + self.client.close(null); + return; + }; + } + + // Called from above, in processMessage which handles client messages + // but can also be called internally. For example, Target.sendMessageToTarget + // calls back into dispatch. + pub fn dispatch( + self: *CDP, + arena: Allocator, + sender: anytype, + str: []const u8, + ) anyerror!void { + const input = try json.parseFromSliceLeaky(InputMessage, arena, str, .{ + .ignore_unknown_fields = true, + }); + + const domain, const action = blk: { + const method = input.method; + const i = std.mem.indexOfScalarPos(u8, method, 0, '.') orelse { + return error.InvalidMethod; + }; + break :blk .{ method[0..i], method[i + 1 ..] }; + }; + + var command = Command(@TypeOf(sender)){ + .json = str, + .cdp = self, + .id = input.id, + .arena = arena, + .action = action, + ._params = input.params, + .session_id = input.sessionId, + .sender = sender, + .session = self.session orelse blk: { + try self.newSession(); + break :blk self.session.?; + }, + }; + + switch (domain.len) { + 3 => switch (@as(u24, @bitCast(domain[0..3].*))) { + asUint("DOM") => return @import("dom.zig").processMessage(&command), + asUint("Log") => return @import("log.zig").processMessage(&command), + asUint("CSS") => return @import("css.zig").processMessage(&command), + else => {}, + }, + 4 => switch (@as(u32, @bitCast(domain[0..4].*))) { + asUint("Page") => return @import("page.zig").processMessage(&command), + else => {}, + }, + 5 => switch (@as(u40, @bitCast(domain[0..5].*))) { + asUint("Fetch") => return @import("fetch.zig").processMessage(&command), + else => {}, + }, + 6 => switch (@as(u48, @bitCast(domain[0..6].*))) { + asUint("Target") => return @import("target.zig").processMessage(&command), + else => {}, + }, + 7 => switch (@as(u56, @bitCast(domain[0..7].*))) { + asUint("Browser") => return @import("browser.zig").processMessage(&command), + asUint("Runtime") => return @import("runtime.zig").processMessage(&command), + asUint("Network") => return @import("network.zig").processMessage(&command), + else => {}, + }, + 8 => switch (@as(u64, @bitCast(domain[0..8].*))) { + asUint("Security") => return @import("security.zig").processMessage(&command), + else => {}, + }, + 9 => switch (@as(u72, @bitCast(domain[0..9].*))) { + asUint("Emulation") => return @import("emulation.zig").processMessage(&command), + asUint("Inspector") => return @import("inspector.zig").processMessage(&command), + else => {}, + }, + 11 => switch (@as(u88, @bitCast(domain[0..11].*))) { + asUint("Performance") => return @import("performance.zig").processMessage(&command), + else => {}, + }, + else => {}, } + return error.UnknownDomain; } - return null; -} -const Domains = enum { - Browser, - Target, - Page, - Log, - Runtime, - Network, - DOM, - CSS, - Inspector, - Emulation, - Fetch, - Performance, - Security, -}; + fn sendJSON(self: *CDP, message: anytype) !void { + return self.client.sendJSON(message, .{ + .emit_null_optional_fields = false, + }); + } -// The caller is responsible for calling `free` on the returned slice. -pub fn do( - alloc: std.mem.Allocator, - s: []const u8, - ctx: *Ctx, -) anyerror![]const u8 { + pub fn onInspectorResponse(ctx: *anyopaque, _: u32, msg: []const u8) void { + if (std.log.defaultLogEnabled(.debug)) { + // msg should be {"id":,... + std.debug.assert(std.mem.startsWith(u8, msg, "{\"id\":")); - // incoming message parser - var msg = IncomingMessage.init(alloc, s); - defer msg.deinit(); + const id_end = std.mem.indexOfScalar(u8, msg, ',') orelse { + log.warn("invalid inspector response message: {s}", .{msg}); + return; + }; + const id = msg[6..id_end]; + log.debug("Res (inspector) > id {s}", .{id}); + } + sendInspectorMessage(@alignCast(@ptrCast(ctx)), msg) catch |err| { + log.err("Failed to send inspector response: {any}", .{err}); + }; + } - return dispatch(alloc, &msg, ctx); -} + pub fn onInspectorEvent(ctx: *anyopaque, msg: []const u8) void { + if (std.log.defaultLogEnabled(.debug)) { + // msg should be {"method":,... + std.debug.assert(std.mem.startsWith(u8, msg, "{\"method\":")); + const method_end = std.mem.indexOfScalar(u8, msg, ',') orelse { + log.warn("invalid inspector event message: {s}", .{msg}); + return; + }; + const method = msg[10..method_end]; + log.debug("Event (inspector) > method {s}", .{method}); + } -pub fn dispatch( - alloc: std.mem.Allocator, - msg: *IncomingMessage, - ctx: *Ctx, -) anyerror![]const u8 { - const method = try msg.getMethod(); - - // retrieve domain from method - var iter = std.mem.splitScalar(u8, method, '.'); - const domain = std.meta.stringToEnum(Domains, iter.first()) orelse - return error.UnknonwDomain; - - // select corresponding domain - const action = iter.next() orelse return error.BadMethod; - return switch (domain) { - .Browser => browser(alloc, msg, action, ctx), - .Target => target(alloc, msg, action, ctx), - .Page => page(alloc, msg, action, ctx), - .Log => log(alloc, msg, action, ctx), - .Runtime => runtime(alloc, msg, action, ctx), - .Network => network(alloc, msg, action, ctx), - .DOM => dom(alloc, msg, action, ctx), - .CSS => css(alloc, msg, action, ctx), - .Inspector => inspector(alloc, msg, action, ctx), - .Emulation => emulation(alloc, msg, action, ctx), - .Fetch => fetch(alloc, msg, action, ctx), - .Performance => performance(alloc, msg, action, ctx), - .Security => security(alloc, msg, action, ctx), - }; -} + sendInspectorMessage(@alignCast(@ptrCast(ctx)), msg) catch |err| { + log.err("Failed to send inspector event: {any}", .{err}); + }; + } + + // This is hacky * 2. First, we have 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: *CDP, msg: []const u8) !void { + var arena = std.heap.ArenaAllocator.init(self.allocator); + errdefer arena.deinit(); + + const field = ",\"sessionId\":\""; + const session_id = @tagName(self.session_id); + + // + 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(arena.allocator(), message_len) catch |err| { + log.err("Failed to expand inspector buffer: {any}", .{err}); + return; + }; -pub const State = struct { - executionContextId: u32 = 0, - contextID: ?[]const u8 = null, - sessionID: SessionID = .CONTEXTSESSIONID0497A05C95417CF4, - frameID: []const u8 = FrameID, - url: []const u8 = URLBase, - securityOrigin: []const u8 = URLBase, - secureContextType: []const u8 = "Secure", // TODO: enum - loaderID: []const u8 = LoaderID, - - page_life_cycle_events: bool = false, // TODO; Target based value + // 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 self.client.sendJSONRaw(arena, buf); + } }; -// Utils -// ----- +// 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 Sender: type) type { + return struct { + // refernece to our CDP instance + cdp: *CDP, -pub fn dumpFile( - alloc: std.mem.Allocator, - id: u16, - script: []const u8, -) !void { - const name = try std.fmt.allocPrint(alloc, "id_{d}.js", .{id}); - defer alloc.free(name); - var dir = try std.fs.cwd().makeOpenPath("zig-cache/tmp", .{}); - defer dir.close(); - const f = try dir.createFile(name, .{}); - defer f.close(); - const nb = try f.write(script); - std.debug.assert(nb == script.len); - const p = try dir.realpathAlloc(alloc, name); - defer alloc.free(p); -} + // Comes directly from the input.id field + id: ?i64, + + // A misc arena that can be used for any allocation for processing + // the message + arena: Allocator, + + // the browser session + session: *Session, + + // The "action" of the message.Given a method of "LOG.enable", the + // action is "enable" + action: []const u8, + + // Comes directly from the input.sessionId field + session_id: ?[]const u8, -// caller owns the slice returned -pub fn stringify(alloc: std.mem.Allocator, res: anytype) ![]const u8 { - var out = std.ArrayList(u8).init(alloc); - defer out.deinit(); + // Unparsed / untyped input.params. + _params: ?InputParams, - // Do not emit optional null fields - const options: std.json.StringifyOptions = .{ .emit_null_optional_fields = false }; + // The full raw json input + json: []const u8, - try std.json.stringify(res, options, out.writer()); - const ret = try alloc.alloc(u8, out.items.len); - @memcpy(ret, out.items); - return ret; + sender: Sender, + + const Self = @This(); + + pub fn params(self: *const Self, comptime T: type) !?T { + if (self._params) |p| { + return try json.parseFromSliceLeaky( + T, + self.arena, + p.raw, + .{ .ignore_unknown_fields = true }, + ); + } + return null; + } + + const SendResultOpts = struct { + include_session_id: bool = true, + }; + pub fn sendResult(self: *Self, result: anytype, opts: SendResultOpts) !void { + return self.sender.sendJSON(.{ + .id = self.id, + .result = if (comptime @typeInfo(@TypeOf(result)) == .Null) struct {}{} else result, + .sessionId = if (opts.include_session_id) self.session_id else null, + }); + } + const SendEventOpts = struct { + session_id: ?[]const u8 = null, + }; + + pub fn sendEvent(self: *Self, method: []const u8, p: anytype, opts: SendEventOpts) !void { + // Events ALWAYS go to the client. self.sender should not be used + return self.cdp.sendJSON(.{ + .method = method, + .params = if (comptime @typeInfo(@TypeOf(p)) == .Null) struct {}{} else p, + .sessionId = opts.session_id, + }); + } + }; } -const resultNull = "{{\"id\": {d}, \"result\": {{}}}}"; -const resultNullSession = "{{\"id\": {d}, \"result\": {{}}, \"sessionId\": \"{s}\"}}"; - -// caller owns the slice returned -pub fn result( - alloc: std.mem.Allocator, - id: u16, - comptime T: ?type, - res: anytype, - sessionID: ?[]const u8, -) ![]const u8 { - log_cdp.debug( - "Res > id {d}, sessionID {?s}, result {any}", - .{ id, sessionID, res }, - ); - if (T == null) { - // No need to stringify a custom JSON msg, just use string templates - if (sessionID) |sID| { - return try std.fmt.allocPrint(alloc, resultNullSession, .{ id, sID }); +// When we parse a JSON message from the client, this is the structure +// we always expect +const InputMessage = struct { + id: ?i64, + 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; } - return try std.fmt.allocPrint(alloc, resultNull, .{id}); + try scanner.skipUntilStackHeight(height); + const end = scanner.cursor; + + return .{ .raw = scanner.input[start..end] }; } +}; - const Resp = struct { - id: u16, - result: T.?, - sessionId: ?[]const u8, - }; - const resp = Resp{ .id = id, .result = res, .sessionId = sessionID }; +// Utils +// ----- - return stringify(alloc, resp); -} +// pub fn dumpFile( +// alloc: std.mem.Allocator, +// id: u16, +// script: []const u8, +// ) !void { +// const name = try std.fmt.allocPrint(alloc, "id_{d}.js", .{id}); +// defer alloc.free(name); +// var dir = try std.fs.cwd().makeOpenPath("zig-cache/tmp", .{}); +// defer dir.close(); +// const f = try dir.createFile(name, .{}); +// defer f.close(); +// const nb = try f.write(script); +// std.debug.assert(nb == script.len); +// const p = try dir.realpathAlloc(alloc, name); +// defer alloc.free(p); +// } -pub fn sendEvent( - alloc: std.mem.Allocator, - ctx: *Ctx, - name: []const u8, - comptime T: type, - params: T, - sessionID: ?[]const u8, -) !void { - // some clients like chromedp expects empty parameters structs. - if (T == void) @compileError("sendEvent: use struct{} instead of void for empty parameters"); - - log_cdp.debug("Event > method {s}, sessionID {?s}", .{ name, sessionID }); - const Resp = struct { - method: []const u8, - params: T, - sessionId: ?[]const u8, - }; - const resp = Resp{ .method = name, .params = params, .sessionId = sessionID }; +// // caller owns the slice returned +// pub fn stringify(alloc: std.mem.Allocator, res: anytype) ![]const u8 { +// var out = std.ArrayList(u8).init(alloc); +// defer out.deinit(); - const event_msg = try stringify(alloc, resp); - try ctx.send(event_msg); -} +// // Do not emit optional null fields +// const options: std.json.StringifyOptions = .{ .emit_null_optional_fields = false }; + +// try std.json.stringify(res, options, out.writer()); +// const ret = try alloc.alloc(u8, out.items.len); +// @memcpy(ret, out.items); +// return ret; +// } // Common // ------ @@ -230,20 +408,9 @@ pub const SessionID = enum { CONTEXTSESSIONID0497A05C95417CF4, pub fn parse(str: []const u8) !SessionID { - inline for (@typeInfo(SessionID).Enum.fields) |enumField| { - if (std.mem.eql(u8, str, enumField.name)) { - return @field(SessionID, enumField.name); - } - } - return error.InvalidSessionID; + return std.meta.stringToEnum(SessionID, str) orelse { + log.err("parse sessionID: {s}", .{str}); + return error.InvalidSessionID; + }; } }; -pub const BrowserSessionID = @tagName(SessionID.BROWSERSESSIONID597D9875C664CAC0); -pub const ContextSessionID = @tagName(SessionID.CONTEXTSESSIONID0497A05C95417CF4); -pub const URLBase = "chrome://newtab/"; -pub const LoaderID = "LOADERID24DD2FD56CF1EF33C965C79C"; -pub const FrameID = "FRAMEIDD8AED408A0467AC93100BCDBE"; - -pub const TimestampEvent = struct { - timestamp: f64, -}; diff --git a/src/cdp/css.zig b/src/cdp/css.zig index 542a24004..21834d839 100644 --- a/src/cdp/css.zig +++ b/src/cdp/css.zig @@ -17,43 +17,14 @@ // along with this program. If not, see . const std = @import("std"); - -const server = @import("../server.zig"); -const Ctx = server.Ctx; const cdp = @import("cdp.zig"); -const result = cdp.result; -const IncomingMessage = @import("msg.zig").IncomingMessage; -const Input = @import("msg.zig").Input; - -const log = std.log.scoped(.cdp); - -const Methods = enum { - enable, -}; - -pub fn css( - alloc: std.mem.Allocator, - msg: *IncomingMessage, - action: []const u8, - ctx: *Ctx, -) ![]const u8 { - const method = std.meta.stringToEnum(Methods, action) orelse - return error.UnknownMethod; - - return switch (method) { - .enable => enable(alloc, msg, ctx), - }; -} -fn enable( - alloc: std.mem.Allocator, - msg: *IncomingMessage, - _: *Ctx, -) ![]const u8 { - // input - const input = try Input(void).get(alloc, msg); - defer input.deinit(); - log.debug("Req > id {d}, method {s}", .{ input.id, "inspector.enable" }); +pub fn processMessage(cmd: anytype) !void { + const action = std.meta.stringToEnum(enum { + enable, + }, cmd.action) orelse return error.UnknownMethod; - return result(alloc, input.id, null, null, input.sessionId); + switch (action) { + .enable => return cmd.sendResult(null, .{}), + } } diff --git a/src/cdp/dom.zig b/src/cdp/dom.zig index 0e8c1d66f..21834d839 100644 --- a/src/cdp/dom.zig +++ b/src/cdp/dom.zig @@ -17,43 +17,14 @@ // along with this program. If not, see . const std = @import("std"); - -const server = @import("../server.zig"); -const Ctx = server.Ctx; const cdp = @import("cdp.zig"); -const result = cdp.result; -const IncomingMessage = @import("msg.zig").IncomingMessage; -const Input = @import("msg.zig").Input; - -const log = std.log.scoped(.cdp); - -const Methods = enum { - enable, -}; - -pub fn dom( - alloc: std.mem.Allocator, - msg: *IncomingMessage, - action: []const u8, - ctx: *Ctx, -) ![]const u8 { - const method = std.meta.stringToEnum(Methods, action) orelse - return error.UnknownMethod; - - return switch (method) { - .enable => enable(alloc, msg, ctx), - }; -} -fn enable( - alloc: std.mem.Allocator, - msg: *IncomingMessage, - _: *Ctx, -) ![]const u8 { - // input - const input = try Input(void).get(alloc, msg); - defer input.deinit(); - log.debug("Req > id {d}, method {s}", .{ input.id, "inspector.enable" }); +pub fn processMessage(cmd: anytype) !void { + const action = std.meta.stringToEnum(enum { + enable, + }, cmd.action) orelse return error.UnknownMethod; - return result(alloc, input.id, null, null, input.sessionId); + switch (action) { + .enable => return cmd.sendResult(null, .{}), + } } diff --git a/src/cdp/emulation.zig b/src/cdp/emulation.zig index 3fe75fb8e..88c5ddf72 100644 --- a/src/cdp/emulation.zig +++ b/src/cdp/emulation.zig @@ -17,107 +17,52 @@ // along with this program. If not, see . const std = @import("std"); - -const server = @import("../server.zig"); -const Ctx = server.Ctx; const cdp = @import("cdp.zig"); -const result = cdp.result; -const stringify = cdp.stringify; -const IncomingMessage = @import("msg.zig").IncomingMessage; -const Input = @import("msg.zig").Input; - -const log = std.log.scoped(.cdp); +const Runtime = @import("runtime.zig"); -const Methods = enum { - setEmulatedMedia, - setFocusEmulationEnabled, - setDeviceMetricsOverride, - setTouchEmulationEnabled, -}; +pub fn processMessage(cmd: anytype) !void { + const action = std.meta.stringToEnum(enum { + setEmulatedMedia, + setFocusEmulationEnabled, + setDeviceMetricsOverride, + setTouchEmulationEnabled, + }, cmd.action) orelse return error.UnknownMethod; -pub fn emulation( - alloc: std.mem.Allocator, - msg: *IncomingMessage, - action: []const u8, - ctx: *Ctx, -) ![]const u8 { - const method = std.meta.stringToEnum(Methods, action) orelse - return error.UnknownMethod; - return switch (method) { - .setEmulatedMedia => setEmulatedMedia(alloc, msg, ctx), - .setFocusEmulationEnabled => setFocusEmulationEnabled(alloc, msg, ctx), - .setDeviceMetricsOverride => setDeviceMetricsOverride(alloc, msg, ctx), - .setTouchEmulationEnabled => setTouchEmulationEnabled(alloc, msg, ctx), - }; + switch (action) { + .setEmulatedMedia => return setEmulatedMedia(cmd), + .setFocusEmulationEnabled => return setFocusEmulationEnabled(cmd), + .setDeviceMetricsOverride => return setDeviceMetricsOverride(cmd), + .setTouchEmulationEnabled => return setTouchEmulationEnabled(cmd), + } } -const MediaFeature = struct { - name: []const u8, - value: []const u8, -}; - // TODO: noop method -fn setEmulatedMedia( - alloc: std.mem.Allocator, - msg: *IncomingMessage, - _: *Ctx, -) ![]const u8 { - - // input - const Params = struct { - media: ?[]const u8 = null, - features: ?[]MediaFeature = null, - }; - const input = try Input(Params).get(alloc, msg); - defer input.deinit(); - log.debug("Req > id {d}, method {s}", .{ input.id, "emulation.setEmulatedMedia" }); +fn setEmulatedMedia(cmd: anytype) !void { + // const input = (try const incoming.params(struct { + // media: ?[]const u8 = null, + // features: ?[]struct{ + // name: []const u8, + // value: [] const u8 + // } = null, + // })) orelse return error.InvalidParams; - // output - return result(alloc, input.id, null, null, input.sessionId); + return cmd.sendResult(null, .{}); } // TODO: noop method -fn setFocusEmulationEnabled( - alloc: std.mem.Allocator, - msg: *IncomingMessage, - _: *Ctx, -) ![]const u8 { - // input - const Params = struct { - enabled: bool, - }; - const input = try Input(Params).get(alloc, msg); - defer input.deinit(); - log.debug("Req > id {d}, method {s}", .{ input.id, "emulation.setFocusEmulationEnabled" }); - - // output - return result(alloc, input.id, null, null, input.sessionId); +fn setFocusEmulationEnabled(cmd: anytype) !void { + // const input = (try const incoming.params(struct { + // enabled: bool, + // })) orelse return error.InvalidParams; + return cmd.sendResult(null, .{}); } // TODO: noop method -fn setDeviceMetricsOverride( - alloc: std.mem.Allocator, - msg: *IncomingMessage, - _: *Ctx, -) ![]const u8 { - // input - const input = try Input(void).get(alloc, msg); - defer input.deinit(); - log.debug("Req > id {d}, method {s}", .{ input.id, "emulation.setDeviceMetricsOverride" }); - - // output - return result(alloc, input.id, null, null, input.sessionId); +fn setDeviceMetricsOverride(cmd: anytype) !void { + return cmd.sendResult(null, .{}); } // TODO: noop method -fn setTouchEmulationEnabled( - alloc: std.mem.Allocator, - msg: *IncomingMessage, - _: *Ctx, -) ![]const u8 { - const input = try Input(void).get(alloc, msg); - defer input.deinit(); - log.debug("Req > id {d}, method {s}", .{ input.id, "emulation.setTouchEmulationEnabled" }); - - return result(alloc, input.id, null, null, input.sessionId); +fn setTouchEmulationEnabled(cmd: anytype) !void { + return cmd.sendResult(null, .{}); } diff --git a/src/cdp/fetch.zig b/src/cdp/fetch.zig index d56e40675..0a9a8cae4 100644 --- a/src/cdp/fetch.zig +++ b/src/cdp/fetch.zig @@ -17,43 +17,14 @@ // along with this program. If not, see . const std = @import("std"); - -const server = @import("../server.zig"); -const Ctx = server.Ctx; const cdp = @import("cdp.zig"); -const result = cdp.result; -const IncomingMessage = @import("msg.zig").IncomingMessage; -const Input = @import("msg.zig").Input; - -const log = std.log.scoped(.cdp); - -const Methods = enum { - disable, -}; - -pub fn fetch( - alloc: std.mem.Allocator, - msg: *IncomingMessage, - action: []const u8, - ctx: *Ctx, -) ![]const u8 { - const method = std.meta.stringToEnum(Methods, action) orelse - return error.UnknownMethod; - - return switch (method) { - .disable => disable(alloc, msg, ctx), - }; -} -// TODO: noop method -fn disable( - alloc: std.mem.Allocator, - msg: *IncomingMessage, - _: *Ctx, -) ![]const u8 { - const input = try Input(void).get(alloc, msg); - defer input.deinit(); - log.debug("Req > id {d}, method {s}", .{ input.id, "fetch.disable" }); +pub fn processMessage(cmd: anytype) !void { + const action = std.meta.stringToEnum(enum { + disable, + }, cmd.action) orelse return error.UnknownMethod; - return result(alloc, input.id, null, null, input.sessionId); + switch (action) { + .disable => return cmd.sendResult(null, .{}), + } } diff --git a/src/cdp/inspector.zig b/src/cdp/inspector.zig index 9d67fa741..21834d839 100644 --- a/src/cdp/inspector.zig +++ b/src/cdp/inspector.zig @@ -17,43 +17,14 @@ // along with this program. If not, see . const std = @import("std"); - -const server = @import("../server.zig"); -const Ctx = server.Ctx; const cdp = @import("cdp.zig"); -const result = cdp.result; -const IncomingMessage = @import("msg.zig").IncomingMessage; -const Input = @import("msg.zig").Input; - -const log = std.log.scoped(.cdp); - -const Methods = enum { - enable, -}; - -pub fn inspector( - alloc: std.mem.Allocator, - msg: *IncomingMessage, - action: []const u8, - ctx: *Ctx, -) ![]const u8 { - const method = std.meta.stringToEnum(Methods, action) orelse - return error.UnknownMethod; - - return switch (method) { - .enable => enable(alloc, msg, ctx), - }; -} -fn enable( - alloc: std.mem.Allocator, - msg: *IncomingMessage, - _: *Ctx, -) ![]const u8 { - // input - const input = try Input(void).get(alloc, msg); - defer input.deinit(); - log.debug("Req > id {d}, method {s}", .{ input.id, "inspector.enable" }); +pub fn processMessage(cmd: anytype) !void { + const action = std.meta.stringToEnum(enum { + enable, + }, cmd.action) orelse return error.UnknownMethod; - return result(alloc, input.id, null, null, input.sessionId); + switch (action) { + .enable => return cmd.sendResult(null, .{}), + } } diff --git a/src/cdp/log.zig b/src/cdp/log.zig index d54487b23..21834d839 100644 --- a/src/cdp/log.zig +++ b/src/cdp/log.zig @@ -17,43 +17,14 @@ // along with this program. If not, see . const std = @import("std"); - -const server = @import("../server.zig"); -const Ctx = server.Ctx; const cdp = @import("cdp.zig"); -const result = cdp.result; -const IncomingMessage = @import("msg.zig").IncomingMessage; -const Input = @import("msg.zig").Input; -const stringify = cdp.stringify; - -const log_cdp = std.log.scoped(.cdp); - -const Methods = enum { - enable, -}; - -pub fn log( - alloc: std.mem.Allocator, - msg: *IncomingMessage, - action: []const u8, - ctx: *Ctx, -) ![]const u8 { - const method = std.meta.stringToEnum(Methods, action) orelse - return error.UnknownMethod; - - return switch (method) { - .enable => enable(alloc, msg, ctx), - }; -} -fn enable( - alloc: std.mem.Allocator, - msg: *IncomingMessage, - _: *Ctx, -) ![]const u8 { - const input = try Input(void).get(alloc, msg); - defer input.deinit(); - log_cdp.debug("Req > id {d}, method {s}", .{ input.id, "log.enable" }); +pub fn processMessage(cmd: anytype) !void { + const action = std.meta.stringToEnum(enum { + enable, + }, cmd.action) orelse return error.UnknownMethod; - return result(alloc, input.id, null, null, input.sessionId); + switch (action) { + .enable => return cmd.sendResult(null, .{}), + } } diff --git a/src/cdp/msg.zig b/src/cdp/msg.zig deleted file mode 100644 index fdc364c50..000000000 --- a/src/cdp/msg.zig +++ /dev/null @@ -1,293 +0,0 @@ -// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) -// -// Francis Bouvier -// Pierre Tachoire -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as -// published by the Free Software Foundation, either version 3 of the -// License, or (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -const std = @import("std"); - -// Parse incoming protocol message in json format. -pub const IncomingMessage = struct { - scanner: std.json.Scanner, - json: []const u8, - - obj_begin: bool = false, - obj_end: bool = false, - - id: ?u16 = null, - scan_sessionId: bool = false, - sessionId: ?[]const u8 = null, - method: ?[]const u8 = null, - params_skip: bool = false, - - pub fn init(alloc: std.mem.Allocator, json: []const u8) IncomingMessage { - return .{ - .json = json, - .scanner = std.json.Scanner.initCompleteInput(alloc, json), - }; - } - - pub fn deinit(self: *IncomingMessage) void { - self.scanner.deinit(); - } - - fn scanUntil(self: *IncomingMessage, key: []const u8) !void { - while (true) { - switch (try self.scanner.next()) { - .end_of_document => return error.EndOfDocument, - .object_begin => { - if (self.obj_begin) return error.InvalidObjectBegin; - self.obj_begin = true; - }, - .object_end => { - if (!self.obj_begin) return error.InvalidObjectEnd; - if (self.obj_end) return error.InvalidObjectEnd; - self.obj_end = true; - }, - .string => |s| { - // is the key what we expects? - if (std.mem.eql(u8, s, key)) return; - - // save other known keys - if (std.mem.eql(u8, s, "id")) try self.scanId(); - if (std.mem.eql(u8, s, "sessionId")) try self.scanSessionId(); - if (std.mem.eql(u8, s, "method")) try self.scanMethod(); - if (std.mem.eql(u8, s, "params")) try self.scanParams(); - - // TODO should we skip unknown key? - }, - else => return error.InvalidToken, - } - } - } - - fn scanId(self: *IncomingMessage) !void { - const t = try self.scanner.next(); - if (t != .number) return error.InvalidId; - self.id = try std.fmt.parseUnsigned(u16, t.number, 10); - } - - fn getId(self: *IncomingMessage) !u16 { - if (self.id != null) return self.id.?; - - try self.scanUntil("id"); - try self.scanId(); - return self.id.?; - } - - fn scanSessionId(self: *IncomingMessage) !void { - switch (try self.scanner.next()) { - // session id can be null. - .null => return, - .string => |s| self.sessionId = s, - else => return error.InvalidSessionId, - } - - self.scan_sessionId = true; - } - - fn getSessionId(self: *IncomingMessage) !?[]const u8 { - if (self.scan_sessionId) return self.sessionId; - - self.scanUntil("sessionId") catch |err| { - if (err != error.EndOfDocument) return err; - // if the document doesn't contains any session id key, we must - // return null value. - self.scan_sessionId = true; - return null; - }; - try self.scanSessionId(); - return self.sessionId; - } - - fn scanMethod(self: *IncomingMessage) !void { - const t = try self.scanner.next(); - if (t != .string) return error.InvalidMethod; - self.method = t.string; - } - - pub fn getMethod(self: *IncomingMessage) ![]const u8 { - if (self.method != null) return self.method.?; - - try self.scanUntil("method"); - try self.scanMethod(); - return self.method.?; - } - - // scanParams skip found parameters b/c if we encounter params *before* - // asking for getParams, we don't know how to parse them. - fn scanParams(self: *IncomingMessage) !void { - const tt = try self.scanner.peekNextTokenType(); - // accept object begin or null JSON value. - if (tt != .object_begin and tt != .null) return error.InvalidParams; - try self.scanner.skipValue(); - self.params_skip = true; - } - - // getParams restart the JSON parsing - fn getParams(self: *IncomingMessage, alloc: ?std.mem.Allocator, T: type) !T { - if (T == void) return void{}; - std.debug.assert(alloc != null); // if T is not void, alloc should not be null - - if (self.params_skip) { - // TODO if the params have been skipped, we have to retart the - // parsing from start. - return error.SkippedParams; - } - - self.scanUntil("params") catch |err| { - // handle nullable type - if (@typeInfo(T) == .Optional) { - if (err == error.InvalidToken or err == error.EndOfDocument) { - return null; - } - } - return err; - }; - - // parse "params" - const options = std.json.ParseOptions{ - .ignore_unknown_fields = true, - .max_value_len = self.scanner.input.len, - .allocate = .alloc_always, - }; - return try std.json.innerParse(T, alloc.?, &self.scanner, options); - } -}; - -pub fn Input(T: type) type { - return struct { - arena: ?*std.heap.ArenaAllocator = null, - id: u16, - params: T, - sessionId: ?[]const u8, - - const Self = @This(); - - pub fn get(alloc: std.mem.Allocator, msg: *IncomingMessage) !Self { - var arena: ?*std.heap.ArenaAllocator = null; - var allocator: ?std.mem.Allocator = null; - - if (T != void) { - arena = try alloc.create(std.heap.ArenaAllocator); - arena.?.* = std.heap.ArenaAllocator.init(alloc); - allocator = arena.?.allocator(); - } - - errdefer { - if (arena) |_arena| { - _arena.deinit(); - alloc.destroy(_arena); - } - } - - return .{ - .arena = arena, - .params = try msg.getParams(allocator, T), - .id = try msg.getId(), - .sessionId = try msg.getSessionId(), - }; - } - - pub fn deinit(self: Self) void { - if (self.arena) |arena| { - const allocator = arena.child_allocator; - arena.deinit(); - allocator.destroy(arena); - } - } - }; -} - -test "read incoming message" { - const inputs = [_][]const u8{ - \\{"id":1,"method":"foo","sessionId":"bar","params":{"bar":"baz"}} - , - \\{"params":{"bar":"baz"},"id":1,"method":"foo","sessionId":"bar"} - , - \\{"sessionId":"bar","params":{"bar":"baz"},"id":1,"method":"foo"} - , - \\{"method":"foo","sessionId":"bar","params":{"bar":"baz"},"id":1} - , - }; - - for (inputs) |input| { - var msg = IncomingMessage.init(std.testing.allocator, input); - defer msg.deinit(); - - try std.testing.expectEqual(1, try msg.getId()); - try std.testing.expectEqualSlices(u8, "foo", try msg.getMethod()); - try std.testing.expectEqualSlices(u8, "bar", (try msg.getSessionId()).?); - - const T = struct { bar: []const u8 }; - const in = Input(T).get(std.testing.allocator, &msg) catch |err| { - if (err != error.SkippedParams) return err; - // TODO remove this check when params in the beginning is handled. - continue; - }; - defer in.deinit(); - try std.testing.expectEqualSlices(u8, "baz", in.params.bar); - } -} - -test "read incoming message with null session id" { - const inputs = [_][]const u8{ - \\{"id":1} - , - \\{"params":{"bar":"baz"},"id":1,"method":"foo"} - , - \\{"sessionId":null,"params":{"bar":"baz"},"id":1,"method":"foo"} - , - }; - - for (inputs) |input| { - var msg = IncomingMessage.init(std.testing.allocator, input); - defer msg.deinit(); - - try std.testing.expect(try msg.getSessionId() == null); - try std.testing.expectEqual(1, try msg.getId()); - } -} - -test "message with nullable params" { - const T = struct { - bar: []const u8, - }; - - // nullable type, params is present => value - const not_null = - \\{"id": 1,"method":"foo","params":{"bar":"baz"}} - ; - var msg = IncomingMessage.init(std.testing.allocator, not_null); - defer msg.deinit(); - const input = try Input(?T).get(std.testing.allocator, &msg); - defer input.deinit(); - try std.testing.expectEqualStrings(input.params.?.bar, "baz"); - - // nullable type, params is not present => null - const is_null = - \\{"id": 1,"method":"foo","sessionId":"AAA"} - ; - var msg_null = IncomingMessage.init(std.testing.allocator, is_null); - defer msg_null.deinit(); - const input_null = try Input(?T).get(std.testing.allocator, &msg_null); - defer input_null.deinit(); - try std.testing.expectEqual(null, input_null.params); - try std.testing.expectEqualStrings("AAA", input_null.sessionId.?); - - // not nullable type, params is not present => error - const params_or_error = msg_null.getParams(std.testing.allocator, T); - try std.testing.expectError(error.EndOfDocument, params_or_error); -} diff --git a/src/cdp/network.zig b/src/cdp/network.zig index c7c599a6e..60d9cbbbc 100644 --- a/src/cdp/network.zig +++ b/src/cdp/network.zig @@ -17,59 +17,16 @@ // along with this program. If not, see . const std = @import("std"); - -const server = @import("../server.zig"); -const Ctx = server.Ctx; const cdp = @import("cdp.zig"); -const result = cdp.result; -const IncomingMessage = @import("msg.zig").IncomingMessage; -const Input = @import("msg.zig").Input; - -const log = std.log.scoped(.cdp); - -const Methods = enum { - enable, - setCacheDisabled, -}; - -pub fn network( - alloc: std.mem.Allocator, - msg: *IncomingMessage, - action: []const u8, - ctx: *Ctx, -) ![]const u8 { - const method = std.meta.stringToEnum(Methods, action) orelse - return error.UnknownMethod; - - return switch (method) { - .enable => enable(alloc, msg, ctx), - .setCacheDisabled => setCacheDisabled(alloc, msg, ctx), - }; -} - -fn enable( - alloc: std.mem.Allocator, - msg: *IncomingMessage, - _: *Ctx, -) ![]const u8 { - // input - const input = try Input(void).get(alloc, msg); - defer input.deinit(); - log.debug("Req > id {d}, method {s}", .{ input.id, "network.enable" }); - - return result(alloc, input.id, null, null, input.sessionId); -} -// TODO: noop method -fn setCacheDisabled( - alloc: std.mem.Allocator, - msg: *IncomingMessage, - _: *Ctx, -) ![]const u8 { - // input - const input = try Input(void).get(alloc, msg); - defer input.deinit(); - log.debug("Req > id {d}, method {s}", .{ input.id, "network.setCacheDisabled" }); +pub fn processMessage(cmd: anytype) !void { + const action = std.meta.stringToEnum(enum { + enable, + setCacheDisabled, + }, cmd.action) orelse return error.UnknownMethod; - return result(alloc, input.id, null, null, input.sessionId); + switch (action) { + .enable => return cmd.sendResult(null, .{}), + .setCacheDisabled => return cmd.sendResult(null, .{}), + } } diff --git a/src/cdp/page.zig b/src/cdp/page.zig index 2079f106f..ac5ef6740 100644 --- a/src/cdp/page.zig +++ b/src/cdp/page.zig @@ -17,58 +17,27 @@ // along with this program. If not, see . const std = @import("std"); - -const server = @import("../server.zig"); -const Ctx = server.Ctx; const cdp = @import("cdp.zig"); -const result = cdp.result; -const stringify = cdp.stringify; -const sendEvent = cdp.sendEvent; -const IncomingMessage = @import("msg.zig").IncomingMessage; -const Input = @import("msg.zig").Input; - -const log = std.log.scoped(.cdp); - -const Runtime = @import("runtime.zig"); - -const Methods = enum { - enable, - getFrameTree, - setLifecycleEventsEnabled, - addScriptToEvaluateOnNewDocument, - createIsolatedWorld, - navigate, -}; - -pub fn page( - alloc: std.mem.Allocator, - msg: *IncomingMessage, - action: []const u8, - ctx: *Ctx, -) ![]const u8 { - const method = std.meta.stringToEnum(Methods, action) orelse - return error.UnknownMethod; - return switch (method) { - .enable => enable(alloc, msg, ctx), - .getFrameTree => getFrameTree(alloc, msg, ctx), - .setLifecycleEventsEnabled => setLifecycleEventsEnabled(alloc, msg, ctx), - .addScriptToEvaluateOnNewDocument => addScriptToEvaluateOnNewDocument(alloc, msg, ctx), - .createIsolatedWorld => createIsolatedWorld(alloc, msg, ctx), - .navigate => navigate(alloc, msg, ctx), - }; -} - -fn enable( - alloc: std.mem.Allocator, - msg: *IncomingMessage, - _: *Ctx, -) ![]const u8 { - // input - const input = try Input(void).get(alloc, msg); - defer input.deinit(); - log.debug("Req > id {d}, method {s}", .{ input.id, "page.enable" }); - - return result(alloc, input.id, null, null, input.sessionId); +const runtime = @import("runtime.zig"); + +pub fn processMessage(cmd: anytype) !void { + const action = std.meta.stringToEnum(enum { + enable, + getFrameTree, + setLifecycleEventsEnabled, + addScriptToEvaluateOnNewDocument, + createIsolatedWorld, + navigate, + }, cmd.action) orelse return error.UnknownMethod; + + switch (action) { + .enable => return cmd.sendResult(null, .{}), + .getFrameTree => return getFrameTree(cmd), + .setLifecycleEventsEnabled => return setLifecycleEventsEnabled(cmd), + .addScriptToEvaluateOnNewDocument => return addScriptToEvaluateOnNewDocument(cmd), + .createIsolatedWorld => return createIsolatedWorld(cmd), + .navigate => return navigate(cmd), + } } const Frame = struct { @@ -86,16 +55,7 @@ const Frame = struct { gatedAPIFeatures: [][]const u8 = &[0][]const u8{}, }; -fn getFrameTree( - alloc: std.mem.Allocator, - msg: *IncomingMessage, - ctx: *Ctx, -) ![]const u8 { - // input - const input = try Input(void).get(alloc, msg); - defer input.deinit(); - log.debug("Req > id {d}, method {s}", .{ input.id, "page.getFrameTree" }); - +fn getFrameTree(cmd: anytype) !void { // output const FrameTree = struct { frameTree: struct { @@ -112,6 +72,7 @@ fn getFrameTree( try writer.writeAll("cdp.page.getFrameTree { "); try writer.writeAll(".frameTree = { "); try writer.writeAll(".frame = { "); + const frame = self.frameTree.frame; try writer.writeAll(".id = "); try std.fmt.formatText(frame.id, "s", options, writer); @@ -122,65 +83,40 @@ fn getFrameTree( try writer.writeAll(" } } }"); } }; - const frameTree = FrameTree{ + + const state = cmd.cdp; + return cmd.sendResult(FrameTree{ .frameTree = .{ .frame = .{ - .id = ctx.state.frameID, - .url = ctx.state.url, - .securityOrigin = ctx.state.securityOrigin, - .secureContextType = ctx.state.secureContextType, - .loaderId = ctx.state.loaderID, + .id = state.frame_id, + .url = state.url, + .securityOrigin = state.security_origin, + .secureContextType = state.secure_context_type, + .loaderId = state.loader_id, }, }, - }; - return result(alloc, input.id, FrameTree, frameTree, input.sessionId); + }, .{}); } -fn setLifecycleEventsEnabled( - alloc: std.mem.Allocator, - msg: *IncomingMessage, - ctx: *Ctx, -) ![]const u8 { - // input - const Params = struct { - enabled: bool, - }; - const input = try Input(Params).get(alloc, msg); - defer input.deinit(); - log.debug("Req > id {d}, method {s}", .{ input.id, "page.setLifecycleEventsEnabled" }); - - ctx.state.page_life_cycle_events = true; +fn setLifecycleEventsEnabled(cmd: anytype) !void { + // const params = (try cmd.params(struct { + // enabled: bool, + // })) orelse return error.InvalidParams; - // output - return result(alloc, input.id, null, null, input.sessionId); + cmd.cdp.page_life_cycle_events = true; + return cmd.sendResult(null, .{}); } -const LifecycleEvent = struct { - frameId: []const u8, - loaderId: ?[]const u8, - name: []const u8 = undefined, - timestamp: f32 = undefined, -}; - // TODO: hard coded method -fn addScriptToEvaluateOnNewDocument( - alloc: std.mem.Allocator, - msg: *IncomingMessage, - _: *Ctx, -) ![]const u8 { - // input - const Params = struct { - source: []const u8, - worldName: ?[]const u8 = null, - includeCommandLineAPI: bool = false, - runImmediately: bool = false, - }; - const input = try Input(Params).get(alloc, msg); - defer input.deinit(); - log.debug("Req > id {d}, method {s}", .{ input.id, "page.addScriptToEvaluateOnNewDocument" }); - - // output - const Res = struct { +fn addScriptToEvaluateOnNewDocument(cmd: anytype) !void { + // const params = (try cmd.params(struct { + // source: []const u8, + // worldName: ?[]const u8 = null, + // includeCommandLineAPI: bool = false, + // runImmediately: bool = false, + // })) orelse return error.InvalidParams; + + const Response = struct { identifier: []const u8 = "1", pub fn format( @@ -195,109 +131,84 @@ fn addScriptToEvaluateOnNewDocument( try writer.writeAll(" }"); } }; - return result(alloc, input.id, Res, Res{}, input.sessionId); + return cmd.sendResult(Response{}, .{}); } // TODO: hard coded method -fn createIsolatedWorld( - alloc: std.mem.Allocator, - msg: *IncomingMessage, - ctx: *Ctx, -) ![]const u8 { - // input - const Params = struct { +fn createIsolatedWorld(cmd: anytype) !void { + const session_id = cmd.session_id orelse return error.SessionIdRequired; + + const params = (try cmd.params(struct { frameId: []const u8, worldName: []const u8, grantUniveralAccess: bool, - }; - const input = try Input(Params).get(alloc, msg); - defer input.deinit(); - std.debug.assert(input.sessionId != null); - log.debug("Req > id {d}, method {s}", .{ input.id, "page.createIsolatedWorld" }); + })) orelse return error.InvalidParams; // noop executionContextCreated event - try Runtime.executionContextCreated( - alloc, - ctx, - 0, - "", - input.params.worldName, - // TODO: hard coded ID - "7102379147004877974.3265385113993241162", - .{ - .isDefault = false, - .type = "isolated", - .frameId = input.params.frameId, + try cmd.sendEvent("Runtime.executionContextCreated", .{ + .context = runtime.ExecutionContextCreated{ + .id = 0, + .origin = "", + .name = params.worldName, + // TODO: hard coded ID + .uniqueId = "7102379147004877974.3265385113993241162", + .auxData = .{ + .isDefault = false, + .type = "isolated", + .frameId = params.frameId, + }, }, - input.sessionId, - ); + }, .{ .session_id = session_id }); - // output - const Resp = struct { - executionContextId: u8 = 0, - }; - - return result(alloc, input.id, Resp, .{}, input.sessionId); + return cmd.sendResult(.{ + .executionContextId = 0, + }, .{}); } -fn navigate( - alloc: std.mem.Allocator, - msg: *IncomingMessage, - ctx: *Ctx, -) ![]const u8 { - // input - const Params = struct { +fn navigate(cmd: anytype) !void { + const session_id = cmd.session_id orelse return error.SessionIdRequired; + + const params = (try cmd.params(struct { url: []const u8, referrer: ?[]const u8 = null, transitionType: ?[]const u8 = null, // TODO: enum frameId: ?[]const u8 = null, referrerPolicy: ?[]const u8 = null, // TODO: enum - }; - const input = try Input(Params).get(alloc, msg); - defer input.deinit(); - std.debug.assert(input.sessionId != null); - log.debug("Req > id {d}, method {s}", .{ input.id, "page.navigate" }); + })) orelse return error.InvalidParams; // change state - ctx.state.url = input.params.url; + var state = cmd.cdp; + state.url = params.url; + // TODO: hard coded ID - ctx.state.loaderID = "AF8667A203C5392DBE9AC290044AA4C2"; + state.loader_id = "AF8667A203C5392DBE9AC290044AA4C2"; + + const LifecycleEvent = struct { + frameId: []const u8, + loaderId: ?[]const u8, + name: []const u8, + timestamp: f32, + }; var life_event = LifecycleEvent{ - .frameId = ctx.state.frameID, - .loaderId = ctx.state.loaderID, + .frameId = state.frame_id, + .loaderId = state.loader_id, + .name = "init", + .timestamp = 343721.796037, }; - var ts_event: cdp.TimestampEvent = undefined; // frameStartedLoading event // TODO: event partially hard coded - const FrameStartedLoading = struct { - frameId: []const u8, - }; - const frame_started_loading = FrameStartedLoading{ .frameId = ctx.state.frameID }; - try sendEvent( - alloc, - ctx, - "Page.frameStartedLoading", - FrameStartedLoading, - frame_started_loading, - input.sessionId, - ); - if (ctx.state.page_life_cycle_events) { - life_event.name = "init"; - life_event.timestamp = 343721.796037; - try sendEvent( - alloc, - ctx, - "Page.lifecycleEvent", - LifecycleEvent, - life_event, - input.sessionId, - ); + try cmd.sendEvent("Page.frameStartedLoading", .{ + .frameId = state.frame_id, + }, .{ .session_id = session_id }); + + if (state.page_life_cycle_events) { + try cmd.sendEvent("Page.lifecycleEvent", life_event, .{ .session_id = session_id }); } // output - const Resp = struct { + const Response = struct { frameId: []const u8, loaderId: ?[]const u8, errorText: ?[]const u8 = null, @@ -318,136 +229,87 @@ fn navigate( try writer.writeAll(" }"); } }; - const resp = Resp{ - .frameId = ctx.state.frameID, - .loaderId = ctx.state.loaderID, - }; - const res = try result(alloc, input.id, Resp, resp, input.sessionId); - try ctx.send(res); + + try cmd.sendResult(Response{ + .frameId = state.frame_id, + .loaderId = state.loader_id, + }, .{}); // TODO: at this point do we need async the following actions to be async? // Send Runtime.executionContextsCleared event // TODO: noop event, we have no env context at this point, is it necesarry? - try sendEvent(alloc, ctx, "Runtime.executionContextsCleared", struct {}, .{}, input.sessionId); + try cmd.sendEvent("Runtime.executionContextsCleared", null, .{ .session_id = session_id }); // Launch navigate, the page must have been created by a // target.createTarget. - var p = ctx.browser.session.page orelse return error.NoPage; - ctx.state.executionContextId += 1; - const auxData = try std.fmt.allocPrint( - alloc, + var p = cmd.session.page orelse return error.NoPage; + state.execution_context_id += 1; + + const aux_data = try std.fmt.allocPrint( + cmd.arena, // NOTE: we assume this is the default web page "{{\"isDefault\":true,\"type\":\"default\",\"frameId\":\"{s}\"}}", - .{ctx.state.frameID}, + .{state.frame_id}, ); - defer alloc.free(auxData); - try p.navigate(input.params.url, auxData); + try p.navigate(params.url, aux_data); // Events // lifecycle init event // TODO: partially hard coded - if (ctx.state.page_life_cycle_events) { + if (state.page_life_cycle_events) { life_event.name = "init"; life_event.timestamp = 343721.796037; - try sendEvent( - alloc, - ctx, - "Page.lifecycleEvent", - LifecycleEvent, - life_event, - input.sessionId, - ); + try cmd.sendEvent("Page.lifecycleEvent", life_event, .{ .session_id = session_id }); } // frameNavigated event - const FrameNavigated = struct { - frame: Frame, - type: []const u8 = "Navigation", - }; - const frame_navigated = FrameNavigated{ - .frame = .{ - .id = ctx.state.frameID, - .url = ctx.state.url, - .securityOrigin = ctx.state.securityOrigin, - .secureContextType = ctx.state.secureContextType, - .loaderId = ctx.state.loaderID, + try cmd.sendEvent("Page.frameNavigated", .{ + .type = "Navigation", + .frame = Frame{ + .id = state.frame_id, + .url = state.url, + .securityOrigin = state.security_origin, + .secureContextType = state.secure_context_type, + .loaderId = state.loader_id, }, - }; - try sendEvent( - alloc, - ctx, - "Page.frameNavigated", - FrameNavigated, - frame_navigated, - input.sessionId, - ); + }, .{ .session_id = session_id }); // domContentEventFired event // TODO: partially hard coded - ts_event = .{ .timestamp = 343721.803338 }; - try sendEvent( - alloc, - ctx, + try cmd.sendEvent( "Page.domContentEventFired", - cdp.TimestampEvent, - ts_event, - input.sessionId, + cdp.TimestampEvent{ .timestamp = 343721.803338 }, + .{ .session_id = session_id }, ); // lifecycle DOMContentLoaded event // TODO: partially hard coded - if (ctx.state.page_life_cycle_events) { + if (state.page_life_cycle_events) { life_event.name = "DOMContentLoaded"; life_event.timestamp = 343721.803338; - try sendEvent( - alloc, - ctx, - "Page.lifecycleEvent", - LifecycleEvent, - life_event, - input.sessionId, - ); + try cmd.sendEvent("Page.lifecycleEvent", life_event, .{ .session_id = session_id }); } // loadEventFired event // TODO: partially hard coded - ts_event = .{ .timestamp = 343721.824655 }; - try sendEvent( - alloc, - ctx, + try cmd.sendEvent( "Page.loadEventFired", - cdp.TimestampEvent, - ts_event, - input.sessionId, + cdp.TimestampEvent{ .timestamp = 343721.824655 }, + .{ .session_id = session_id }, ); // lifecycle DOMContentLoaded event // TODO: partially hard coded - if (ctx.state.page_life_cycle_events) { + if (state.page_life_cycle_events) { life_event.name = "load"; life_event.timestamp = 343721.824655; - try sendEvent( - alloc, - ctx, - "Page.lifecycleEvent", - LifecycleEvent, - life_event, - input.sessionId, - ); + try cmd.sendEvent("Page.lifecycleEvent", life_event, .{ .session_id = session_id }); } // frameStoppedLoading - const FrameStoppedLoading = struct { frameId: []const u8 }; - try sendEvent( - alloc, - ctx, - "Page.frameStoppedLoading", - FrameStoppedLoading, - .{ .frameId = ctx.state.frameID }, - input.sessionId, - ); - - return ""; + return cmd.sendEvent("Page.frameStoppedLoading", .{ + .frameId = state.frame_id, + }, .{ .session_id = session_id }); } diff --git a/src/cdp/performance.zig b/src/cdp/performance.zig index bf210c360..8db70ed4d 100644 --- a/src/cdp/performance.zig +++ b/src/cdp/performance.zig @@ -17,43 +17,15 @@ // along with this program. If not, see . const std = @import("std"); - -const server = @import("../server.zig"); -const Ctx = server.Ctx; const cdp = @import("cdp.zig"); -const result = cdp.result; -const IncomingMessage = @import("msg.zig").IncomingMessage; -const Input = @import("msg.zig").Input; - -const log = std.log.scoped(.cdp); - -const Methods = enum { - enable, -}; - -pub fn performance( - alloc: std.mem.Allocator, - msg: *IncomingMessage, - action: []const u8, - ctx: *Ctx, -) ![]const u8 { - const method = std.meta.stringToEnum(Methods, action) orelse - return error.UnknownMethod; - - return switch (method) { - .enable => enable(alloc, msg, ctx), - }; -} +const asUint = @import("../str/parser.zig").asUint; -fn enable( - alloc: std.mem.Allocator, - msg: *IncomingMessage, - _: *Ctx, -) ![]const u8 { - // input - const input = try Input(void).get(alloc, msg); - defer input.deinit(); - log.debug("Req > id {d}, method {s}", .{ input.id, "performance.enable" }); +pub fn processMessage(cmd: anytype) !void { + const action = std.meta.stringToEnum(enum { + enable, + }, cmd.action) orelse return error.UnknownMethod; - return result(alloc, input.id, null, null, input.sessionId); + switch (action) { + .enable => return cmd.sendResult(null, .{}), + } } diff --git a/src/cdp/runtime.zig b/src/cdp/runtime.zig index 054d5a786..3da661055 100644 --- a/src/cdp/runtime.zig +++ b/src/cdp/runtime.zig @@ -17,179 +17,106 @@ // along with this program. If not, see . const std = @import("std"); -const builtin = @import("builtin"); - -const jsruntime = @import("jsruntime"); - -const server = @import("../server.zig"); -const Ctx = server.Ctx; const cdp = @import("cdp.zig"); -const result = cdp.result; -const IncomingMessage = @import("msg.zig").IncomingMessage; -const Input = @import("msg.zig").Input; -const stringify = cdp.stringify; -const target = @import("target.zig"); - -const log = std.log.scoped(.cdp); - -const Methods = enum { - enable, - runIfWaitingForDebugger, - evaluate, - addBinding, - callFunctionOn, - releaseObject, -}; -pub fn runtime( - alloc: std.mem.Allocator, - msg: *IncomingMessage, - action: []const u8, - ctx: *Ctx, -) ![]const u8 { - const method = std.meta.stringToEnum(Methods, action) orelse - // NOTE: we could send it anyway to the JS runtime but it's good to check it - return error.UnknownMethod; - return switch (method) { - .runIfWaitingForDebugger => runIfWaitingForDebugger(alloc, msg, ctx), - else => sendInspector(alloc, method, msg, ctx), - }; +pub fn processMessage(cmd: anytype) !void { + const action = std.meta.stringToEnum(enum { + enable, + runIfWaitingForDebugger, + evaluate, + addBinding, + callFunctionOn, + releaseObject, + }, cmd.action) orelse return error.UnknownMethod; + + switch (action) { + .runIfWaitingForDebugger => return cmd.sendResult(null, .{}), + else => return sendInspector(cmd, action), + } } -fn sendInspector( - alloc: std.mem.Allocator, - method: Methods, - msg: *IncomingMessage, - ctx: *Ctx, -) ![]const u8 { - +fn sendInspector(cmd: anytype, action: anytype) !void { // save script in file at debug mode if (std.log.defaultLogEnabled(.debug)) { - - // input - var id: u16 = undefined; - var script: ?[]const u8 = null; - - if (method == .evaluate) { - const Params = struct { - expression: []const u8, - contextId: ?u8 = null, - returnByValue: ?bool = null, - awaitPromise: ?bool = null, - userGesture: ?bool = null, - }; - - const input = try Input(Params).get(alloc, msg); - defer input.deinit(); - log.debug("Req > id {d}, method {s} (script saved on cache)", .{ input.id, "runtime.evaluate" }); - const params = input.params; - const func = try alloc.alloc(u8, params.expression.len); - @memcpy(func, params.expression); - script = func; - id = input.id; - } else if (method == .callFunctionOn) { - const Params = struct { - functionDeclaration: []const u8, - objectId: ?[]const u8 = null, - executionContextId: ?u8 = null, - arguments: ?[]struct { - value: ?[]const u8 = null, - objectId: ?[]const u8 = null, - } = null, - returnByValue: ?bool = null, - awaitPromise: ?bool = null, - userGesture: ?bool = null, - }; - - const input = try Input(Params).get(alloc, msg); - defer input.deinit(); - log.debug("Req > id {d}, method {s} (script saved on cache)", .{ input.id, "runtime.callFunctionOn" }); - const params = input.params; - const func = try alloc.alloc(u8, params.functionDeclaration.len); - @memcpy(func, params.functionDeclaration); - script = func; - id = input.id; - } - - if (script) |src| { - try cdp.dumpFile(alloc, id, src); - alloc.free(src); - } + try logInspector(cmd, action); } - if (msg.sessionId) |s| { - ctx.state.sessionID = cdp.SessionID.parse(s) catch |err| { - log.err("parse sessionID: {s} {any}", .{ s, err }); - return err; - }; + if (cmd.session_id) |s| { + cmd.cdp.session_id = try cdp.SessionID.parse(s); } // remove awaitPromise true params // TODO: delete when Promise are correctly handled by zig-js-runtime - if (method == .callFunctionOn or method == .evaluate) { - if (std.mem.indexOf(u8, msg.json, "\"awaitPromise\":true")) |_| { - const buf = try alloc.alloc(u8, msg.json.len + 1); - defer alloc.free(buf); - _ = std.mem.replace(u8, msg.json, "\"awaitPromise\":true", "\"awaitPromise\":false", buf); - try ctx.sendInspector(buf); - return ""; + if (action == .callFunctionOn or action == .evaluate) { + const json = cmd.json; + if (std.mem.indexOf(u8, json, "\"awaitPromise\":true")) |_| { + // +1 because we'll be turning a true -> false + const buf = try cmd.arena.alloc(u8, json.len + 1); + _ = std.mem.replace(u8, json, "\"awaitPromise\":true", "\"awaitPromise\":false", buf); + cmd.session.callInspector(buf); + return; } } - try ctx.sendInspector(msg.json); + cmd.session.callInspector(cmd.json); - if (msg.id == null) return ""; - - return result(alloc, msg.id.?, null, null, msg.sessionId); + if (cmd.id != null) { + return cmd.sendResult(null, .{}); + } } -pub const AuxData = struct { - isDefault: bool = true, - type: []const u8 = "default", - frameId: []const u8 = cdp.FrameID, -}; - -pub fn executionContextCreated( - alloc: std.mem.Allocator, - ctx: *Ctx, - id: u16, +pub const ExecutionContextCreated = struct { + id: u64, origin: []const u8, name: []const u8, - uniqueID: []const u8, - auxData: ?AuxData, - sessionID: ?[]const u8, -) !void { - const Params = struct { - context: struct { - id: u64, - origin: []const u8, - name: []const u8, - uniqueId: []const u8, - auxData: ?AuxData = null, - }, + uniqueId: []const u8, + auxData: ?AuxData = null, + + pub const AuxData = struct { + isDefault: bool = true, + type: []const u8 = "default", + frameId: []const u8 = cdp.FRAME_ID, }; - const params = Params{ - .context = .{ - .id = id, - .origin = origin, - .name = name, - .uniqueId = uniqueID, - .auxData = auxData, +}; + +fn logInspector(cmd: anytype, action: anytype) !void { + const script = switch (action) { + .evaluate => blk: { + const params = (try cmd.params(struct { + expression: []const u8, + // contextId: ?u8 = null, + // returnByValue: ?bool = null, + // awaitPromise: ?bool = null, + // userGesture: ?bool = null, + })) orelse return error.InvalidParams; + + break :blk params.expression; }, + .callFunctionOn => blk: { + const params = (try cmd.params(struct { + functionDeclaration: []const u8, + // objectId: ?[]const u8 = null, + // executionContextId: ?u8 = null, + // arguments: ?[]struct { + // value: ?[]const u8 = null, + // objectId: ?[]const u8 = null, + // } = null, + // returnByValue: ?bool = null, + // awaitPromise: ?bool = null, + // userGesture: ?bool = null, + })) orelse return error.InvalidParams; + + break :blk params.functionDeclaration; + }, + else => return, }; - try cdp.sendEvent(alloc, ctx, "Runtime.executionContextCreated", Params, params, sessionID); -} + const id = cmd.id orelse return error.RequiredId; + const name = try std.fmt.allocPrint(cmd.arena, "id_{d}.js", .{id}); -// TODO: noop method -// should we be passing this also to the JS Inspector? -fn runIfWaitingForDebugger( - alloc: std.mem.Allocator, - msg: *IncomingMessage, - _: *Ctx, -) ![]const u8 { - const input = try Input(void).get(alloc, msg); - defer input.deinit(); - log.debug("Req > id {d}, method {s}", .{ input.id, "runtime.runIfWaitingForDebugger" }); + var dir = try std.fs.cwd().makeOpenPath("zig-cache/tmp", .{}); + defer dir.close(); - return result(alloc, input.id, null, null, input.sessionId); + const f = try dir.createFile(name, .{}); + defer f.close(); + try f.writeAll(script); } diff --git a/src/cdp/security.zig b/src/cdp/security.zig index 42912544d..21834d839 100644 --- a/src/cdp/security.zig +++ b/src/cdp/security.zig @@ -17,43 +17,14 @@ // along with this program. If not, see . const std = @import("std"); - -const server = @import("../server.zig"); -const Ctx = server.Ctx; const cdp = @import("cdp.zig"); -const result = cdp.result; -const IncomingMessage = @import("msg.zig").IncomingMessage; -const Input = @import("msg.zig").Input; - -const log = std.log.scoped(.cdp); - -const Methods = enum { - enable, -}; - -pub fn security( - alloc: std.mem.Allocator, - msg: *IncomingMessage, - action: []const u8, - ctx: *Ctx, -) ![]const u8 { - const method = std.meta.stringToEnum(Methods, action) orelse - return error.UnknownMethod; - - return switch (method) { - .enable => enable(alloc, msg, ctx), - }; -} -fn enable( - alloc: std.mem.Allocator, - msg: *IncomingMessage, - _: *Ctx, -) ![]const u8 { - // input - const input = try Input(void).get(alloc, msg); - defer input.deinit(); - log.debug("Req > id {d}, method {s}", .{ input.id, "security.enable" }); +pub fn processMessage(cmd: anytype) !void { + const action = std.meta.stringToEnum(enum { + enable, + }, cmd.action) orelse return error.UnknownMethod; - return result(alloc, input.id, null, null, input.sessionId); + switch (action) { + .enable => return cmd.sendResult(null, .{}), + } } diff --git a/src/cdp/target.zig b/src/cdp/target.zig index 90384b641..815741d9f 100644 --- a/src/cdp/target.zig +++ b/src/cdp/target.zig @@ -17,267 +17,174 @@ // along with this program. If not, see . const std = @import("std"); - -const server = @import("../server.zig"); -const Ctx = server.Ctx; const cdp = @import("cdp.zig"); -const result = cdp.result; -const stringify = cdp.stringify; -const IncomingMessage = @import("msg.zig").IncomingMessage; -const Input = @import("msg.zig").Input; const log = std.log.scoped(.cdp); -const Methods = enum { - setDiscoverTargets, - setAutoAttach, - attachToTarget, - getTargetInfo, - getBrowserContexts, - createBrowserContext, - disposeBrowserContext, - createTarget, - closeTarget, - sendMessageToTarget, - detachFromTarget, -}; - -pub fn target( - alloc: std.mem.Allocator, - msg: *IncomingMessage, - action: []const u8, - ctx: *Ctx, -) ![]const u8 { - const method = std.meta.stringToEnum(Methods, action) orelse - return error.UnknownMethod; - return switch (method) { - .setDiscoverTargets => setDiscoverTargets(alloc, msg, ctx), - .setAutoAttach => setAutoAttach(alloc, msg, ctx), - .attachToTarget => attachToTarget(alloc, msg, ctx), - .getTargetInfo => getTargetInfo(alloc, msg, ctx), - .getBrowserContexts => getBrowserContexts(alloc, msg, ctx), - .createBrowserContext => createBrowserContext(alloc, msg, ctx), - .disposeBrowserContext => disposeBrowserContext(alloc, msg, ctx), - .createTarget => createTarget(alloc, msg, ctx), - .closeTarget => closeTarget(alloc, msg, ctx), - .sendMessageToTarget => sendMessageToTarget(alloc, msg, ctx), - .detachFromTarget => detachFromTarget(alloc, msg, ctx), - }; -} - // TODO: hard coded IDs -pub const PageTargetID = "PAGETARGETIDB638E9DC0F52DDC"; -pub const BrowserTargetID = "browser9-targ-et6f-id0e-83f3ab73a30c"; -pub const BrowserContextID = "BROWSERCONTEXTIDA95049E9DFE95EA9"; - +const CONTEXT_ID = "CONTEXTIDDCCDD11109E2D4FEFBE4F89"; +const PAGE_TARGET_ID = "PAGETARGETIDB638E9DC0F52DDC"; +const BROWSER_TARGET_ID = "browser9-targ-et6f-id0e-83f3ab73a30c"; +const BROWER_CONTEXT_ID = "BROWSERCONTEXTIDA95049E9DFE95EA9"; +const TARGET_ID = "TARGETID460A8F29706A2ADF14316298"; +const LOADER_ID = "LOADERID42AA389647D702B4D805F49A"; + +pub fn processMessage(cmd: anytype) !void { + const action = std.meta.stringToEnum(enum { + setDiscoverTargets, + setAutoAttach, + attachToTarget, + getTargetInfo, + getBrowserContexts, + createBrowserContext, + disposeBrowserContext, + createTarget, + closeTarget, + sendMessageToTarget, + detachFromTarget, + }, cmd.action) orelse return error.UnknownMethod; + + switch (action) { + .setDiscoverTargets => return setDiscoverTargets(cmd), + .setAutoAttach => return setAutoAttach(cmd), + .attachToTarget => return attachToTarget(cmd), + .getTargetInfo => return getTargetInfo(cmd), + .getBrowserContexts => return getBrowserContexts(cmd), + .createBrowserContext => return createBrowserContext(cmd), + .disposeBrowserContext => return disposeBrowserContext(cmd), + .createTarget => return createTarget(cmd), + .closeTarget => return closeTarget(cmd), + .sendMessageToTarget => return sendMessageToTarget(cmd), + .detachFromTarget => return detachFromTarget(cmd), + } +} // TODO: noop method -fn setDiscoverTargets( - alloc: std.mem.Allocator, - msg: *IncomingMessage, - _: *Ctx, -) ![]const u8 { - // input - const input = try Input(void).get(alloc, msg); - defer input.deinit(); - log.debug("Req > id {d}, method {s}", .{ input.id, "target.setDiscoverTargets" }); - - // output - return result(alloc, input.id, null, null, input.sessionId); +fn setDiscoverTargets(cmd: anytype) !void { + return cmd.sendResult(null, .{}); } const AttachToTarget = struct { sessionId: []const u8, - targetInfo: struct { - targetId: []const u8, - type: []const u8 = "page", - title: []const u8, - url: []const u8, - attached: bool = true, - canAccessOpener: bool = false, - browserContextId: []const u8, - }, + targetInfo: TargetInfo, waitingForDebugger: bool = false, }; const TargetCreated = struct { sessionId: []const u8, - targetInfo: struct { - targetId: []const u8, - type: []const u8 = "page", - title: []const u8, - url: []const u8, - attached: bool = true, - canAccessOpener: bool = false, - browserContextId: []const u8, - }, + targetInfo: TargetInfo, }; -const TargetFilter = struct { - type: ?[]const u8 = null, - exclude: ?bool = null, +const TargetInfo = struct { + targetId: []const u8, + type: []const u8 = "page", + title: []const u8, + url: []const u8, + attached: bool = true, + canAccessOpener: bool = false, + browserContextId: []const u8, }; // TODO: noop method -fn setAutoAttach( - alloc: std.mem.Allocator, - msg: *IncomingMessage, - ctx: *Ctx, -) ![]const u8 { - // input - const Params = struct { - autoAttach: bool, - waitForDebuggerOnStart: bool, - flatten: bool = true, - filter: ?[]TargetFilter = null, - }; - const input = try Input(Params).get(alloc, msg); - defer input.deinit(); - log.debug("Req > id {d}, method {s}", .{ input.id, "target.setAutoAttach" }); +fn setAutoAttach(cmd: anytype) !void { + // const TargetFilter = struct { + // type: ?[]const u8 = null, + // exclude: ?bool = null, + // }; + + // const params = (try cmd.params(struct { + // autoAttach: bool, + // waitForDebuggerOnStart: bool, + // flatten: bool = true, + // filter: ?[]TargetFilter = null, + // })) orelse return error.InvalidParams; // attachedToTarget event - if (input.sessionId == null) { - const attached = AttachToTarget{ - .sessionId = cdp.BrowserSessionID, + if (cmd.session_id == null) { + try cmd.sendEvent("Target.attachedToTarget", AttachToTarget{ + .sessionId = cdp.BROWSER_SESSION_ID, .targetInfo = .{ - .targetId = PageTargetID, + .targetId = PAGE_TARGET_ID, .title = "about:blank", - .url = cdp.URLBase, - .browserContextId = BrowserContextID, + .url = cdp.URL_BASE, + .browserContextId = BROWER_CONTEXT_ID, }, - }; - try cdp.sendEvent(alloc, ctx, "Target.attachedToTarget", AttachToTarget, attached, null); + }, .{}); } - // output - return result(alloc, input.id, null, null, input.sessionId); + return cmd.sendResult(null, .{}); } // TODO: noop method -fn attachToTarget( - alloc: std.mem.Allocator, - msg: *IncomingMessage, - ctx: *Ctx, -) ![]const u8 { - - // input - const Params = struct { +fn attachToTarget(cmd: anytype) !void { + const params = (try cmd.params(struct { targetId: []const u8, flatten: bool = true, - }; - const input = try Input(Params).get(alloc, msg); - defer input.deinit(); - log.debug("Req > id {d}, method {s}", .{ input.id, "target.attachToTarget" }); + })) orelse return error.InvalidParams; // attachedToTarget event - if (input.sessionId == null) { - const attached = AttachToTarget{ - .sessionId = cdp.BrowserSessionID, + if (cmd.session_id == null) { + try cmd.sendEvent("Target.attachedToTarget", AttachToTarget{ + .sessionId = cdp.BROWSER_SESSION_ID, .targetInfo = .{ - .targetId = input.params.targetId, + .targetId = params.targetId, .title = "about:blank", - .url = cdp.URLBase, - .browserContextId = BrowserContextID, + .url = cdp.URL_BASE, + .browserContextId = BROWER_CONTEXT_ID, }, - }; - try cdp.sendEvent(alloc, ctx, "Target.attachedToTarget", AttachToTarget, attached, null); + }, .{}); } - // output - const SessionId = struct { - sessionId: []const u8, - }; - const output = SessionId{ - .sessionId = input.sessionId orelse cdp.BrowserSessionID, - }; - return result(alloc, input.id, SessionId, output, null); + return cmd.sendResult( + .{ .sessionId = cmd.session_id orelse cdp.BROWSER_SESSION_ID }, + .{ .include_session_id = false }, + ); } -fn getTargetInfo( - alloc: std.mem.Allocator, - msg: *IncomingMessage, - _: *Ctx, -) ![]const u8 { - // input - const Params = struct { - targetId: ?[]const u8 = null, - }; - const input = try Input(?Params).get(alloc, msg); - defer input.deinit(); - log.debug("Req > id {d}, method {s}", .{ input.id, "target.getTargetInfo" }); +fn getTargetInfo(cmd: anytype) !void { + // const params = (try cmd.params(struct { + // targetId: ?[]const u8 = null, + // })) orelse return error.InvalidParams; - // output - const TargetInfo = struct { - targetId: []const u8, - type: []const u8, - title: []const u8 = "", - url: []const u8 = "", - attached: bool = true, - openerId: ?[]const u8 = null, - canAccessOpener: bool = false, - openerFrameId: ?[]const u8 = null, - browserContextId: ?[]const u8 = null, - subtype: ?[]const u8 = null, - }; - const targetInfo = TargetInfo{ - .targetId = BrowserTargetID, + return cmd.sendResult(.{ + .targetId = BROWSER_TARGET_ID, .type = "browser", - }; - return result(alloc, input.id, TargetInfo, targetInfo, null); + .title = "", + .url = "", + .attached = true, + .canAccessOpener = false, + }, .{ .include_session_id = false }); } // Browser context are not handled and not in the roadmap for now // The following methods are "fake" // TODO: noop method -fn getBrowserContexts( - alloc: std.mem.Allocator, - msg: *IncomingMessage, - ctx: *Ctx, -) ![]const u8 { - // input - const input = try Input(void).get(alloc, msg); - defer input.deinit(); - log.debug("Req > id {d}, method {s}", .{ input.id, "target.getBrowserContexts" }); - - // ouptut - const Resp = struct { - browserContextIds: [][]const u8, - }; - var resp: Resp = undefined; - if (ctx.state.contextID) |contextID| { - var contextIDs = [1][]const u8{contextID}; - resp = .{ .browserContextIds = &contextIDs }; +fn getBrowserContexts(cmd: anytype) !void { + var context_ids: []const []const u8 = undefined; + if (cmd.cdp.context_id) |context_id| { + context_ids = &.{context_id}; } else { - const contextIDs = [0][]const u8{}; - resp = .{ .browserContextIds = &contextIDs }; + context_ids = &.{}; } - return result(alloc, input.id, Resp, resp, null); -} -const ContextID = "CONTEXTIDDCCDD11109E2D4FEFBE4F89"; + return cmd.sendResult(.{ + .browserContextIds = context_ids, + }, .{ .include_session_id = false }); +} // TODO: noop method -fn createBrowserContext( - alloc: std.mem.Allocator, - msg: *IncomingMessage, - ctx: *Ctx, -) ![]const u8 { - // input - const Params = struct { - disposeOnDetach: bool = false, - proxyServer: ?[]const u8 = null, - proxyBypassList: ?[]const u8 = null, - originsWithUniversalNetworkAccess: ?[][]const u8 = null, - }; - const input = try Input(Params).get(alloc, msg); - defer input.deinit(); - log.debug("Req > id {d}, method {s}", .{ input.id, "target.createBrowserContext" }); +fn createBrowserContext(cmd: anytype) !void { + // const params = (try cmd.params(struct { + // disposeOnDetach: bool = false, + // proxyServer: ?[]const u8 = null, + // proxyBypassList: ?[]const u8 = null, + // originsWithUniversalNetworkAccess: ?[][]const u8 = null, + // })) orelse return error.InvalidParams; - ctx.state.contextID = ContextID; + cmd.cdp.context_id = CONTEXT_ID; - // output - const Resp = struct { - browserContextId: []const u8 = ContextID, + const Response = struct { + browserContextId: []const u8, pub fn format( self: @This(), @@ -291,40 +198,26 @@ fn createBrowserContext( try writer.writeAll(" }"); } }; - return result(alloc, input.id, Resp, Resp{}, input.sessionId); -} -fn disposeBrowserContext( - alloc: std.mem.Allocator, - msg: *IncomingMessage, - ctx: *Ctx, -) ![]const u8 { - // input - const Params = struct { - browserContextId: []const u8, - }; - const input = try Input(Params).get(alloc, msg); - defer input.deinit(); - log.debug("Req > id {d}, method {s}", .{ input.id, "target.disposeBrowserContext" }); + return cmd.sendResult(Response{ + .browserContextId = CONTEXT_ID, + }, .{}); +} - // output - const res = try result(alloc, input.id, null, .{}, null); - try ctx.send(res); +fn disposeBrowserContext(cmd: anytype) !void { + // const params = (try cmd.params(struct { + // browserContextId: []const u8, + // proxyServer: ?[]const u8 = null, + // proxyBypassList: ?[]const u8 = null, + // originsWithUniversalNetworkAccess: ?[][]const u8 = null, + // })) orelse return error.InvalidParams; - return error.DisposeBrowserContext; + try cmd.cdp.newSession(); + try cmd.sendResult(null, .{}); } -// TODO: hard coded IDs -const TargetID = "TARGETID460A8F29706A2ADF14316298"; -const LoaderID = "LOADERID42AA389647D702B4D805F49A"; - -fn createTarget( - alloc: std.mem.Allocator, - msg: *IncomingMessage, - ctx: *Ctx, -) ![]const u8 { - // input - const Params = struct { +fn createTarget(cmd: anytype) !void { + const params = (try cmd.params(struct { url: []const u8, width: ?u64 = null, height: ?u64 = null, @@ -333,71 +226,67 @@ fn createTarget( newWindow: bool = false, background: bool = false, forTab: ?bool = null, - }; - const input = try Input(Params).get(alloc, msg); - defer input.deinit(); - log.debug("Req > id {d}, method {s}", .{ input.id, "target.createTarget" }); + })) orelse return error.InvalidParams; // change CDP state - ctx.state.frameID = TargetID; - ctx.state.url = "about:blank"; - ctx.state.securityOrigin = "://"; - ctx.state.secureContextType = "InsecureScheme"; - ctx.state.loaderID = LoaderID; - - if (msg.sessionId) |s| { - ctx.state.sessionID = cdp.SessionID.parse(s) catch |err| { - log.err("parse sessionID: {s} {any}", .{ s, err }); - return err; - }; + var state = cmd.cdp; + state.frame_id = TARGET_ID; + state.url = "about:blank"; + state.security_origin = "://"; + state.secure_context_type = "InsecureScheme"; + state.loader_id = LOADER_ID; + + if (cmd.session_id) |s| { + state.session_id = try cdp.SessionID.parse(s); } // TODO stop the previous page instead? - if (ctx.browser.session.page != null) return error.pageAlreadyExists; + if (cmd.session.page != null) { + return error.pageAlreadyExists; + } // create the page - const p = try ctx.browser.session.createPage(); - ctx.state.executionContextId += 1; + const p = try cmd.session.createPage(); + state.execution_context_id += 1; + // start the js env - const auxData = try std.fmt.allocPrint( - alloc, + const aux_data = try std.fmt.allocPrint( + cmd.arena, // NOTE: we assume this is the default web page "{{\"isDefault\":true,\"type\":\"default\",\"frameId\":\"{s}\"}}", - .{ctx.state.frameID}, + .{state.frame_id}, ); - defer alloc.free(auxData); - try p.start(auxData); + try p.start(aux_data); + + const browser_context_id = params.browserContextId orelse CONTEXT_ID; // send targetCreated event - const created = TargetCreated{ - .sessionId = cdp.ContextSessionID, + try cmd.sendEvent("Target.targetCreated", TargetCreated{ + .sessionId = cdp.CONTEXT_SESSION_ID, .targetInfo = .{ - .targetId = ctx.state.frameID, + .targetId = state.frame_id, .title = "about:blank", - .url = ctx.state.url, - .browserContextId = input.params.browserContextId orelse ContextID, + .url = state.url, + .browserContextId = browser_context_id, .attached = true, }, - }; - try cdp.sendEvent(alloc, ctx, "Target.targetCreated", TargetCreated, created, input.sessionId); + }, .{ .session_id = cmd.session_id }); // send attachToTarget event - const attached = AttachToTarget{ - .sessionId = cdp.ContextSessionID, + try cmd.sendEvent("Target.attachedToTarget", AttachToTarget{ + .sessionId = cdp.CONTEXT_SESSION_ID, + .waitingForDebugger = true, .targetInfo = .{ - .targetId = ctx.state.frameID, + .targetId = state.frame_id, .title = "about:blank", - .url = ctx.state.url, - .browserContextId = input.params.browserContextId orelse ContextID, + .url = state.url, + .browserContextId = browser_context_id, .attached = true, }, - .waitingForDebugger = true, - }; - try cdp.sendEvent(alloc, ctx, "Target.attachedToTarget", AttachToTarget, attached, input.sessionId); + }, .{ .session_id = cmd.session_id }); - // output - const Resp = struct { - targetId: []const u8 = TargetID, + const Response = struct { + targetId: []const u8 = TARGET_ID, pub fn format( self: @This(), @@ -411,119 +300,71 @@ fn createTarget( try writer.writeAll(" }"); } }; - return result(alloc, input.id, Resp, Resp{}, input.sessionId); + return cmd.sendResult(Response{}, .{}); } -fn closeTarget( - alloc: std.mem.Allocator, - msg: *IncomingMessage, - ctx: *Ctx, -) ![]const u8 { - // input - const Params = struct { +fn closeTarget(cmd: anytype) !void { + const params = (try cmd.params(struct { targetId: []const u8, - }; - const input = try Input(Params).get(alloc, msg); - defer input.deinit(); - log.debug("Req > id {d}, method {s}", .{ input.id, "target.closeTarget" }); + })) orelse return error.InvalidParams; - // output - const Resp = struct { - success: bool = true, - }; - const res = try result(alloc, input.id, Resp, Resp{}, null); - try ctx.send(res); + try cmd.sendResult(.{ + .success = true, + }, .{ .include_session_id = false }); + + const session_id = cmd.session_id orelse cdp.CONTEXT_SESSION_ID; // Inspector.detached event - const InspectorDetached = struct { - reason: []const u8 = "Render process gone.", - }; - try cdp.sendEvent( - alloc, - ctx, - "Inspector.detached", - InspectorDetached, - .{}, - input.sessionId orelse cdp.ContextSessionID, - ); + try cmd.sendEvent("Inspector.detached", .{ + .reason = "Render process gone.", + }, .{ .session_id = session_id }); // detachedFromTarget event - const TargetDetached = struct { - sessionId: []const u8, - targetId: []const u8, - }; - try cdp.sendEvent( - alloc, - ctx, - "Target.detachedFromTarget", - TargetDetached, - .{ - .sessionId = input.sessionId orelse cdp.ContextSessionID, - .targetId = input.params.targetId, - }, - null, - ); - - if (ctx.browser.session.page != null) ctx.browser.session.page.?.end(); - - return ""; + try cmd.sendEvent("Target.detachedFromTarget", .{ + .sessionId = session_id, + .targetId = params.targetId, + .reason = "Render process gone.", + }, .{}); + + if (cmd.session.page) |*page| { + page.end(); + } } -fn sendMessageToTarget( - alloc: std.mem.Allocator, - msg: *IncomingMessage, - ctx: *Ctx, -) ![]const u8 { - // input - const Params = struct { +fn sendMessageToTarget(cmd: anytype) !void { + const params = (try cmd.params(struct { message: []const u8, sessionId: []const u8, + })) orelse return error.InvalidParams; + + const Capture = struct { + allocator: std.mem.Allocator, + buf: std.ArrayListUnmanaged(u8), + + pub fn sendJSON(self: *@This(), message: anytype) !void { + return std.json.stringify(message, .{ + .emit_null_optional_fields = false, + }, self.buf.writer(self.allocator)); + } }; - const input = try Input(Params).get(alloc, msg); - defer input.deinit(); - log.debug("Req > id {d}, method {s} ({s})", .{ input.id, "target.sendMessageToTarget", input.params.message }); - - // get the wrapped message. - var wmsg = IncomingMessage.init(alloc, input.params.message); - defer wmsg.deinit(); - - const res = cdp.dispatch(alloc, &wmsg, ctx) catch |e| { - log.err("send message {d} ({s}): {any}", .{ input.id, input.params.message, e }); - // TODO dispatch error correctly. - return e; + + var capture = Capture{ + .buf = .{}, + .allocator = cmd.arena, }; - // receivedMessageFromTarget event - const ReceivedMessageFromTarget = struct { - message: []const u8, - sessionId: []const u8, + cmd.cdp.dispatch(cmd.arena, &capture, params.message) catch |err| { + log.err("send message {d} ({s}): {any}", .{ cmd.id orelse -1, params.message, err }); + return err; }; - try cdp.sendEvent( - alloc, - ctx, - "Target.receivedMessageFromTarget", - ReceivedMessageFromTarget, - .{ - .message = res, - .sessionId = input.params.sessionId, - }, - null, - ); - return ""; + try cmd.sendEvent("Target.receivedMessageFromTarget", .{ + .message = capture.buf.items, + .sessionId = params.sessionId, + }, .{}); } // noop -fn detachFromTarget( - alloc: std.mem.Allocator, - msg: *IncomingMessage, - _: *Ctx, -) ![]const u8 { - // input - const input = try Input(void).get(alloc, msg); - defer input.deinit(); - log.debug("Req > id {d}, method {s}", .{ input.id, "target.detachFromTarget" }); - - // output - return result(alloc, input.id, bool, true, input.sessionId); +fn detachFromTarget(cmd: anytype) !void { + return cmd.sendResult(null, .{}); } diff --git a/src/main.zig b/src/main.zig index c5c04996c..59344d38e 100644 --- a/src/main.zig +++ b/src/main.zig @@ -261,12 +261,13 @@ pub fn main() !void { defer loop.deinit(); // browser - var browser = Browser{}; - try Browser.init(&browser, alloc, &loop, vm); + var browser = Browser.init(alloc, &loop); defer browser.deinit(); + var session = try browser.newSession({}); + // page - const page = try browser.session.createPage(); + const page = try session.createPage(); try page.start(null); defer page.end(); diff --git a/src/main_tests.zig b/src/main_tests.zig index 8028827c3..a857110d3 100644 --- a/src/main_tests.zig +++ b/src/main_tests.zig @@ -336,7 +336,6 @@ test { std.testing.refAllDecls(queryTest); std.testing.refAllDecls(@import("generate.zig")); - std.testing.refAllDecls(@import("cdp/msg.zig")); // Don't use refAllDecls, as this will pull in the entire project // and break the test build. diff --git a/src/server.zig b/src/server.zig index 91653e452..a01157e26 100644 --- a/src/server.zig +++ b/src/server.zig @@ -23,6 +23,7 @@ const net = std.net; const posix = std.posix; const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; const jsruntime = @import("jsruntime"); const Completion = jsruntime.IO.Completion; @@ -33,31 +34,7 @@ const CloseError = jsruntime.IO.CloseError; const CancelError = jsruntime.IO.CancelError; const TimeoutError = jsruntime.IO.TimeoutError; -const Browser = @import("browser/browser.zig").Browser; -const cdp = @import("cdp/cdp.zig"); - -const IOError = AcceptError || RecvError || SendError || CloseError || TimeoutError || CancelError; -const HTTPError = error{ - OutOfMemory, - RequestTooLarge, - NotFound, - InvalidRequest, - MissingHeaders, - InvalidProtocol, - InvalidUpgradeHeader, - InvalidVersionHeader, - InvalidConnectionHeader, -}; -const WebSocketError = error{ - OutOfMemory, - ReservedFlags, - NotMasked, - TooLarge, - InvalidMessageType, - InvalidContinuation, - NestedFragementation, -}; -const Error = IOError || cdp.Error || HTTPError || WebSocketError; +const CDP = @import("cdp/cdp.zig").CDP; const TimeoutCheck = std.time.ns_per_ms * 100; @@ -70,10 +47,7 @@ const MAX_HTTP_REQUEST_SIZE = 2048; // +140 for the max control packet that might be interleaved in a message const MAX_MESSAGE_SIZE = 256 * 1024 + 14; -// For now, cdp does @import("server.zig").Ctx. Could change cdp to use "Server" -// but I rather try to decouple the CDP code from the server, so a quick -// stopgap is fine. TODO: Decouple cdp from the server -pub const Ctx = Server; +pub const Client = ClientT(*Server, CDP); const Server = struct { allocator: Allocator, @@ -81,32 +55,27 @@ const Server = struct { // internal fields listener: posix.socket_t, - client: ?Client(*Server) = null, + client: ?*Client = null, timeout: u64, // a memory poor for our Send objects send_pool: std.heap.MemoryPool(Send), + // a memory poor for our Clietns + client_pool: std.heap.MemoryPool(Client), + // I/O fields conn_completion: Completion, close_completion: Completion, accept_completion: Completion, timeout_completion: Completion, - // used when gluing the session id to the inspector message - scrap: std.ArrayListUnmanaged(u8) = .{}, - // The response to send on a GET /json/version request json_version_response: []const u8, - // CDP - state: cdp.State = .{}, - - // JS fields - browser: *Browser, // TODO: is pointer mandatory here? - fn deinit(self: *Server) void { self.send_pool.deinit(); + self.client_pool.deinit(); self.allocator.free(self.json_version_response); } @@ -126,6 +95,7 @@ const Server = struct { completion: *Completion, result: AcceptError!posix.socket_t, ) void { + std.debug.assert(self.client == null); std.debug.assert(completion == &self.accept_completion); const socket = result catch |err| { @@ -134,14 +104,18 @@ const Server = struct { return; }; - self.newSession() catch |err| { - log.err("new session error: {any}", .{err}); - self.queueClose(socket); + const client = self.client_pool.create() catch |err| { + log.err("failed to create client: {any}", .{err}); + posix.close(socket); return; }; + errdefer self.client_pool.destroy(client); + + client.* = Client.init(socket, self); + + self.client = client; log.info("client connected", .{}); - self.client = Client(*Server).init(socket, self); self.queueRead(); self.queueTimeout(); } @@ -163,7 +137,7 @@ const Server = struct { ) void { std.debug.assert(completion == &self.timeout_completion); - const client = &(self.client orelse return); + const client = self.client orelse return; if (result) |_| { if (now().since(client.last_active) > self.timeout) { @@ -184,7 +158,7 @@ const Server = struct { } fn queueRead(self: *Server) void { - if (self.client) |*client| { + if (self.client) |client| { self.loop.io.recv( *Server, self, @@ -203,16 +177,16 @@ const Server = struct { ) void { std.debug.assert(completion == &self.conn_completion); - var client = &(self.client orelse return); + var client = self.client orelse return; const size = result catch |err| { log.err("read error: {any}", .{err}); - self.queueClose(client.socket); + client.close(null); return; }; const more = client.processData(size) catch |err| { - log.err("Client Processing Error: {}\n", .{err}); + log.err("Client Processing Error: {any}\n", .{err}); return; }; @@ -225,19 +199,18 @@ const Server = struct { fn queueSend( self: *Server, socket: posix.socket_t, + arena: ?ArenaAllocator, data: []const u8, - free_when_done: bool, ) !void { const sd = try self.send_pool.create(); errdefer self.send_pool.destroy(sd); sd.* = .{ - .data = data, .unsent = data, .server = self, .socket = socket, .completion = undefined, - .free_when_done = free_when_done, + .arena = arena, }; sd.queueSend(); } @@ -254,128 +227,12 @@ const Server = struct { fn callbackClose(self: *Server, completion: *Completion, _: CloseError!void) void { std.debug.assert(completion == &self.close_completion); - if (self.client != null) { - self.client = null; - } + var client = self.client.?; + client.deinit(); + self.client_pool.destroy(client); + self.client = null; self.queueAccept(); } - - fn handleCDP(self: *Server, cmd: []const u8) !void { - const res = cdp.do(self.allocator, cmd, self) catch |err| { - - // cdp end cmd - if (err == error.DisposeBrowserContext) { - // restart a new browser session - std.log.scoped(.cdp).debug("end cmd, restarting a new session...", .{}); - try self.newSession(); - return; - } - - return err; - }; - - // send result - if (res.len != 0) { - return self.send(res); - } - } - - // called from CDP - pub fn send(self: *Server, data: []const u8) !void { - if (self.client) |*client| { - try client.sendWS(data); - } - } - - fn newSession(self: *Server) !void { - try self.browser.newSession(self.allocator, self.loop); - try self.browser.session.initInspector( - self, - inspectorResponse, - inspectorEvent, - ); - } - - // // inspector - // // --------- - - // called by cdp - pub fn sendInspector(self: *Server, msg: []const u8) !void { - const env = self.browser.session.env; - if (env.getInspector()) |inspector| { - inspector.send(env, msg); - return; - } - return error.InspectNotSet; - } - - fn inspectorResponse(ctx: *anyopaque, _: u32, msg: []const u8) void { - if (std.log.defaultLogEnabled(.debug)) { - // msg should be {"id":,... - std.debug.assert(std.mem.startsWith(u8, msg, "{\"id\":")); - - const id_end = std.mem.indexOfScalar(u8, msg, ',') orelse { - log.warn("invalid inspector response message: {s}", .{msg}); - return; - }; - - const id = msg[6..id_end]; - std.log.scoped(.cdp).debug("Res (inspector) > id {s}", .{id}); - } - sendInspectorMessage(@alignCast(@ptrCast(ctx)), msg); - } - - fn inspectorEvent(ctx: *anyopaque, msg: []const u8) void { - if (std.log.defaultLogEnabled(.debug)) { - // msg should be {"method":,... - std.debug.assert(std.mem.startsWith(u8, msg, "{\"method\":")); - const method_end = std.mem.indexOfScalar(u8, msg, ',') orelse { - log.warn("invalid inspector event message: {s}", .{msg}); - return; - }; - const method = msg[10..method_end]; - std.log.scoped(.cdp).debug("Event (inspector) > method {s}", .{method}); - } - - sendInspectorMessage(@alignCast(@ptrCast(ctx)), msg); - } - - fn sendInspectorMessage(self: *Server, msg: []const u8) void { - var client = &(self.client orelse return); - - var scrap = &self.scrap; - scrap.clearRetainingCapacity(); - - const field = ",\"sessionId\":"; - const sessionID = @tagName(self.state.sessionID); - - // + 2 for the quotes around the session - const message_len = msg.len + sessionID.len + 2 + field.len; - - scrap.ensureTotalCapacity(self.allocator, message_len) catch |err| { - log.err("Failed to expand inspector buffer: {}", .{err}); - return; - }; - - // -1 because we dont' want the closing brace '}' - scrap.appendSliceAssumeCapacity(msg[0 .. msg.len - 1]); - scrap.appendSliceAssumeCapacity(field); - scrap.appendAssumeCapacity('"'); - scrap.appendSliceAssumeCapacity(sessionID); - scrap.appendSliceAssumeCapacity("\"}"); - std.debug.assert(scrap.items.len == message_len); - - // TODO: Remove when we clean up ownership of messages between - // CDD and sending. - const owned = self.allocator.dupe(u8, scrap.items) catch return; - - client.sendWS(owned) catch |err| { - log.debug("Failed to write inspector message to client: {}", .{err}); - // don't bother trying to cleanly close the client, if sendWS fails - // we're almost certainly in a non-recoverable state (i.e. OOM) - self.queueClose(client.socket); - }; - } }; // I/O Send @@ -386,26 +243,20 @@ const Server = struct { // After the send (on the sendCbk) the dedicated context will be destroy // and the data slice will be free. const Send = struct { - // The full data to be sent - data: []const u8, - - // Whether or not to free the data once the message is sent (or fails to) - // send. This is false in cases where the message is comptime known - free_when_done: bool, - - // Any unsent data we have. Initially unsent == data, but as part of the - // message is succesfully sent, unsent becomes a smaller and smaller slice - // of data + // Any unsent data we have. unsent: []const u8, server: *Server, completion: Completion, socket: posix.socket_t, + // If we need to free anything when we're done + arena: ?ArenaAllocator, + fn deinit(self: *Send) void { var server = self.server; - if (self.free_when_done) { - server.allocator.free(self.data); + if (self.arena) |arena| { + arena.deinit(); } server.send_pool.destroy(self); } @@ -421,15 +272,11 @@ const Send = struct { ); } - fn sendCallback( - self: *Send, - _: *Completion, - result: SendError!usize, - ) void { + fn sendCallback(self: *Send, _: *Completion, result: SendError!usize) void { const sent = result catch |err| { - log.err("send error: {any}", .{err}); - if (self.server.client) |*client| { - self.server.queueClose(client.socket); + log.info("send error: {any}", .{err}); + if (self.server.client) |client| { + client.close(null); } self.deinit(); return; @@ -453,19 +300,28 @@ const Send = struct { // and when we send a message, we'll use server.send(...) to send via the server's // IO loop. During tests, we can inject a simple mock to record (and then verify) // the send message -fn Client(comptime S: type) type { +fn ClientT(comptime S: type, comptime C: type) type { const EMPTY_PONG = [_]u8{ 138, 0 }; // CLOSE, 2 length, code const CLOSE_NORMAL = [_]u8{ 136, 2, 3, 232 }; // code: 1000 const CLOSE_TOO_BIG = [_]u8{ 136, 2, 3, 241 }; // 1009 const CLOSE_PROTOCOL_ERROR = [_]u8{ 136, 2, 3, 234 }; //code: 1002 + + // "private-use" close codes must be from 4000-49999 const CLOSE_TIMEOUT = [_]u8{ 136, 2, 15, 160 }; // code: 4000 return struct { // The client is initially serving HTTP requests but, under normal circumstances // should eventually be upgraded to a websocket connections mode: Mode, + + // The CDP instance that processes messages from this client + // (a generic so we can test with a mock + // null until mode == .websocket + cdp: ?C, + + // Our Server (a generic so we can test with a mock) server: S, reader: Reader, socket: posix.socket_t, @@ -480,6 +336,7 @@ fn Client(comptime S: type) type { fn init(socket: posix.socket_t, server: S) Self { return .{ + .cdp = null, .mode = .http, .socket = socket, .server = server, @@ -488,14 +345,22 @@ fn Client(comptime S: type) type { }; } - fn close(self: *Self, close_code: CloseCode) void { - if (self.mode == .websocket) { - switch (close_code) { - .timeout => self.send(&CLOSE_TIMEOUT, false) catch {}, + pub fn deinit(self: *Self) void { + self.reader.deinit(); + if (self.cdp) |*cdp| { + cdp.deinit(); + } + } + + pub fn close(self: *Self, close_code: ?CloseCode) void { + if (close_code) |code| { + if (self.mode == .websocket) { + switch (code) { + .timeout => self.send(&CLOSE_TIMEOUT) catch {}, + } } } self.server.queueClose(self.socket); - self.reader.deinit(); } fn readBuf(self: *Self) []u8 { @@ -515,7 +380,7 @@ fn Client(comptime S: type) type { } } - fn processHTTPRequest(self: *Self) HTTPError!void { + fn processHTTPRequest(self: *Self) !void { std.debug.assert(self.reader.pos == 0); const request = self.reader.buf[0..self.reader.len]; @@ -542,7 +407,7 @@ fn Client(comptime S: type) type { error.InvalidVersionHeader => self.writeHTTPErrorResponse(400, "Invalid websocket version"), error.InvalidConnectionHeader => self.writeHTTPErrorResponse(400, "Invalid connection header"), else => { - log.err("error processing HTTP request: {}", .{err}); + log.err("error processing HTTP request: {any}", .{err}); self.writeHTTPErrorResponse(500, "Internal Server Error"); }, } @@ -574,7 +439,7 @@ fn Client(comptime S: type) type { } if (std.mem.eql(u8, url, "/json/version")) { - return self.send(self.server.json_version_response, false); + return self.send(self.server.json_version_response); } return error.NotFound; @@ -640,6 +505,9 @@ fn Client(comptime S: type) type { // our caller has already made sure this request ended in \r\n\r\n // so it isn't something we need to check again + var arena = ArenaAllocator.init(self.server.allocator); + errdefer arena.deinit(); + const response = blk: { // Response to an ugprade request is always this, with // the Sec-Websocket-Accept value a spacial sha1 hash of the @@ -653,9 +521,8 @@ fn Client(comptime S: type) type { // The response will be sent via the IO Loop and thus has to have its // own lifetime. - const res = try self.server.allocator.dupe(u8, template); - errdefer self.server.allocator.free(res); + const res = try arena.allocator().dupe(u8, template); // magic response const key_pos = res.len - 32; var h: [20]u8 = undefined; @@ -671,7 +538,19 @@ fn Client(comptime S: type) type { }; self.mode = .websocket; - return self.send(response, true); + self.cdp = C.init(self.server.allocator, self, self.server.loop); + return self.sendAlloc(arena, response); + } + + fn writeHTTPErrorResponse(self: *Self, comptime status: u16, comptime body: []const u8) void { + const response = std.fmt.comptimePrint( + "HTTP/1.1 {d} \r\nConnection: Close\r\nContent-Length: {d}\r\n\r\n{s}", + .{ status, body.len, body }, + ); + + // we're going to close this connection anyways, swallowing any + // error seems safe + self.send(response) catch {}; } fn processWebsocketMessage(self: *Self) !bool { @@ -681,12 +560,13 @@ fn Client(comptime S: type) type { while (true) { const msg = reader.next() catch |err| { switch (err) { - error.TooLarge => self.send(&CLOSE_TOO_BIG, false) catch {}, - error.NotMasked => self.send(&CLOSE_PROTOCOL_ERROR, false) catch {}, - error.ReservedFlags => self.send(&CLOSE_PROTOCOL_ERROR, false) catch {}, - error.InvalidMessageType => self.send(&CLOSE_PROTOCOL_ERROR, false) catch {}, - error.InvalidContinuation => self.send(&CLOSE_PROTOCOL_ERROR, false) catch {}, - error.NestedFragementation => self.send(&CLOSE_PROTOCOL_ERROR, false) catch {}, + error.TooLarge => self.send(&CLOSE_TOO_BIG) catch {}, + error.NotMasked => self.send(&CLOSE_PROTOCOL_ERROR) catch {}, + error.ReservedFlags => self.send(&CLOSE_PROTOCOL_ERROR) catch {}, + error.InvalidMessageType => self.send(&CLOSE_PROTOCOL_ERROR) catch {}, + error.ControlTooLarge => self.send(&CLOSE_PROTOCOL_ERROR) catch {}, + error.InvalidContinuation => self.send(&CLOSE_PROTOCOL_ERROR) catch {}, + error.NestedFragementation => self.send(&CLOSE_PROTOCOL_ERROR) catch {}, error.OutOfMemory => {}, // don't borther trying to send an error in this case } return err; @@ -696,11 +576,11 @@ fn Client(comptime S: type) type { .pong => {}, .ping => try self.sendPong(msg.data), .close => { - self.send(&CLOSE_NORMAL, false) catch {}; + self.send(&CLOSE_NORMAL) catch {}; self.server.queueClose(self.socket); return false; }, - .text, .binary => try self.server.handleCDP(msg.data), + .text, .binary => self.cdp.?.processMessage(msg.data), } if (msg.cleanup_fragment) { reader.cleanup(); @@ -715,84 +595,60 @@ fn Client(comptime S: type) type { fn sendPong(self: *Self, data: []const u8) !void { if (data.len == 0) { - return self.send(&EMPTY_PONG, false); + return self.send(&EMPTY_PONG); } + var header_buf: [10]u8 = undefined; + const header = websocketHeader(&header_buf, .pong, data.len); - return self.sendFrame(data, .pong); - } - - fn sendWS(self: *Self, data: []const u8) !void { - std.debug.assert(data.len < 4294967296); + var arena = ArenaAllocator.init(self.server.allocator); + errdefer arena.deinit(); - // for now, we're going to dupe this before we send it, so we don't need - // to keep this around. - defer self.server.allocator.free(data); - return self.sendFrame(data, .text); + var framed = try arena.allocator().alloc(u8, header.len + data.len); + @memcpy(framed[0..header.len], header); + @memcpy(framed[header.len..], data); + return self.sendAlloc(arena, framed); } - // We need to append the websocket header to data. If our IO loop supported - // a writev call, this would be simple. - // For now, we'll just have to dupe data into a larger message. - // TODO: Remove this awful allocation (probably by passing a websocket-aware - // Writer into CDP) - fn sendFrame(self: *Self, data: []const u8, op_code: OpCode) !void { - if (comptime builtin.is_test == false) { - std.debug.assert(self.mode == .websocket); - } - - // 10 is the max possible length of our header - // server->client has no mask, so it's 4 fewer bytes than the reader overhead - var header_buf: [10]u8 = undefined; + // called by CDP + // Websocket frames have a variable lenght header. For server-client, + // it could be anywhere from 2 to 10 bytes. Our IO.Loop doesn't have + // writev, so we need to get creative. We'll JSON serialize to a + // buffer, where the first 10 bytes are reserved. We can then backfill + // the header and send the slice. + pub fn sendJSON(self: *Self, message: anytype, opts: std.json.StringifyOptions) !void { + var arena = ArenaAllocator.init(self.server.allocator); + errdefer arena.deinit(); - const header: []const u8 = blk: { - const len = data.len; - header_buf[0] = 128 | @intFromEnum(op_code); // fin | opcode + const allocator = arena.allocator(); - if (len <= 125) { - header_buf[1] = @intCast(len); - break :blk header_buf[0..2]; - } + var buf: std.ArrayListUnmanaged(u8) = .{}; + try buf.ensureTotalCapacity(allocator, 512); - if (len < 65536) { - header_buf[1] = 126; - header_buf[2] = @intCast((len >> 8) & 0xFF); - header_buf[3] = @intCast(len & 0xFF); - break :blk header_buf[0..4]; - } + // reserve space for the maximum possible header + buf.appendSliceAssumeCapacity(&.{ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }); - header_buf[1] = 127; - header_buf[2] = 0; - header_buf[3] = 0; - header_buf[4] = 0; - header_buf[5] = 0; - header_buf[6] = @intCast((len >> 24) & 0xFF); - header_buf[7] = @intCast((len >> 16) & 0xFF); - header_buf[8] = @intCast((len >> 8) & 0xFF); - header_buf[9] = @intCast(len & 0xFF); - break :blk header_buf[0..10]; - }; - - const allocator = self.server.allocator; - const full = try allocator.alloc(u8, header.len + data.len); - errdefer allocator.free(full); - @memcpy(full[0..header.len], header); - @memcpy(full[header.len..], data); - try self.send(full, true); + try std.json.stringify(message, opts, buf.writer(allocator)); + const framed = fillWebsocketHeader(buf); + return self.sendAlloc(arena, framed); } - fn writeHTTPErrorResponse(self: *Self, comptime status: u16, comptime body: []const u8) void { - const response = std.fmt.comptimePrint( - "HTTP/1.1 {d} \r\nConnection: Close\r\nContent-Length: {d}\r\n\r\n{s}", - .{ status, body.len, body }, - ); + pub fn sendJSONRaw( + self: *Self, + arena: ArenaAllocator, + buf: std.ArrayListUnmanaged(u8), + ) !void { + // Dangerous API!. We assume the caller has reserved the first 10 + // bytes in `buf`. + const framed = fillWebsocketHeader(buf); + return self.sendAlloc(arena, framed); + } - // we're going to close this connection anyways, swallowing any - // error seems safe - self.send(response, false) catch {}; + fn send(self: *Self, data: []const u8) !void { + return self.server.queueSend(self.socket, null, data); } - fn send(self: *Self, data: []const u8, free_when_done: bool) !void { - return self.server.queueSend(self.socket, data, free_when_done); + fn sendAlloc(self: *Self, arena: ArenaAllocator, data: []const u8) !void { + return self.server.queueSend(self.socket, arena, data); } }; } @@ -853,19 +709,33 @@ const Reader = struct { return error.NotMasked; } + var is_control = false; var is_continuation = false; var message_type: Message.Type = undefined; switch (byte1 & 15) { 0 => is_continuation = true, 1 => message_type = .text, 2 => message_type = .binary, - 8 => message_type = .close, - 9 => message_type = .ping, - 10 => message_type = .pong, + 8 => { + is_control = true; + message_type = .close; + }, + 9 => { + is_control = true; + message_type = .ping; + }, + 10 => { + is_control = true; + message_type = .pong; + }, else => return error.InvalidMessageType, } - if (message_len > MAX_MESSAGE_SIZE) { + if (is_control) { + if (message_len > 125) { + return error.ControlTooLarge; + } + } else if (message_len > MAX_MESSAGE_SIZE) { return error.TooLarge; } @@ -1037,17 +907,75 @@ const OpCode = enum(u8) { pong = 128 | 10, }; -// "private-use" close codes must be from 4000-49999 const CloseCode = enum { timeout, }; +fn fillWebsocketHeader(buf: std.ArrayListUnmanaged(u8)) []const u8 { + // can't use buf[0..10] here, because the header length + // is variable. If it's just 2 bytes, for example, we need the + // framed message to be: + // h1, h2, data + // If we use buf[0..10], we'd get: + // h1, h2, 0, 0, 0, 0, 0, 0, 0, 0, data + + var header_buf: [10]u8 = undefined; + + // -10 because we reserved 10 bytes for the header above + const header = websocketHeader(&header_buf, .text, buf.items.len - 10); + const start = 10 - header.len; + + const message = buf.items; + @memcpy(message[start..10], header); + return message[start..]; +} + +// makes the assumption that our caller reserved the first +// 10 bytes for the header +fn websocketHeader(buf: []u8, op_code: OpCode, payload_len: usize) []const u8 { + std.debug.assert(buf.len == 10); + + const len = payload_len; + buf[0] = 128 | @intFromEnum(op_code); // fin | opcode + + if (len <= 125) { + buf[1] = @intCast(len); + return buf[0..2]; + } + + if (len < 65536) { + buf[1] = 126; + buf[2] = @intCast((len >> 8) & 0xFF); + buf[3] = @intCast(len & 0xFF); + return buf[0..4]; + } + + buf[1] = 127; + buf[2] = 0; + buf[3] = 0; + buf[4] = 0; + buf[5] = 0; + buf[6] = @intCast((len >> 24) & 0xFF); + buf[7] = @intCast((len >> 16) & 0xFF); + buf[8] = @intCast((len >> 8) & 0xFF); + buf[9] = @intCast(len & 0xFF); + return buf[0..10]; +} + pub fn run( allocator: Allocator, address: net.Address, timeout: u64, loop: *jsruntime.Loop, ) !void { + if (comptime builtin.is_test) { + // There's bunch of code that won't compiler in a test build (because + // it relies on a global root.Types). So we fight the compiler and make + // sure it doesn't include any of that code. Hopefully one day we can + // remove all this. + return; + } + // create socket const flags = posix.SOCK.STREAM | posix.SOCK.CLOEXEC | posix.SOCK.NONBLOCK; const listener = try posix.socket(address.any.family, flags, posix.IPPROTO.TCP); @@ -1069,17 +997,11 @@ pub fn run( const vm = jsruntime.VM.init(); defer vm.deinit(); - // browser - var browser: Browser = undefined; - try Browser.init(&browser, allocator, loop, vm); - defer browser.deinit(); - const json_version_response = try buildJSONVersionResponse(allocator, address); var server = Server{ .loop = loop, .timeout = timeout, - .browser = &browser, .listener = listener, .allocator = allocator, .conn_completion = undefined, @@ -1088,11 +1010,10 @@ pub fn run( .timeout_completion = undefined, .json_version_response = json_version_response, .send_pool = std.heap.MemoryPool(Send).init(allocator), + .client_pool = std.heap.MemoryPool(Client).init(allocator), }; defer server.deinit(); - try browser.session.initInspector(&server, Server.inspectorResponse, Server.inspectorEvent); - // accept an connection server.queueAccept(); @@ -1255,7 +1176,8 @@ test "Client: http valid handshake" { var ms = MockServer{}; defer ms.deinit(); - var client = Client(*MockServer).init(0, &ms); + var client = ClientT(*MockServer, MockCDP).init(0, &ms); + defer client.deinit(); const request = "GET / HTTP/1.1\r\n" ++ @@ -1282,7 +1204,8 @@ test "Client: http get json version" { var ms = MockServer{}; defer ms.deinit(); - var client = Client(*MockServer).init(0, &ms); + var client = ClientT(*MockServer, MockCDP).init(0, &ms); + defer client.deinit(); const request = "GET /json/version HTTP/1.1\r\n\r\n"; @@ -1296,20 +1219,21 @@ test "Client: http get json version" { } test "Client: write websocket message" { - var ms = MockServer{}; - defer ms.deinit(); - - var client = Client(*MockServer).init(0, &ms); - const cases = [_]struct { expected: []const u8, message: []const u8 }{ - .{ .expected = &.{ 129, 0 }, .message = "" }, - .{ .expected = [_]u8{ 129, 12 } ++ "hello world!", .message = "hello world!" }, - .{ .expected = [_]u8{ 129, 126, 0, 130 } ++ ("A" ** 130), .message = "A" ** 130 }, + .{ .expected = &.{ 129, 2, '"', '"' }, .message = "" }, + .{ .expected = [_]u8{ 129, 14 } ++ "\"hello world!\"", .message = "hello world!" }, + .{ .expected = [_]u8{ 129, 126, 0, 132 } ++ "\"" ++ ("A" ** 130) ++ "\"", .message = "A" ** 130 }, }; for (cases) |c| { - ms.sent.clearRetainingCapacity(); - try client.sendWS(try testing.allocator.dupe(u8, c.message)); + var ms = MockServer{}; + defer ms.deinit(); + + var client = ClientT(*MockServer, MockCDP).init(0, &ms); + defer client.deinit(); + + try client.sendJSON(c.message, .{}); + try testing.expectEqual(1, ms.sent.items.len); try testing.expectEqualSlices(u8, c.expected, ms.sent.items[0]); } @@ -1350,6 +1274,16 @@ test "Client: read invalid websocket message" { &.{ 129, 1, 'a' }, ); + // control types (ping/ping/close) can't be > 125 bytes + for ([_]u8{ 136, 137, 138 }) |op| { + try assertWebSocketError( + error.ControlTooLarge, + 1002, + "", + &.{ op, 254, 1, 1 }, + ); + } + // length of message is 0000 0401, i.e: 1024 * 256 + 1 try assertWebSocketError( error.TooLarge, @@ -1537,7 +1471,8 @@ test "Client: fuzz" { var ms = MockServer{}; defer ms.deinit(); - var client = Client(*MockServer).init(0, &ms); + var client = ClientT(*MockServer, MockCDP).init(0, &ms); + defer client.deinit(); try SendRandom.send(&client, random, "GET /json/version HTTP/1.1\r\nContent-Length: 0\r\n\r\n"); try SendRandom.send(&client, random, "GET / HTTP/1.1\r\n" ++ @@ -1579,23 +1514,24 @@ test "Client: fuzz" { ms.sent.items[4], ); - try testing.expectEqual(3, ms.cdp.items.len); + const received = client.cdp.?.messages.items; + try testing.expectEqual(3, received.len); try testing.expectEqualSlices( u8, &.{ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }, - ms.cdp.items[0], + received[0], ); try testing.expectEqualSlices( u8, &([_]u8{ 64, 67, 66, 69 } ** 171 ++ [_]u8{ 64, 67, 66 }), - ms.cdp.items[1], + received[1], ); try testing.expectEqualSlices( u8, &.{ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 }, - ms.cdp.items[2], + received[2], ); try testing.expectEqual(true, ms.closed); @@ -1619,7 +1555,7 @@ test "server: mask" { } fn assertHTTPError( - expected_error: HTTPError, + expected_error: anyerror, comptime expected_status: u16, comptime expected_body: []const u8, input: []const u8, @@ -1627,7 +1563,9 @@ fn assertHTTPError( var ms = MockServer{}; defer ms.deinit(); - var client = Client(*MockServer).init(0, &ms); + var client = ClientT(*MockServer, MockCDP).init(0, &ms); + defer client.deinit(); + @memcpy(client.reader.buf[0..input.len], input); try testing.expectError(expected_error, client.processData(input.len)); @@ -1641,7 +1579,7 @@ fn assertHTTPError( } fn assertWebSocketError( - expected_error: WebSocketError, + expected_error: anyerror, close_code: u16, close_payload: []const u8, input: []const u8, @@ -1649,7 +1587,9 @@ fn assertWebSocketError( var ms = MockServer{}; defer ms.deinit(); - var client = Client(*MockServer).init(0, &ms); + var client = ClientT(*MockServer, MockCDP).init(0, &ms); + defer client.deinit(); + client.mode = .websocket; // force websocket message processing @memcpy(client.reader.buf[0..input.len], input); @@ -1679,7 +1619,8 @@ fn assertWebSocketMessage( var ms = MockServer{}; defer ms.deinit(); - var client = Client(*MockServer).init(0, &ms); + var client = ClientT(*MockServer, MockCDP).init(0, &ms); + defer client.deinit(); client.mode = .websocket; // force websocket message processing @memcpy(client.reader.buf[0..input.len], input); @@ -1700,14 +1641,12 @@ fn assertWebSocketMessage( } const MockServer = struct { + loop: *jsruntime.Loop = undefined, closed: bool = false, // record the messages we sent to the client sent: std.ArrayListUnmanaged([]const u8) = .{}, - // record the CDP messages we need to process - cdp: std.ArrayListUnmanaged([]const u8) = .{}, - allocator: Allocator = testing.allocator, json_version_response: []const u8 = "the json version response", @@ -1719,33 +1658,48 @@ const MockServer = struct { allocator.free(msg); } self.sent.deinit(allocator); - - for (self.cdp.items) |msg| { - allocator.free(msg); - } - self.cdp.deinit(allocator); } fn queueClose(self: *MockServer, _: anytype) void { self.closed = true; } - fn handleCDP(self: *MockServer, message: []const u8) !void { - const owned = try self.allocator.dupe(u8, message); - try self.cdp.append(self.allocator, owned); - } - fn queueSend( self: *MockServer, socket: posix.socket_t, + arena: ?ArenaAllocator, data: []const u8, - free_when_done: bool, ) !void { _ = socket; const owned = try self.allocator.dupe(u8, data); try self.sent.append(self.allocator, owned); - if (free_when_done) { - testing.allocator.free(data); + if (arena) |a| { + a.deinit(); } } }; + +const MockCDP = struct { + messages: std.ArrayListUnmanaged([]const u8) = .{}, + + allocator: Allocator = testing.allocator, + + fn init(_: Allocator, client: anytype, loop: *jsruntime.Loop) MockCDP { + _ = loop; + _ = client; + return .{}; + } + + fn deinit(self: *MockCDP) void { + const allocator = self.allocator; + for (self.messages.items) |msg| { + allocator.free(msg); + } + self.messages.deinit(allocator); + } + + fn processMessage(self: *MockCDP, message: []const u8) void { + const owned = self.allocator.dupe(u8, message) catch unreachable; + self.messages.append(self.allocator, owned) catch unreachable; + } +}; diff --git a/src/unit_tests.zig b/src/unit_tests.zig index 2ab87f9a8..37d23e11e 100644 --- a/src/unit_tests.zig +++ b/src/unit_tests.zig @@ -334,14 +334,12 @@ test { std.testing.refAllDecls(@import("browser/dump.zig")); std.testing.refAllDecls(@import("browser/loader.zig")); std.testing.refAllDecls(@import("browser/mime.zig")); - std.testing.refAllDecls(@import("cdp/msg.zig")); std.testing.refAllDecls(@import("css/css.zig")); std.testing.refAllDecls(@import("css/libdom_test.zig")); std.testing.refAllDecls(@import("css/match_test.zig")); std.testing.refAllDecls(@import("css/parser.zig")); std.testing.refAllDecls(@import("generate.zig")); std.testing.refAllDecls(@import("http/Client.zig")); - std.testing.refAllDecls(@import("msg.zig")); std.testing.refAllDecls(@import("storage/storage.zig")); - std.testing.refAllDecls(@import("iterator/iterator.zig")); + std.testing.refAllDecls(@import("server.zig")); } From 1846d0bc21c42c62afb2497b50f6a05fcdea01e6 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 12 Feb 2025 18:32:33 +0800 Subject: [PATCH 09/15] drats, zig fmt again --- src/cdp/dom.zig | 11 ++++------- src/cdp/page.zig | 3 +-- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/cdp/dom.zig b/src/cdp/dom.zig index 349d02259..2bcacd416 100644 --- a/src/cdp/dom.zig +++ b/src/cdp/dom.zig @@ -37,7 +37,6 @@ pub fn processMessage(cmd: anytype) !void { .performSearch => return performSearch(cmd), .getSearchResults => return getSearchResults(cmd), .discardSearchResults => return discardSearchResults(cmd), - } } @@ -181,8 +180,8 @@ pub const NodeSearchList = std.ArrayList(NodeSearch); // https://chromedevtools.github.io/devtools-protocol/tot/DOM/#method-performSearch fn performSearch(cmd: anytype) !void { const params = (try cmd.params(struct { - query: []const u8, - includeUserAgentShadowDOM: ?bool = null, + query: []const u8, + includeUserAgentShadowDOM: ?bool = null, })) orelse return error.InvalidParams; // retrieve the root node @@ -210,7 +209,7 @@ fn performSearch(cmd: anytype) !void { // https://chromedevtools.github.io/devtools-protocol/tot/DOM/#method-discardSearchResults fn discardSearchResults(cmd: anytype) !void { const params = (try cmd.params(struct { - searchId: []const u8, + searchId: []const u8, })) orelse return error.InvalidParams; var state = cmd.cdp; @@ -256,7 +255,5 @@ fn getSearchResults(cmd: anytype) !void { if (params.fromIndex >= items.len) return error.BadFromIndex; if (params.toIndex > items.len) return error.BadToIndex; - return cmd.sendResult(.{ - .nodeIds = ns.?.coll.items[params.fromIndex..params.toIndex] - }, .{}); + return cmd.sendResult(.{ .nodeIds = ns.?.coll.items[params.fromIndex..params.toIndex] }, .{}); } diff --git a/src/cdp/page.zig b/src/cdp/page.zig index dbc3218e8..7770d8e8b 100644 --- a/src/cdp/page.zig +++ b/src/cdp/page.zig @@ -265,8 +265,7 @@ fn navigate(cmd: anytype) !void { try cmd.sendEvent("Page.lifecycleEvent", life_event, .{ .session_id = session_id }); } - - try cmd.sendEvent("DOM.documentUpdated", null, .{.session_id = session_id}); + try cmd.sendEvent("DOM.documentUpdated", null, .{ .session_id = session_id }); // frameNavigated event try cmd.sendEvent("Page.frameNavigated", .{ From b1c3de6518ac714164377e9a10a11fcfb47dcd4b Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 13 Feb 2025 17:32:01 +0800 Subject: [PATCH 10/15] zig fmt --- src/server.zig | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/server.zig b/src/server.zig index 12d1ecadb..0f27f6e27 100644 --- a/src/server.zig +++ b/src/server.zig @@ -47,7 +47,6 @@ const MAX_HTTP_REQUEST_SIZE = 2048; // +140 for the max control packet that might be interleaved in a message const MAX_MESSAGE_SIZE = 256 * 1024 + 14; - pub const Client = ClientT(*Server, CDP); const Server = struct { @@ -243,7 +242,7 @@ const Server = struct { // (with its own completion), allocated on the heap. // After the send (on the sendCbk) the dedicated context will be destroy // and the data slice will be free. -const Send = struct { // Any unsent data we have. +const Send = struct { // Any unsent data we have. unsent: []const u8, server: *Server, @@ -1222,7 +1221,6 @@ test "Client: write websocket message" { .{ .expected = &.{ 129, 2, '"', '"' }, .message = "" }, .{ .expected = [_]u8{ 129, 14 } ++ "\"hello world!\"", .message = "hello world!" }, .{ .expected = [_]u8{ 129, 126, 0, 132 } ++ "\"" ++ ("A" ** 130) ++ "\"", .message = "A" ** 130 }, - }; for (cases) |c| { From b60a91f53c7a4507466c54612e6367b9502486fe Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 17 Feb 2025 11:45:19 +0800 Subject: [PATCH 11/15] fix memory leak --- src/browser/browser.zig | 6 +++++- src/cdp/page.zig | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/browser/browser.zig b/src/browser/browser.zig index afb9d72ca..1ef78a607 100644 --- a/src/browser/browser.zig +++ b/src/browser/browser.zig @@ -178,7 +178,7 @@ pub const Session = struct { fn deinit(self: *Session) void { if (self.page) |*p| { - p.end(); + p.deinit(); } self.env.deinit(); @@ -214,6 +214,10 @@ pub const Session = struct { self.page = Page.init(self.allocator, self); return &self.page.?; } + + pub fn currentPage(self: *Session) ?*Page { + return &(self.page orelse return null); + } }; // Page navigates to an url. diff --git a/src/cdp/page.zig b/src/cdp/page.zig index 7770d8e8b..6ca9655dd 100644 --- a/src/cdp/page.zig +++ b/src/cdp/page.zig @@ -244,7 +244,7 @@ fn navigate(cmd: anytype) !void { // Launch navigate, the page must have been created by a // target.createTarget. - var p = cmd.session.page orelse return error.NoPage; + var p = cmd.session.currentPage() orelse return error.NoPage; state.execution_context_id += 1; const aux_data = try std.fmt.allocPrint( From c4eeef2a8606048d776091de7deb72e3a5c68ce8 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 17 Feb 2025 12:05:25 +0800 Subject: [PATCH 12/15] On CDP process error, let client decide how to close Fixes issue where CDP closes the client, but client still registers a recv operation. --- src/cdp/cdp.zig | 6 +++--- src/server.zig | 6 +++++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/cdp/cdp.zig b/src/cdp/cdp.zig index 61f245e30..cfd650290 100644 --- a/src/cdp/cdp.zig +++ b/src/cdp/cdp.zig @@ -114,15 +114,15 @@ pub const CDP = struct { self.session = try self.browser.newSession(self); } - pub fn processMessage(self: *CDP, msg: []const u8) void { + pub fn processMessage(self: *CDP, msg: []const u8) bool { const arena = &self.message_arena; defer _ = arena.reset(.{ .retain_with_limit = 1024 * 16 }); self.dispatch(arena.allocator(), self, msg) catch |err| { log.err("failed to process message: {}\n{s}", .{ err, msg }); - self.client.close(null); - return; + return false; }; + return true; } // Called from above, in processMessage which handles client messages diff --git a/src/server.zig b/src/server.zig index 0f27f6e27..5bf3139d9 100644 --- a/src/server.zig +++ b/src/server.zig @@ -555,6 +555,7 @@ fn ClientT(comptime S: type, comptime C: type) type { errdefer self.server.queueClose(self.socket); var reader = &self.reader; + while (true) { const msg = reader.next() catch |err| { switch (err) { @@ -578,7 +579,10 @@ fn ClientT(comptime S: type, comptime C: type) type { self.server.queueClose(self.socket); return false; }, - .text, .binary => self.cdp.?.processMessage(msg.data), + .text, .binary => if (self.cdp.?.processMessage(msg.data) == false) { + self.close(null); + return false; + }, } if (msg.cleanup_fragment) { reader.cleanup(); From 18080cef9f02f84d6ac730d2ed7ac21f81ab7985 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 17 Feb 2025 12:14:11 +0800 Subject: [PATCH 13/15] fix test --- src/server.zig | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/server.zig b/src/server.zig index 5bf3139d9..c3a072aa3 100644 --- a/src/server.zig +++ b/src/server.zig @@ -1699,8 +1699,9 @@ const MockCDP = struct { self.messages.deinit(allocator); } - fn processMessage(self: *MockCDP, message: []const u8) void { + fn processMessage(self: *MockCDP, message: []const u8) bool { const owned = self.allocator.dupe(u8, message) catch unreachable; self.messages.append(self.allocator, owned) catch unreachable; + return true; } }; From f508288ce393d44480f9d53f3efcc70e0ba39e92 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 17 Feb 2025 18:43:41 +0800 Subject: [PATCH 14/15] Fix segfault when multiple inflight Send completions fail --- src/server.zig | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/server.zig b/src/server.zig index c3a072aa3..efa1a0bed 100644 --- a/src/server.zig +++ b/src/server.zig @@ -223,14 +223,14 @@ const Server = struct { &self.close_completion, socket, ); - } - - fn callbackClose(self: *Server, completion: *Completion, _: CloseError!void) void { - std.debug.assert(completion == &self.close_completion); var client = self.client.?; client.deinit(); self.client_pool.destroy(client); self.client = null; + } + + fn callbackClose(self: *Server, completion: *Completion, _: CloseError!void) void { + std.debug.assert(completion == &self.close_completion); self.queueAccept(); } }; From bc4560877a397b0457af15d5aadebd20850c165d Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 20 Feb 2025 22:08:56 +0800 Subject: [PATCH 15/15] zig fmt --- src/server.zig | 1 - 1 file changed, 1 deletion(-) diff --git a/src/server.zig b/src/server.zig index 1bdd219c9..f6af58fb4 100644 --- a/src/server.zig +++ b/src/server.zig @@ -1722,7 +1722,6 @@ const MockServer = struct { } }; - const MockCDP = struct { messages: std.ArrayListUnmanaged([]const u8) = .{},