diff --git a/.github/actions/install/action.yml b/.github/actions/install/action.yml index 6c98f2e5..d9d7b803 100644 --- a/.github/actions/install/action.yml +++ b/.github/actions/install/action.yml @@ -13,7 +13,7 @@ inputs: zig-v8: description: 'zig v8 version to install' required: false - default: 'v0.3.1' + default: 'v0.3.2' v8: description: 'v8 version to install' required: false diff --git a/Dockerfile b/Dockerfile index 8729f992..e30fbdbe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ FROM debian:stable-slim ARG MINISIG=0.12 ARG ZIG_MINISIG=RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U ARG V8=14.0.365.4 -ARG ZIG_V8=v0.3.1 +ARG ZIG_V8=v0.3.2 ARG TARGETPLATFORM RUN apt-get update -yq && \ diff --git a/build.zig b/build.zig index d4eabcea..1f9ae5ef 100644 --- a/build.zig +++ b/build.zig @@ -64,7 +64,7 @@ pub fn build(b: *Build) !void { b.default_step.dependOn(fmt_step); try linkV8(b, mod, enable_asan, enable_tsan, prebuilt_v8_path); - try linkCurl(b, mod); + try linkCurl(b, mod, enable_tsan); try linkHtml5Ever(b, mod); break :blk mod; @@ -200,19 +200,19 @@ fn linkHtml5Ever(b: *Build, mod: *Build.Module) !void { mod.addObjectFile(obj); } -fn linkCurl(b: *Build, mod: *Build.Module) !void { +fn linkCurl(b: *Build, mod: *Build.Module, is_tsan: bool) !void { const target = mod.resolved_target.?; - const curl = buildCurl(b, target, mod.optimize.?); + const curl = buildCurl(b, target, mod.optimize.?, is_tsan); mod.linkLibrary(curl); - const zlib = buildZlib(b, target, mod.optimize.?); + const zlib = buildZlib(b, target, mod.optimize.?, is_tsan); curl.root_module.linkLibrary(zlib); - const brotli = buildBrotli(b, target, mod.optimize.?); + const brotli = buildBrotli(b, target, mod.optimize.?, is_tsan); for (brotli) |lib| curl.root_module.linkLibrary(lib); - const nghttp2 = buildNghttp2(b, target, mod.optimize.?); + const nghttp2 = buildNghttp2(b, target, mod.optimize.?, is_tsan); curl.root_module.linkLibrary(nghttp2); const boringssl = buildBoringSsl(b, target, mod.optimize.?); @@ -229,13 +229,14 @@ fn linkCurl(b: *Build, mod: *Build.Module) !void { } } -fn buildZlib(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.OptimizeMode) *Build.Step.Compile { +fn buildZlib(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, is_tsan: bool) *Build.Step.Compile { const dep = b.dependency("zlib", .{}); const mod = b.createModule(.{ .target = target, .optimize = optimize, .link_libc = true, + .sanitize_thread = is_tsan, }); const lib = b.addLibrary(.{ .name = "z", .root_module = mod }); @@ -260,13 +261,14 @@ fn buildZlib(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.Opti return lib; } -fn buildBrotli(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.OptimizeMode) [3]*Build.Step.Compile { +fn buildBrotli(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, is_tsan: bool) [3]*Build.Step.Compile { const dep = b.dependency("brotli", .{}); const mod = b.createModule(.{ .target = target, .optimize = optimize, .link_libc = true, + .sanitize_thread = is_tsan, }); mod.addIncludePath(dep.path("c/include")); @@ -322,13 +324,14 @@ fn buildBoringSsl(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin return .{ ssl, crypto }; } -fn buildNghttp2(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.OptimizeMode) *Build.Step.Compile { +fn buildNghttp2(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, is_tsan: bool) *Build.Step.Compile { const dep = b.dependency("nghttp2", .{}); const mod = b.createModule(.{ .target = target, .optimize = optimize, .link_libc = true, + .sanitize_thread = is_tsan, }); mod.addIncludePath(dep.path("lib/includes")); @@ -373,6 +376,7 @@ fn buildCurl( b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, + is_tsan: bool, ) *Build.Step.Compile { const dep = b.dependency("curl", .{}); @@ -380,6 +384,7 @@ fn buildCurl( .target = target, .optimize = optimize, .link_libc = true, + .sanitize_thread = is_tsan, }); mod.addIncludePath(dep.path("lib")); mod.addIncludePath(dep.path("include")); diff --git a/build.zig.zon b/build.zig.zon index eb3812a8..b7f9cf3b 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -5,10 +5,10 @@ .minimum_zig_version = "0.15.2", .dependencies = .{ .v8 = .{ - .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.3.1.tar.gz", - .hash = "v8-0.0.0-xddH64J7BAC81mkf6G9RbEJxS-W3TIRl5iFnShwbqCqy", + .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.3.2.tar.gz", + .hash = "v8-0.0.0-xddH6wx-BABNgL7YIDgbnFgKZuXZ68yZNngNSrV6OjrY", }, - //.v8 = .{ .path = "../zig-v8-fork" }, + // .v8 = .{ .path = "../zig-v8-fork" }, .brotli = .{ // v1.2.0 .url = "https://github.com/google/brotli/archive/028fb5a23661f123017c060daa546b55cf4bde29.tar.gz", diff --git a/src/App.zig b/src/App.zig index 2d930fd6..9039cec5 100644 --- a/src/App.zig +++ b/src/App.zig @@ -25,23 +25,20 @@ const Config = @import("Config.zig"); const Snapshot = @import("browser/js/Snapshot.zig"); const Platform = @import("browser/js/Platform.zig"); const Telemetry = @import("telemetry/telemetry.zig").Telemetry; -const RobotStore = @import("browser/Robots.zig").RobotStore; -pub const Http = @import("http/Http.zig"); +const Network = @import("network/Runtime.zig"); pub const ArenaPool = @import("ArenaPool.zig"); const App = @This(); -http: Http, +network: Network, config: *const Config, platform: Platform, snapshot: Snapshot, telemetry: Telemetry, allocator: Allocator, arena_pool: ArenaPool, -robots: RobotStore, app_dir_path: ?[]const u8, -shutdown: bool = false, pub fn init(allocator: Allocator, config: *const Config) !*App { const app = try allocator.create(App); @@ -50,8 +47,7 @@ pub fn init(allocator: Allocator, config: *const Config) !*App { app.* = .{ .config = config, .allocator = allocator, - .robots = RobotStore.init(allocator), - .http = undefined, + .network = undefined, .platform = undefined, .snapshot = undefined, .app_dir_path = undefined, @@ -59,8 +55,8 @@ pub fn init(allocator: Allocator, config: *const Config) !*App { .arena_pool = undefined, }; - app.http = try Http.init(allocator, &app.robots, config); - errdefer app.http.deinit(); + app.network = try Network.init(allocator, config); + errdefer app.network.deinit(); app.platform = try Platform.init(); errdefer app.platform.deinit(); @@ -79,19 +75,18 @@ pub fn init(allocator: Allocator, config: *const Config) !*App { return app; } -pub fn deinit(self: *App) void { - if (@atomicRmw(bool, &self.shutdown, .Xchg, true, .monotonic)) { - return; - } +pub fn shutdown(self: *const App) bool { + return self.network.shutdown.load(.acquire); +} +pub fn deinit(self: *App) void { const allocator = self.allocator; if (self.app_dir_path) |app_dir_path| { allocator.free(app_dir_path); self.app_dir_path = null; } self.telemetry.deinit(); - self.robots.deinit(); - self.http.deinit(); + self.network.deinit(); self.snapshot.deinit(); self.platform.deinit(); self.arena_pool.deinit(); diff --git a/src/Config.zig b/src/Config.zig index f93c0efa..73c7f3a7 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -31,6 +31,7 @@ pub const RunMode = enum { mcp, }; +pub const MAX_LISTENERS = 16; pub const CDP_MAX_HTTP_REQUEST_SIZE = 4096; // max message size @@ -153,6 +154,13 @@ pub fn userAgentSuffix(self: *const Config) ?[]const u8 { }; } +pub fn cdpTimeout(self: *const Config) usize { + return switch (self.mode) { + .serve => |opts| if (opts.timeout > 604_800) 604_800_000 else @as(usize, opts.timeout) * 1000, + else => unreachable, + }; +} + pub fn maxConnections(self: *const Config) u16 { return switch (self.mode) { .serve => |opts| opts.cdp_max_connections, diff --git a/src/Notification.zig b/src/Notification.zig index 186cc04e..e025820a 100644 --- a/src/Notification.zig +++ b/src/Notification.zig @@ -21,7 +21,7 @@ const lp = @import("lightpanda"); const log = @import("log.zig"); const Page = @import("browser/Page.zig"); -const Transfer = @import("http/Client.zig").Transfer; +const Transfer = @import("browser/HttpClient.zig").Transfer; const Allocator = std.mem.Allocator; diff --git a/src/Server.zig b/src/Server.zig index bd990560..23ddefb5 100644 --- a/src/Server.zig +++ b/src/Server.zig @@ -18,8 +18,6 @@ const std = @import("std"); const lp = @import("lightpanda"); -const builtin = @import("builtin"); - const net = std.net; const posix = std.posix; @@ -30,16 +28,13 @@ const log = @import("log.zig"); const App = @import("App.zig"); const Config = @import("Config.zig"); const CDP = @import("cdp/cdp.zig").CDP; -const Net = @import("Net.zig"); -const Http = @import("http/Http.zig"); -const HttpClient = @import("http/Client.zig"); +const Net = @import("network/websocket.zig"); +const HttpClient = @import("browser/HttpClient.zig"); const Server = @This(); app: *App, -shutdown: std.atomic.Value(bool) = .init(false), allocator: Allocator, -listener: ?posix.socket_t, json_version_response: []const u8, // Thread management @@ -48,103 +43,52 @@ clients: std.ArrayList(*Client) = .{}, client_mutex: std.Thread.Mutex = .{}, clients_pool: std.heap.MemoryPool(Client), -pub fn init(app: *App, address: net.Address) !Server { +pub fn init(app: *App, address: net.Address) !*Server { const allocator = app.allocator; const json_version_response = try buildJSONVersionResponse(allocator, address); errdefer allocator.free(json_version_response); - return .{ + const self = try allocator.create(Server); + errdefer allocator.destroy(self); + + self.* = .{ .app = app, - .listener = null, .allocator = allocator, .json_version_response = json_version_response, - .clients_pool = std.heap.MemoryPool(Client).init(app.allocator), + .clients_pool = std.heap.MemoryPool(Client).init(allocator), }; + + try self.app.network.bind(address, self, onAccept); + log.info(.app, "server running", .{ .address = address }); + + return self; } -/// Interrupts the server so that main can complete normally and call all defer handlers. -pub fn stop(self: *Server) void { - if (self.shutdown.swap(true, .release)) { - return; - } - - // Shutdown all active clients +pub fn deinit(self: *Server) void { + // Stop all active clients { self.client_mutex.lock(); defer self.client_mutex.unlock(); + for (self.clients.items) |client| { client.stop(); } } - // Linux and BSD/macOS handle canceling a socket blocked on accept differently. - // For Linux, we use std.shutdown, which will cause accept to return error.SocketNotListening (EINVAL). - // For BSD, shutdown will return an error. Instead we call posix.close, which will result with error.ConnectionAborted (BADF). - if (self.listener) |listener| switch (builtin.target.os.tag) { - .linux => posix.shutdown(listener, .recv) catch |err| { - log.warn(.app, "listener shutdown", .{ .err = err }); - }, - .macos, .freebsd, .netbsd, .openbsd => { - self.listener = null; - posix.close(listener); - }, - else => unreachable, - }; -} - -pub fn deinit(self: *Server) void { - if (!self.shutdown.load(.acquire)) { - self.stop(); - } - self.joinThreads(); - if (self.listener) |listener| { - posix.close(listener); - self.listener = null; - } self.clients.deinit(self.allocator); self.clients_pool.deinit(); self.allocator.free(self.json_version_response); + self.allocator.destroy(self); } -pub fn run(self: *Server, address: net.Address, timeout_ms: u32) !void { - const flags = posix.SOCK.STREAM | posix.SOCK.CLOEXEC | posix.SOCK.NONBLOCK; - const listener = try posix.socket(address.any.family, flags, posix.IPPROTO.TCP); - self.listener = listener; - - try posix.setsockopt(listener, posix.SOL.SOCKET, posix.SO.REUSEADDR, &std.mem.toBytes(@as(c_int, 1))); - if (@hasDecl(posix.TCP, "NODELAY")) { - try posix.setsockopt(listener, posix.IPPROTO.TCP, posix.TCP.NODELAY, &std.mem.toBytes(@as(c_int, 1))); - } - - try posix.bind(listener, &address.any, address.getOsSockLen()); - try posix.listen(listener, self.app.config.maxPendingConnections()); - - log.info(.app, "server running", .{ .address = address }); - while (!self.shutdown.load(.acquire)) { - const socket = posix.accept(listener, null, null, posix.SOCK.NONBLOCK) catch |err| { - switch (err) { - error.SocketNotListening, error.ConnectionAborted => { - log.info(.app, "server stopped", .{}); - break; - }, - error.WouldBlock => { - std.Thread.sleep(10 * std.time.ns_per_ms); - continue; - }, - else => { - log.err(.app, "CDP accept", .{ .err = err }); - std.Thread.sleep(std.time.ns_per_s); - continue; - }, - } - }; - - self.spawnWorker(socket, timeout_ms) catch |err| { - log.err(.app, "CDP spawn", .{ .err = err }); - posix.close(socket); - }; - } +fn onAccept(ctx: *anyopaque, socket: posix.socket_t) void { + const self: *Server = @ptrCast(@alignCast(ctx)); + const timeout_ms: u32 = @intCast(self.app.config.cdpTimeout()); + self.spawnWorker(socket, timeout_ms) catch |err| { + log.err(.app, "CDP spawn", .{ .err = err }); + posix.close(socket); + }; } fn handleConnection(self: *Server, socket: posix.socket_t, timeout_ms: u32) void { @@ -173,10 +117,10 @@ fn handleConnection(self: *Server, socket: posix.socket_t, timeout_ms: u32) void self.registerClient(client); defer self.unregisterClient(client); - // Check shutdown after registering to avoid missing stop() signal. - // If stop() already iterated over clients, this client won't receive stop() + // Check shutdown after registering to avoid missing the stop signal. + // If deinit() already iterated over clients, this client won't receive stop() // and would block joinThreads() indefinitely. - if (self.shutdown.load(.acquire)) { + if (self.app.shutdown()) { return; } @@ -213,7 +157,7 @@ fn unregisterClient(self: *Server, client: *Client) void { } fn spawnWorker(self: *Server, socket: posix.socket_t, timeout_ms: u32) !void { - if (self.shutdown.load(.acquire)) { + if (self.app.shutdown()) { return error.ShuttingDown; } @@ -283,7 +227,7 @@ pub const Client = struct { log.info(.app, "client connected", .{ .ip = client_address }); } - const http = try app.http.createClient(allocator); + const http = try HttpClient.init(allocator, &app.network); errdefer http.deinit(); return .{ diff --git a/src/browser/Browser.zig b/src/browser/Browser.zig index 503306d3..8f8c4aa2 100644 --- a/src/browser/Browser.zig +++ b/src/browser/Browser.zig @@ -24,7 +24,7 @@ const ArenaAllocator = std.heap.ArenaAllocator; const js = @import("js/js.zig"); const log = @import("../log.zig"); const App = @import("../App.zig"); -const HttpClient = @import("../http/Client.zig"); +const HttpClient = @import("HttpClient.zig"); const ArenaPool = App.ArenaPool; diff --git a/src/browser/EventManager.zig b/src/browser/EventManager.zig index 573aa4f9..5588b704 100644 --- a/src/browser/EventManager.zig +++ b/src/browser/EventManager.zig @@ -205,7 +205,7 @@ pub fn dispatch(self: *EventManager, target: *EventTarget, event: *Event) Dispat pub fn dispatchOpts(self: *EventManager, target: *EventTarget, event: *Event, comptime opts: DispatchOpts) DispatchError!void { event.acquireRef(); - defer event.deinit(false, self.page); + defer event.deinit(false, self.page._session); if (comptime IS_DEBUG) { log.debug(.event, "eventManager.dispatch", .{ .type = event._type_string.str(), .bubbles = event._bubbles }); @@ -234,7 +234,7 @@ pub fn dispatchDirect(self: *EventManager, target: *EventTarget, event: *Event, const page = self.page; event.acquireRef(); - defer event.deinit(false, page); + defer event.deinit(false, page._session); if (comptime IS_DEBUG) { log.debug(.event, "dispatchDirect", .{ .type = event._type_string, .context = opts.context }); diff --git a/src/browser/Factory.zig b/src/browser/Factory.zig index cbc2170d..edb6baee 100644 --- a/src/browser/Factory.zig +++ b/src/browser/Factory.zig @@ -48,13 +48,11 @@ const Factory = @This(); _arena: Allocator, _slab: SlabAllocator, -pub fn init(arena: Allocator) !*Factory { - const self = try arena.create(Factory); - self.* = .{ +pub fn init(arena: Allocator) Factory { + return .{ ._arena = arena, ._slab = SlabAllocator.init(arena, 128), }; - return self; } // this is a root object @@ -249,16 +247,15 @@ fn eventInit(arena: Allocator, typ: String, value: anytype) !Event { }; } -pub fn blob(self: *Factory, child: anytype) !*@TypeOf(child) { - const allocator = self._slab.allocator(); - +pub fn blob(_: *const Factory, arena: Allocator, child: anytype) !*@TypeOf(child) { // Special case: Blob has slice and mime fields, so we need manual setup const chain = try PrototypeChain( &.{ Blob, @TypeOf(child) }, - ).allocate(allocator); + ).allocate(arena); const blob_ptr = chain.get(0); blob_ptr.* = .{ + ._arena = arena, ._type = unionInit(Blob.Type, chain.get(1)), ._slice = "", ._mime = "", @@ -273,14 +270,16 @@ pub fn abstractRange(self: *Factory, child: anytype, page: *Page) !*@TypeOf(chil const chain = try PrototypeChain(&.{ AbstractRange, @TypeOf(child) }).allocate(allocator); const doc = page.document.asNode(); - chain.set(0, AbstractRange{ + const abstract_range = chain.get(0); + abstract_range.* = AbstractRange{ ._type = unionInit(AbstractRange.Type, chain.get(1)), ._end_offset = 0, ._start_offset = 0, ._end_container = doc, ._start_container = doc, - }); + }; chain.setLeaf(1, child); + page._live_ranges.append(&abstract_range._range_link); return chain.get(1); } diff --git a/src/http/Client.zig b/src/browser/HttpClient.zig similarity index 97% rename from src/http/Client.zig rename to src/browser/HttpClient.zig index 5701de27..98292efc 100644 --- a/src/http/Client.zig +++ b/src/browser/HttpClient.zig @@ -17,28 +17,29 @@ // along with this program. If not, see . const std = @import("std"); -const lp = @import("lightpanda"); - -const log = @import("../log.zig"); const builtin = @import("builtin"); +const posix = std.posix; -const Net = @import("../Net.zig"); +const lp = @import("lightpanda"); +const log = @import("../log.zig"); +const Net = @import("../network/http.zig"); +const Network = @import("../network/Runtime.zig"); const Config = @import("../Config.zig"); const URL = @import("../browser/URL.zig"); const Notification = @import("../Notification.zig"); const CookieJar = @import("../browser/webapi/storage/Cookie.zig").Jar; -const Robots = @import("../browser/Robots.zig"); +const Robots = @import("../network/Robots.zig"); const RobotStore = Robots.RobotStore; -const posix = std.posix; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const IS_DEBUG = builtin.mode == .Debug; -const Method = Net.Method; -const ResponseHead = Net.ResponseHead; -const HeaderIterator = Net.HeaderIterator; +pub const Method = Net.Method; +pub const Headers = Net.Headers; +pub const ResponseHead = Net.ResponseHead; +pub const HeaderIterator = Net.HeaderIterator; // This is loosely tied to a browser Page. Loading all the , doing // XHR requests, and loading imports all happens through here. Sine the app @@ -77,8 +78,7 @@ queue: TransferQueue, // The main app allocator allocator: Allocator, -// Reference to the App-owned Robot Store. -robot_store: *RobotStore, +network: *Network, // Queue of requests that depend on a robots.txt. // Allows us to fetch the robots.txt just once. pending_robots_queue: std.StringHashMapUnmanaged(std.ArrayList(Request)) = .empty, @@ -97,8 +97,6 @@ http_proxy: ?[:0]const u8 = null, // CDP. use_proxy: bool, -config: *const Config, - cdp_client: ?CDPClient = null, // libcurl can monitor arbitrary sockets, this lets us use libcurl to poll @@ -121,14 +119,14 @@ pub const CDPClient = struct { const TransferQueue = std.DoublyLinkedList; -pub fn init(allocator: Allocator, ca_blob: ?Net.Blob, robot_store: *RobotStore, config: *const Config) !*Client { +pub fn init(allocator: Allocator, network: *Network) !*Client { var transfer_pool = std.heap.MemoryPool(Transfer).init(allocator); errdefer transfer_pool.deinit(); const client = try allocator.create(Client); errdefer allocator.destroy(client); - var handles = try Net.Handles.init(allocator, ca_blob, config); + var handles = try Net.Handles.init(allocator, network.ca_blob, network.config); errdefer handles.deinit(allocator); // Set transfer callbacks on each connection. @@ -136,7 +134,7 @@ pub fn init(allocator: Allocator, ca_blob: ?Net.Blob, robot_store: *RobotStore, try conn.setCallbacks(Transfer.headerCallback, Transfer.dataCallback); } - const http_proxy = config.httpProxy(); + const http_proxy = network.config.httpProxy(); client.* = .{ .queue = .{}, @@ -144,10 +142,9 @@ pub fn init(allocator: Allocator, ca_blob: ?Net.Blob, robot_store: *RobotStore, .intercepted = 0, .handles = handles, .allocator = allocator, - .robot_store = robot_store, + .network = network, .http_proxy = http_proxy, .use_proxy = http_proxy != null, - .config = config, .transfer_pool = transfer_pool, }; @@ -170,7 +167,7 @@ pub fn deinit(self: *Client) void { } pub fn newHeaders(self: *const Client) !Net.Headers { - return Net.Headers.init(self.config.http_headers.user_agent_header); + return Net.Headers.init(self.network.config.http_headers.user_agent_header); } pub fn abort(self: *Client) void { @@ -255,12 +252,12 @@ pub fn tick(self: *Client, timeout_ms: u32) !PerformStatus { } pub fn request(self: *Client, req: Request) !void { - if (self.config.obeyRobots()) { + if (self.network.config.obeyRobots()) { const robots_url = try URL.getRobotsUrl(self.allocator, req.url); errdefer self.allocator.free(robots_url); // If we have this robots cached, we can take a fast path. - if (self.robot_store.get(robots_url)) |robot_entry| { + if (self.network.robot_store.get(robots_url)) |robot_entry| { defer self.allocator.free(robots_url); switch (robot_entry) { @@ -401,18 +398,18 @@ fn robotsDoneCallback(ctx_ptr: *anyopaque) !void { switch (ctx.status) { 200 => { if (ctx.buffer.items.len > 0) { - const robots: ?Robots = ctx.client.robot_store.robotsFromBytes( - ctx.client.config.http_headers.user_agent, + const robots: ?Robots = ctx.client.network.robot_store.robotsFromBytes( + ctx.client.network.config.http_headers.user_agent, ctx.buffer.items, ) catch blk: { log.warn(.browser, "failed to parse robots", .{ .robots_url = ctx.robots_url }); // If we fail to parse, we just insert it as absent and ignore. - try ctx.client.robot_store.putAbsent(ctx.robots_url); + try ctx.client.network.robot_store.putAbsent(ctx.robots_url); break :blk null; }; if (robots) |r| { - try ctx.client.robot_store.put(ctx.robots_url, r); + try ctx.client.network.robot_store.put(ctx.robots_url, r); const path = URL.getPathname(ctx.req.url); allowed = r.isAllowed(path); } @@ -421,12 +418,12 @@ fn robotsDoneCallback(ctx_ptr: *anyopaque) !void { 404 => { log.debug(.http, "robots not found", .{ .url = ctx.robots_url }); // If we get a 404, we just insert it as absent. - try ctx.client.robot_store.putAbsent(ctx.robots_url); + try ctx.client.network.robot_store.putAbsent(ctx.robots_url); }, else => { log.debug(.http, "unexpected status on robots", .{ .url = ctx.robots_url, .status = ctx.status }); // If we get an unexpected status, we just insert as absent. - try ctx.client.robot_store.putAbsent(ctx.robots_url); + try ctx.client.network.robot_store.putAbsent(ctx.robots_url); }, } @@ -609,7 +606,7 @@ fn makeTransfer(self: *Client, req: Request) !*Transfer { .req = req, .ctx = req.ctx, .client = self, - .max_response_size = self.config.httpMaxResponseSize(), + .max_response_size = self.network.config.httpMaxResponseSize(), }; return transfer; } @@ -706,7 +703,7 @@ fn makeRequest(self: *Client, conn: *Net.Connection, transfer: *Transfer) anyerr } var header_list = req.headers; - try conn.secretHeaders(&header_list, &self.config.http_headers); // Add headers that must be hidden from intercepts + try conn.secretHeaders(&header_list, &self.network.config.http_headers); // Add headers that must be hidden from intercepts try conn.setHeaders(&header_list); // Add cookies. diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 014ebb62..889e0d3c 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -54,6 +54,7 @@ const Performance = @import("webapi/Performance.zig"); const Screen = @import("webapi/Screen.zig"); const VisualViewport = @import("webapi/VisualViewport.zig"); const PerformanceObserver = @import("webapi/PerformanceObserver.zig"); +const AbstractRange = @import("webapi/AbstractRange.zig"); const MutationObserver = @import("webapi/MutationObserver.zig"); const IntersectionObserver = @import("webapi/IntersectionObserver.zig"); const CustomElementDefinition = @import("webapi/CustomElementDefinition.zig"); @@ -62,8 +63,7 @@ const PageTransitionEvent = @import("webapi/event/PageTransitionEvent.zig"); const NavigationKind = @import("webapi/navigation/root.zig").NavigationKind; const KeyboardEvent = @import("webapi/event/KeyboardEvent.zig"); -const Http = App.Http; -const Net = @import("../Net.zig"); +const HttpClient = @import("HttpClient.zig"); const ArenaPool = App.ArenaPool; const timestamp = @import("../datetime.zig").timestamp; @@ -143,6 +143,9 @@ _to_load: std.ArrayList(*Element.Html) = .{}, _script_manager: ScriptManager, +// List of active live ranges (for mutation updates per DOM spec) +_live_ranges: std.DoublyLinkedList = .{}, + // List of active MutationObservers _mutation_observers: std.DoublyLinkedList = .{}, _mutation_delivery_scheduled: bool = false, @@ -191,6 +194,8 @@ _queued_navigation: ?*QueuedNavigation = null, // The URL of the current page url: [:0]const u8 = "about:blank", +origin: ?[]const u8 = null, + // The base url specifies the base URL used to resolve the relative urls. // It is set by a tag. // If null the url must be used. @@ -213,14 +218,6 @@ arena: Allocator, // from JS. Best arena to use, when possible. call_arena: Allocator, -arena_pool: *ArenaPool, -// In Debug, we use this to see if anything fails to release an arena back to -// the pool. -_arena_pool_leak_track: (if (IS_DEBUG) std.AutoHashMapUnmanaged(usize, struct { - owner: []const u8, - count: usize, -}) else void) = if (IS_DEBUG) .empty else {}, - parent: ?*Page, window: *Window, document: *Document, @@ -247,17 +244,11 @@ pub fn init(self: *Page, frame_id: u32, session: *Session, parent: ?*Page) !void if (comptime IS_DEBUG) { log.debug(.page, "page.init", .{}); } - const browser = session.browser; - const arena_pool = browser.arena_pool; - const page_arena = if (parent) |p| p.arena else try arena_pool.acquire(); - errdefer if (parent == null) arena_pool.release(page_arena); - - var factory = if (parent) |p| p._factory else try Factory.init(page_arena); - - const call_arena = try arena_pool.acquire(); - errdefer arena_pool.release(call_arena); + const call_arena = try session.getArena(.{ .debug = "call_arena" }); + errdefer session.releaseArena(call_arena); + const factory = &session.factory; const document = (try factory.document(Node.Document.HTMLDocument{ ._proto = undefined, })).asDocument(); @@ -265,10 +256,9 @@ pub fn init(self: *Page, frame_id: u32, session: *Session, parent: ?*Page) !void self.* = .{ .js = undefined, .parent = parent, - .arena = page_arena, + .arena = session.page_arena, .document = document, .window = undefined, - .arena_pool = arena_pool, .call_arena = call_arena, ._frame_id = frame_id, ._session = session, @@ -276,7 +266,7 @@ pub fn init(self: *Page, frame_id: u32, session: *Session, parent: ?*Page) !void ._pending_loads = 1, // always 1 for the ScriptManager ._type = if (parent == null) .root else .frame, ._script_manager = undefined, - ._event_manager = EventManager.init(page_arena, self), + ._event_manager = EventManager.init(session.page_arena, self), }; var screen: *Screen = undefined; @@ -304,6 +294,7 @@ pub fn init(self: *Page, frame_id: u32, session: *Session, parent: ?*Page) !void ._visual_viewport = visual_viewport, }); + const browser = session.browser; self._script_manager = ScriptManager.init(browser.allocator, browser.http_client, self); errdefer self._script_manager.deinit(); @@ -339,11 +330,12 @@ pub fn deinit(self: *Page, abort_http: bool) void { // stats.print(&stream) catch unreachable; } + const session = self._session; + if (self._queued_navigation) |qn| { - self.arena_pool.release(qn.arena); + session.releaseArena(qn.arena); } - const session = self._session; session.browser.env.destroyContext(self.js); self._script_manager.shutdown = true; @@ -359,23 +351,7 @@ pub fn deinit(self: *Page, abort_http: bool) void { self._script_manager.deinit(); - if (comptime IS_DEBUG) { - var it = self._arena_pool_leak_track.valueIterator(); - while (it.next()) |value_ptr| { - if (value_ptr.count > 0) { - log.err(.bug, "ArenaPool Leak", .{ .owner = value_ptr.owner, .type = self._type, .url = self.url }); - if (comptime builtin.is_test) { - @panic("ArenaPool Leak"); - } - } - } - } - - self.arena_pool.release(self.call_arena); - - if (self.parent == null) { - self.arena_pool.release(self.arena); - } + session.releaseArena(self.call_arena); } pub fn base(self: *const Page) [:0]const u8 { @@ -389,14 +365,10 @@ pub fn getTitle(self: *Page) !?[]const u8 { return null; } -pub fn getOrigin(self: *Page, allocator: Allocator) !?[]const u8 { - return try URL.getOrigin(allocator, self.url); -} - // Add comon headers for a request: // * cookies // * referer -pub fn headersForRequest(self: *Page, temp: Allocator, url: [:0]const u8, headers: *Http.Headers) !void { +pub fn headersForRequest(self: *Page, temp: Allocator, url: [:0]const u8, headers: *HttpClient.Headers) !void { try self.requestCookie(.{}).headersForRequest(temp, url, headers); // Build the referer @@ -419,38 +391,16 @@ pub fn headersForRequest(self: *Page, temp: Allocator, url: [:0]const u8, header } } -const GetArenaOpts = struct { - debug: []const u8, -}; -pub fn getArena(self: *Page, comptime opts: GetArenaOpts) !Allocator { - const allocator = try self.arena_pool.acquire(); - if (comptime IS_DEBUG) { - const gop = try self._arena_pool_leak_track.getOrPut(self.arena, @intFromPtr(allocator.ptr)); - if (gop.found_existing) { - std.debug.assert(gop.value_ptr.count == 0); - } - gop.value_ptr.* = .{ .owner = opts.debug, .count = 1 }; - } - return allocator; +pub fn getArena(self: *Page, comptime opts: Session.GetArenaOpts) !Allocator { + return self._session.getArena(opts); } pub fn releaseArena(self: *Page, allocator: Allocator) void { - if (comptime IS_DEBUG) { - const found = self._arena_pool_leak_track.getPtr(@intFromPtr(allocator.ptr)).?; - if (found.count != 1) { - log.err(.bug, "ArenaPool Double Free", .{ .owner = found.owner, .count = found.count, .type = self._type, .url = self.url }); - if (comptime builtin.is_test) { - @panic("ArenaPool Double Free"); - } - return; - } - found.count = 0; - } - return self.arena_pool.release(allocator); + return self._session.releaseArena(allocator); } pub fn isSameOrigin(self: *const Page, url: [:0]const u8) !bool { - const current_origin = (try URL.getOrigin(self.call_arena, self.url)) orelse return false; + const current_origin = self.origin orelse return false; return std.mem.startsWith(u8, url, current_origin); } @@ -473,6 +423,14 @@ pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !voi // page and dispatch the events. if (std.mem.eql(u8, "about:blank", request_url)) { self.url = "about:blank"; + + if (self.parent) |parent| { + self.origin = parent.origin; + } else { + self.origin = null; + } + try self.js.setOrigin(self.origin); + // Assume we parsed the document. // It's important to force a reset during the following navigation. self._parse_state = .complete; @@ -519,6 +477,7 @@ pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !voi var http_client = session.browser.http_client; self.url = try self.arena.dupeZ(u8, request_url); + self.origin = try URL.getOrigin(self.arena, self.url); self._req_id = req_id; self._navigated_options = .{ @@ -579,8 +538,8 @@ pub fn scheduleNavigation(self: *Page, request_url: []const u8, opts: NavigateOp if (self.canScheduleNavigation(std.meta.activeTag(nt)) == false) { return; } - const arena = try self.arena_pool.acquire(); - errdefer self.arena_pool.release(arena); + const arena = try self._session.getArena(.{ .debug = "scheduleNavigation" }); + errdefer self._session.releaseArena(arena); return self.scheduleNavigationWithArena(arena, request_url, opts, nt); } @@ -619,9 +578,8 @@ fn scheduleNavigationWithArena(originator: *Page, arena: Allocator, request_url: if (target.parent == null) { try session.navigation.updateEntries(target.url, opts.kind, target, true); } - // doin't defer this, the caller, the caller is responsible for freeing - // it on error - target.arena_pool.release(arena); + // don't defer this, the caller is responsible for freeing it on error + session.releaseArena(arena); return; } @@ -653,7 +611,7 @@ fn scheduleNavigationWithArena(originator: *Page, arena: Allocator, request_url: }; if (target._queued_navigation) |existing| { - target.arena_pool.release(existing.arena); + session.releaseArena(existing.arena); } target._queued_navigation = qn; @@ -823,12 +781,18 @@ fn notifyParentLoadComplete(self: *Page) void { parent.iframeCompletedLoading(self.iframe.?); } -fn pageHeaderDoneCallback(transfer: *Http.Transfer) !bool { +fn pageHeaderDoneCallback(transfer: *HttpClient.Transfer) !bool { var self: *Page = @ptrCast(@alignCast(transfer.ctx)); - // would be different than self.url in the case of a redirect const header = &transfer.response_header.?; - self.url = try self.arena.dupeZ(u8, std.mem.span(header.url)); + + const response_url = std.mem.span(header.url); + if (std.mem.eql(u8, response_url, self.url) == false) { + // would be different than self.url in the case of a redirect + self.url = try self.arena.dupeZ(u8, response_url); + self.origin = try URL.getOrigin(self.arena, self.url); + } + try self.js.setOrigin(self.origin); self.window._location = try Location.init(self.url, self); self.document._location = self.window._location; @@ -845,7 +809,7 @@ fn pageHeaderDoneCallback(transfer: *Http.Transfer) !bool { return true; } -fn pageDataCallback(transfer: *Http.Transfer, data: []const u8) !void { +fn pageDataCallback(transfer: *HttpClient.Transfer, data: []const u8) !void { var self: *Page = @ptrCast(@alignCast(transfer.ctx)); if (self._parse_state == .pre) { @@ -2434,6 +2398,12 @@ pub fn removeNode(self: *Page, parent: *Node, child: *Node, opts: RemoveNodeOpts const previous_sibling = child.previousSibling(); const next_sibling = child.nextSibling(); + // Capture child's index before removal for live range updates (DOM spec remove steps 4-7) + const child_index_for_ranges: ?u32 = if (self._live_ranges.first != null) + parent.getChildIndex(child) + else + null; + const children = parent._children.?; switch (children.*) { .one => |n| { @@ -2462,6 +2432,11 @@ pub fn removeNode(self: *Page, parent: *Node, child: *Node, opts: RemoveNodeOpts child._parent = null; child._child_link = .{}; + // Update live ranges for removal (DOM spec remove steps 4-7) + if (child_index_for_ranges) |idx| { + self.updateRangesForNodeRemoval(parent, child, idx); + } + // Handle slot assignment removal before mutation observers if (child.is(Element)) |el| { // Check if the parent was a shadow host @@ -2609,6 +2584,21 @@ pub fn _insertNodeRelative(self: *Page, comptime from_parser: bool, parent: *Nod } child._parent = parent; + // Update live ranges for insertion (DOM spec insert step 6). + // For .before/.after the child was inserted at a specific position; + // ranges on parent with offsets past that position must be incremented. + // For .append no range update is needed (spec: "if child is non-null"). + if (self._live_ranges.first != null) { + switch (relative) { + .append => {}, + .before, .after => { + if (parent.getChildIndex(child)) |idx| { + self.updateRangesForNodeInsertion(parent, idx); + } + }, + } + } + // Tri-state behavior for mutations: // 1. from_parser=true, parse_mode=document -> no mutations (initial document parse) // 2. from_parser=true, parse_mode=fragment -> mutations (innerHTML additions) @@ -2867,6 +2857,54 @@ pub fn childListChange( } } +// --- Live range update methods (DOM spec §4.2.3, §4.2.4, §4.7, §4.8) --- + +/// Update all live ranges after a replaceData mutation on a CharacterData node. +/// Per DOM spec: insertData = replaceData(offset, 0, data), +/// deleteData = replaceData(offset, count, ""). +/// All parameters are in UTF-16 code unit offsets. +pub fn updateRangesForCharacterDataReplace(self: *Page, target: *Node, offset: u32, count: u32, data_len: u32) void { + var it: ?*std.DoublyLinkedList.Node = self._live_ranges.first; + while (it) |link| : (it = link.next) { + const ar: *AbstractRange = @fieldParentPtr("_range_link", link); + ar.updateForCharacterDataReplace(target, offset, count, data_len); + } +} + +/// Update all live ranges after a splitText operation. +/// Steps 7b-7e of the DOM spec splitText algorithm. +/// Steps 7d-7e complement (not overlap) updateRangesForNodeInsertion: +/// the insert update handles offsets > child_index, while 7d/7e handle +/// offsets == node_index+1 (these are equal values but with > vs == checks). +pub fn updateRangesForSplitText(self: *Page, target: *Node, new_node: *Node, offset: u32, parent: *Node, node_index: u32) void { + var it: ?*std.DoublyLinkedList.Node = self._live_ranges.first; + while (it) |link| : (it = link.next) { + const ar: *AbstractRange = @fieldParentPtr("_range_link", link); + ar.updateForSplitText(target, new_node, offset, parent, node_index); + } +} + +/// Update all live ranges after a node insertion. +/// Per DOM spec insert algorithm step 6: only applies when inserting before a +/// non-null reference node. +pub fn updateRangesForNodeInsertion(self: *Page, parent: *Node, child_index: u32) void { + var it: ?*std.DoublyLinkedList.Node = self._live_ranges.first; + while (it) |link| : (it = link.next) { + const ar: *AbstractRange = @fieldParentPtr("_range_link", link); + ar.updateForNodeInsertion(parent, child_index); + } +} + +/// Update all live ranges after a node removal. +/// Per DOM spec remove algorithm steps 4-7. +pub fn updateRangesForNodeRemoval(self: *Page, parent: *Node, child: *Node, child_index: u32) void { + var it: ?*std.DoublyLinkedList.Node = self._live_ranges.first; + while (it) |link| : (it = link.next) { + const ar: *AbstractRange = @fieldParentPtr("_range_link", link); + ar.updateForNodeRemoval(parent, child, child_index); + } +} + // TODO: optimize and cleanup, this is called a lot (e.g., innerHTML = '') pub fn parseHtmlAsChildren(self: *Page, node: *Node, html: []const u8) !void { const previous_parse_mode = self._parse_mode; @@ -3047,7 +3085,7 @@ pub const NavigateReason = enum { pub const NavigateOpts = struct { cdp_id: ?i64 = null, reason: NavigateReason = .address_bar, - method: Http.Method = .GET, + method: HttpClient.Method = .GET, body: ?[]const u8 = null, header: ?[:0]const u8 = null, force: bool = false, @@ -3057,7 +3095,7 @@ pub const NavigateOpts = struct { pub const NavigatedOpts = struct { cdp_id: ?i64 = null, reason: NavigateReason = .address_bar, - method: Http.Method = .GET, + method: HttpClient.Method = .GET, }; const NavigationType = enum { @@ -3164,7 +3202,7 @@ pub fn handleClick(self: *Page, target: *Node) !void { pub fn triggerKeyboard(self: *Page, keyboard_event: *KeyboardEvent) !void { const event = keyboard_event.asEvent(); const element = self.window._document._active_element orelse { - keyboard_event.deinit(false, self); + keyboard_event.deinit(false, self._session); return; }; @@ -3240,7 +3278,7 @@ pub fn submitForm(self: *Page, submitter_: ?*Element, form_: ?*Element.Html.Form // so submit_event is still valid when we check _prevent_default submit_event.acquireRef(); - defer submit_event.deinit(false, self); + defer submit_event.deinit(false, self._session); try self._event_manager.dispatch(form_element.asEventTarget(), submit_event); // If the submit event was prevented, don't submit the form @@ -3254,8 +3292,8 @@ pub fn submitForm(self: *Page, submitter_: ?*Element, form_: ?*Element.Html.Form // I don't think this is technically correct, but FormData handles it ok const form_data = try FormData.init(form, submitter_, self); - const arena = try self.arena_pool.acquire(); - errdefer self.arena_pool.release(arena); + const arena = try self._session.getArena(.{ .debug = "submitForm" }); + errdefer self._session.releaseArena(arena); const encoding = form_element.getAttributeSafe(comptime .wrap("enctype")); @@ -3302,7 +3340,7 @@ const RequestCookieOpts = struct { is_http: bool = true, is_navigation: bool = false, }; -pub fn requestCookie(self: *const Page, opts: RequestCookieOpts) Http.Client.RequestCookie { +pub fn requestCookie(self: *const Page, opts: RequestCookieOpts) HttpClient.RequestCookie { return .{ .jar = &self._session.cookie_jar, .origin = self.url, diff --git a/src/browser/ScriptManager.zig b/src/browser/ScriptManager.zig index 0466f125..6f55f43b 100644 --- a/src/browser/ScriptManager.zig +++ b/src/browser/ScriptManager.zig @@ -21,7 +21,8 @@ const lp = @import("lightpanda"); const builtin = @import("builtin"); const log = @import("../log.zig"); -const Http = @import("../http/Http.zig"); +const HttpClient = @import("HttpClient.zig"); +const net_http = @import("../network/http.zig"); const String = @import("../string.zig").String; const js = @import("js/js.zig"); @@ -60,7 +61,7 @@ ready_scripts: std.DoublyLinkedList, shutdown: bool = false, -client: *Http.Client, +client: *HttpClient, allocator: Allocator, buffer_pool: BufferPool, @@ -88,7 +89,7 @@ importmap: std.StringHashMapUnmanaged([:0]const u8), // event). page_notified_of_completion: bool, -pub fn init(allocator: Allocator, http_client: *Http.Client, page: *Page) ScriptManager { +pub fn init(allocator: Allocator, http_client: *HttpClient, page: *Page) ScriptManager { return .{ .page = page, .async_scripts = .{}, @@ -141,7 +142,7 @@ fn clearList(list: *std.DoublyLinkedList) void { } } -pub fn getHeaders(self: *ScriptManager, url: [:0]const u8) !Http.Headers { +pub fn getHeaders(self: *ScriptManager, url: [:0]const u8) !net_http.Headers { var headers = try self.client.newHeaders(); try self.page.headersForRequest(self.page.arena, url, &headers); return headers; @@ -675,11 +676,11 @@ pub const Script = struct { self.manager.script_pool.destroy(self); } - fn startCallback(transfer: *Http.Transfer) !void { + fn startCallback(transfer: *HttpClient.Transfer) !void { log.debug(.http, "script fetch start", .{ .req = transfer }); } - fn headerCallback(transfer: *Http.Transfer) !bool { + fn headerCallback(transfer: *HttpClient.Transfer) !bool { const self: *Script = @ptrCast(@alignCast(transfer.ctx)); const header = &transfer.response_header.?; self.status = header.status; @@ -746,14 +747,14 @@ pub const Script = struct { return true; } - fn dataCallback(transfer: *Http.Transfer, data: []const u8) !void { + fn dataCallback(transfer: *HttpClient.Transfer, data: []const u8) !void { const self: *Script = @ptrCast(@alignCast(transfer.ctx)); self._dataCallback(transfer, data) catch |err| { log.err(.http, "SM.dataCallback", .{ .err = err, .transfer = transfer, .len = data.len }); return err; }; } - fn _dataCallback(self: *Script, _: *Http.Transfer, data: []const u8) !void { + fn _dataCallback(self: *Script, _: *HttpClient.Transfer, data: []const u8) !void { try self.source.remote.appendSlice(self.manager.allocator, data); } diff --git a/src/browser/Session.zig b/src/browser/Session.zig index d8a85fa2..529f0847 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -21,6 +21,7 @@ const lp = @import("lightpanda"); const builtin = @import("builtin"); const log = @import("../log.zig"); +const App = @import("../App.zig"); const js = @import("js/js.zig"); const storage = @import("webapi/storage/storage.zig"); @@ -29,20 +30,53 @@ const History = @import("webapi/History.zig"); const Page = @import("Page.zig"); const Browser = @import("Browser.zig"); +const Factory = @import("Factory.zig"); const Notification = @import("../Notification.zig"); const QueuedNavigation = Page.QueuedNavigation; const Allocator = std.mem.Allocator; +const ArenaPool = App.ArenaPool; const IS_DEBUG = builtin.mode == .Debug; -// Session is like a browser's tab. -// It owns the js env and the loader for all the pages of the session. // You can create successively multiple pages for a session, but you must -// deinit a page before running another one. +// deinit a page before running another one. It manages two distinct lifetimes. +// +// The first is the lifetime of the Session itself, where pages are created and +// removed, but share the same cookie jar and navigation history (etc...) +// +// The second is as a container the data needed by the full page hierarchy, i.e. \ +// the root page and all of its frames (and all of their frames.) const Session = @This(); +// These are the fields that remain intact for the duration of the Session browser: *Browser, +arena: Allocator, +history: History, +navigation: Navigation, +storage_shed: storage.Shed, notification: *Notification, +cookie_jar: storage.Cookie.Jar, + +// These are the fields that get reset whenever the Session's page (the root) is reset. +factory: Factory, + +page_arena: Allocator, + +// Origin map for same-origin context sharing. Scoped to the root page lifetime. +origins: std.StringHashMapUnmanaged(*js.Origin) = .empty, + +// Shared resources for all pages in this session. +// These live for the duration of the page tree (root + frames). +arena_pool: *ArenaPool, + +// In Debug, we use this to see if anything fails to release an arena back to +// the pool. +_arena_pool_leak_track: if (IS_DEBUG) std.AutoHashMapUnmanaged(usize, struct { + owner: []const u8, + count: usize, +}) else void = if (IS_DEBUG) .empty else {}, + +page: ?Page, queued_navigation: std.ArrayList(*Page), // Temporary buffer for about:blank navigations during processing. @@ -50,27 +84,24 @@ queued_navigation: std.ArrayList(*Page), // about:blank navigations (which may add to queued_navigation). queued_queued_navigation: std.ArrayList(*Page), -// Used to create our Inspector and in the BrowserContext. -arena: Allocator, - -cookie_jar: storage.Cookie.Jar, -storage_shed: storage.Shed, - -history: History, -navigation: Navigation, - -page: ?Page, - frame_id_gen: u32, pub fn init(self: *Session, browser: *Browser, notification: *Notification) !void { const allocator = browser.app.allocator; - const arena = try browser.arena_pool.acquire(); - errdefer browser.arena_pool.release(arena); + const arena_pool = browser.arena_pool; + + const arena = try arena_pool.acquire(); + errdefer arena_pool.release(arena); + + const page_arena = try arena_pool.acquire(); + errdefer arena_pool.release(page_arena); self.* = .{ .page = null, .arena = arena, + .arena_pool = arena_pool, + .page_arena = page_arena, + .factory = Factory.init(page_arena), .history = .{}, .frame_id_gen = 0, // The prototype (EventTarget) for Navigation is created when a Page is created. @@ -90,9 +121,9 @@ pub fn deinit(self: *Session) void { } self.cookie_jar.deinit(); - const browser = self.browser; - self.storage_shed.deinit(browser.app.allocator); - browser.arena_pool.release(self.arena); + self.storage_shed.deinit(self.browser.app.allocator); + self.arena_pool.release(self.page_arena); + self.arena_pool.release(self.arena); } // NOTE: the caller is not the owner of the returned value, @@ -126,29 +157,133 @@ pub fn removePage(self: *Session) void { self.page = null; self.navigation.onRemovePage(); + self.resetPageResources(); if (comptime IS_DEBUG) { log.debug(.browser, "remove page", .{}); } } +pub const GetArenaOpts = struct { + debug: []const u8, +}; + +pub fn getArena(self: *Session, opts: GetArenaOpts) !Allocator { + const allocator = try self.arena_pool.acquire(); + if (comptime IS_DEBUG) { + // Use session's arena (not page_arena) since page_arena gets reset between pages + const gop = try self._arena_pool_leak_track.getOrPut(self.arena, @intFromPtr(allocator.ptr)); + if (gop.found_existing and gop.value_ptr.count != 0) { + log.err(.bug, "ArenaPool Double Use", .{ .owner = gop.value_ptr.*.owner }); + @panic("ArenaPool Double Use"); + } + gop.value_ptr.* = .{ .owner = opts.debug, .count = 1 }; + } + return allocator; +} + +pub fn releaseArena(self: *Session, allocator: Allocator) void { + if (comptime IS_DEBUG) { + const found = self._arena_pool_leak_track.getPtr(@intFromPtr(allocator.ptr)).?; + if (found.count != 1) { + log.err(.bug, "ArenaPool Double Free", .{ .owner = found.owner, .count = found.count }); + if (comptime builtin.is_test) { + @panic("ArenaPool Double Free"); + } + return; + } + found.count = 0; + } + return self.arena_pool.release(allocator); +} + +pub fn getOrCreateOrigin(self: *Session, key_: ?[]const u8) !*js.Origin { + const key = key_ orelse { + var opaque_origin: [36]u8 = undefined; + @import("../id.zig").uuidv4(&opaque_origin); + // Origin.init will dupe opaque_origin. It's fine that this doesn't + // get added to self.origins. In fact, it further isolates it. When the + // context is freed, it'll call session.releaseOrigin which will free it. + return js.Origin.init(self.browser.app, self.browser.env.isolate, &opaque_origin); + }; + + const gop = try self.origins.getOrPut(self.arena, key); + if (gop.found_existing) { + const origin = gop.value_ptr.*; + origin.rc += 1; + return origin; + } + + errdefer _ = self.origins.remove(key); + + const origin = try js.Origin.init(self.browser.app, self.browser.env.isolate, key); + gop.key_ptr.* = origin.key; + gop.value_ptr.* = origin; + return origin; +} + +pub fn releaseOrigin(self: *Session, origin: *js.Origin) void { + const rc = origin.rc; + if (rc == 1) { + _ = self.origins.remove(origin.key); + origin.deinit(self.browser.app); + } else { + origin.rc = rc - 1; + } +} + +/// Reset page_arena and factory for a clean slate. +/// Called when root page is removed. +fn resetPageResources(self: *Session) void { + // Check for arena leaks before releasing + if (comptime IS_DEBUG) { + var it = self._arena_pool_leak_track.valueIterator(); + while (it.next()) |value_ptr| { + if (value_ptr.count > 0) { + log.err(.bug, "ArenaPool Leak", .{ .owner = value_ptr.owner }); + } + } + self._arena_pool_leak_track.clearRetainingCapacity(); + } + + // All origins should have been released when contexts were destroyed + if (comptime IS_DEBUG) { + std.debug.assert(self.origins.count() == 0); + } + // Defensive cleanup in case origins leaked + { + const app = self.browser.app; + var it = self.origins.valueIterator(); + while (it.next()) |value| { + value.*.deinit(app); + } + self.origins.clearRetainingCapacity(); + } + + // Release old page_arena and acquire fresh one + self.frame_id_gen = 0; + self.arena_pool.reset(self.page_arena, 64 * 1024); + self.factory = Factory.init(self.page_arena); +} + pub fn replacePage(self: *Session) !*Page { if (comptime IS_DEBUG) { log.debug(.browser, "replace page", .{}); } lp.assert(self.page != null, "Session.replacePage null page", .{}); + lp.assert(self.page.?.parent == null, "Session.replacePage with parent", .{}); var current = self.page.?; const frame_id = current._frame_id; - const parent = current.parent; - current.deinit(false); + current.deinit(true); + self.resetPageResources(); self.browser.env.memoryPressureNotification(.moderate); self.page = @as(Page, undefined); const page = &self.page.?; - try Page.init(page, frame_id, self, parent); + try Page.init(page, frame_id, self, null); return page; } @@ -428,12 +563,11 @@ fn processQueuedNavigation(self: *Session) !void { fn processFrameNavigation(self: *Session, page: *Page, qn: *QueuedNavigation) !void { lp.assert(page.parent != null, "root queued navigation", .{}); - const browser = self.browser; const iframe = page.iframe.?; const parent = page.parent.?; page._queued_navigation = null; - defer browser.arena_pool.release(qn.arena); + defer self.releaseArena(qn.arena); errdefer iframe._window = null; @@ -465,9 +599,21 @@ fn processRootQueuedNavigation(self: *Session) !void { // create a copy before the page is cleared const qn = current_page._queued_navigation.?; current_page._queued_navigation = null; - defer self.browser.arena_pool.release(qn.arena); + + defer self.arena_pool.release(qn.arena); + + // HACK + // Mark as released in tracking BEFORE removePage clears the map. + // We can't call releaseArena() because that would also return the arena + // to the pool, making the memory invalid before we use qn.url/qn.opts. + if (comptime IS_DEBUG) { + if (self._arena_pool_leak_track.getPtr(@intFromPtr(qn.arena.ptr))) |found| { + found.count = 0; + } + } self.removePage(); + self.page = @as(Page, undefined); const new_page = &self.page.?; try Page.init(new_page, frame_id, self, null); diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index 9180223c..787527b6 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -23,9 +23,11 @@ const log = @import("../../log.zig"); const js = @import("js.zig"); const Env = @import("Env.zig"); const bridge = @import("bridge.zig"); +const Origin = @import("Origin.zig"); const Scheduler = @import("Scheduler.zig"); const Page = @import("../Page.zig"); +const Session = @import("../Session.zig"); const ScriptManager = @import("../ScriptManager.zig"); const v8 = js.v8; @@ -41,6 +43,7 @@ const Context = @This(); id: usize, env: *Env, page: *Page, +session: *Session, isolate: js.Isolate, // Per-context microtask queue for isolation between contexts @@ -74,39 +77,11 @@ call_depth: usize = 0, // context.localScope local: ?*const js.Local = null, -// Serves two purposes. Like `global_objects`, this is used to free -// every Global(Object) we've created during the lifetime of the context. -// More importantly, it serves as an identity map - for a given Zig -// instance, we map it to the same Global(Object). -// The key is the @intFromPtr of the Zig value -identity_map: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty, +origin: *Origin, -// Any type that is stored in the identity_map which has a finalizer declared -// will have its finalizer stored here. This is only used when shutting down -// if v8 hasn't called the finalizer directly itself. -finalizer_callbacks: std.AutoHashMapUnmanaged(usize, *FinalizerCallback) = .empty, -finalizer_callback_pool: std.heap.MemoryPool(FinalizerCallback), - -// Some web APIs have to manage opaque values. Ideally, they use an -// js.Object, but the js.Object has no lifetime guarantee beyond the -// current call. They can call .persist() on their js.Object to get -// a `Global(Object)`. We need to track these to free them. -// This used to be a map and acted like identity_map; the key was -// the @intFromPtr(js_obj.handle). But v8 can re-use address. Without -// a reliable way to know if an object has already been persisted, -// we now simply persist every time persist() is called. -global_values: std.ArrayList(v8.Global) = .empty, -global_objects: std.ArrayList(v8.Global) = .empty, +// Unlike other v8 types, like functions or objects, modules are not shared +// across origins. global_modules: std.ArrayList(v8.Global) = .empty, -global_promises: std.ArrayList(v8.Global) = .empty, -global_functions: std.ArrayList(v8.Global) = .empty, -global_promise_resolvers: std.ArrayList(v8.Global) = .empty, - -// Temp variants stored in HashMaps for O(1) early cleanup. -// Key is global.data_ptr. -global_values_temp: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty, -global_promises_temp: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty, -global_functions_temp: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty, // Our module cache: normalized module specifier => module. module_cache: std.StringHashMapUnmanaged(ModuleEntry) = .empty, @@ -174,64 +149,11 @@ pub fn deinit(self: *Context) void { // this can release objects self.scheduler.deinit(); - { - var it = self.identity_map.valueIterator(); - while (it.next()) |global| { - v8.v8__Global__Reset(global); - } - } - { - var it = self.finalizer_callbacks.valueIterator(); - while (it.next()) |finalizer| { - finalizer.*.deinit(); - } - self.finalizer_callback_pool.deinit(); - } - - for (self.global_values.items) |*global| { - v8.v8__Global__Reset(global); - } - - for (self.global_objects.items) |*global| { - v8.v8__Global__Reset(global); - } - for (self.global_modules.items) |*global| { v8.v8__Global__Reset(global); } - for (self.global_functions.items) |*global| { - v8.v8__Global__Reset(global); - } - - for (self.global_promises.items) |*global| { - v8.v8__Global__Reset(global); - } - - for (self.global_promise_resolvers.items) |*global| { - v8.v8__Global__Reset(global); - } - - { - var it = self.global_values_temp.valueIterator(); - while (it.next()) |global| { - v8.v8__Global__Reset(global); - } - } - - { - var it = self.global_promises_temp.valueIterator(); - while (it.next()) |global| { - v8.v8__Global__Reset(global); - } - } - - { - var it = self.global_functions_temp.valueIterator(); - while (it.next()) |global| { - v8.v8__Global__Reset(global); - } - } + self.session.releaseOrigin(self.origin); v8.v8__Global__Reset(&self.handle); env.isolate.notifyContextDisposed(); @@ -241,8 +163,40 @@ pub fn deinit(self: *Context) void { v8.v8__MicrotaskQueue__DELETE(self.microtask_queue); } +pub fn setOrigin(self: *Context, key: ?[]const u8) !void { + const env = self.env; + const isolate = env.isolate; + + const origin = try self.session.getOrCreateOrigin(key); + errdefer self.session.releaseOrigin(origin); + + try self.origin.transferTo(origin); + self.origin.deinit(env.app); + + self.origin = origin; + + { + var ls: js.Local.Scope = undefined; + self.localScope(&ls); + defer ls.deinit(); + + // Set the V8::Context SecurityToken, which is a big part of what allows + // one context to access another. + const token_local = v8.v8__Global__Get(&origin.security_token, isolate.handle); + v8.v8__Context__SetSecurityToken(ls.local.handle, token_local); + } +} + +pub fn trackGlobal(self: *Context, global: v8.Global) !void { + return self.origin.trackGlobal(global); +} + +pub fn trackTemp(self: *Context, global: v8.Global) !void { + return self.origin.trackTemp(global); +} + pub fn weakRef(self: *Context, obj: anytype) void { - const fc = self.finalizer_callbacks.get(@intFromPtr(obj)) orelse { + const fc = self.origin.finalizer_callbacks.get(@intFromPtr(obj)) orelse { if (comptime IS_DEBUG) { // should not be possible std.debug.assert(false); @@ -253,7 +207,7 @@ pub fn weakRef(self: *Context, obj: anytype) void { } pub fn safeWeakRef(self: *Context, obj: anytype) void { - const fc = self.finalizer_callbacks.get(@intFromPtr(obj)) orelse { + const fc = self.origin.finalizer_callbacks.get(@intFromPtr(obj)) orelse { if (comptime IS_DEBUG) { // should not be possible std.debug.assert(false); @@ -265,7 +219,7 @@ pub fn safeWeakRef(self: *Context, obj: anytype) void { } pub fn strongRef(self: *Context, obj: anytype) void { - const fc = self.finalizer_callbacks.get(@intFromPtr(obj)) orelse { + const fc = self.origin.finalizer_callbacks.get(@intFromPtr(obj)) orelse { if (comptime IS_DEBUG) { // should not be possible std.debug.assert(false); @@ -275,45 +229,6 @@ pub fn strongRef(self: *Context, obj: anytype) void { v8.v8__Global__ClearWeak(&fc.global); } -pub fn release(self: *Context, item: anytype) void { - if (@TypeOf(item) == *anyopaque) { - // Existing *anyopaque path for identity_map. Called internally from - // finalizers - var global = self.identity_map.fetchRemove(@intFromPtr(item)) orelse { - if (comptime IS_DEBUG) { - // should not be possible - std.debug.assert(false); - } - return; - }; - v8.v8__Global__Reset(&global.value); - - // The item has been fianalized, remove it for the finalizer callback so that - // we don't try to call it again on shutdown. - const fc = self.finalizer_callbacks.fetchRemove(@intFromPtr(item)) orelse { - if (comptime IS_DEBUG) { - // should not be possible - std.debug.assert(false); - } - return; - }; - self.finalizer_callback_pool.destroy(fc.value); - return; - } - - var map = switch (@TypeOf(item)) { - js.Value.Temp => &self.global_values_temp, - js.Promise.Temp => &self.global_promises_temp, - js.Function.Temp => &self.global_functions_temp, - else => |T| @compileError("Context.release cannot be called with a " ++ @typeName(T)), - }; - - if (map.fetchRemove(item.handle.data_ptr)) |kv| { - var global = kv.value; - v8.v8__Global__Reset(&global); - } -} - // Any operation on the context have to be made from a local. pub fn localScope(self: *Context, ls: *js.Local.Scope) void { const isolate = self.isolate; @@ -336,28 +251,18 @@ pub fn toLocal(self: *Context, global: anytype) js.Local.ToLocalReturnType(@Type return l.toLocal(global); } -// This isn't expected to be called often. It's for converting attributes into -// function calls, e.g. will turn that "doSomething" -// string into a js.Function which looks like: function(e) { doSomething(e) } -// There might be more efficient ways to do this, but doing it this way means -// our code only has to worry about js.Funtion, not some union of a js.Function -// or a string. -pub fn stringToPersistedFunction(self: *Context, str: []const u8) !js.Function.Global { +pub fn stringToPersistedFunction( + self: *Context, + function_body: []const u8, + comptime parameter_names: []const []const u8, + extensions: []const v8.Object, +) !js.Function.Global { var ls: js.Local.Scope = undefined; self.localScope(&ls); defer ls.deinit(); - var extra: []const u8 = ""; - const normalized = std.mem.trim(u8, str, &std.ascii.whitespace); - if (normalized.len > 0 and normalized[normalized.len - 1] != ')') { - extra = "(e)"; - } - const full = try std.fmt.allocPrintSentinel(self.call_arena, "(function(e) {{ {s}{s} }})", .{ normalized, extra }, 0); - const js_val = try ls.local.compileAndRun(full, null); - if (!js_val.isFunction()) { - return error.StringFunctionError; - } - return try (js.Function{ .local = &ls.local, .handle = @ptrCast(js_val.handle) }).persist(); + const js_function = try ls.local.compileFunction(function_body, parameter_names, extensions); + return js_function.persist(); } pub fn module(self: *Context, comptime want_result: bool, local: *const js.Local, src: []const u8, url: []const u8, cacheable: bool) !(if (want_result) ModuleEntry else void) { @@ -1039,34 +944,6 @@ pub fn queueMicrotaskFunc(self: *Context, cb: js.Function) void { v8.v8__MicrotaskQueue__EnqueueMicrotaskFunc(self.microtask_queue, self.isolate.handle, cb.handle); } -pub fn createFinalizerCallback(self: *Context, global: v8.Global, ptr: *anyopaque, finalizerFn: *const fn (ptr: *anyopaque, page: *Page) void) !*FinalizerCallback { - const fc = try self.finalizer_callback_pool.create(); - fc.* = .{ - .ctx = self, - .ptr = ptr, - .global = global, - .finalizerFn = finalizerFn, - }; - return fc; -} - -// == Misc == -// A type that has a finalizer can have its finalizer called one of two ways. -// The first is from V8 via the WeakCallback we give to weakRef. But that isn't -// guaranteed to fire, so we track this in ctx._finalizers and call them on -// context shutdown. -pub const FinalizerCallback = struct { - ctx: *Context, - ptr: *anyopaque, - global: v8.Global, - finalizerFn: *const fn (ptr: *anyopaque, page: *Page) void, - - pub fn deinit(self: *FinalizerCallback) void { - self.finalizerFn(self.ptr, self.ctx.page); - self.ctx.finalizer_callback_pool.destroy(self); - } -}; - // == Profiler == pub fn startCpuProfiler(self: *Context) void { if (comptime !IS_DEBUG) { diff --git a/src/browser/js/Env.zig b/src/browser/js/Env.zig index 1a86ffd5..e8488541 100644 --- a/src/browser/js/Env.zig +++ b/src/browser/js/Env.zig @@ -26,6 +26,7 @@ const App = @import("../../App.zig"); const log = @import("../../log.zig"); const bridge = @import("bridge.zig"); +const Origin = @import("Origin.zig"); const Context = @import("Context.zig"); const Isolate = @import("Isolate.zig"); const Platform = @import("Platform.zig"); @@ -57,6 +58,8 @@ const Env = @This(); app: *App, +allocator: Allocator, + platform: *const Platform, // the global isolate @@ -70,6 +73,11 @@ isolate_params: *v8.CreateParams, context_id: usize, +// Maps origin -> shared Origin contains, for v8 values shared across +// same-origin Contexts. There's a mismatch here between our JS model and our +// Browser model. Origins only live as long as the root page of a session exists. +// It would be wrong/dangerous to re-use an Origin across root page navigations. + // Global handles that need to be freed on deinit eternal_function_templates: []v8.Eternal, @@ -206,6 +214,7 @@ pub fn init(app: *App, opts: InitOpts) !Env { return .{ .app = app, .context_id = 0, + .allocator = allocator, .contexts = undefined, .context_count = 0, .isolate = isolate, @@ -228,7 +237,9 @@ pub fn deinit(self: *Env) void { ctx.deinit(); } - const allocator = self.app.allocator; + const app = self.app; + const allocator = app.allocator; + if (self.inspector) |i| { i.deinit(allocator); } @@ -272,6 +283,7 @@ pub fn createContext(self: *Env, page: *Page) !*Context { // get the global object for the context, this maps to our Window const global_obj = v8.v8__Context__Global(v8_context).?; + { // Store our TAO inside the internal field of the global object. This // maps the v8::Object -> Zig instance. Almost all objects have this, and @@ -287,6 +299,7 @@ pub fn createContext(self: *Env, page: *Page) !*Context { }; v8.v8__Object__SetAlignedPointerInInternalField(global_obj, 0, tao); } + // our window wrapped in a v8::Global var global_global: v8.Global = undefined; v8.v8__Global__New(isolate.handle, global_obj, &global_global); @@ -294,10 +307,15 @@ pub fn createContext(self: *Env, page: *Page) !*Context { const context_id = self.context_id; self.context_id = context_id + 1; + const origin = try page._session.getOrCreateOrigin(null); + errdefer page._session.releaseOrigin(origin); + const context = try context_arena.create(Context); context.* = .{ .env = self, .page = page, + .session = page._session, + .origin = origin, .id = context_id, .isolate = isolate, .arena = context_arena, @@ -307,9 +325,8 @@ pub fn createContext(self: *Env, page: *Page) !*Context { .microtask_queue = microtask_queue, .script_manager = &page._script_manager, .scheduler = .init(context_arena), - .finalizer_callback_pool = std.heap.MemoryPool(Context.FinalizerCallback).init(self.app.allocator), }; - try context.identity_map.putNoClobber(context_arena, @intFromPtr(page.window), global_global); + try context.origin.identity_map.putNoClobber(context_arena, @intFromPtr(page.window), global_global); // Store a pointer to our context inside the v8 context so that, given // a v8 context, we can get our context out diff --git a/src/browser/js/Function.zig b/src/browser/js/Function.zig index 01243d35..bfb5e53d 100644 --- a/src/browser/js/Function.zig +++ b/src/browser/js/Function.zig @@ -209,11 +209,11 @@ fn _persist(self: *const Function, comptime is_global: bool) !(if (is_global) Gl var global: v8.Global = undefined; v8.v8__Global__New(ctx.isolate.handle, self.handle, &global); if (comptime is_global) { - try ctx.global_functions.append(ctx.arena, global); - } else { - try ctx.global_functions_temp.put(ctx.arena, global.data_ptr, global); + try ctx.trackGlobal(global); + return .{ .handle = global, .origin = {} }; } - return .{ .handle = global }; + try ctx.trackTemp(global); + return .{ .handle = global, .origin = ctx.origin }; } pub fn tempWithThis(self: *const Function, value: anytype) !Temp { @@ -226,15 +226,18 @@ pub fn persistWithThis(self: *const Function, value: anytype) !Global { return with_this.persist(); } -pub const Temp = G(0); -pub const Global = G(1); +pub const Temp = G(.temp); +pub const Global = G(.global); -fn G(comptime discriminator: u8) type { +const GlobalType = enum(u8) { + temp, + global, +}; + +fn G(comptime global_type: GlobalType) type { return struct { handle: v8.Global, - - // makes the types different (G(0) != G(1)), without taking up space - comptime _: u8 = discriminator, + origin: if (global_type == .temp) *js.Origin else void, const Self = @This(); @@ -252,5 +255,9 @@ fn G(comptime discriminator: u8) type { pub fn isEqual(self: *const Self, other: Function) bool { return v8.v8__Global__IsEqual(&self.handle, other.handle); } + + pub fn release(self: *const Self) void { + self.origin.releaseTemp(self.handle); + } }; } diff --git a/src/browser/js/Inspector.zig b/src/browser/js/Inspector.zig index a9032e33..aa35be7b 100644 --- a/src/browser/js/Inspector.zig +++ b/src/browser/js/Inspector.zig @@ -130,6 +130,12 @@ pub fn contextCreated( pub fn contextDestroyed(self: *Inspector, context: *const v8.Context) void { v8.v8_inspector__Inspector__ContextDestroyed(self.handle, context); + + if (self.default_context) |*dc| { + if (v8.v8__Global__IsEqual(dc, context)) { + self.default_context = null; + } + } } pub fn resetContextGroup(self: *const Inspector) void { diff --git a/src/browser/js/Local.zig b/src/browser/js/Local.zig index 6a68b332..ee2ca5cd 100644 --- a/src/browser/js/Local.zig +++ b/src/browser/js/Local.zig @@ -18,6 +18,7 @@ const std = @import("std"); const Page = @import("../Page.zig"); +const Session = @import("../Session.zig"); const log = @import("../../log.zig"); const string = @import("../../string.zig"); @@ -115,6 +116,49 @@ pub fn exec(self: *const Local, src: []const u8, name: ?[]const u8) !js.Value { return self.compileAndRun(src, name); } +/// Compiles a function body as function. +/// +/// https://v8.github.io/api/head/classv8_1_1ScriptCompiler.html#a3a15bb5a7dfc3f998e6ac789e6b4646a +pub fn compileFunction( + self: *const Local, + function_body: []const u8, + /// We tend to know how many params we'll pass; can remove the comptime if necessary. + comptime parameter_names: []const []const u8, + extensions: []const v8.Object, +) !js.Function { + // TODO: Make configurable. + const script_name = self.isolate.initStringHandle("anonymous"); + const script_source = self.isolate.initStringHandle(function_body); + + var parameter_list: [parameter_names.len]*const v8.String = undefined; + inline for (0..parameter_names.len) |i| { + parameter_list[i] = self.isolate.initStringHandle(parameter_names[i]); + } + + // Create `ScriptOrigin`. + var origin: v8.ScriptOrigin = undefined; + v8.v8__ScriptOrigin__CONSTRUCT(&origin, script_name); + + // Create `ScriptCompilerSource`. + var script_compiler_source: v8.ScriptCompilerSource = undefined; + v8.v8__ScriptCompiler__Source__CONSTRUCT2(script_source, &origin, null, &script_compiler_source); + defer v8.v8__ScriptCompiler__Source__DESTRUCT(&script_compiler_source); + + // Compile the function. + const result = v8.v8__ScriptCompiler__CompileFunction( + self.handle, + &script_compiler_source, + parameter_list.len, + ¶meter_list, + extensions.len, + @ptrCast(&extensions), + v8.kNoCompileOptions, + v8.kNoCacheNoReason, + ) orelse return error.CompilationError; + + return .{ .local = self, .handle = result }; +} + pub fn compileAndRun(self: *const Local, src: []const u8, name: ?[]const u8) !js.Value { const script_name = self.isolate.initStringHandle(name orelse "anonymous"); const script_source = self.isolate.initStringHandle(src); @@ -171,7 +215,7 @@ pub fn mapZigInstanceToJs(self: *const Local, js_obj_handle: ?*const v8.Object, .pointer => |ptr| { const resolved = resolveValue(value); - const gop = try ctx.identity_map.getOrPut(arena, @intFromPtr(resolved.ptr)); + const gop = try ctx.origin.identity_map.getOrPut(arena, @intFromPtr(resolved.ptr)); if (gop.found_existing) { // we've seen this instance before, return the same object return (js.Object.Global{ .handle = gop.value_ptr.* }).local(self); @@ -225,16 +269,17 @@ pub fn mapZigInstanceToJs(self: *const Local, js_obj_handle: ?*const v8.Object, // can't figure out how to make that work, since it depends on // the [runtime] `value`. // We need the resolved finalizer, which we have in resolved. + // // The above if statement would be more clear as: // if (resolved.finalizer_from_v8) |finalizer| { // But that's a runtime check. // Instead, we check if the base has finalizer. The assumption // here is that if a resolve type has a finalizer, then the base // should have a finalizer too. - const fc = try ctx.createFinalizerCallback(gop.value_ptr.*, resolved.ptr, resolved.finalizer_from_zig.?); + const fc = try ctx.origin.createFinalizerCallback(ctx.session, gop.value_ptr.*, resolved.ptr, resolved.finalizer_from_zig.?); { errdefer fc.deinit(); - try ctx.finalizer_callbacks.put(ctx.arena, @intFromPtr(resolved.ptr), fc); + try ctx.origin.finalizer_callbacks.put(ctx.origin.arena, @intFromPtr(resolved.ptr), fc); } conditionallyReference(value); @@ -1083,7 +1128,7 @@ const Resolved = struct { class_id: u16, prototype_chain: []const @import("TaggedOpaque.zig").PrototypeChainEntry, finalizer_from_v8: ?*const fn (handle: ?*const v8.WeakCallbackInfo) callconv(.c) void = null, - finalizer_from_zig: ?*const fn (ptr: *anyopaque, page: *Page) void = null, + finalizer_from_zig: ?*const fn (ptr: *anyopaque, session: *Session) void = null, }; pub fn resolveValue(value: anytype) Resolved { const T = bridge.Struct(@TypeOf(value)); diff --git a/src/browser/js/Object.zig b/src/browser/js/Object.zig index 981f4a2b..fbf036e4 100644 --- a/src/browser/js/Object.zig +++ b/src/browser/js/Object.zig @@ -97,7 +97,7 @@ pub fn persist(self: Object) !Global { var global: v8.Global = undefined; v8.v8__Global__New(ctx.isolate.handle, self.handle, &global); - try ctx.global_objects.append(ctx.arena, global); + try ctx.trackGlobal(global); return .{ .handle = global }; } diff --git a/src/browser/js/Origin.zig b/src/browser/js/Origin.zig new file mode 100644 index 00000000..486888f1 --- /dev/null +++ b/src/browser/js/Origin.zig @@ -0,0 +1,240 @@ +// Copyright (C) 2023-2025 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 . + +// Origin represents the shared Zig<->JS bridge state for all contexts within +// the same origin. Multiple contexts (frames) from the same origin share a +// single Origin, ensuring that JS objects maintain their identity across frames. + +const std = @import("std"); +const js = @import("js.zig"); + +const App = @import("../../App.zig"); +const Session = @import("../Session.zig"); + +const v8 = js.v8; +const Allocator = std.mem.Allocator; +const IS_DEBUG = @import("builtin").mode == .Debug; + +const Origin = @This(); + +rc: usize = 1, +arena: Allocator, + +// The key, e.g. lightpanda.io:443 +key: []const u8, + +// Security token - all contexts in this realm must use the same v8::Value instance +// as their security token for V8 to allow cross-context access +security_token: v8.Global, + +// Serves two purposes. Like `global_objects`, this is used to free +// every Global(Object) we've created during the lifetime of the realm. +// More importantly, it serves as an identity map - for a given Zig +// instance, we map it to the same Global(Object). +// The key is the @intFromPtr of the Zig value +identity_map: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty, + +// Some web APIs have to manage opaque values. Ideally, they use an +// js.Object, but the js.Object has no lifetime guarantee beyond the +// current call. They can call .persist() on their js.Object to get +// a `Global(Object)`. We need to track these to free them. +// This used to be a map and acted like identity_map; the key was +// the @intFromPtr(js_obj.handle). But v8 can re-use address. Without +// a reliable way to know if an object has already been persisted, +// we now simply persist every time persist() is called. +globals: std.ArrayList(v8.Global) = .empty, + +// Temp variants stored in HashMaps for O(1) early cleanup. +// Key is global.data_ptr. +temps: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty, + +// Any type that is stored in the identity_map which has a finalizer declared +// will have its finalizer stored here. This is only used when shutting down +// if v8 hasn't called the finalizer directly itself. +finalizer_callbacks: std.AutoHashMapUnmanaged(usize, *FinalizerCallback) = .empty, + +pub fn init(app: *App, isolate: js.Isolate, key: []const u8) !*Origin { + const arena = try app.arena_pool.acquire(); + errdefer app.arena_pool.release(arena); + + var hs: js.HandleScope = undefined; + hs.init(isolate); + defer hs.deinit(); + + const owned_key = try arena.dupe(u8, key); + const token_local = isolate.initStringHandle(owned_key); + var token_global: v8.Global = undefined; + v8.v8__Global__New(isolate.handle, token_local, &token_global); + + const self = try arena.create(Origin); + self.* = .{ + .rc = 1, + .arena = arena, + .key = owned_key, + .globals = .empty, + .temps = .empty, + .security_token = token_global, + }; + return self; +} + +pub fn deinit(self: *Origin, app: *App) void { + // Call finalizers before releasing anything + { + var it = self.finalizer_callbacks.valueIterator(); + while (it.next()) |finalizer| { + finalizer.*.deinit(); + } + } + + v8.v8__Global__Reset(&self.security_token); + + { + var it = self.identity_map.valueIterator(); + while (it.next()) |global| { + v8.v8__Global__Reset(global); + } + } + + for (self.globals.items) |*global| { + v8.v8__Global__Reset(global); + } + + { + var it = self.temps.valueIterator(); + while (it.next()) |global| { + v8.v8__Global__Reset(global); + } + } + + app.arena_pool.release(self.arena); +} + +pub fn trackGlobal(self: *Origin, global: v8.Global) !void { + return self.globals.append(self.arena, global); +} + +pub fn trackTemp(self: *Origin, global: v8.Global) !void { + return self.temps.put(self.arena, global.data_ptr, global); +} + +pub fn releaseTemp(self: *Origin, global: v8.Global) void { + if (self.temps.fetchRemove(global.data_ptr)) |kv| { + var g = kv.value; + v8.v8__Global__Reset(&g); + } +} + +/// Release an item from the identity_map (called after finalizer runs from V8) +pub fn release(self: *Origin, item: *anyopaque) void { + var global = self.identity_map.fetchRemove(@intFromPtr(item)) orelse { + if (comptime IS_DEBUG) { + std.debug.assert(false); + } + return; + }; + v8.v8__Global__Reset(&global.value); + + // The item has been finalized, remove it from the finalizer callback so that + // we don't try to call it again on shutdown. + const kv = self.finalizer_callbacks.fetchRemove(@intFromPtr(item)) orelse { + if (comptime IS_DEBUG) { + std.debug.assert(false); + } + return; + }; + const fc = kv.value; + fc.session.releaseArena(fc.arena); +} + +pub fn createFinalizerCallback( + self: *Origin, + session: *Session, + global: v8.Global, + ptr: *anyopaque, + zig_finalizer: *const fn (ptr: *anyopaque, session: *Session) void, +) !*FinalizerCallback { + const arena = try session.getArena(.{ .debug = "FinalizerCallback" }); + errdefer session.releaseArena(arena); + const fc = try arena.create(FinalizerCallback); + fc.* = .{ + .arena = arena, + .origin = self, + .session = session, + .ptr = ptr, + .global = global, + .zig_finalizer = zig_finalizer, + }; + return fc; +} + +pub fn transferTo(self: *Origin, dest: *Origin) !void { + const arena = dest.arena; + + try dest.globals.ensureUnusedCapacity(arena, self.globals.items.len); + for (self.globals.items) |obj| { + dest.globals.appendAssumeCapacity(obj); + } + self.globals.clearRetainingCapacity(); + + { + try dest.temps.ensureUnusedCapacity(arena, self.temps.count()); + var it = self.temps.iterator(); + while (it.next()) |kv| { + try dest.temps.put(arena, kv.key_ptr.*, kv.value_ptr.*); + } + self.temps.clearRetainingCapacity(); + } + + { + try dest.finalizer_callbacks.ensureUnusedCapacity(arena, self.finalizer_callbacks.count()); + var it = self.finalizer_callbacks.iterator(); + while (it.next()) |kv| { + kv.value_ptr.*.origin = dest; + try dest.finalizer_callbacks.put(arena, kv.key_ptr.*, kv.value_ptr.*); + } + self.finalizer_callbacks.clearRetainingCapacity(); + } + + { + try dest.identity_map.ensureUnusedCapacity(arena, self.identity_map.count()); + var it = self.identity_map.iterator(); + while (it.next()) |kv| { + try dest.identity_map.put(arena, kv.key_ptr.*, kv.value_ptr.*); + } + self.identity_map.clearRetainingCapacity(); + } +} + +// A type that has a finalizer can have its finalizer called one of two ways. +// The first is from V8 via the WeakCallback we give to weakRef. But that isn't +// guaranteed to fire, so we track this in finalizer_callbacks and call them on +// origin shutdown. +pub const FinalizerCallback = struct { + arena: Allocator, + origin: *Origin, + session: *Session, + ptr: *anyopaque, + global: v8.Global, + zig_finalizer: *const fn (ptr: *anyopaque, session: *Session) void, + + pub fn deinit(self: *FinalizerCallback) void { + self.zig_finalizer(self.ptr, self.session); + self.session.releaseArena(self.arena); + } +}; diff --git a/src/browser/js/Promise.zig b/src/browser/js/Promise.zig index afadbe82..372d2578 100644 --- a/src/browser/js/Promise.zig +++ b/src/browser/js/Promise.zig @@ -62,22 +62,25 @@ fn _persist(self: *const Promise, comptime is_global: bool) !(if (is_global) Glo var global: v8.Global = undefined; v8.v8__Global__New(ctx.isolate.handle, self.handle, &global); if (comptime is_global) { - try ctx.global_promises.append(ctx.arena, global); - } else { - try ctx.global_promises_temp.put(ctx.arena, global.data_ptr, global); + try ctx.trackGlobal(global); + return .{ .handle = global, .origin = {} }; } - return .{ .handle = global }; + try ctx.trackTemp(global); + return .{ .handle = global, .origin = ctx.origin }; } -pub const Temp = G(0); -pub const Global = G(1); +pub const Temp = G(.temp); +pub const Global = G(.global); -fn G(comptime discriminator: u8) type { +const GlobalType = enum(u8) { + temp, + global, +}; + +fn G(comptime global_type: GlobalType) type { return struct { handle: v8.Global, - - // makes the types different (G(0) != G(1)), without taking up space - comptime _: u8 = discriminator, + origin: if (global_type == .temp) *js.Origin else void, const Self = @This(); @@ -91,5 +94,9 @@ fn G(comptime discriminator: u8) type { .handle = @ptrCast(v8.v8__Global__Get(&self.handle, l.isolate.handle)), }; } + + pub fn release(self: *const Self) void { + self.origin.releaseTemp(self.handle); + } }; } diff --git a/src/browser/js/PromiseResolver.zig b/src/browser/js/PromiseResolver.zig index 183effee..f2aac0e0 100644 --- a/src/browser/js/PromiseResolver.zig +++ b/src/browser/js/PromiseResolver.zig @@ -79,7 +79,7 @@ pub fn persist(self: PromiseResolver) !Global { var ctx = self.local.ctx; var global: v8.Global = undefined; v8.v8__Global__New(ctx.isolate.handle, self.handle, &global); - try ctx.global_promise_resolvers.append(ctx.arena, global); + try ctx.trackGlobal(global); return .{ .handle = global }; } diff --git a/src/browser/js/Value.zig b/src/browser/js/Value.zig index 7963ae7c..309bdb6b 100644 --- a/src/browser/js/Value.zig +++ b/src/browser/js/Value.zig @@ -259,11 +259,11 @@ fn _persist(self: *const Value, comptime is_global: bool) !(if (is_global) Globa var global: v8.Global = undefined; v8.v8__Global__New(ctx.isolate.handle, self.handle, &global); if (comptime is_global) { - try ctx.global_values.append(ctx.arena, global); - } else { - try ctx.global_values_temp.put(ctx.arena, global.data_ptr, global); + try ctx.trackGlobal(global); + return .{ .handle = global, .origin = {} }; } - return .{ .handle = global }; + try ctx.trackTemp(global); + return .{ .handle = global, .origin = ctx.origin }; } pub fn toZig(self: Value, comptime T: type) !T { @@ -310,15 +310,18 @@ pub fn format(self: Value, writer: *std.Io.Writer) !void { return js_str.format(writer); } -pub const Temp = G(0); -pub const Global = G(1); +pub const Temp = G(.temp); +pub const Global = G(.global); -fn G(comptime discriminator: u8) type { +const GlobalType = enum(u8) { + temp, + global, +}; + +fn G(comptime global_type: GlobalType) type { return struct { handle: v8.Global, - - // makes the types different (G(0) != G(1)), without taking up space - comptime _: u8 = discriminator, + origin: if (global_type == .temp) *js.Origin else void, const Self = @This(); @@ -336,5 +339,9 @@ fn G(comptime discriminator: u8) type { pub fn isEqual(self: *const Self, other: Value) bool { return v8.v8__Global__IsEqual(&self.handle, other.handle); } + + pub fn release(self: *const Self) void { + self.origin.releaseTemp(self.handle); + } }; } diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index fcbbea21..f95329dc 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -21,11 +21,13 @@ const js = @import("js.zig"); const lp = @import("lightpanda"); const log = @import("../../log.zig"); const Page = @import("../Page.zig"); +const Session = @import("../Session.zig"); const v8 = js.v8; const Caller = @import("Caller.zig"); const Context = @import("Context.zig"); +const Origin = @import("Origin.zig"); const IS_DEBUG = @import("builtin").mode == .Debug; @@ -104,24 +106,24 @@ pub fn Builder(comptime T: type) type { return entries; } - pub fn finalizer(comptime func: *const fn (self: *T, shutdown: bool, page: *Page) void) Finalizer { + pub fn finalizer(comptime func: *const fn (self: *T, shutdown: bool, session: *Session) void) Finalizer { return .{ .from_zig = struct { - fn wrap(ptr: *anyopaque, page: *Page) void { - func(@ptrCast(@alignCast(ptr)), true, page); + fn wrap(ptr: *anyopaque, session: *Session) void { + func(@ptrCast(@alignCast(ptr)), true, session); } }.wrap, .from_v8 = struct { fn wrap(handle: ?*const v8.WeakCallbackInfo) callconv(.c) void { const ptr = v8.v8__WeakCallbackInfo__GetParameter(handle.?).?; - const fc: *Context.FinalizerCallback = @ptrCast(@alignCast(ptr)); + const fc: *Origin.FinalizerCallback = @ptrCast(@alignCast(ptr)); - const ctx = fc.ctx; + const origin = fc.origin; const value_ptr = fc.ptr; - if (ctx.finalizer_callbacks.contains(@intFromPtr(value_ptr))) { - func(@ptrCast(@alignCast(value_ptr)), false, ctx.page); - ctx.release(value_ptr); + if (origin.finalizer_callbacks.contains(@intFromPtr(value_ptr))) { + func(@ptrCast(@alignCast(value_ptr)), false, fc.session); + origin.release(value_ptr); } else { // A bit weird, but v8 _requires_ that we release it // If we don't. We'll 100% crash. @@ -413,12 +415,12 @@ pub const Property = struct { }; const Finalizer = struct { - // The finalizer wrapper when called fro Zig. This is only called on - // Context.deinit - from_zig: *const fn (ctx: *anyopaque, page: *Page) void, + // The finalizer wrapper when called from Zig. This is only called on + // Origin.deinit + from_zig: *const fn (ctx: *anyopaque, session: *Session) void, // The finalizer wrapper when called from V8. This may never be called - // (hence why we fallback to calling in Context.denit). If it is called, + // (hence why we fallback to calling in Origin.deinit). If it is called, // it is only ever called after we SetWeak on the Global. from_v8: *const fn (?*const v8.WeakCallbackInfo) callconv(.c) void, }; diff --git a/src/browser/js/js.zig b/src/browser/js/js.zig index 9415b717..0c196e5b 100644 --- a/src/browser/js/js.zig +++ b/src/browser/js/js.zig @@ -24,6 +24,7 @@ const string = @import("../../string.zig"); pub const Env = @import("Env.zig"); pub const bridge = @import("bridge.zig"); pub const Caller = @import("Caller.zig"); +pub const Origin = @import("Origin.zig"); pub const Context = @import("Context.zig"); pub const Local = @import("Local.zig"); pub const Inspector = @import("Inspector.zig"); @@ -161,7 +162,7 @@ pub fn ArrayBufferRef(comptime kind: ArrayType) type { var ctx = self.local.ctx; var global: v8.Global = undefined; v8.v8__Global__New(ctx.isolate.handle, self.handle, &global); - try ctx.global_values.append(ctx.arena, global); + try ctx.trackGlobal(global); return .{ .handle = global }; } diff --git a/src/browser/tests/css/stylesheet.html b/src/browser/tests/css/stylesheet.html index e717e806..ec7928af 100644 --- a/src/browser/tests/css/stylesheet.html +++ b/src/browser/tests/css/stylesheet.html @@ -293,6 +293,28 @@ div.style.top = '0'; testing.expectEqual('0px', div.style.top); + // Scroll properties + div.style.scrollMarginTop = '0'; + testing.expectEqual('0px', div.style.scrollMarginTop); + + div.style.scrollPaddingBottom = '0'; + testing.expectEqual('0px', div.style.scrollPaddingBottom); + + // Multi-column + div.style.columnWidth = '0'; + testing.expectEqual('0px', div.style.columnWidth); + + div.style.columnRuleWidth = '0'; + testing.expectEqual('0px', div.style.columnRuleWidth); + + // Outline shorthand + div.style.outline = '0'; + testing.expectEqual('0px', div.style.outline); + + // Shapes + div.style.shapeMargin = '0'; + testing.expectEqual('0px', div.style.shapeMargin); + // Non-length properties should not be affected div.style.opacity = '0'; testing.expectEqual('0', div.style.opacity); @@ -313,6 +335,12 @@ div.style.alignContent = 'first baseline'; testing.expectEqual('baseline', div.style.alignContent); + div.style.alignSelf = 'first baseline'; + testing.expectEqual('baseline', div.style.alignSelf); + + div.style.justifySelf = 'first baseline'; + testing.expectEqual('baseline', div.style.justifySelf); + // "last baseline" should remain unchanged div.style.alignItems = 'last baseline'; testing.expectEqual('last baseline', div.style.alignItems); @@ -339,6 +367,16 @@ div.style.gap = '10px 20px'; testing.expectEqual('10px 20px', div.style.gap); + + // New shorthands + div.style.overflow = 'hidden hidden'; + testing.expectEqual('hidden', div.style.overflow); + + div.style.scrollSnapAlign = 'start start'; + testing.expectEqual('start', div.style.scrollSnapAlign); + + div.style.overscrollBehavior = 'auto auto'; + testing.expectEqual('auto', div.style.overscrollBehavior); } diff --git a/src/browser/tests/element/html/form.html b/src/browser/tests/element/html/form.html index f148fae0..f62cb221 100644 --- a/src/browser/tests/element/html/form.html +++ b/src/browser/tests/element/html/form.html @@ -23,6 +23,22 @@ } + +
diff --git a/src/browser/tests/frames/frames.html b/src/browser/tests/frames/frames.html index 4e614de9..97bed281 100644 --- a/src/browser/tests/frames/frames.html +++ b/src/browser/tests/frames/frames.html @@ -64,11 +64,12 @@ // child frame's top.parent is itself (root has no parent) testing.expectEqual(window, window[0].top.parent); - // Todo: Context security tokens - // testing.expectEqual(true, window.sub1_loaded); - // testing.expectEqual(true, window.sub2_loaded); - // testing.expectEqual(1, window.sub1_count); - // testing.expectEqual(2, window.sub2_count); + // Cross-frame property access + testing.expectEqual(true, window.sub1_loaded); + testing.expectEqual(true, window.sub2_loaded); + testing.expectEqual(1, window.sub1_count); + // depends on how far the initial load got before it was cancelled. + testing.expectEqual(true, window.sub2_count == 1 || window.sub2_count == 2); }); diff --git a/src/browser/tests/range_mutations.html b/src/browser/tests/range_mutations.html new file mode 100644 index 00000000..3e4efc7e --- /dev/null +++ b/src/browser/tests/range_mutations.html @@ -0,0 +1,315 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/tests/testing.js b/src/browser/tests/testing.js index 90434f0f..987ba042 100644 --- a/src/browser/tests/testing.js +++ b/src/browser/tests/testing.js @@ -118,7 +118,7 @@ BASE_URL: 'http://127.0.0.1:9582/src/browser/tests/', }; - if (!IS_TEST_RUNNER) { + if (window.navigator.userAgent.startsWith("Lightpanda/") == false) { // The page is running in a different browser. Probably a developer making sure // a test is correct. There are a few tweaks we need to do to make this a // seemless, namely around adapting paths/urls. diff --git a/src/browser/tests/window/body_onload1.html b/src/browser/tests/window/body_onload1.html index 7eb7ee61..3858810e 100644 --- a/src/browser/tests/window/body_onload1.html +++ b/src/browser/tests/window/body_onload1.html @@ -1,5 +1,5 @@ - + - diff --git a/src/browser/webapi/AbstractRange.zig b/src/browser/webapi/AbstractRange.zig index 5f3edc31..e766ac29 100644 --- a/src/browser/webapi/AbstractRange.zig +++ b/src/browser/webapi/AbstractRange.zig @@ -33,6 +33,9 @@ _start_offset: u32, _end_container: *Node, _start_container: *Node, +// Intrusive linked list node for tracking live ranges on the Page. +_range_link: std.DoublyLinkedList.Node = .{}, + pub const Type = union(enum) { range: *Range, // TODO: static_range: *StaticRange, @@ -215,6 +218,91 @@ fn isInclusiveAncestorOf(potential_ancestor: *Node, node: *Node) bool { return isAncestorOf(potential_ancestor, node); } +/// Update this range's boundaries after a replaceData mutation on target. +/// All parameters are in UTF-16 code unit offsets. +pub fn updateForCharacterDataReplace(self: *AbstractRange, target: *Node, offset: u32, count: u32, data_len: u32) void { + if (self._start_container == target) { + if (self._start_offset > offset and self._start_offset <= offset + count) { + self._start_offset = offset; + } else if (self._start_offset > offset + count) { + // Use i64 intermediate to avoid u32 underflow when count > data_len + self._start_offset = @intCast(@as(i64, self._start_offset) + @as(i64, data_len) - @as(i64, count)); + } + } + + if (self._end_container == target) { + if (self._end_offset > offset and self._end_offset <= offset + count) { + self._end_offset = offset; + } else if (self._end_offset > offset + count) { + self._end_offset = @intCast(@as(i64, self._end_offset) + @as(i64, data_len) - @as(i64, count)); + } + } +} + +/// Update this range's boundaries after a splitText operation. +/// Steps 7b-7e of the DOM spec splitText algorithm. +pub fn updateForSplitText(self: *AbstractRange, target: *Node, new_node: *Node, offset: u32, parent: *Node, node_index: u32) void { + // Step 7b: ranges on the original node with start > offset move to new node + if (self._start_container == target and self._start_offset > offset) { + self._start_container = new_node; + self._start_offset = self._start_offset - offset; + } + // Step 7c: ranges on the original node with end > offset move to new node + if (self._end_container == target and self._end_offset > offset) { + self._end_container = new_node; + self._end_offset = self._end_offset - offset; + } + // Step 7d: ranges on parent with start == node_index + 1 increment + if (self._start_container == parent and self._start_offset == node_index + 1) { + self._start_offset += 1; + } + // Step 7e: ranges on parent with end == node_index + 1 increment + if (self._end_container == parent and self._end_offset == node_index + 1) { + self._end_offset += 1; + } +} + +/// Update this range's boundaries after a node insertion. +pub fn updateForNodeInsertion(self: *AbstractRange, parent: *Node, child_index: u32) void { + if (self._start_container == parent and self._start_offset > child_index) { + self._start_offset += 1; + } + if (self._end_container == parent and self._end_offset > child_index) { + self._end_offset += 1; + } +} + +/// Update this range's boundaries after a node removal. +pub fn updateForNodeRemoval(self: *AbstractRange, parent: *Node, child: *Node, child_index: u32) void { + // Steps 4-5: ranges whose start/end is an inclusive descendant of child + // get moved to (parent, child_index). + if (isInclusiveDescendantOf(self._start_container, child)) { + self._start_container = parent; + self._start_offset = child_index; + } + if (isInclusiveDescendantOf(self._end_container, child)) { + self._end_container = parent; + self._end_offset = child_index; + } + + // Steps 6-7: ranges on parent at offsets > child_index get decremented. + if (self._start_container == parent and self._start_offset > child_index) { + self._start_offset -= 1; + } + if (self._end_container == parent and self._end_offset > child_index) { + self._end_offset -= 1; + } +} + +fn isInclusiveDescendantOf(node: *Node, potential_ancestor: *Node) bool { + var current: ?*Node = node; + while (current) |n| { + if (n == potential_ancestor) return true; + current = n.parentNode(); + } + return false; +} + pub const JsApi = struct { pub const bridge = js.Bridge(AbstractRange); diff --git a/src/browser/webapi/Blob.zig b/src/browser/webapi/Blob.zig index aa955ce5..5b8bf81c 100644 --- a/src/browser/webapi/Blob.zig +++ b/src/browser/webapi/Blob.zig @@ -21,8 +21,12 @@ const Writer = std.Io.Writer; const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); +const Session = @import("../Session.zig"); + const Mime = @import("../Mime.zig"); +const Allocator = std.mem.Allocator; + /// https://w3c.github.io/FileAPI/#blob-section /// https://developer.mozilla.org/en-US/docs/Web/API/Blob const Blob = @This(); @@ -31,6 +35,8 @@ pub const _prototype_root = true; _type: Type, +_arena: Allocator, + /// Immutable slice of blob. /// Note that another blob may hold a pointer/slice to this, /// so its better to leave the deallocation of it to arena allocator. @@ -69,6 +75,9 @@ pub fn initWithMimeValidation( validate_mime: bool, page: *Page, ) !*Blob { + const arena = try page.getArena(.{ .debug = "Blob" }); + errdefer page.releaseArena(arena); + const options: InitOptions = maybe_options orelse .{}; const mime: []const u8 = blk: { @@ -77,7 +86,7 @@ pub fn initWithMimeValidation( break :blk ""; } - const buf = try page.arena.dupe(u8, t); + const buf = try arena.dupe(u8, t); if (validate_mime) { // Full MIME parsing per MIME sniff spec (for Content-Type headers) @@ -99,7 +108,7 @@ pub fn initWithMimeValidation( const data = blk: { if (maybe_blob_parts) |blob_parts| { - var w: Writer.Allocating = .init(page.arena); + var w: Writer.Allocating = .init(arena); const use_native_endings = std.mem.eql(u8, options.endings, "native"); try writeBlobParts(&w.writer, blob_parts, use_native_endings); @@ -109,11 +118,19 @@ pub fn initWithMimeValidation( break :blk ""; }; - return page._factory.create(Blob{ + const self = try arena.create(Blob); + self.* = .{ + ._arena = arena, ._type = .generic, ._slice = data, ._mime = mime, - }); + }; + return self; +} + +pub fn deinit(self: *Blob, shutdown: bool, session: *Session) void { + _ = shutdown; + session.releaseArena(self._arena); } const largest_vector = @max(std.simd.suggestVectorLength(u8) orelse 1, 8); @@ -264,57 +281,31 @@ pub fn bytes(self: *const Blob, page: *Page) !js.Promise { /// from a subset of the blob on which it's called. pub fn slice( self: *const Blob, - maybe_start: ?i32, - maybe_end: ?i32, - maybe_content_type: ?[]const u8, + start_: ?i32, + end_: ?i32, + content_type_: ?[]const u8, page: *Page, ) !*Blob { - const mime: []const u8 = blk: { - if (maybe_content_type) |content_type| { - if (content_type.len == 0) { - break :blk ""; - } + const data = self._slice; - break :blk try page.dupeString(content_type); + const start = blk: { + const requested_start = start_ orelse break :blk 0; + if (requested_start < 0) { + break :blk data.len -| @abs(requested_start); } - - break :blk ""; + break :blk @min(data.len, @as(u31, @intCast(requested_start))); }; - const data = self._slice; - if (maybe_start) |_start| { - const start = blk: { - if (_start < 0) { - break :blk data.len -| @abs(_start); - } + const end: usize = blk: { + const requested_end = end_ orelse break :blk data.len; + if (requested_end < 0) { + break :blk @max(start, data.len -| @abs(requested_end)); + } - break :blk @min(data.len, @as(u31, @intCast(_start))); - }; + break :blk @min(data.len, @max(start, @as(u31, @intCast(requested_end)))); + }; - const end: usize = blk: { - if (maybe_end) |_end| { - if (_end < 0) { - break :blk @max(start, data.len -| @abs(_end)); - } - - break :blk @min(data.len, @max(start, @as(u31, @intCast(_end)))); - } - - break :blk data.len; - }; - - return page._factory.create(Blob{ - ._type = .generic, - ._slice = data[start..end], - ._mime = mime, - }); - } - - return page._factory.create(Blob{ - ._type = .generic, - ._slice = data, - ._mime = mime, - }); + return Blob.init(&.{data[start..end]}, .{ .type = content_type_ orelse "" }, page); } /// Returns the size of the Blob in bytes. @@ -334,6 +325,8 @@ pub const JsApi = struct { pub const name = "Blob"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; + pub const weak = true; + pub const finalizer = bridge.finalizer(Blob.deinit); }; pub const constructor = bridge.constructor(Blob.init, .{}); diff --git a/src/browser/webapi/CData.zig b/src/browser/webapi/CData.zig index 13e075ad..4fb6de6f 100644 --- a/src/browser/webapi/CData.zig +++ b/src/browser/webapi/CData.zig @@ -37,7 +37,7 @@ _data: String = .empty, /// Count UTF-16 code units in a UTF-8 string. /// 4-byte UTF-8 sequences (codepoints >= U+10000) produce 2 UTF-16 code units (surrogate pair), /// everything else produces 1. -fn utf16Len(data: []const u8) usize { +pub fn utf16Len(data: []const u8) usize { var count: usize = 0; var i: usize = 0; while (i < data.len) { @@ -232,14 +232,13 @@ pub fn setData(self: *CData, value: ?[]const u8, page: *Page) !void { } /// JS bridge wrapper for `data` setter. -/// Handles [LegacyNullToEmptyString]: null → setData(null) → "". -/// Passes everything else (including undefined) through V8 toString, -/// so `undefined` becomes the string "undefined" per spec. +/// Per spec, setting .data runs replaceData(0, this.length, value), +/// which includes live range updates. +/// Handles [LegacyNullToEmptyString]: null → "" per spec. pub fn _setData(self: *CData, value: js.Value, page: *Page) !void { - if (value.isNull()) { - return self.setData(null, page); - } - return self.setData(try value.toZig([]const u8), page); + const new_value: []const u8 = if (value.isNull()) "" else try value.toZig([]const u8); + const length = self.getLength(); + try self.replaceData(0, length, new_value, page); } pub fn format(self: *const CData, writer: *std.io.Writer) !void { @@ -272,15 +271,20 @@ pub fn isEqualNode(self: *const CData, other: *const CData) bool { } pub fn appendData(self: *CData, data: []const u8, page: *Page) !void { - const old_value = self._data; - self._data = try String.concat(page.arena, &.{ self._data.str(), data }); - page.characterDataChange(self.asNode(), old_value); + // Per DOM spec, appendData(data) is replaceData(length, 0, data). + const length = self.getLength(); + try self.replaceData(length, 0, data, page); } pub fn deleteData(self: *CData, offset: usize, count: usize, page: *Page) !void { const end_utf16 = std.math.add(usize, offset, count) catch std.math.maxInt(usize); const range = try utf16RangeToUtf8(self._data.str(), offset, end_utf16); + // Update live ranges per DOM spec replaceData steps (deleteData = replaceData with data="") + const length = self.getLength(); + const effective_count: u32 = @intCast(@min(count, length - offset)); + page.updateRangesForCharacterDataReplace(self.asNode(), @intCast(offset), effective_count, 0); + const old_data = self._data; const old_value = old_data.str(); if (range.start == 0) { @@ -299,6 +303,10 @@ pub fn deleteData(self: *CData, offset: usize, count: usize, page: *Page) !void pub fn insertData(self: *CData, offset: usize, data: []const u8, page: *Page) !void { const byte_offset = try utf16OffsetToUtf8(self._data.str(), offset); + + // Update live ranges per DOM spec replaceData steps (insertData = replaceData with count=0) + page.updateRangesForCharacterDataReplace(self.asNode(), @intCast(offset), 0, @intCast(utf16Len(data))); + const old_value = self._data; const existing = old_value.str(); self._data = try String.concat(page.arena, &.{ @@ -312,6 +320,12 @@ pub fn insertData(self: *CData, offset: usize, data: []const u8, page: *Page) !v pub fn replaceData(self: *CData, offset: usize, count: usize, data: []const u8, page: *Page) !void { const end_utf16 = std.math.add(usize, offset, count) catch std.math.maxInt(usize); const range = try utf16RangeToUtf8(self._data.str(), offset, end_utf16); + + // Update live ranges per DOM spec replaceData steps + const length = self.getLength(); + const effective_count: u32 = @intCast(@min(count, length - offset)); + page.updateRangesForCharacterDataReplace(self.asNode(), @intCast(offset), effective_count, @intCast(utf16Len(data))); + const old_value = self._data; const existing = old_value.str(); self._data = try String.concat(page.arena, &.{ diff --git a/src/browser/webapi/Event.zig b/src/browser/webapi/Event.zig index 2821e9f1..d70042f8 100644 --- a/src/browser/webapi/Event.zig +++ b/src/browser/webapi/Event.zig @@ -20,6 +20,7 @@ const std = @import("std"); const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); +const Session = @import("../Session.zig"); const EventTarget = @import("EventTarget.zig"); const Node = @import("Node.zig"); const String = @import("../../string.zig").String; @@ -139,9 +140,9 @@ pub fn acquireRef(self: *Event) void { self._rc += 1; } -pub fn deinit(self: *Event, shutdown: bool, page: *Page) void { +pub fn deinit(self: *Event, shutdown: bool, session: *Session) void { if (shutdown) { - page.releaseArena(self._arena); + session.releaseArena(self._arena); return; } @@ -151,7 +152,7 @@ pub fn deinit(self: *Event, shutdown: bool, page: *Page) void { } if (rc == 1) { - page.releaseArena(self._arena); + session.releaseArena(self._arena); } else { self._rc = rc - 1; } diff --git a/src/browser/webapi/EventTarget.zig b/src/browser/webapi/EventTarget.zig index 31f472c3..715c209a 100644 --- a/src/browser/webapi/EventTarget.zig +++ b/src/browser/webapi/EventTarget.zig @@ -59,7 +59,7 @@ pub fn dispatchEvent(self: *EventTarget, event: *Event, page: *Page) !bool { event._is_trusted = false; event.acquireRef(); - defer event.deinit(false, page); + defer event.deinit(false, page._session); try page._event_manager.dispatch(self, event); return !event._cancelable or !event._prevent_default; } diff --git a/src/browser/webapi/File.zig b/src/browser/webapi/File.zig index a67a8a6f..f41e44bb 100644 --- a/src/browser/webapi/File.zig +++ b/src/browser/webapi/File.zig @@ -18,9 +18,11 @@ const std = @import("std"); -const Page = @import("../Page.zig"); -const Blob = @import("Blob.zig"); const js = @import("../js/js.zig"); +const Page = @import("../Page.zig"); +const Session = @import("../Session.zig"); + +const Blob = @import("Blob.zig"); const File = @This(); @@ -29,7 +31,13 @@ _proto: *Blob, // TODO: Implement File API. pub fn init(page: *Page) !*File { - return page._factory.blob(File{ ._proto = undefined }); + const arena = try page.getArena(.{ .debug = "File" }); + errdefer page.releaseArena(arena); + return page._factory.blob(arena, File{ ._proto = undefined }); +} + +pub fn deinit(self: *File, shutdown: bool, session: *Session) void { + self._proto.deinit(shutdown, session); } pub const JsApi = struct { @@ -39,6 +47,8 @@ pub const JsApi = struct { pub const name = "File"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; + pub const weak = true; + pub const finalizer = bridge.finalizer(File.deinit); }; pub const constructor = bridge.constructor(File.init, .{}); diff --git a/src/browser/webapi/FileReader.zig b/src/browser/webapi/FileReader.zig index 90e26aa0..da7ec71c 100644 --- a/src/browser/webapi/FileReader.zig +++ b/src/browser/webapi/FileReader.zig @@ -20,6 +20,7 @@ const std = @import("std"); const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); +const Session = @import("../Session.zig"); const EventTarget = @import("EventTarget.zig"); const ProgressEvent = @import("event/ProgressEvent.zig"); const Blob = @import("Blob.zig"); @@ -69,17 +70,15 @@ pub fn init(page: *Page) !*FileReader { }); } -pub fn deinit(self: *FileReader, _: bool, page: *Page) void { - const js_ctx = page.js; +pub fn deinit(self: *FileReader, _: bool, session: *Session) void { + if (self._on_abort) |func| func.release(); + if (self._on_error) |func| func.release(); + if (self._on_load) |func| func.release(); + if (self._on_load_end) |func| func.release(); + if (self._on_load_start) |func| func.release(); + if (self._on_progress) |func| func.release(); - if (self._on_abort) |func| js_ctx.release(func); - if (self._on_error) |func| js_ctx.release(func); - if (self._on_load) |func| js_ctx.release(func); - if (self._on_load_end) |func| js_ctx.release(func); - if (self._on_load_start) |func| js_ctx.release(func); - if (self._on_progress) |func| js_ctx.release(func); - - page.releaseArena(self._arena); + session.releaseArena(self._arena); } fn asEventTarget(self: *FileReader) *EventTarget { diff --git a/src/browser/webapi/IntersectionObserver.zig b/src/browser/webapi/IntersectionObserver.zig index 9d64de3c..74a5d79e 100644 --- a/src/browser/webapi/IntersectionObserver.zig +++ b/src/browser/webapi/IntersectionObserver.zig @@ -24,6 +24,7 @@ const IS_DEBUG = @import("builtin").mode == .Debug; const Allocator = std.mem.Allocator; const Page = @import("../Page.zig"); +const Session = @import("../Session.zig"); const Element = @import("Element.zig"); const DOMRect = @import("DOMRect.zig"); @@ -91,13 +92,13 @@ pub fn init(callback: js.Function.Temp, options: ?ObserverInit, page: *Page) !*I return self; } -pub fn deinit(self: *IntersectionObserver, shutdown: bool, page: *Page) void { - page.js.release(self._callback); +pub fn deinit(self: *IntersectionObserver, shutdown: bool, session: *Session) void { + self._callback.release(); if ((comptime IS_DEBUG) and !shutdown) { std.debug.assert(self._observing.items.len == 0); } - page.releaseArena(self._arena); + session.releaseArena(self._arena); } pub fn observe(self: *IntersectionObserver, target: *Element, page: *Page) !void { @@ -137,7 +138,7 @@ pub fn unobserve(self: *IntersectionObserver, target: *Element, page: *Page) voi while (j < self._pending_entries.items.len) { if (self._pending_entries.items[j]._target == target) { const entry = self._pending_entries.swapRemove(j); - entry.deinit(false, page); + entry.deinit(false, page._session); } else { j += 1; } @@ -157,7 +158,7 @@ pub fn disconnect(self: *IntersectionObserver, page: *Page) void { self._previous_states.clearRetainingCapacity(); for (self._pending_entries.items) |entry| { - entry.deinit(false, page); + entry.deinit(false, page._session); } self._pending_entries.clearRetainingCapacity(); page.js.safeWeakRef(self); @@ -302,8 +303,8 @@ pub const IntersectionObserverEntry = struct { _intersection_ratio: f64, _is_intersecting: bool, - pub fn deinit(self: *const IntersectionObserverEntry, _: bool, page: *Page) void { - page.releaseArena(self._arena); + pub fn deinit(self: *IntersectionObserverEntry, _: bool, session: *Session) void { + session.releaseArena(self._arena); } pub fn getTarget(self: *const IntersectionObserverEntry) *Element { diff --git a/src/browser/webapi/MutationObserver.zig b/src/browser/webapi/MutationObserver.zig index a5dd15dd..b8608381 100644 --- a/src/browser/webapi/MutationObserver.zig +++ b/src/browser/webapi/MutationObserver.zig @@ -21,6 +21,7 @@ const String = @import("../../string.zig").String; const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); +const Session = @import("../Session.zig"); const Node = @import("Node.zig"); const Element = @import("Element.zig"); const log = @import("../../log.zig"); @@ -84,13 +85,13 @@ pub fn init(callback: js.Function.Temp, page: *Page) !*MutationObserver { return self; } -pub fn deinit(self: *MutationObserver, shutdown: bool, page: *Page) void { - page.js.release(self._callback); +pub fn deinit(self: *MutationObserver, shutdown: bool, session: *Session) void { + self._callback.release(); if ((comptime IS_DEBUG) and !shutdown) { std.debug.assert(self._observing.items.len == 0); } - page.releaseArena(self._arena); + session.releaseArena(self._arena); } pub fn observe(self: *MutationObserver, target: *Node, options: ObserveOptions, page: *Page) !void { @@ -171,7 +172,7 @@ pub fn disconnect(self: *MutationObserver, page: *Page) void { page.unregisterMutationObserver(self); self._observing.clearRetainingCapacity(); for (self._pending_records.items) |record| { - record.deinit(false, page); + record.deinit(false, page._session); } self._pending_records.clearRetainingCapacity(); page.js.safeWeakRef(self); @@ -363,8 +364,8 @@ pub const MutationRecord = struct { characterData, }; - pub fn deinit(self: *const MutationRecord, _: bool, page: *Page) void { - page.releaseArena(self._arena); + pub fn deinit(self: *MutationRecord, _: bool, session: *Session) void { + session.releaseArena(self._arena); } pub fn getType(self: *const MutationRecord) []const u8 { diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig index 15541491..7bfa7cca 100644 --- a/src/browser/webapi/Node.zig +++ b/src/browser/webapi/Node.zig @@ -293,7 +293,8 @@ pub fn setTextContent(self: *Node, data: []const u8, page: *Page) !void { } return el.replaceChildren(&.{.{ .text = data }}, page); }, - .cdata => |c| c._data = try page.dupeSSO(data), + // Per spec, setting textContent on CharacterData runs replaceData(0, length, value) + .cdata => |c| try c.replaceData(0, c.getLength(), data, page), .document => {}, .document_type => {}, .document_fragment => |frag| { @@ -612,7 +613,11 @@ pub fn getNodeValue(self: *const Node) ?String { pub fn setNodeValue(self: *const Node, value: ?String, page: *Page) !void { switch (self._type) { - .cdata => |c| try c.setData(if (value) |v| v.str() else null, page), + // Per spec, setting nodeValue on CharacterData runs replaceData(0, length, value) + .cdata => |c| { + const new_value: []const u8 = if (value) |v| v.str() else ""; + try c.replaceData(0, c.getLength(), new_value, page); + }, .attribute => |attr| try attr.setValue(value, page), .element => {}, .document => {}, diff --git a/src/browser/webapi/Range.zig b/src/browser/webapi/Range.zig index f0c904a6..66ce1c93 100644 --- a/src/browser/webapi/Range.zig +++ b/src/browser/webapi/Range.zig @@ -322,6 +322,11 @@ pub fn insertNode(self: *Range, node: *Node, page: *Page) !void { const container = self._proto._start_container; const offset = self._proto._start_offset; + // Per spec: if range is collapsed, end offset should extend to include + // the inserted node. Capture before insertion since live range updates + // in the insert path will adjust non-collapsed ranges automatically. + const was_collapsed = self._proto.getCollapsed(); + if (container.is(Node.CData)) |_| { // If container is a text node, we need to split it const parent = container.parentNode() orelse return error.InvalidNodeType; @@ -351,9 +356,10 @@ pub fn insertNode(self: *Range, node: *Node, page: *Page) !void { _ = try container.insertBefore(node, ref_child, page); } - // Update range to be after the inserted node - if (self._proto._start_container == self._proto._end_container) { - self._proto._end_offset += 1; + // Per spec step 11: if range was collapsed, extend end to include inserted node. + // Non-collapsed ranges are already handled by the live range update in the insert path. + if (was_collapsed) { + self._proto._end_offset = self._proto._start_offset + 1; } } @@ -375,9 +381,12 @@ pub fn deleteContents(self: *Range, page: *Page) !void { ); page.characterDataChange(self._proto._start_container, old_value); } else { - // Delete child nodes in range - var offset = self._proto._start_offset; - while (offset < self._proto._end_offset) : (offset += 1) { + // Delete child nodes in range. + // Capture count before the loop: removeChild triggers live range + // updates that decrement _end_offset on each removal. + const count = self._proto._end_offset - self._proto._start_offset; + var i: u32 = 0; + while (i < count) : (i += 1) { if (self._proto._start_container.getChildAt(self._proto._start_offset)) |child| { _ = try self._proto._start_container.removeChild(child, page); } @@ -717,3 +726,6 @@ const testing = @import("../../testing.zig"); test "WebApi: Range" { try testing.htmlRunner("range.html", .{}); } +test "WebApi: Range mutations" { + try testing.htmlRunner("range_mutations.html", .{}); +} diff --git a/src/browser/webapi/URL.zig b/src/browser/webapi/URL.zig index 3bc6f586..fda7d2a5 100644 --- a/src/browser/webapi/URL.zig +++ b/src/browser/webapi/URL.zig @@ -243,11 +243,10 @@ pub fn createObjectURL(blob: *Blob, page: *Page) ![]const u8 { var uuid_buf: [36]u8 = undefined; @import("../../id.zig").uuidv4(&uuid_buf); - const origin = (try page.getOrigin(page.call_arena)) orelse "null"; const blob_url = try std.fmt.allocPrint( page.arena, "blob:{s}/{s}", - .{ origin, uuid_buf }, + .{ page.origin orelse "null", uuid_buf }, ); try page._blob_urls.put(page.arena, blob_url, blob); return blob_url; diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index 40265bb5..2bc9fbb0 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -646,9 +646,9 @@ const ScheduleCallback = struct { } fn deinit(self: *ScheduleCallback) void { - self.page.js.release(self.cb); + self.cb.release(); for (self.params) |param| { - self.page.js.release(param); + param.release(); } self.page.releaseArena(self.arena); } diff --git a/src/browser/webapi/animation/Animation.zig b/src/browser/webapi/animation/Animation.zig index 96143952..8d445733 100644 --- a/src/browser/webapi/animation/Animation.zig +++ b/src/browser/webapi/animation/Animation.zig @@ -20,6 +20,7 @@ const std = @import("std"); const log = @import("../../../log.zig"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); +const Session = @import("../../Session.zig"); const Allocator = std.mem.Allocator; @@ -61,8 +62,8 @@ pub fn init(page: *Page) !*Animation { return self; } -pub fn deinit(self: *Animation, _: bool, page: *Page) void { - page.releaseArena(self._arena); +pub fn deinit(self: *Animation, _: bool, session: *Session) void { + session.releaseArena(self._arena); } pub fn play(self: *Animation, page: *Page) !void { diff --git a/src/browser/webapi/cdata/Text.zig b/src/browser/webapi/cdata/Text.zig index 5eb096f3..e7a338b5 100644 --- a/src/browser/webapi/cdata/Text.zig +++ b/src/browser/webapi/cdata/Text.zig @@ -43,16 +43,26 @@ pub fn splitText(self: *Text, offset: usize, page: *Page) !*Text { const new_node = try page.createTextNode(new_data); const new_text = new_node.as(Text); - const old_data = data[0..byte_offset]; - try self._proto.setData(old_data, page); - - // If this node has a parent, insert the new node right after this one const node = self._proto.asNode(); + + // Per DOM spec splitText: insert first (step 7a), then update ranges (7b-7e), + // then truncate original node (step 8). if (node.parentNode()) |parent| { const next_sibling = node.nextSibling(); _ = try parent.insertBefore(new_node, next_sibling, page); + + // splitText-specific range updates (steps 7b-7e) + if (parent.getChildIndex(node)) |node_index| { + page.updateRangesForSplitText(node, new_node, @intCast(offset), parent, node_index); + } } + // Step 8: truncate original node via replaceData(offset, count, ""). + // Use replaceData instead of setData so live range updates fire + // (matters for detached text nodes where steps 7b-7e were skipped). + const length = self._proto.getLength(); + try self._proto.replaceData(offset, length - offset, "", page); + return new_text; } diff --git a/src/browser/webapi/collections/ChildNodes.zig b/src/browser/webapi/collections/ChildNodes.zig index 5ba6256a..9c2bde91 100644 --- a/src/browser/webapi/collections/ChildNodes.zig +++ b/src/browser/webapi/collections/ChildNodes.zig @@ -20,6 +20,7 @@ const std = @import("std"); const Node = @import("../Node.zig"); const Page = @import("../../Page.zig"); +const Session = @import("../../Session.zig"); const GenericIterator = @import("iterator.zig").Entry; // Optimized for node.childNodes, which has to be a live list. @@ -53,8 +54,8 @@ pub fn init(node: *Node, page: *Page) !*ChildNodes { return self; } -pub fn deinit(self: *const ChildNodes, page: *Page) void { - page.releaseArena(self._arena); +pub fn deinit(self: *const ChildNodes, session: *Session) void { + session.releaseArena(self._arena); } pub fn length(self: *ChildNodes, page: *Page) !u32 { diff --git a/src/browser/webapi/collections/NodeList.zig b/src/browser/webapi/collections/NodeList.zig index 0237a76c..a61cc598 100644 --- a/src/browser/webapi/collections/NodeList.zig +++ b/src/browser/webapi/collections/NodeList.zig @@ -21,6 +21,7 @@ const std = @import("std"); const log = @import("../../../log.zig"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); +const Session = @import("../../Session.zig"); const Node = @import("../Node.zig"); const ChildNodes = @import("ChildNodes.zig"); @@ -38,7 +39,7 @@ _data: union(enum) { }, _rc: usize = 0, -pub fn deinit(self: *NodeList, _: bool, page: *Page) void { +pub fn deinit(self: *NodeList, _: bool, session: *Session) void { const rc = self._rc; if (rc > 1) { self._rc = rc - 1; @@ -46,8 +47,8 @@ pub fn deinit(self: *NodeList, _: bool, page: *Page) void { } switch (self._data) { - .selector_list => |list| list.deinit(page), - .child_nodes => |cn| cn.deinit(page), + .selector_list => |list| list.deinit(session), + .child_nodes => |cn| cn.deinit(session), else => {}, } } @@ -118,8 +119,8 @@ const Iterator = struct { const Entry = struct { u32, *Node }; - pub fn deinit(self: *Iterator, shutdown: bool, page: *Page) void { - self.list.deinit(shutdown, page); + pub fn deinit(self: *Iterator, shutdown: bool, session: *Session) void { + self.list.deinit(shutdown, session); } pub fn acquireRef(self: *Iterator) void { diff --git a/src/browser/webapi/collections/iterator.zig b/src/browser/webapi/collections/iterator.zig index 3c43f3f8..9fe3354d 100644 --- a/src/browser/webapi/collections/iterator.zig +++ b/src/browser/webapi/collections/iterator.zig @@ -19,6 +19,7 @@ const std = @import("std"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); +const Session = @import("../../Session.zig"); pub fn Entry(comptime Inner: type, comptime field: ?[]const u8) type { const R = reflect(Inner, field); @@ -39,9 +40,9 @@ pub fn Entry(comptime Inner: type, comptime field: ?[]const u8) type { return page._factory.create(Self{ .inner = inner }); } - pub fn deinit(self: *Self, shutdown: bool, page: *Page) void { + pub fn deinit(self: *Self, shutdown: bool, session: *Session) void { if (@hasDecl(Inner, "deinit")) { - self.inner.deinit(shutdown, page); + self.inner.deinit(shutdown, session); } } diff --git a/src/browser/webapi/css/CSSStyleDeclaration.zig b/src/browser/webapi/css/CSSStyleDeclaration.zig index adc8982a..ebaafbe0 100644 --- a/src/browser/webapi/css/CSSStyleDeclaration.zig +++ b/src/browser/webapi/css/CSSStyleDeclaration.zig @@ -483,6 +483,16 @@ fn isTwoValueShorthand(name: []const u8) bool { .{ "overflow", {} }, .{ "overscroll-behavior", {} }, .{ "gap", {} }, + .{ "grid-gap", {} }, + // Scroll + .{ "scroll-padding-block", {} }, + .{ "scroll-padding-inline", {} }, + .{ "scroll-snap-align", {} }, + // Background/Mask + .{ "background-size", {} }, + .{ "border-image-repeat", {} }, + .{ "mask-repeat", {} }, + .{ "mask-size", {} }, }); return shorthands.has(name); } @@ -552,7 +562,6 @@ fn isLengthProperty(name: []const u8) bool { .{ "border-bottom-right-radius", {} }, // Text .{ "font-size", {} }, - .{ "line-height", {} }, .{ "letter-spacing", {} }, .{ "word-spacing", {} }, .{ "text-indent", {} }, @@ -561,17 +570,52 @@ fn isLengthProperty(name: []const u8) bool { .{ "row-gap", {} }, .{ "column-gap", {} }, .{ "flex-basis", {} }, + // Legacy grid aliases + .{ "grid-column-gap", {} }, + .{ "grid-row-gap", {} }, // Outline + .{ "outline", {} }, .{ "outline-width", {} }, .{ "outline-offset", {} }, + // Multi-column + .{ "column-rule-width", {} }, + .{ "column-width", {} }, + // Scroll + .{ "scroll-margin", {} }, + .{ "scroll-margin-top", {} }, + .{ "scroll-margin-right", {} }, + .{ "scroll-margin-bottom", {} }, + .{ "scroll-margin-left", {} }, + .{ "scroll-padding", {} }, + .{ "scroll-padding-top", {} }, + .{ "scroll-padding-right", {} }, + .{ "scroll-padding-bottom", {} }, + .{ "scroll-padding-left", {} }, + // Shapes + .{ "shape-margin", {} }, + // Motion path + .{ "offset-distance", {} }, + // Transforms + .{ "translate", {} }, + // Animations + .{ "animation-range-end", {} }, + .{ "animation-range-start", {} }, // Other .{ "border-spacing", {} }, .{ "text-shadow", {} }, .{ "box-shadow", {} }, .{ "baseline-shift", {} }, .{ "vertical-align", {} }, + .{ "text-decoration-inset", {} }, + .{ "block-step-size", {} }, // Grid lanes .{ "flow-tolerance", {} }, + .{ "column-rule-edge-inset", {} }, + .{ "column-rule-interior-inset", {} }, + .{ "row-rule-edge-inset", {} }, + .{ "row-rule-interior-inset", {} }, + .{ "rule-edge-inset", {} }, + .{ "rule-interior-inset", {} }, }); return length_properties.has(name); @@ -693,3 +737,55 @@ pub const JsApi = struct { pub const removeProperty = bridge.function(CSSStyleDeclaration.removeProperty, .{}); pub const cssFloat = bridge.accessor(CSSStyleDeclaration.getFloat, CSSStyleDeclaration.setFloat, .{}); }; + +const testing = @import("std").testing; + +test "normalizePropertyValue: unitless zero to 0px" { + const cases = .{ + .{ "width", "0", "0px" }, + .{ "height", "0", "0px" }, + .{ "scroll-margin-top", "0", "0px" }, + .{ "scroll-padding-bottom", "0", "0px" }, + .{ "column-width", "0", "0px" }, + .{ "column-rule-width", "0", "0px" }, + .{ "outline", "0", "0px" }, + .{ "shape-margin", "0", "0px" }, + .{ "offset-distance", "0", "0px" }, + .{ "translate", "0", "0px" }, + .{ "grid-column-gap", "0", "0px" }, + .{ "grid-row-gap", "0", "0px" }, + // Non-length properties should NOT normalize + .{ "opacity", "0", "0" }, + .{ "z-index", "0", "0" }, + }; + inline for (cases) |case| { + const result = try normalizePropertyValue(testing.allocator, case[0], case[1]); + try testing.expectEqualStrings(case[2], result); + } +} + +test "normalizePropertyValue: first baseline to baseline" { + const result = try normalizePropertyValue(testing.allocator, "align-items", "first baseline"); + try testing.expectEqualStrings("baseline", result); + + const result2 = try normalizePropertyValue(testing.allocator, "align-self", "last baseline"); + try testing.expectEqualStrings("last baseline", result2); +} + +test "normalizePropertyValue: collapse duplicate two-value shorthands" { + const cases = .{ + .{ "overflow", "hidden hidden", "hidden" }, + .{ "gap", "10px 10px", "10px" }, + .{ "scroll-snap-align", "start start", "start" }, + .{ "scroll-padding-block", "5px 5px", "5px" }, + .{ "background-size", "auto auto", "auto" }, + .{ "overscroll-behavior", "auto auto", "auto" }, + // Different values should NOT collapse + .{ "overflow", "hidden scroll", "hidden scroll" }, + .{ "gap", "10px 20px", "10px 20px" }, + }; + inline for (cases) |case| { + const result = try normalizePropertyValue(testing.allocator, case[0], case[1]); + try testing.expectEqualStrings(case[2], result); + } +} diff --git a/src/browser/webapi/css/FontFace.zig b/src/browser/webapi/css/FontFace.zig index f824259a..f3c4059d 100644 --- a/src/browser/webapi/css/FontFace.zig +++ b/src/browser/webapi/css/FontFace.zig @@ -19,6 +19,7 @@ const std = @import("std"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); +const Session = @import("../../Session.zig"); const Allocator = std.mem.Allocator; @@ -41,8 +42,8 @@ pub fn init(family: []const u8, source: []const u8, page: *Page) !*FontFace { return self; } -pub fn deinit(self: *FontFace, _: bool, page: *Page) void { - page.releaseArena(self._arena); +pub fn deinit(self: *FontFace, _: bool, session: *Session) void { + session.releaseArena(self._arena); } pub fn getFamily(self: *const FontFace) []const u8 { diff --git a/src/browser/webapi/css/FontFaceSet.zig b/src/browser/webapi/css/FontFaceSet.zig index 6e5cd941..2a4a000d 100644 --- a/src/browser/webapi/css/FontFaceSet.zig +++ b/src/browser/webapi/css/FontFaceSet.zig @@ -19,6 +19,7 @@ const std = @import("std"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); +const Session = @import("../../Session.zig"); const FontFace = @import("FontFace.zig"); const Allocator = std.mem.Allocator; @@ -38,8 +39,8 @@ pub fn init(page: *Page) !*FontFaceSet { return self; } -pub fn deinit(self: *FontFaceSet, _: bool, page: *Page) void { - page.releaseArena(self._arena); +pub fn deinit(self: *FontFaceSet, _: bool, session: *Session) void { + session.releaseArena(self._arena); } // FontFaceSet.ready - returns an already-resolved Promise. diff --git a/src/browser/webapi/element/Html.zig b/src/browser/webapi/element/Html.zig index ea962c9e..40f70b67 100644 --- a/src/browser/webapi/element/Html.zig +++ b/src/browser/webapi/element/Html.zig @@ -387,22 +387,17 @@ pub fn getAttributeFunction( } const attr = element.getAttributeSafe(.wrap(@tagName(listener_type))) orelse return null; - const callback = page.js.stringToPersistedFunction(attr) catch |err| switch (err) { - error.OutOfMemory => return err, - else => { - // Not a valid expression; log this to find out if its something we should be supporting. - log.warn(.js, "Html.getAttributeFunction", .{ - .expression = attr, - .err = err, - }); - - return null; - }, + const function = page.js.stringToPersistedFunction(attr, &.{"event"}, &.{}) catch |err| { + // Not a valid expression; log this to find out if its something we should be supporting. + log.warn(.js, "Html.getAttributeFunction", .{ + .expression = attr, + .err = err, + }); + return null; }; - try self.setAttributeListener(listener_type, callback, page); - - return callback; + try self.setAttributeListener(listener_type, function, page); + return function; } pub fn hasAttributeFunction(self: *HtmlElement, listener_type: GlobalEventHandler, page: *const Page) bool { diff --git a/src/browser/webapi/element/html/Body.zig b/src/browser/webapi/element/html/Body.zig index 7b5b530e..dccb892d 100644 --- a/src/browser/webapi/element/html/Body.zig +++ b/src/browser/webapi/element/html/Body.zig @@ -50,7 +50,7 @@ pub const Build = struct { pub fn complete(node: *Node, page: *Page) !void { const el = node.as(Element); const on_load = el.getAttributeSafe(comptime .wrap("onload")) orelse return; - if (page.js.stringToPersistedFunction(on_load)) |func| { + if (page.js.stringToPersistedFunction(on_load, &.{"event"}, &.{})) |func| { page.window._on_load = func; } else |err| { log.err(.js, "body.onload", .{ .err = err, .str = on_load }); diff --git a/src/browser/webapi/element/html/Form.zig b/src/browser/webapi/element/html/Form.zig index 9c0fd2ed..c4fdf260 100644 --- a/src/browser/webapi/element/html/Form.zig +++ b/src/browser/webapi/element/html/Form.zig @@ -18,7 +18,9 @@ const std = @import("std"); const js = @import("../../../js/js.zig"); +const URL = @import("../../../URL.zig"); const Page = @import("../../../Page.zig"); + const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); @@ -85,6 +87,19 @@ pub fn getElements(self: *Form, page: *Page) !*collections.HTMLFormControlsColle }); } +pub fn getAction(self: *Form, page: *Page) ![]const u8 { + const element = self.asElement(); + const action = element.getAttributeSafe(comptime .wrap("action")) orelse return page.url; + if (action.len == 0) { + return page.url; + } + return URL.resolve(page.call_arena, page.base(), action, .{ .encode = true }); +} + +pub fn setAction(self: *Form, value: []const u8, page: *Page) !void { + try self.asElement().setAttributeSafe(comptime .wrap("action"), .wrap(value), page); +} + pub fn getLength(self: *Form, page: *Page) !u32 { const elements = try self.getElements(page); return elements.length(page); @@ -104,6 +119,7 @@ pub const JsApi = struct { pub const name = bridge.accessor(Form.getName, Form.setName, .{}); pub const method = bridge.accessor(Form.getMethod, Form.setMethod, .{}); + pub const action = bridge.accessor(Form.getAction, Form.setAction, .{}); pub const elements = bridge.accessor(Form.getElements, null, .{}); pub const length = bridge.accessor(Form.getLength, null, .{}); pub const submit = bridge.function(Form.submit, .{}); diff --git a/src/browser/webapi/encoding/TextDecoder.zig b/src/browser/webapi/encoding/TextDecoder.zig index 4f23cfcc..736a4008 100644 --- a/src/browser/webapi/encoding/TextDecoder.zig +++ b/src/browser/webapi/encoding/TextDecoder.zig @@ -20,6 +20,7 @@ const std = @import("std"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); +const Session = @import("../../Session.zig"); const Allocator = std.mem.Allocator; const TextDecoder = @This(); @@ -59,8 +60,8 @@ pub fn init(label_: ?[]const u8, opts_: ?InitOpts, page: *Page) !*TextDecoder { return self; } -pub fn deinit(self: *TextDecoder, _: bool, page: *Page) void { - page.releaseArena(self._arena); +pub fn deinit(self: *TextDecoder, _: bool, session: *Session) void { + session.releaseArena(self._arena); } pub fn getIgnoreBOM(self: *const TextDecoder) bool { diff --git a/src/browser/webapi/event/CompositionEvent.zig b/src/browser/webapi/event/CompositionEvent.zig index b98fdd6f..a761e3ba 100644 --- a/src/browser/webapi/event/CompositionEvent.zig +++ b/src/browser/webapi/event/CompositionEvent.zig @@ -20,6 +20,7 @@ const String = @import("../../../string.zig").String; const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); +const Session = @import("../../Session.zig"); const Event = @import("../Event.zig"); const Allocator = std.mem.Allocator; @@ -53,8 +54,8 @@ pub fn init(typ: []const u8, opts_: ?Options, page: *Page) !*CompositionEvent { return event; } -pub fn deinit(self: *CompositionEvent, shutdown: bool, page: *Page) void { - self._proto.deinit(shutdown, page); +pub fn deinit(self: *CompositionEvent, shutdown: bool, session: *Session) void { + self._proto.deinit(shutdown, session); } pub fn asEvent(self: *CompositionEvent) *Event { diff --git a/src/browser/webapi/event/CustomEvent.zig b/src/browser/webapi/event/CustomEvent.zig index e303d901..eaba2e7d 100644 --- a/src/browser/webapi/event/CustomEvent.zig +++ b/src/browser/webapi/event/CustomEvent.zig @@ -21,6 +21,7 @@ const String = @import("../../../string.zig").String; const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); +const Session = @import("../../Session.zig"); const Event = @import("../Event.zig"); const Allocator = std.mem.Allocator; @@ -72,11 +73,11 @@ pub fn initCustomEvent( self._detail = detail_; } -pub fn deinit(self: *CustomEvent, shutdown: bool, page: *Page) void { +pub fn deinit(self: *CustomEvent, shutdown: bool, session: *Session) void { if (self._detail) |d| { - page.js.release(d); + d.release(); } - self._proto.deinit(shutdown, page); + self._proto.deinit(shutdown, session); } pub fn asEvent(self: *CustomEvent) *Event { diff --git a/src/browser/webapi/event/ErrorEvent.zig b/src/browser/webapi/event/ErrorEvent.zig index 5dd12a26..2d3b857f 100644 --- a/src/browser/webapi/event/ErrorEvent.zig +++ b/src/browser/webapi/event/ErrorEvent.zig @@ -21,6 +21,7 @@ const String = @import("../../../string.zig").String; const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); +const Session = @import("../../Session.zig"); const Event = @import("../Event.zig"); const Allocator = std.mem.Allocator; @@ -79,11 +80,11 @@ fn initWithTrusted(arena: Allocator, typ: String, opts_: ?Options, trusted: bool return event; } -pub fn deinit(self: *ErrorEvent, shutdown: bool, page: *Page) void { +pub fn deinit(self: *ErrorEvent, shutdown: bool, session: *Session) void { if (self._error) |e| { - page.js.release(e); + e.release(); } - self._proto.deinit(shutdown, page); + self._proto.deinit(shutdown, session); } pub fn asEvent(self: *ErrorEvent) *Event { diff --git a/src/browser/webapi/event/FocusEvent.zig b/src/browser/webapi/event/FocusEvent.zig index 37065936..f6823c23 100644 --- a/src/browser/webapi/event/FocusEvent.zig +++ b/src/browser/webapi/event/FocusEvent.zig @@ -20,6 +20,7 @@ const std = @import("std"); const Allocator = std.mem.Allocator; const String = @import("../../../string.zig").String; const Page = @import("../../Page.zig"); +const Session = @import("../../Session.zig"); const js = @import("../../js/js.zig"); const Event = @import("../Event.zig"); @@ -69,8 +70,8 @@ fn initWithTrusted(arena: Allocator, typ: String, _opts: ?Options, trusted: bool return event; } -pub fn deinit(self: *FocusEvent, shutdown: bool, page: *Page) void { - self._proto.deinit(shutdown, page); +pub fn deinit(self: *FocusEvent, shutdown: bool, session: *Session) void { + self._proto.deinit(shutdown, session); } pub fn asEvent(self: *FocusEvent) *Event { diff --git a/src/browser/webapi/event/KeyboardEvent.zig b/src/browser/webapi/event/KeyboardEvent.zig index 6e391812..1d4494b3 100644 --- a/src/browser/webapi/event/KeyboardEvent.zig +++ b/src/browser/webapi/event/KeyboardEvent.zig @@ -21,6 +21,7 @@ const String = @import("../../../string.zig").String; const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); +const Session = @import("../../Session.zig"); const Event = @import("../Event.zig"); const UIEvent = @import("UIEvent.zig"); @@ -221,8 +222,8 @@ fn initWithTrusted(arena: Allocator, typ: String, _opts: ?Options, trusted: bool return event; } -pub fn deinit(self: *KeyboardEvent, shutdown: bool, page: *Page) void { - self._proto.deinit(shutdown, page); +pub fn deinit(self: *KeyboardEvent, shutdown: bool, session: *Session) void { + self._proto.deinit(shutdown, session); } pub fn asEvent(self: *KeyboardEvent) *Event { diff --git a/src/browser/webapi/event/MessageEvent.zig b/src/browser/webapi/event/MessageEvent.zig index 34e04518..32ced1d8 100644 --- a/src/browser/webapi/event/MessageEvent.zig +++ b/src/browser/webapi/event/MessageEvent.zig @@ -22,6 +22,7 @@ const String = @import("../../../string.zig").String; const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); +const Session = @import("../../Session.zig"); const Event = @import("../Event.zig"); const Window = @import("../Window.zig"); const Allocator = std.mem.Allocator; @@ -72,11 +73,11 @@ fn initWithTrusted(arena: Allocator, typ: String, opts_: ?Options, trusted: bool return event; } -pub fn deinit(self: *MessageEvent, shutdown: bool, page: *Page) void { +pub fn deinit(self: *MessageEvent, shutdown: bool, session: *Session) void { if (self._data) |d| { - page.js.release(d); + d.release(); } - self._proto.deinit(shutdown, page); + self._proto.deinit(shutdown, session); } pub fn asEvent(self: *MessageEvent) *Event { diff --git a/src/browser/webapi/event/MouseEvent.zig b/src/browser/webapi/event/MouseEvent.zig index c94a37d1..6b032433 100644 --- a/src/browser/webapi/event/MouseEvent.zig +++ b/src/browser/webapi/event/MouseEvent.zig @@ -19,6 +19,7 @@ const std = @import("std"); const String = @import("../../../string.zig").String; const Page = @import("../../Page.zig"); +const Session = @import("../../Session.zig"); const js = @import("../../js/js.zig"); const Event = @import("../Event.zig"); @@ -109,8 +110,8 @@ pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*MouseEvent { return event; } -pub fn deinit(self: *MouseEvent, shutdown: bool, page: *Page) void { - self._proto.deinit(shutdown, page); +pub fn deinit(self: *MouseEvent, shutdown: bool, session: *Session) void { + self._proto.deinit(shutdown, session); } pub fn asEvent(self: *MouseEvent) *Event { diff --git a/src/browser/webapi/event/NavigationCurrentEntryChangeEvent.zig b/src/browser/webapi/event/NavigationCurrentEntryChangeEvent.zig index 59e32b06..98cba330 100644 --- a/src/browser/webapi/event/NavigationCurrentEntryChangeEvent.zig +++ b/src/browser/webapi/event/NavigationCurrentEntryChangeEvent.zig @@ -21,6 +21,7 @@ const String = @import("../../../string.zig").String; const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); +const Session = @import("../../Session.zig"); const Event = @import("../Event.zig"); const NavigationHistoryEntry = @import("../navigation/NavigationHistoryEntry.zig"); @@ -82,8 +83,8 @@ fn initWithTrusted( return event; } -pub fn deinit(self: *NavigationCurrentEntryChangeEvent, shutdown: bool, page: *Page) void { - self._proto.deinit(shutdown, page); +pub fn deinit(self: *NavigationCurrentEntryChangeEvent, shutdown: bool, session: *Session) void { + self._proto.deinit(shutdown, session); } pub fn asEvent(self: *NavigationCurrentEntryChangeEvent) *Event { diff --git a/src/browser/webapi/event/PageTransitionEvent.zig b/src/browser/webapi/event/PageTransitionEvent.zig index eceab4f2..bf747c9a 100644 --- a/src/browser/webapi/event/PageTransitionEvent.zig +++ b/src/browser/webapi/event/PageTransitionEvent.zig @@ -21,6 +21,7 @@ const String = @import("../../../string.zig").String; const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); +const Session = @import("../../Session.zig"); const Event = @import("../Event.zig"); const Allocator = std.mem.Allocator; @@ -65,8 +66,8 @@ fn initWithTrusted(arena: Allocator, typ: String, _opts: ?Options, trusted: bool return event; } -pub fn deinit(self: *PageTransitionEvent, shutdown: bool, page: *Page) void { - self._proto.deinit(shutdown, page); +pub fn deinit(self: *PageTransitionEvent, shutdown: bool, session: *Session) void { + self._proto.deinit(shutdown, session); } pub fn asEvent(self: *PageTransitionEvent) *Event { diff --git a/src/browser/webapi/event/PointerEvent.zig b/src/browser/webapi/event/PointerEvent.zig index 82f4874f..c5178faf 100644 --- a/src/browser/webapi/event/PointerEvent.zig +++ b/src/browser/webapi/event/PointerEvent.zig @@ -21,6 +21,7 @@ const String = @import("../../../string.zig").String; const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); +const Session = @import("../../Session.zig"); const Event = @import("../Event.zig"); const MouseEvent = @import("MouseEvent.zig"); @@ -127,8 +128,8 @@ pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*PointerEvent { return event; } -pub fn deinit(self: *PointerEvent, shutdown: bool, page: *Page) void { - self._proto.deinit(shutdown, page); +pub fn deinit(self: *PointerEvent, shutdown: bool, session: *Session) void { + self._proto.deinit(shutdown, session); } pub fn asEvent(self: *PointerEvent) *Event { diff --git a/src/browser/webapi/event/PopStateEvent.zig b/src/browser/webapi/event/PopStateEvent.zig index 45774998..f26c17b6 100644 --- a/src/browser/webapi/event/PopStateEvent.zig +++ b/src/browser/webapi/event/PopStateEvent.zig @@ -21,6 +21,7 @@ const String = @import("../../../string.zig").String; const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); +const Session = @import("../../Session.zig"); const Event = @import("../Event.zig"); const Allocator = std.mem.Allocator; @@ -66,8 +67,8 @@ fn initWithTrusted(arena: Allocator, typ: String, _opts: ?Options, trusted: bool return event; } -pub fn deinit(self: *PopStateEvent, shutdown: bool, page: *Page) void { - self._proto.deinit(shutdown, page); +pub fn deinit(self: *PopStateEvent, shutdown: bool, session: *Session) void { + self._proto.deinit(shutdown, session); } pub fn asEvent(self: *PopStateEvent) *Event { diff --git a/src/browser/webapi/event/ProgressEvent.zig b/src/browser/webapi/event/ProgressEvent.zig index a78982a1..b257f12b 100644 --- a/src/browser/webapi/event/ProgressEvent.zig +++ b/src/browser/webapi/event/ProgressEvent.zig @@ -20,6 +20,7 @@ const std = @import("std"); const String = @import("../../../string.zig").String; const Page = @import("../../Page.zig"); +const Session = @import("../../Session.zig"); const Event = @import("../Event.zig"); const Allocator = std.mem.Allocator; @@ -67,8 +68,8 @@ fn initWithTrusted(arena: Allocator, typ: String, _opts: ?Options, trusted: bool return event; } -pub fn deinit(self: *ProgressEvent, shutdown: bool, page: *Page) void { - self._proto.deinit(shutdown, page); +pub fn deinit(self: *ProgressEvent, shutdown: bool, session: *Session) void { + self._proto.deinit(shutdown, session); } pub fn asEvent(self: *ProgressEvent) *Event { diff --git a/src/browser/webapi/event/PromiseRejectionEvent.zig b/src/browser/webapi/event/PromiseRejectionEvent.zig index 957228df..f0a195b9 100644 --- a/src/browser/webapi/event/PromiseRejectionEvent.zig +++ b/src/browser/webapi/event/PromiseRejectionEvent.zig @@ -20,6 +20,7 @@ const String = @import("../../../string.zig").String; const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); +const Session = @import("../../Session.zig"); const Event = @import("../Event.zig"); const Allocator = std.mem.Allocator; @@ -56,14 +57,14 @@ pub fn init(typ: []const u8, opts_: ?Options, page: *Page) !*PromiseRejectionEve return event; } -pub fn deinit(self: *PromiseRejectionEvent, shutdown: bool, page: *Page) void { +pub fn deinit(self: *PromiseRejectionEvent, shutdown: bool, session: *Session) void { if (self._reason) |r| { - page.js.release(r); + r.release(); } if (self._promise) |p| { - page.js.release(p); + p.release(); } - self._proto.deinit(shutdown, page); + self._proto.deinit(shutdown, session); } pub fn asEvent(self: *PromiseRejectionEvent) *Event { diff --git a/src/browser/webapi/event/TextEvent.zig b/src/browser/webapi/event/TextEvent.zig index 54789c13..fd5e32fb 100644 --- a/src/browser/webapi/event/TextEvent.zig +++ b/src/browser/webapi/event/TextEvent.zig @@ -19,6 +19,7 @@ const std = @import("std"); const String = @import("../../../string.zig").String; const Page = @import("../../Page.zig"); +const Session = @import("../../Session.zig"); const js = @import("../../js/js.zig"); const Event = @import("../Event.zig"); @@ -58,8 +59,8 @@ pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*TextEvent { return event; } -pub fn deinit(self: *TextEvent, shutdown: bool, page: *Page) void { - self._proto.deinit(shutdown, page); +pub fn deinit(self: *TextEvent, shutdown: bool, session: *Session) void { + self._proto.deinit(shutdown, session); } pub fn asEvent(self: *TextEvent) *Event { diff --git a/src/browser/webapi/event/UIEvent.zig b/src/browser/webapi/event/UIEvent.zig index 6d329221..0aa2943b 100644 --- a/src/browser/webapi/event/UIEvent.zig +++ b/src/browser/webapi/event/UIEvent.zig @@ -18,6 +18,7 @@ const String = @import("../../../string.zig").String; const Page = @import("../../Page.zig"); +const Session = @import("../../Session.zig"); const js = @import("../../js/js.zig"); const Event = @import("../Event.zig"); @@ -69,8 +70,8 @@ pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*UIEvent { return event; } -pub fn deinit(self: *UIEvent, shutdown: bool, page: *Page) void { - self._proto.deinit(shutdown, page); +pub fn deinit(self: *UIEvent, shutdown: bool, session: *Session) void { + self._proto.deinit(shutdown, session); } pub fn as(self: *UIEvent, comptime T: type) *T { diff --git a/src/browser/webapi/event/WheelEvent.zig b/src/browser/webapi/event/WheelEvent.zig index ee725941..831c4e02 100644 --- a/src/browser/webapi/event/WheelEvent.zig +++ b/src/browser/webapi/event/WheelEvent.zig @@ -19,6 +19,7 @@ const std = @import("std"); const String = @import("../../../string.zig").String; const Page = @import("../../Page.zig"); +const Session = @import("../../Session.zig"); const js = @import("../../js/js.zig"); const Event = @import("../Event.zig"); @@ -86,8 +87,8 @@ pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*WheelEvent { return event; } -pub fn deinit(self: *WheelEvent, shutdown: bool, page: *Page) void { - self._proto.deinit(shutdown, page); +pub fn deinit(self: *WheelEvent, shutdown: bool, session: *Session) void { + self._proto.deinit(shutdown, session); } pub fn asEvent(self: *WheelEvent) *Event { diff --git a/src/browser/webapi/net/Fetch.zig b/src/browser/webapi/net/Fetch.zig index 35fce366..9b0f2f98 100644 --- a/src/browser/webapi/net/Fetch.zig +++ b/src/browser/webapi/net/Fetch.zig @@ -19,7 +19,7 @@ const std = @import("std"); const log = @import("../../../log.zig"); -const Http = @import("../../../http/Http.zig"); +const HttpClient = @import("../../HttpClient.zig"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); @@ -45,7 +45,7 @@ pub const InitOpts = Request.InitOpts; pub fn init(input: Input, options: ?InitOpts, page: *Page) !js.Promise { const request = try Request.init(input, options, page); const response = try Response.init(null, .{ .status = 0 }, page); - errdefer response.deinit(true, page); + errdefer response.deinit(true, page._session); const resolver = page.js.local.?.createPromiseResolver(); @@ -90,7 +90,7 @@ pub fn init(input: Input, options: ?InitOpts, page: *Page) !js.Promise { return resolver.promise(); } -fn httpStartCallback(transfer: *Http.Transfer) !void { +fn httpStartCallback(transfer: *HttpClient.Transfer) !void { const self: *Fetch = @ptrCast(@alignCast(transfer.ctx)); if (comptime IS_DEBUG) { log.debug(.http, "request start", .{ .url = self._url, .source = "fetch" }); @@ -98,7 +98,7 @@ fn httpStartCallback(transfer: *Http.Transfer) !void { self._response._transfer = transfer; } -fn httpHeaderDoneCallback(transfer: *Http.Transfer) !bool { +fn httpHeaderDoneCallback(transfer: *HttpClient.Transfer) !bool { const self: *Fetch = @ptrCast(@alignCast(transfer.ctx)); const arena = self._response._arena; @@ -148,7 +148,7 @@ fn httpHeaderDoneCallback(transfer: *Http.Transfer) !bool { return true; } -fn httpDataCallback(transfer: *Http.Transfer, data: []const u8) !void { +fn httpDataCallback(transfer: *HttpClient.Transfer, data: []const u8) !void { const self: *Fetch = @ptrCast(@alignCast(transfer.ctx)); try self._buf.appendSlice(self._response._arena, data); } @@ -184,7 +184,7 @@ fn httpErrorCallback(ctx: *anyopaque, err: anyerror) void { // clear this. (defer since `self is in the response's arena). defer if (self._owns_response) { - response.deinit(err == error.Abort, self._page); + response.deinit(err == error.Abort, self._page._session); self._owns_response = false; }; @@ -205,7 +205,7 @@ fn httpShutdownCallback(ctx: *anyopaque) void { if (self._owns_response) { var response = self._response; response._transfer = null; - response.deinit(true, self._page); + response.deinit(true, self._page._session); // Do not access `self` after this point: the Fetch struct was // allocated from response._arena which has been released. } diff --git a/src/browser/webapi/net/Headers.zig b/src/browser/webapi/net/Headers.zig index e5462d82..2c9879ab 100644 --- a/src/browser/webapi/net/Headers.zig +++ b/src/browser/webapi/net/Headers.zig @@ -86,8 +86,8 @@ pub fn forEach(self: *Headers, cb_: js.Function, js_this_: ?js.Object) !void { } // TODO: do we really need 2 different header structs?? -const Http = @import("../../../http/Http.zig"); -pub fn populateHttpHeader(self: *Headers, allocator: Allocator, http_headers: *Http.Headers) !void { +const net_http = @import("../../../network/http.zig"); +pub fn populateHttpHeader(self: *Headers, allocator: Allocator, http_headers: *net_http.Headers) !void { for (self._list._entries.items) |entry| { const merged = try std.mem.concatWithSentinel(allocator, u8, &.{ entry.name.str(), ": ", entry.value.str() }, 0); try http_headers.add(merged); diff --git a/src/browser/webapi/net/Request.zig b/src/browser/webapi/net/Request.zig index aa7e0dd7..3d3ca825 100644 --- a/src/browser/webapi/net/Request.zig +++ b/src/browser/webapi/net/Request.zig @@ -19,7 +19,7 @@ const std = @import("std"); const js = @import("../../js/js.zig"); -const Http = @import("../../../http/Http.zig"); +const net_http = @import("../../../network/http.zig"); const URL = @import("../URL.zig"); const Page = @import("../../Page.zig"); @@ -30,7 +30,7 @@ const Allocator = std.mem.Allocator; const Request = @This(); _url: [:0]const u8, -_method: Http.Method, +_method: net_http.Method, _headers: ?*Headers, _body: ?[]const u8, _arena: Allocator, @@ -108,14 +108,14 @@ pub fn init(input: Input, opts_: ?InitOpts, page: *Page) !*Request { }); } -fn parseMethod(method: []const u8, page: *Page) !Http.Method { +fn parseMethod(method: []const u8, page: *Page) !net_http.Method { if (method.len > "propfind".len) { return error.InvalidMethod; } const lower = std.ascii.lowerString(&page.buf, method); - const method_lookup = std.StaticStringMap(Http.Method).initComptime(.{ + const method_lookup = std.StaticStringMap(net_http.Method).initComptime(.{ .{ "get", .GET }, .{ "post", .POST }, .{ "delete", .DELETE }, diff --git a/src/browser/webapi/net/Response.zig b/src/browser/webapi/net/Response.zig index d2c270ce..6a926369 100644 --- a/src/browser/webapi/net/Response.zig +++ b/src/browser/webapi/net/Response.zig @@ -18,9 +18,10 @@ const std = @import("std"); const js = @import("../../js/js.zig"); -const Http = @import("../../../http/Http.zig"); +const HttpClient = @import("../../HttpClient.zig"); const Page = @import("../../Page.zig"); +const Session = @import("../../Session.zig"); const Headers = @import("Headers.zig"); const ReadableStream = @import("../streams/ReadableStream.zig"); const Blob = @import("../Blob.zig"); @@ -45,7 +46,7 @@ _type: Type, _status_text: []const u8, _url: [:0]const u8, _is_redirected: bool, -_transfer: ?*Http.Transfer = null, +_transfer: ?*HttpClient.Transfer = null, const InitOpts = struct { status: u16 = 200, @@ -77,7 +78,7 @@ pub fn init(body_: ?[]const u8, opts_: ?InitOpts, page: *Page) !*Response { return self; } -pub fn deinit(self: *Response, shutdown: bool, page: *Page) void { +pub fn deinit(self: *Response, shutdown: bool, session: *Session) void { if (self._transfer) |transfer| { if (shutdown) { transfer.terminate(); @@ -86,7 +87,7 @@ pub fn deinit(self: *Response, shutdown: bool, page: *Page) void { } self._transfer = null; } - page.releaseArena(self._arena); + session.releaseArena(self._arena); } pub fn getStatus(self: *const Response) u16 { diff --git a/src/browser/webapi/net/XMLHttpRequest.zig b/src/browser/webapi/net/XMLHttpRequest.zig index bf442c13..d8d5e369 100644 --- a/src/browser/webapi/net/XMLHttpRequest.zig +++ b/src/browser/webapi/net/XMLHttpRequest.zig @@ -20,11 +20,14 @@ const std = @import("std"); const js = @import("../../js/js.zig"); const log = @import("../../../log.zig"); -const Http = @import("../../../http/Http.zig"); +const HttpClient = @import("../../HttpClient.zig"); +const net_http = @import("../../../network/http.zig"); const URL = @import("../../URL.zig"); const Mime = @import("../../Mime.zig"); const Page = @import("../../Page.zig"); +const Session = @import("../../Session.zig"); + const Node = @import("../Node.zig"); const Event = @import("../Event.zig"); const Headers = @import("Headers.zig"); @@ -38,10 +41,10 @@ const XMLHttpRequest = @This(); _page: *Page, _proto: *XMLHttpRequestEventTarget, _arena: Allocator, -_transfer: ?*Http.Transfer = null, +_transfer: ?*HttpClient.Transfer = null, _url: [:0]const u8 = "", -_method: Http.Method = .GET, +_method: net_http.Method = .GET, _request_headers: *Headers, _request_body: ?[]const u8 = null, @@ -92,7 +95,7 @@ pub fn init(page: *Page) !*XMLHttpRequest { }); } -pub fn deinit(self: *XMLHttpRequest, shutdown: bool, page: *Page) void { +pub fn deinit(self: *XMLHttpRequest, shutdown: bool, session: *Session) void { if (self._transfer) |transfer| { if (shutdown) { transfer.terminate(); @@ -102,37 +105,36 @@ pub fn deinit(self: *XMLHttpRequest, shutdown: bool, page: *Page) void { self._transfer = null; } - const js_ctx = page.js; if (self._on_ready_state_change) |func| { - js_ctx.release(func); + func.release(); } { const proto = self._proto; if (proto._on_abort) |func| { - js_ctx.release(func); + func.release(); } if (proto._on_error) |func| { - js_ctx.release(func); + func.release(); } if (proto._on_load) |func| { - js_ctx.release(func); + func.release(); } if (proto._on_load_end) |func| { - js_ctx.release(func); + func.release(); } if (proto._on_load_start) |func| { - js_ctx.release(func); + func.release(); } if (proto._on_progress) |func| { - js_ctx.release(func); + func.release(); } if (proto._on_timeout) |func| { - js_ctx.release(func); + func.release(); } } - page.releaseArena(self._arena); + session.releaseArena(self._arena); } fn asEventTarget(self: *XMLHttpRequest) *EventTarget { @@ -341,7 +343,7 @@ pub fn getResponseXML(self: *XMLHttpRequest, page: *Page) !?*Node.Document { }; } -fn httpStartCallback(transfer: *Http.Transfer) !void { +fn httpStartCallback(transfer: *HttpClient.Transfer) !void { const self: *XMLHttpRequest = @ptrCast(@alignCast(transfer.ctx)); if (comptime IS_DEBUG) { log.debug(.http, "request start", .{ .method = self._method, .url = self._url, .source = "xhr" }); @@ -349,13 +351,13 @@ fn httpStartCallback(transfer: *Http.Transfer) !void { self._transfer = transfer; } -fn httpHeaderCallback(transfer: *Http.Transfer, header: Http.Header) !void { +fn httpHeaderCallback(transfer: *HttpClient.Transfer, header: net_http.Header) !void { const self: *XMLHttpRequest = @ptrCast(@alignCast(transfer.ctx)); const joined = try std.fmt.allocPrint(self._arena, "{s}: {s}", .{ header.name, header.value }); try self._response_headers.append(self._arena, joined); } -fn httpHeaderDoneCallback(transfer: *Http.Transfer) !bool { +fn httpHeaderDoneCallback(transfer: *HttpClient.Transfer) !bool { const self: *XMLHttpRequest = @ptrCast(@alignCast(transfer.ctx)); const header = &transfer.response_header.?; @@ -405,7 +407,7 @@ fn httpHeaderDoneCallback(transfer: *Http.Transfer) !bool { return true; } -fn httpDataCallback(transfer: *Http.Transfer, data: []const u8) !void { +fn httpDataCallback(transfer: *HttpClient.Transfer, data: []const u8) !void { const self: *XMLHttpRequest = @ptrCast(@alignCast(transfer.ctx)); try self._response_data.appendSlice(self._arena, data); @@ -515,7 +517,7 @@ fn stateChanged(self: *XMLHttpRequest, state: ReadyState, page: *Page) !void { } } -fn parseMethod(method: []const u8) !Http.Method { +fn parseMethod(method: []const u8) !net_http.Method { if (std.ascii.eqlIgnoreCase(method, "get")) { return .GET; } diff --git a/src/browser/webapi/selector/List.zig b/src/browser/webapi/selector/List.zig index 04055910..1061320d 100644 --- a/src/browser/webapi/selector/List.zig +++ b/src/browser/webapi/selector/List.zig @@ -19,6 +19,7 @@ const std = @import("std"); const Page = @import("../../Page.zig"); +const Session = @import("../../Session.zig"); const Node = @import("../Node.zig"); const Part = @import("Selector.zig").Part; @@ -40,8 +41,8 @@ pub const EntryIterator = GenericIterator(Iterator, null); pub const KeyIterator = GenericIterator(Iterator, "0"); pub const ValueIterator = GenericIterator(Iterator, "1"); -pub fn deinit(self: *const List, page: *Page) void { - page.releaseArena(self._arena); +pub fn deinit(self: *const List, session: *Session) void { + session.releaseArena(self._arena); } pub fn collect( diff --git a/src/cdp/Node.zig b/src/cdp/Node.zig index 34c58e3a..1260217b 100644 --- a/src/cdp/Node.zig +++ b/src/cdp/Node.zig @@ -406,7 +406,7 @@ test "cdp Node: search list" { { const l1 = try doc.querySelectorAll(.wrap("a"), page); - defer l1.deinit(page); + defer l1.deinit(page._session); const s1 = try search_list.create(l1._nodes); try testing.expectEqual("1", s1.name); try testing.expectEqualSlices(u32, &.{ 1, 2 }, s1.node_ids); @@ -417,7 +417,7 @@ test "cdp Node: search list" { { const l2 = try doc.querySelectorAll(.wrap("#a1"), page); - defer l2.deinit(page); + defer l2.deinit(page._session); const s2 = try search_list.create(l2._nodes); try testing.expectEqual("2", s2.name); try testing.expectEqualSlices(u32, &.{1}, s2.node_ids); @@ -425,7 +425,7 @@ test "cdp Node: search list" { { const l3 = try doc.querySelectorAll(.wrap("#a2"), page); - defer l3.deinit(page); + defer l3.deinit(page._session); const s3 = try search_list.create(l3._nodes); try testing.expectEqual("3", s3.name); try testing.expectEqualSlices(u32, &.{2}, s3.node_ids); diff --git a/src/cdp/cdp.zig b/src/cdp/cdp.zig index 8783e5a0..78e5ab50 100644 --- a/src/cdp/cdp.zig +++ b/src/cdp/cdp.zig @@ -28,7 +28,7 @@ const js = @import("../browser/js/js.zig"); const App = @import("../App.zig"); const Browser = @import("../browser/Browser.zig"); const Session = @import("../browser/Session.zig"); -const HttpClient = @import("../http/Client.zig"); +const HttpClient = @import("../browser/HttpClient.zig"); const Page = @import("../browser/Page.zig"); const Incrementing = @import("id.zig").Incrementing; const Notification = @import("../Notification.zig"); @@ -459,6 +459,12 @@ pub fn BrowserContext(comptime CDP_T: type) type { } self.isolated_worlds.clearRetainingCapacity(); + // do this before closeSession, since we don't want to process any + // new notification (Or maybe, instead of the deinit above, we just + // rely on those notifications to do our normal cleanup?) + + self.notification.unregisterAll(self); + // If the session has a page, we need to clear it first. The page // context is always nested inside of the isolated world context, // so we need to shutdown the page one first. @@ -466,7 +472,6 @@ pub fn BrowserContext(comptime CDP_T: type) type { self.node_registry.deinit(); self.node_search_list.deinit(); - self.notification.unregisterAll(self); self.notification.deinit(); if (self.http_proxy_changed) { diff --git a/src/cdp/domains/dom.zig b/src/cdp/domains/dom.zig index 2f2befa5..df1d37c2 100644 --- a/src/cdp/domains/dom.zig +++ b/src/cdp/domains/dom.zig @@ -98,7 +98,7 @@ fn performSearch(cmd: anytype) !void { const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; const page = bc.session.currentPage() orelse return error.PageNotLoaded; const list = try Selector.querySelectorAll(page.window._document.asNode(), params.query, page); - defer list.deinit(page); + defer list.deinit(page._session); const search = try bc.node_search_list.create(list._nodes); @@ -249,7 +249,7 @@ fn querySelectorAll(cmd: anytype) !void { }; const selected_nodes = try Selector.querySelectorAll(node.dom, params.selector, page); - defer selected_nodes.deinit(page); + defer selected_nodes.deinit(page._session); const nodes = selected_nodes._nodes; diff --git a/src/cdp/domains/fetch.zig b/src/cdp/domains/fetch.zig index beed6d76..310479b2 100644 --- a/src/cdp/domains/fetch.zig +++ b/src/cdp/domains/fetch.zig @@ -23,7 +23,8 @@ const id = @import("../id.zig"); const log = @import("../../log.zig"); const network = @import("network.zig"); -const Http = @import("../../http/Http.zig"); +const HttpClient = @import("../../browser/HttpClient.zig"); +const net_http = @import("../../network/http.zig"); const Notification = @import("../../Notification.zig"); pub fn processMessage(cmd: anytype) !void { @@ -49,7 +50,7 @@ pub fn processMessage(cmd: anytype) !void { // Stored in CDP pub const InterceptState = struct { allocator: Allocator, - waiting: std.AutoArrayHashMapUnmanaged(u32, *Http.Transfer), + waiting: std.AutoArrayHashMapUnmanaged(u32, *HttpClient.Transfer), pub fn init(allocator: Allocator) !InterceptState { return .{ @@ -62,11 +63,11 @@ pub const InterceptState = struct { return self.waiting.count() == 0; } - pub fn put(self: *InterceptState, transfer: *Http.Transfer) !void { + pub fn put(self: *InterceptState, transfer: *HttpClient.Transfer) !void { return self.waiting.put(self.allocator, transfer.id, transfer); } - pub fn remove(self: *InterceptState, request_id: u32) ?*Http.Transfer { + pub fn remove(self: *InterceptState, request_id: u32) ?*HttpClient.Transfer { const entry = self.waiting.fetchSwapRemove(request_id) orelse return null; return entry.value; } @@ -75,7 +76,7 @@ pub const InterceptState = struct { self.waiting.deinit(self.allocator); } - pub fn pendingTransfers(self: *const InterceptState) []*Http.Transfer { + pub fn pendingTransfers(self: *const InterceptState) []*HttpClient.Transfer { return self.waiting.values(); } }; @@ -221,7 +222,7 @@ fn continueRequest(cmd: anytype) !void { url: ?[]const u8 = null, method: ?[]const u8 = null, postData: ?[]const u8 = null, - headers: ?[]const Http.Header = null, + headers: ?[]const net_http.Header = null, interceptResponse: bool = false, })) orelse return error.InvalidParams; @@ -246,7 +247,7 @@ fn continueRequest(cmd: anytype) !void { try transfer.updateURL(try arena.dupeZ(u8, url)); } if (params.method) |method| { - transfer.req.method = std.meta.stringToEnum(Http.Method, method) orelse return error.InvalidParams; + transfer.req.method = std.meta.stringToEnum(net_http.Method, method) orelse return error.InvalidParams; } if (params.headers) |headers| { @@ -323,7 +324,7 @@ fn fulfillRequest(cmd: anytype) !void { const params = (try cmd.params(struct { requestId: []const u8, // "INT-{d}" responseCode: u16, - responseHeaders: ?[]const Http.Header = null, + responseHeaders: ?[]const net_http.Header = null, binaryResponseHeaders: ?[]const u8 = null, body: ?[]const u8 = null, responsePhrase: ?[]const u8 = null, diff --git a/src/cdp/domains/network.zig b/src/cdp/domains/network.zig index b353dc76..a2a36bbe 100644 --- a/src/cdp/domains/network.zig +++ b/src/cdp/domains/network.zig @@ -24,7 +24,7 @@ const CdpStorage = @import("storage.zig"); const id = @import("../id.zig"); const URL = @import("../../browser/URL.zig"); -const Transfer = @import("../../http/Client.zig").Transfer; +const Transfer = @import("../../browser/HttpClient.zig").Transfer; const Notification = @import("../../Notification.zig"); const Mime = @import("../../browser/Mime.zig"); diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig index 70f349a9..6e406c05 100644 --- a/src/cdp/domains/page.zig +++ b/src/cdp/domains/page.zig @@ -292,6 +292,10 @@ pub fn pageNavigate(bc: anytype, event: *const Notification.PageNavigate) !void } pub fn pageRemove(bc: anytype) !void { + // Clear all remote object mappings to prevent stale objectIds from being used + // after the context is destroy + bc.inspector_session.inspector.resetContextGroup(); + // The main page is going to be removed, we need to remove contexts from other worlds first. for (bc.isolated_worlds.items) |isolated_world| { try isolated_world.removeContext(); @@ -410,7 +414,7 @@ pub fn pageNavigated(arena: Allocator, bc: anytype, event: *const Notification.P bc.inspector_session.inspector.contextCreated( &ls.local, "", - try page.getOrigin(arena) orelse "", + page.origin orelse "", aux_data, true, ); diff --git a/src/http/Http.zig b/src/http/Http.zig deleted file mode 100644 index 778a1be4..00000000 --- a/src/http/Http.zig +++ /dev/null @@ -1,89 +0,0 @@ -// Copyright (C) 2023-2026 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 Net = @import("../Net.zig"); - -const ENABLE_DEBUG = Net.ENABLE_DEBUG; -pub const Client = @import("Client.zig"); -pub const Transfer = Client.Transfer; - -pub const Method = Net.Method; -pub const Header = Net.Header; -pub const Headers = Net.Headers; - -const Config = @import("../Config.zig"); -const RobotStore = @import("../browser/Robots.zig").RobotStore; - -const Allocator = std.mem.Allocator; -const ArenaAllocator = std.heap.ArenaAllocator; - -// Client.zig does the bulk of the work and is loosely tied to a browser Page. -// But we still need something above Client.zig for the "utility" http stuff -// we need to do, like telemetry. The most important thing we want from this -// is to be able to share the ca_blob, which can be quite large - loading it -// once for all http connections is a win. -const Http = @This(); - -arena: ArenaAllocator, -allocator: Allocator, -config: *const Config, -ca_blob: ?Net.Blob, -robot_store: *RobotStore, - -pub fn init(allocator: Allocator, robot_store: *RobotStore, config: *const Config) !Http { - try Net.globalInit(); - errdefer Net.globalDeinit(); - - if (comptime ENABLE_DEBUG) { - std.debug.print("curl version: {s}\n\n", .{Net.curl_version()}); - } - - var arena = ArenaAllocator.init(allocator); - errdefer arena.deinit(); - - var ca_blob: ?Net.Blob = null; - if (config.tlsVerifyHost()) { - ca_blob = try Net.loadCerts(allocator); - } - - return .{ - .arena = arena, - .allocator = allocator, - .config = config, - .ca_blob = ca_blob, - .robot_store = robot_store, - }; -} - -pub fn deinit(self: *Http) void { - if (self.ca_blob) |ca_blob| { - const data: [*]u8 = @ptrCast(ca_blob.data); - self.allocator.free(data[0..ca_blob.len]); - } - Net.globalDeinit(); - self.arena.deinit(); -} - -pub fn createClient(self: *Http, allocator: Allocator) !*Client { - return Client.init(allocator, self.ca_blob, self.robot_store, self.config); -} - -pub fn newConnection(self: *Http) !Net.Connection { - return Net.Connection.init(self.ca_blob, self.config); -} diff --git a/src/lightpanda.zig b/src/lightpanda.zig index 03bced65..4fac3921 100644 --- a/src/lightpanda.zig +++ b/src/lightpanda.zig @@ -18,6 +18,7 @@ const std = @import("std"); pub const App = @import("App.zig"); +pub const Network = @import("network/Runtime.zig"); pub const Server = @import("Server.zig"); pub const Config = @import("Config.zig"); pub const URL = @import("browser/URL.zig"); @@ -39,6 +40,7 @@ pub const mcp = @import("mcp.zig"); pub const build_config = @import("build_config"); pub const crash_handler = @import("crash_handler.zig"); +pub const HttpClient = @import("browser/HttpClient.zig"); const IS_DEBUG = @import("builtin").mode == .Debug; pub const FetchOpts = struct { @@ -48,7 +50,7 @@ pub const FetchOpts = struct { writer: ?*std.Io.Writer = null, }; pub fn fetch(app: *App, url: [:0]const u8, opts: FetchOpts) !void { - const http_client = try app.http.createClient(app.allocator); + const http_client = try HttpClient.init(app.allocator, &app.network); defer http_client.deinit(); const notification = try Notification.init(app.allocator); diff --git a/src/main.zig b/src/main.zig index dd6a759a..26e29b22 100644 --- a/src/main.zig +++ b/src/main.zig @@ -93,18 +93,14 @@ fn run(allocator: Allocator, main_arena: Allocator) !void { return args.printUsageAndExit(false); }; - // _server is global to handle graceful shutdown. - var server = try lp.Server.init(app, address); - defer server.deinit(); - - try sighandler.on(lp.Server.stop, .{&server}); - - // max timeout of 1 week. - const timeout = if (opts.timeout > 604_800) 604_800_000 else @as(u32, opts.timeout) * 1000; - server.run(address, timeout) catch |err| { + var server = lp.Server.init(app, address) catch |err| { log.fatal(.app, "server run error", .{ .err = err }); return err; }; + defer server.deinit(); + + try sighandler.on(lp.Network.stop, .{&app.network}); + app.network.run(); }, .fetch => |opts| { const url = opts.url; diff --git a/src/main_legacy_test.zig b/src/main_legacy_test.zig index 11c7588e..a6d1593f 100644 --- a/src/main_legacy_test.zig +++ b/src/main_legacy_test.zig @@ -46,7 +46,7 @@ pub fn main() !void { var test_arena = std.heap.ArenaAllocator.init(allocator); defer test_arena.deinit(); - const http_client = try app.http.createClient(allocator); + const http_client = try lp.HttpClient.init(allocator, &app.network); defer http_client.deinit(); var browser = try lp.Browser.init(app, .{ .http_client = http_client }); diff --git a/src/mcp/Server.zig b/src/mcp/Server.zig index 1e67792c..6f8b1f21 100644 --- a/src/mcp/Server.zig +++ b/src/mcp/Server.zig @@ -3,7 +3,7 @@ const std = @import("std"); const lp = @import("lightpanda"); const App = @import("../App.zig"); -const HttpClient = @import("../http/Client.zig"); +const HttpClient = @import("../browser/HttpClient.zig"); const testing = @import("../testing.zig"); const protocol = @import("protocol.zig"); const router = @import("router.zig"); @@ -25,7 +25,7 @@ mutex: std.Thread.Mutex = .{}, aw: std.io.Writer.Allocating, pub fn init(allocator: std.mem.Allocator, app: *App, writer: *std.io.Writer) !*Self { - const http_client = try app.http.createClient(allocator); + const http_client = try HttpClient.init(allocator, &app.network); errdefer http_client.deinit(); const notification = try lp.Notification.init(allocator); diff --git a/src/mcp/protocol.zig b/src/mcp/protocol.zig index 97035c0f..1c488535 100644 --- a/src/mcp/protocol.zig +++ b/src/mcp/protocol.zig @@ -114,6 +114,7 @@ pub const Tool = struct { }; pub fn minify(comptime json: []const u8) []const u8 { + @setEvalBranchQuota(100000); return comptime blk: { var res: []const u8 = ""; var in_string = false; diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig index a475b987..f5126be0 100644 --- a/src/mcp/tools.zig +++ b/src/mcp/tools.zig @@ -74,6 +74,30 @@ pub const tool_list = [_]protocol.Tool{ \\} ), }, + .{ + .name = "interactiveElements", + .description = "Extract interactive elements from the opened page. If a url is provided, it navigates to that url first.", + .inputSchema = protocol.minify( + \\{ + \\ "type": "object", + \\ "properties": { + \\ "url": { "type": "string", "description": "Optional URL to navigate to before extracting interactive elements." } + \\ } + \\} + ), + }, + .{ + .name = "structuredData", + .description = "Extract structured data (like JSON-LD, OpenGraph, etc) from the opened page. If a url is provided, it navigates to that url first.", + .inputSchema = protocol.minify( + \\{ + \\ "type": "object", + \\ "properties": { + \\ "url": { "type": "string", "description": "Optional URL to navigate to before extracting structured data." } + \\ } + \\} + ), + }, }; pub fn handleList(server: *Server, arena: std.mem.Allocator, req: protocol.Request) !void { @@ -108,7 +132,8 @@ const ToolStreamingText = struct { }, .links => { if (Selector.querySelectorAll(self.page.document.asNode(), "a[href]", self.page)) |list| { - defer list.deinit(self.page); + defer list.deinit(self.page._session); + var first = true; for (list._nodes) |node| { if (node.is(Element.Html.Anchor)) |anchor| { @@ -153,6 +178,8 @@ const ToolAction = enum { navigate, markdown, links, + interactiveElements, + structuredData, evaluate, semantic_tree, }; @@ -162,6 +189,8 @@ const tool_map = std.StaticStringMap(ToolAction).initComptime(.{ .{ "navigate", .navigate }, .{ "markdown", .markdown }, .{ "links", .links }, + .{ "interactiveElements", .interactiveElements }, + .{ "structuredData", .structuredData }, .{ "evaluate", .evaluate }, .{ "semantic_tree", .semantic_tree }, }); @@ -188,6 +217,8 @@ pub fn handleCall(server: *Server, arena: std.mem.Allocator, req: protocol.Reque .goto, .navigate => try handleGoto(server, arena, req.id.?, call_params.arguments), .markdown => try handleMarkdown(server, arena, req.id.?, call_params.arguments), .links => try handleLinks(server, arena, req.id.?, call_params.arguments), + .interactiveElements => try handleInteractiveElements(server, arena, req.id.?, call_params.arguments), + .structuredData => try handleStructuredData(server, arena, req.id.?, call_params.arguments), .evaluate => try handleEvaluate(server, arena, req.id.?, call_params.arguments), .semantic_tree => try handleSemanticTree(server, arena, req.id.?, call_params.arguments), } @@ -264,6 +295,58 @@ fn handleSemanticTree(server: *Server, arena: std.mem.Allocator, id: std.json.Va try server.sendResult(id, protocol.CallToolResult(ToolStreamingText){ .content = &content }); } +fn handleInteractiveElements(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { + const Params = struct { + url: ?[:0]const u8 = null, + }; + if (arguments) |args_raw| { + if (std.json.parseFromValueLeaky(Params, arena, args_raw, .{ .ignore_unknown_fields = true })) |args| { + if (args.url) |u| { + try performGoto(server, u, id); + } + } else |_| {} + } + const page = server.session.currentPage() orelse { + return server.sendError(id, .PageNotLoaded, "Page not loaded"); + }; + + const elements = lp.interactive.collectInteractiveElements(page.document.asNode(), arena, page) catch |err| { + log.err(.mcp, "elements collection failed", .{ .err = err }); + return server.sendError(id, .InternalError, "Failed to collect interactive elements"); + }; + var aw: std.Io.Writer.Allocating = .init(arena); + try std.json.Stringify.value(elements, .{}, &aw.writer); + + const content = [_]protocol.TextContent([]const u8){.{ .text = aw.written() }}; + try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); +} + +fn handleStructuredData(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { + const Params = struct { + url: ?[:0]const u8 = null, + }; + if (arguments) |args_raw| { + if (std.json.parseFromValueLeaky(Params, arena, args_raw, .{ .ignore_unknown_fields = true })) |args| { + if (args.url) |u| { + try performGoto(server, u, id); + } + } else |_| {} + } + const page = server.session.currentPage() orelse { + return server.sendError(id, .PageNotLoaded, "Page not loaded"); + }; + + const data = lp.structured_data.collectStructuredData(page.document.asNode(), arena, page) catch |err| { + log.err(.mcp, "struct data collection failed", .{ .err = err }); + return server.sendError(id, .InternalError, "Failed to collect structured data"); + }; + var aw: std.Io.Writer.Allocating = .init(arena); + try std.json.Stringify.value(data, .{}, &aw.writer); + + const content = [_]protocol.TextContent([]const u8){.{ .text = aw.written() }}; + try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); +} + fn handleEvaluate(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { const args = try parseArguments(EvaluateParams, arena, arguments, server, id, "evaluate"); diff --git a/src/browser/Robots.zig b/src/network/Robots.zig similarity index 100% rename from src/browser/Robots.zig rename to src/network/Robots.zig diff --git a/src/network/Runtime.zig b/src/network/Runtime.zig new file mode 100644 index 00000000..af441a6a --- /dev/null +++ b/src/network/Runtime.zig @@ -0,0 +1,402 @@ +// Copyright (C) 2023-2026 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 builtin = @import("builtin"); +const net = std.net; +const posix = std.posix; +const Allocator = std.mem.Allocator; + +const lp = @import("lightpanda"); +const Config = @import("../Config.zig"); +const libcurl = @import("../sys/libcurl.zig"); + +const net_http = @import("http.zig"); +const RobotStore = @import("Robots.zig").RobotStore; + +const Runtime = @This(); + +const Listener = struct { + socket: posix.socket_t, + ctx: *anyopaque, + onAccept: *const fn (ctx: *anyopaque, socket: posix.socket_t) void, +}; + +allocator: Allocator, + +config: *const Config, +ca_blob: ?net_http.Blob, +robot_store: RobotStore, + +pollfds: []posix.pollfd, +listener: ?Listener = null, + +// Wakeup pipe: workers write to [1], main thread polls [0] +wakeup_pipe: [2]posix.fd_t = .{ -1, -1 }, + +shutdown: std.atomic.Value(bool) = .init(false), + +const ZigToCurlAllocator = struct { + // C11 requires malloc to return memory aligned to max_align_t (16 bytes on x86_64). + // We match this guarantee since libcurl expects malloc-compatible alignment. + const alignment = 16; + + const Block = extern struct { + size: usize = 0, + _padding: [alignment - @sizeOf(usize)]u8 = .{0} ** (alignment - @sizeOf(usize)), + + inline fn fullsize(bytes: usize) usize { + return alignment + bytes; + } + + inline fn fromPtr(ptr: *anyopaque) *Block { + const raw: [*]u8 = @ptrCast(ptr); + return @ptrCast(@alignCast(raw - @sizeOf(Block))); + } + + inline fn data(self: *Block) [*]u8 { + const ptr: [*]u8 = @ptrCast(self); + return ptr + @sizeOf(Block); + } + + inline fn slice(self: *Block) []align(alignment) u8 { + const base: [*]align(alignment) u8 = @ptrCast(@alignCast(self)); + return base[0 .. alignment + self.size]; + } + }; + + comptime { + std.debug.assert(@sizeOf(Block) == alignment); + } + + var instance: ?ZigToCurlAllocator = null; + + allocator: Allocator, + + pub fn init(allocator: Allocator) void { + lp.assert(instance == null, "Initialization of curl must happen only once", .{}); + instance = .{ .allocator = allocator }; + } + + pub fn interface() libcurl.CurlAllocator { + return .{ + .free = free, + .strdup = strdup, + .malloc = malloc, + .calloc = calloc, + .realloc = realloc, + }; + } + + fn _allocBlock(size: usize) ?*Block { + const slice = instance.?.allocator.alignedAlloc(u8, .fromByteUnits(alignment), Block.fullsize(size)) catch return null; + const block: *Block = @ptrCast(@alignCast(slice.ptr)); + block.size = size; + return block; + } + + fn _freeBlock(header: *Block) void { + instance.?.allocator.free(header.slice()); + } + + fn malloc(size: usize) ?*anyopaque { + const block = _allocBlock(size) orelse return null; + return @ptrCast(block.data()); + } + + fn calloc(nmemb: usize, size: usize) ?*anyopaque { + const total = nmemb * size; + const block = _allocBlock(total) orelse return null; + const ptr = block.data(); + @memset(ptr[0..total], 0); // for historical reasons, calloc zeroes memory, but malloc does not. + return @ptrCast(ptr); + } + + fn realloc(ptr: ?*anyopaque, size: usize) ?*anyopaque { + const p = ptr orelse return malloc(size); + const block = Block.fromPtr(p); + + const old_size = block.size; + if (size == old_size) return ptr; + + if (instance.?.allocator.resize(block.slice(), alignment + size)) { + block.size = size; + return ptr; + } + + const copy_size = @min(old_size, size); + const new_block = _allocBlock(size) orelse return null; + @memcpy(new_block.data()[0..copy_size], block.data()[0..copy_size]); + _freeBlock(block); + return @ptrCast(new_block.data()); + } + + fn free(ptr: ?*anyopaque) void { + const p = ptr orelse return; + _freeBlock(Block.fromPtr(p)); + } + + fn strdup(str: [*:0]const u8) ?[*:0]u8 { + const len = std.mem.len(str); + const header = _allocBlock(len + 1) orelse return null; + const ptr = header.data(); + @memcpy(ptr[0..len], str[0..len]); + ptr[len] = 0; + return ptr[0..len :0]; + } +}; + +fn globalInit(allocator: Allocator) void { + ZigToCurlAllocator.init(allocator); + + libcurl.curl_global_init(.{ .ssl = true }, ZigToCurlAllocator.interface()) catch |err| { + lp.assert(false, "curl global init", .{ .err = err }); + }; +} + +fn globalDeinit() void { + libcurl.curl_global_cleanup(); +} + +pub fn init(allocator: Allocator, config: *const Config) !Runtime { + globalInit(allocator); + errdefer globalDeinit(); + + const pipe = try posix.pipe2(.{ .NONBLOCK = true, .CLOEXEC = true }); + + // 0 is wakeup, 1 is listener + const pollfds = try allocator.alloc(posix.pollfd, 2); + errdefer allocator.free(pollfds); + + @memset(pollfds, .{ .fd = -1, .events = 0, .revents = 0 }); + pollfds[0] = .{ .fd = pipe[0], .events = posix.POLL.IN, .revents = 0 }; + + var ca_blob: ?net_http.Blob = null; + if (config.tlsVerifyHost()) { + ca_blob = try loadCerts(allocator); + } + + return .{ + .allocator = allocator, + .config = config, + .ca_blob = ca_blob, + .robot_store = RobotStore.init(allocator), + .pollfds = pollfds, + .wakeup_pipe = pipe, + }; +} + +pub fn deinit(self: *Runtime) void { + for (&self.wakeup_pipe) |*fd| { + if (fd.* >= 0) { + posix.close(fd.*); + fd.* = -1; + } + } + + self.allocator.free(self.pollfds); + + if (self.ca_blob) |ca_blob| { + const data: [*]u8 = @ptrCast(ca_blob.data); + self.allocator.free(data[0..ca_blob.len]); + } + + self.robot_store.deinit(); + + globalDeinit(); +} + +pub fn bind( + self: *Runtime, + address: net.Address, + ctx: *anyopaque, + on_accept: *const fn (ctx: *anyopaque, socket: posix.socket_t) void, +) !void { + const flags = posix.SOCK.STREAM | posix.SOCK.CLOEXEC | posix.SOCK.NONBLOCK; + const listener = try posix.socket(address.any.family, flags, posix.IPPROTO.TCP); + errdefer posix.close(listener); + + try posix.setsockopt(listener, posix.SOL.SOCKET, posix.SO.REUSEADDR, &std.mem.toBytes(@as(c_int, 1))); + if (@hasDecl(posix.TCP, "NODELAY")) { + try posix.setsockopt(listener, posix.IPPROTO.TCP, posix.TCP.NODELAY, &std.mem.toBytes(@as(c_int, 1))); + } + + try posix.bind(listener, &address.any, address.getOsSockLen()); + try posix.listen(listener, self.config.maxPendingConnections()); + + if (self.listener != null) return error.TooManyListeners; + + self.listener = .{ + .socket = listener, + .ctx = ctx, + .onAccept = on_accept, + }; + self.pollfds[1] = .{ + .fd = listener, + .events = posix.POLL.IN, + .revents = 0, + }; +} + +pub fn run(self: *Runtime) void { + while (!self.shutdown.load(.acquire)) { + const listener = self.listener orelse return; + + _ = posix.poll(self.pollfds, -1) catch |err| { + lp.log.err(.app, "poll", .{ .err = err }); + continue; + }; + + // check wakeup socket + if (self.pollfds[0].revents != 0) { + self.pollfds[0].revents = 0; + + // If we were woken up, perhaps everything was cancelled and the iteration can be completed. + if (self.shutdown.load(.acquire)) break; + } + + // check new connections; + if (self.pollfds[1].revents == 0) continue; + self.pollfds[1].revents = 0; + + const socket = posix.accept(listener.socket, null, null, posix.SOCK.NONBLOCK) catch |err| { + switch (err) { + error.SocketNotListening, error.ConnectionAborted => { + self.pollfds[1] = .{ .fd = -1, .events = 0, .revents = 0 }; + self.listener = null; + }, + error.WouldBlock => {}, + else => { + lp.log.err(.app, "accept", .{ .err = err }); + }, + } + continue; + }; + + listener.onAccept(listener.ctx, socket); + } + + if (self.listener) |listener| { + posix.shutdown(listener.socket, .both) catch |err| blk: { + if (err == error.SocketNotConnected and builtin.os.tag != .linux) { + // This error is normal/expected on BSD/MacOS. We probably + // shouldn't bother calling shutdown at all, but I guess this + // is safer. + break :blk; + } + lp.log.warn(.app, "listener shutdown", .{ .err = err }); + }; + posix.close(listener.socket); + } +} + +pub fn stop(self: *Runtime) void { + self.shutdown.store(true, .release); + _ = posix.write(self.wakeup_pipe[1], &.{1}) catch {}; +} + +pub fn newConnection(self: *Runtime) !net_http.Connection { + return net_http.Connection.init(self.ca_blob, self.config); +} + +// Wraps lines @ 64 columns. A PEM is basically a base64 encoded DER (which is +// what Zig has), with lines wrapped at 64 characters and with a basic header +// and footer +const LineWriter = struct { + col: usize = 0, + inner: std.ArrayList(u8).Writer, + + pub fn writeAll(self: *LineWriter, data: []const u8) !void { + var writer = self.inner; + + var col = self.col; + const len = 64 - col; + + var remain = data; + if (remain.len > len) { + col = 0; + try writer.writeAll(data[0..len]); + try writer.writeByte('\n'); + remain = data[len..]; + } + + while (remain.len > 64) { + try writer.writeAll(remain[0..64]); + try writer.writeByte('\n'); + remain = data[len..]; + } + try writer.writeAll(remain); + self.col = col + remain.len; + } +}; + +// TODO: on BSD / Linux, we could just read the PEM file directly. +// This whole rescan + decode is really just needed for MacOS. On Linux +// bundle.rescan does find the .pem file(s) which could be in a few different +// places, so it's still useful, just not efficient. +fn loadCerts(allocator: Allocator) !libcurl.CurlBlob { + var bundle: std.crypto.Certificate.Bundle = .{}; + try bundle.rescan(allocator); + defer bundle.deinit(allocator); + + const bytes = bundle.bytes.items; + if (bytes.len == 0) { + lp.log.warn(.app, "No system certificates", .{}); + return .{ + .len = 0, + .flags = 0, + .data = bytes.ptr, + }; + } + + const encoder = std.base64.standard.Encoder; + var arr: std.ArrayList(u8) = .empty; + + const encoded_size = encoder.calcSize(bytes.len); + const buffer_size = encoded_size + + (bundle.map.count() * 75) + // start / end per certificate + extra, just in case + (encoded_size / 64) // newline per 64 characters + ; + try arr.ensureTotalCapacity(allocator, buffer_size); + errdefer arr.deinit(allocator); + var writer = arr.writer(allocator); + + var it = bundle.map.valueIterator(); + while (it.next()) |index| { + const cert = try std.crypto.Certificate.der.Element.parse(bytes, index.*); + + try writer.writeAll("-----BEGIN CERTIFICATE-----\n"); + var line_writer = LineWriter{ .inner = writer }; + try encoder.encodeWriter(&line_writer, bytes[index.*..cert.slice.end]); + try writer.writeAll("\n-----END CERTIFICATE-----\n"); + } + + // Final encoding should not be larger than our initial size estimate + lp.assert(buffer_size > arr.items.len, "Http loadCerts", .{ .estimate = buffer_size, .len = arr.items.len }); + + // Allocate exactly the size needed and copy the data + const result = try allocator.dupe(u8, arr.items); + // Free the original oversized allocation + arr.deinit(allocator); + + return .{ + .len = result.len, + .data = result.ptr, + .flags = 0, + }; +} diff --git a/src/network/http.zig b/src/network/http.zig new file mode 100644 index 00000000..28fd7736 --- /dev/null +++ b/src/network/http.zig @@ -0,0 +1,610 @@ +// Copyright (C) 2023-2026 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 builtin = @import("builtin"); +const posix = std.posix; +const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; + +const Config = @import("../Config.zig"); +const libcurl = @import("../sys/libcurl.zig"); + +const log = @import("lightpanda").log; +const assert = @import("lightpanda").assert; + +pub const ENABLE_DEBUG = false; +const IS_DEBUG = builtin.mode == .Debug; + +pub const Blob = libcurl.CurlBlob; +pub const WaitFd = libcurl.CurlWaitFd; +pub const writefunc_error = libcurl.curl_writefunc_error; + +const Error = libcurl.Error; +const ErrorMulti = libcurl.ErrorMulti; +const errorFromCode = libcurl.errorFromCode; +const errorMFromCode = libcurl.errorMFromCode; +const errorCheck = libcurl.errorCheck; +const errorMCheck = libcurl.errorMCheck; + +pub fn curl_version() [*c]const u8 { + return libcurl.curl_version(); +} + +pub const Method = enum(u8) { + GET = 0, + PUT = 1, + POST = 2, + DELETE = 3, + HEAD = 4, + OPTIONS = 5, + PATCH = 6, + PROPFIND = 7, +}; + +pub const Header = struct { + name: []const u8, + value: []const u8, +}; + +pub const Headers = struct { + headers: ?*libcurl.CurlSList, + cookies: ?[*c]const u8, + + pub fn init(user_agent: [:0]const u8) !Headers { + const header_list = libcurl.curl_slist_append(null, user_agent); + if (header_list == null) { + return error.OutOfMemory; + } + return .{ .headers = header_list, .cookies = null }; + } + + pub fn deinit(self: *const Headers) void { + if (self.headers) |hdr| { + libcurl.curl_slist_free_all(hdr); + } + } + + pub fn add(self: *Headers, header: [*c]const u8) !void { + // Copies the value + const updated_headers = libcurl.curl_slist_append(self.headers, header); + if (updated_headers == null) { + return error.OutOfMemory; + } + + self.headers = updated_headers; + } + + fn parseHeader(header_str: []const u8) ?Header { + const colon_pos = std.mem.indexOfScalar(u8, header_str, ':') orelse return null; + + const name = std.mem.trim(u8, header_str[0..colon_pos], " \t"); + const value = std.mem.trim(u8, header_str[colon_pos + 1 ..], " \t"); + + return .{ .name = name, .value = value }; + } + + pub fn iterator(self: *Headers) Iterator { + return .{ + .header = self.headers, + .cookies = self.cookies, + }; + } + + const Iterator = struct { + header: [*c]libcurl.CurlSList, + cookies: ?[*c]const u8, + + pub fn next(self: *Iterator) ?Header { + const h = self.header orelse { + const cookies = self.cookies orelse return null; + self.cookies = null; + return .{ .name = "Cookie", .value = std.mem.span(@as([*:0]const u8, cookies)) }; + }; + + self.header = h.*.next; + return parseHeader(std.mem.span(@as([*:0]const u8, @ptrCast(h.*.data)))); + } + }; +}; + +// In normal cases, the header iterator comes from the curl linked list. +// But it's also possible to inject a response, via `transfer.fulfill`. In that +// case, the resposne headers are a list, []const Http.Header. +// This union, is an iterator that exposes the same API for either case. +pub const HeaderIterator = union(enum) { + curl: CurlHeaderIterator, + list: ListHeaderIterator, + + pub fn next(self: *HeaderIterator) ?Header { + switch (self.*) { + inline else => |*it| return it.next(), + } + } + + const CurlHeaderIterator = struct { + conn: *const Connection, + prev: ?*libcurl.CurlHeader = null, + + pub fn next(self: *CurlHeaderIterator) ?Header { + const h = libcurl.curl_easy_nextheader(self.conn.easy, .header, -1, self.prev) orelse return null; + self.prev = h; + + const header = h.*; + return .{ + .name = std.mem.span(header.name), + .value = std.mem.span(header.value), + }; + } + }; + + const ListHeaderIterator = struct { + index: usize = 0, + list: []const Header, + + pub fn next(self: *ListHeaderIterator) ?Header { + const idx = self.index; + if (idx == self.list.len) { + return null; + } + self.index = idx + 1; + return self.list[idx]; + } + }; +}; + +const HeaderValue = struct { + value: []const u8, + amount: usize, +}; + +pub const AuthChallenge = struct { + status: u16, + source: ?enum { server, proxy }, + scheme: ?enum { basic, digest }, + realm: ?[]const u8, + + pub fn parse(status: u16, header: []const u8) !AuthChallenge { + var ac: AuthChallenge = .{ + .status = status, + .source = null, + .realm = null, + .scheme = null, + }; + + const sep = std.mem.indexOfPos(u8, header, 0, ": ") orelse return error.InvalidHeader; + const hname = header[0..sep]; + const hvalue = header[sep + 2 ..]; + + if (std.ascii.eqlIgnoreCase("WWW-Authenticate", hname)) { + ac.source = .server; + } else if (std.ascii.eqlIgnoreCase("Proxy-Authenticate", hname)) { + ac.source = .proxy; + } else { + return error.InvalidAuthChallenge; + } + + const pos = std.mem.indexOfPos(u8, std.mem.trim(u8, hvalue, std.ascii.whitespace[0..]), 0, " ") orelse hvalue.len; + const _scheme = hvalue[0..pos]; + if (std.ascii.eqlIgnoreCase(_scheme, "basic")) { + ac.scheme = .basic; + } else if (std.ascii.eqlIgnoreCase(_scheme, "digest")) { + ac.scheme = .digest; + } else { + return error.UnknownAuthChallengeScheme; + } + + return ac; + } +}; + +pub const ResponseHead = struct { + pub const MAX_CONTENT_TYPE_LEN = 64; + + status: u16, + url: [*c]const u8, + redirect_count: u32, + _content_type_len: usize = 0, + _content_type: [MAX_CONTENT_TYPE_LEN]u8 = undefined, + // this is normally an empty list, but if the response is being injected + // than it'll be populated. It isn't meant to be used directly, but should + // be used through the transfer.responseHeaderIterator() which abstracts + // whether the headers are from a live curl easy handle, or injected. + _injected_headers: []const Header = &.{}, + + pub fn contentType(self: *ResponseHead) ?[]u8 { + if (self._content_type_len == 0) { + return null; + } + return self._content_type[0..self._content_type_len]; + } +}; + +pub const Connection = struct { + easy: *libcurl.Curl, + node: Handles.HandleList.Node = .{}, + + pub fn init( + ca_blob_: ?libcurl.CurlBlob, + config: *const Config, + ) !Connection { + const easy = libcurl.curl_easy_init() orelse return error.FailedToInitializeEasy; + errdefer libcurl.curl_easy_cleanup(easy); + + // timeouts + try libcurl.curl_easy_setopt(easy, .timeout_ms, config.httpTimeout()); + try libcurl.curl_easy_setopt(easy, .connect_timeout_ms, config.httpConnectTimeout()); + + // redirect behavior + try libcurl.curl_easy_setopt(easy, .max_redirs, config.httpMaxRedirects()); + try libcurl.curl_easy_setopt(easy, .follow_location, 2); + try libcurl.curl_easy_setopt(easy, .redir_protocols_str, "HTTP,HTTPS"); // remove FTP and FTPS from the default + + // proxy + const http_proxy = config.httpProxy(); + if (http_proxy) |proxy| { + try libcurl.curl_easy_setopt(easy, .proxy, proxy.ptr); + } + + // tls + if (ca_blob_) |ca_blob| { + try libcurl.curl_easy_setopt(easy, .ca_info_blob, ca_blob); + if (http_proxy != null) { + try libcurl.curl_easy_setopt(easy, .proxy_ca_info_blob, ca_blob); + } + } else { + assert(config.tlsVerifyHost() == false, "Http.init tls_verify_host", .{}); + + try libcurl.curl_easy_setopt(easy, .ssl_verify_host, false); + try libcurl.curl_easy_setopt(easy, .ssl_verify_peer, false); + + if (http_proxy != null) { + try libcurl.curl_easy_setopt(easy, .proxy_ssl_verify_host, false); + try libcurl.curl_easy_setopt(easy, .proxy_ssl_verify_peer, false); + } + } + + // compression, don't remove this. CloudFront will send gzip content + // even if we don't support it, and then it won't be decompressed. + // empty string means: use whatever's available + try libcurl.curl_easy_setopt(easy, .accept_encoding, ""); + + // debug + if (comptime ENABLE_DEBUG) { + try libcurl.curl_easy_setopt(easy, .verbose, true); + + // Sometimes the default debug output hides some useful data. You can + // uncomment the following line (BUT KEEP THE LIVE ABOVE AS-IS), to + // get more control over the data (specifically, the `CURLINFO_TEXT` + // can include useful data). + + // try libcurl.curl_easy_setopt(easy, .debug_function, debugCallback); + } + + return .{ + .easy = easy, + }; + } + + pub fn deinit(self: *const Connection) void { + libcurl.curl_easy_cleanup(self.easy); + } + + pub fn setURL(self: *const Connection, url: [:0]const u8) !void { + try libcurl.curl_easy_setopt(self.easy, .url, url.ptr); + } + + // a libcurl request has 2 methods. The first is the method that + // controls how libcurl behaves. This specifically influences how redirects + // are handled. For example, if you do a POST and get a 301, libcurl will + // change that to a GET. But if you do a POST and get a 308, libcurl will + // keep the POST (and re-send the body). + // The second method is the actual string that's included in the request + // headers. + // These two methods can be different - you can tell curl to behave as though + // you made a GET, but include "POST" in the request header. + // + // Here, we're only concerned about the 2nd method. If we want, we'll set + // the first one based on whether or not we have a body. + // + // It's important that, for each use of this connection, we set the 2nd + // method. Else, if we make a HEAD request and re-use the connection, but + // DON'T reset this, it'll keep making HEAD requests. + // (I don't know if it's as important to reset the 1st method, or if libcurl + // can infer that based on the presence of the body, but we also reset it + // to be safe); + pub fn setMethod(self: *const Connection, method: Method) !void { + const easy = self.easy; + const m: [:0]const u8 = switch (method) { + .GET => "GET", + .POST => "POST", + .PUT => "PUT", + .DELETE => "DELETE", + .HEAD => "HEAD", + .OPTIONS => "OPTIONS", + .PATCH => "PATCH", + .PROPFIND => "PROPFIND", + }; + try libcurl.curl_easy_setopt(easy, .custom_request, m.ptr); + } + + pub fn setBody(self: *const Connection, body: []const u8) !void { + const easy = self.easy; + try libcurl.curl_easy_setopt(easy, .post, true); + try libcurl.curl_easy_setopt(easy, .post_field_size, body.len); + try libcurl.curl_easy_setopt(easy, .copy_post_fields, body.ptr); + } + + pub fn setGetMode(self: *const Connection) !void { + try libcurl.curl_easy_setopt(self.easy, .http_get, true); + } + + pub fn setHeaders(self: *const Connection, headers: *Headers) !void { + try libcurl.curl_easy_setopt(self.easy, .http_header, headers.headers); + } + + pub fn setCookies(self: *const Connection, cookies: [*c]const u8) !void { + try libcurl.curl_easy_setopt(self.easy, .cookie, cookies); + } + + pub fn setPrivate(self: *const Connection, ptr: *anyopaque) !void { + try libcurl.curl_easy_setopt(self.easy, .private, ptr); + } + + pub fn setProxyCredentials(self: *const Connection, creds: [:0]const u8) !void { + try libcurl.curl_easy_setopt(self.easy, .proxy_user_pwd, creds.ptr); + } + + pub fn setCredentials(self: *const Connection, creds: [:0]const u8) !void { + try libcurl.curl_easy_setopt(self.easy, .user_pwd, creds.ptr); + } + + pub fn setCallbacks( + self: *const Connection, + comptime header_cb: libcurl.CurlHeaderFunction, + comptime data_cb: libcurl.CurlWriteFunction, + ) !void { + try libcurl.curl_easy_setopt(self.easy, .header_data, self.easy); + try libcurl.curl_easy_setopt(self.easy, .header_function, header_cb); + try libcurl.curl_easy_setopt(self.easy, .write_data, self.easy); + try libcurl.curl_easy_setopt(self.easy, .write_function, data_cb); + } + + pub fn setProxy(self: *const Connection, proxy: ?[*:0]const u8) !void { + try libcurl.curl_easy_setopt(self.easy, .proxy, proxy); + } + + pub fn setTlsVerify(self: *const Connection, verify: bool, use_proxy: bool) !void { + try libcurl.curl_easy_setopt(self.easy, .ssl_verify_host, verify); + try libcurl.curl_easy_setopt(self.easy, .ssl_verify_peer, verify); + if (use_proxy) { + try libcurl.curl_easy_setopt(self.easy, .proxy_ssl_verify_host, verify); + try libcurl.curl_easy_setopt(self.easy, .proxy_ssl_verify_peer, verify); + } + } + + pub fn getEffectiveUrl(self: *const Connection) ![*c]const u8 { + var url: [*c]u8 = undefined; + try libcurl.curl_easy_getinfo(self.easy, .effective_url, &url); + return url; + } + + pub fn getResponseCode(self: *const Connection) !u16 { + var status: c_long = undefined; + try libcurl.curl_easy_getinfo(self.easy, .response_code, &status); + if (status < 0 or status > std.math.maxInt(u16)) { + return 0; + } + return @intCast(status); + } + + pub fn getRedirectCount(self: *const Connection) !u32 { + var count: c_long = undefined; + try libcurl.curl_easy_getinfo(self.easy, .redirect_count, &count); + return @intCast(count); + } + + pub fn getResponseHeader(self: *const Connection, name: [:0]const u8, index: usize) ?HeaderValue { + var hdr: ?*libcurl.CurlHeader = null; + libcurl.curl_easy_header(self.easy, name, index, .header, -1, &hdr) catch |err| { + // ErrorHeader includes OutOfMemory — rare but real errors from curl internals. + // Logged and returned as null since callers don't expect errors. + log.err(.http, "get response header", .{ + .name = name, + .err = err, + }); + return null; + }; + const h = hdr orelse return null; + return .{ + .amount = h.amount, + .value = std.mem.span(h.value), + }; + } + + pub fn getPrivate(self: *const Connection) !*anyopaque { + var private: *anyopaque = undefined; + try libcurl.curl_easy_getinfo(self.easy, .private, &private); + return private; + } + + // These are headers that may not be send to the users for inteception. + pub fn secretHeaders(_: *const Connection, headers: *Headers, http_headers: *const Config.HttpHeaders) !void { + if (http_headers.proxy_bearer_header) |hdr| { + try headers.add(hdr); + } + } + + pub fn request(self: *const Connection, http_headers: *const Config.HttpHeaders) !u16 { + var header_list = try Headers.init(http_headers.user_agent_header); + defer header_list.deinit(); + try self.secretHeaders(&header_list, http_headers); + try self.setHeaders(&header_list); + + // Add cookies. + if (header_list.cookies) |cookies| { + try self.setCookies(cookies); + } + + try libcurl.curl_easy_perform(self.easy); + return self.getResponseCode(); + } +}; + +pub const Handles = struct { + connections: []Connection, + dirty: HandleList, + in_use: HandleList, + available: HandleList, + multi: *libcurl.CurlM, + performing: bool = false, + + pub const HandleList = std.DoublyLinkedList; + + pub fn init( + allocator: Allocator, + ca_blob: ?libcurl.CurlBlob, + config: *const Config, + ) !Handles { + const count: usize = config.httpMaxConcurrent(); + if (count == 0) return error.InvalidMaxConcurrent; + + const multi = libcurl.curl_multi_init() orelse return error.FailedToInitializeMulti; + errdefer libcurl.curl_multi_cleanup(multi) catch {}; + + try libcurl.curl_multi_setopt(multi, .max_host_connections, config.httpMaxHostOpen()); + + const connections = try allocator.alloc(Connection, count); + errdefer allocator.free(connections); + + var available: HandleList = .{}; + for (0..count) |i| { + connections[i] = try Connection.init(ca_blob, config); + available.append(&connections[i].node); + } + + return .{ + .dirty = .{}, + .in_use = .{}, + .connections = connections, + .available = available, + .multi = multi, + }; + } + + pub fn deinit(self: *Handles, allocator: Allocator) void { + for (self.connections) |*conn| { + conn.deinit(); + } + allocator.free(self.connections); + libcurl.curl_multi_cleanup(self.multi) catch {}; + } + + pub fn hasAvailable(self: *const Handles) bool { + return self.available.first != null; + } + + pub fn get(self: *Handles) ?*Connection { + if (self.available.popFirst()) |node| { + self.in_use.append(node); + return @as(*Connection, @fieldParentPtr("node", node)); + } + return null; + } + + pub fn add(self: *Handles, conn: *const Connection) !void { + try libcurl.curl_multi_add_handle(self.multi, conn.easy); + } + + pub fn remove(self: *Handles, conn: *Connection) void { + if (libcurl.curl_multi_remove_handle(self.multi, conn.easy)) { + self.isAvailable(conn); + } else |err| { + // can happen if we're in a perform() call, so we'll queue this + // for cleanup later. + const node = &conn.node; + self.in_use.remove(node); + self.dirty.append(node); + log.warn(.http, "multi remove handle", .{ .err = err }); + } + } + + pub fn isAvailable(self: *Handles, conn: *Connection) void { + const node = &conn.node; + self.in_use.remove(node); + self.available.append(node); + } + + pub fn perform(self: *Handles) !c_int { + self.performing = true; + defer self.performing = false; + + const multi = self.multi; + var running: c_int = undefined; + try libcurl.curl_multi_perform(self.multi, &running); + + { + const list = &self.dirty; + while (list.first) |node| { + list.remove(node); + const conn: *Connection = @fieldParentPtr("node", node); + if (libcurl.curl_multi_remove_handle(multi, conn.easy)) { + self.available.append(node); + } else |err| { + log.fatal(.http, "multi remove handle", .{ .err = err, .src = "perform" }); + @panic("multi_remove_handle"); + } + } + } + + return running; + } + + pub fn poll(self: *Handles, extra_fds: []libcurl.CurlWaitFd, timeout_ms: c_int) !void { + try libcurl.curl_multi_poll(self.multi, extra_fds, timeout_ms, null); + } + + pub const MultiMessage = struct { + conn: Connection, + err: ?Error, + }; + + pub fn readMessage(self: *Handles) ?MultiMessage { + var messages_count: c_int = 0; + const msg = libcurl.curl_multi_info_read(self.multi, &messages_count) orelse return null; + return switch (msg.data) { + .done => |err| .{ + .conn = .{ .easy = msg.easy_handle }, + .err = err, + }, + else => unreachable, + }; + } +}; + +fn debugCallback(_: *libcurl.Curl, msg_type: libcurl.CurlInfoType, raw: [*c]u8, len: usize, _: *anyopaque) c_int { + const data = raw[0..len]; + switch (msg_type) { + .text => std.debug.print("libcurl [text]: {s}\n", .{data}), + .header_out => std.debug.print("libcurl [req-h]: {s}\n", .{data}), + .header_in => std.debug.print("libcurl [res-h]: {s}\n", .{data}), + // .data_in => std.debug.print("libcurl [res-b]: {s}\n", .{data}), + else => std.debug.print("libcurl ?? {d}\n", .{msg_type}), + } + return 0; +} diff --git a/src/Net.zig b/src/network/websocket.zig similarity index 53% rename from src/Net.zig rename to src/network/websocket.zig index c45707e4..5a5b4747 100644 --- a/src/Net.zig +++ b/src/network/websocket.zig @@ -21,721 +21,10 @@ const builtin = @import("builtin"); const posix = std.posix; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; -const libcurl = @import("sys/libcurl.zig"); -const log = @import("log.zig"); -const Config = @import("Config.zig"); +const log = @import("lightpanda").log; const assert = @import("lightpanda").assert; - -pub const ENABLE_DEBUG = false; -const IS_DEBUG = builtin.mode == .Debug; - -pub const Blob = libcurl.CurlBlob; -pub const WaitFd = libcurl.CurlWaitFd; -pub const writefunc_error = libcurl.curl_writefunc_error; - -const Error = libcurl.Error; -const ErrorMulti = libcurl.ErrorMulti; -const errorFromCode = libcurl.errorFromCode; -const errorMFromCode = libcurl.errorMFromCode; -const errorCheck = libcurl.errorCheck; -const errorMCheck = libcurl.errorMCheck; - -pub fn curl_version() [*c]const u8 { - return libcurl.curl_version(); -} - -pub const Method = enum(u8) { - GET = 0, - PUT = 1, - POST = 2, - DELETE = 3, - HEAD = 4, - OPTIONS = 5, - PATCH = 6, - PROPFIND = 7, -}; - -pub const Header = struct { - name: []const u8, - value: []const u8, -}; - -pub const Headers = struct { - headers: ?*libcurl.CurlSList, - cookies: ?[*c]const u8, - - pub fn init(user_agent: [:0]const u8) !Headers { - const header_list = libcurl.curl_slist_append(null, user_agent); - if (header_list == null) { - return error.OutOfMemory; - } - return .{ .headers = header_list, .cookies = null }; - } - - pub fn deinit(self: *const Headers) void { - if (self.headers) |hdr| { - libcurl.curl_slist_free_all(hdr); - } - } - - pub fn add(self: *Headers, header: [*c]const u8) !void { - // Copies the value - const updated_headers = libcurl.curl_slist_append(self.headers, header); - if (updated_headers == null) { - return error.OutOfMemory; - } - - self.headers = updated_headers; - } - - fn parseHeader(header_str: []const u8) ?Header { - const colon_pos = std.mem.indexOfScalar(u8, header_str, ':') orelse return null; - - const name = std.mem.trim(u8, header_str[0..colon_pos], " \t"); - const value = std.mem.trim(u8, header_str[colon_pos + 1 ..], " \t"); - - return .{ .name = name, .value = value }; - } - - pub fn iterator(self: *Headers) Iterator { - return .{ - .header = self.headers, - .cookies = self.cookies, - }; - } - - const Iterator = struct { - header: [*c]libcurl.CurlSList, - cookies: ?[*c]const u8, - - pub fn next(self: *Iterator) ?Header { - const h = self.header orelse { - const cookies = self.cookies orelse return null; - self.cookies = null; - return .{ .name = "Cookie", .value = std.mem.span(@as([*:0]const u8, cookies)) }; - }; - - self.header = h.*.next; - return parseHeader(std.mem.span(@as([*:0]const u8, @ptrCast(h.*.data)))); - } - }; -}; - -// In normal cases, the header iterator comes from the curl linked list. -// But it's also possible to inject a response, via `transfer.fulfill`. In that -// case, the resposne headers are a list, []const Http.Header. -// This union, is an iterator that exposes the same API for either case. -pub const HeaderIterator = union(enum) { - curl: CurlHeaderIterator, - list: ListHeaderIterator, - - pub fn next(self: *HeaderIterator) ?Header { - switch (self.*) { - inline else => |*it| return it.next(), - } - } - - const CurlHeaderIterator = struct { - conn: *const Connection, - prev: ?*libcurl.CurlHeader = null, - - pub fn next(self: *CurlHeaderIterator) ?Header { - const h = libcurl.curl_easy_nextheader(self.conn.easy, .header, -1, self.prev) orelse return null; - self.prev = h; - - const header = h.*; - return .{ - .name = std.mem.span(header.name), - .value = std.mem.span(header.value), - }; - } - }; - - const ListHeaderIterator = struct { - index: usize = 0, - list: []const Header, - - pub fn next(self: *ListHeaderIterator) ?Header { - const idx = self.index; - if (idx == self.list.len) { - return null; - } - self.index = idx + 1; - return self.list[idx]; - } - }; -}; - -const HeaderValue = struct { - value: []const u8, - amount: usize, -}; - -pub const AuthChallenge = struct { - status: u16, - source: ?enum { server, proxy }, - scheme: ?enum { basic, digest }, - realm: ?[]const u8, - - pub fn parse(status: u16, header: []const u8) !AuthChallenge { - var ac: AuthChallenge = .{ - .status = status, - .source = null, - .realm = null, - .scheme = null, - }; - - const sep = std.mem.indexOfPos(u8, header, 0, ": ") orelse return error.InvalidHeader; - const hname = header[0..sep]; - const hvalue = header[sep + 2 ..]; - - if (std.ascii.eqlIgnoreCase("WWW-Authenticate", hname)) { - ac.source = .server; - } else if (std.ascii.eqlIgnoreCase("Proxy-Authenticate", hname)) { - ac.source = .proxy; - } else { - return error.InvalidAuthChallenge; - } - - const pos = std.mem.indexOfPos(u8, std.mem.trim(u8, hvalue, std.ascii.whitespace[0..]), 0, " ") orelse hvalue.len; - const _scheme = hvalue[0..pos]; - if (std.ascii.eqlIgnoreCase(_scheme, "basic")) { - ac.scheme = .basic; - } else if (std.ascii.eqlIgnoreCase(_scheme, "digest")) { - ac.scheme = .digest; - } else { - return error.UnknownAuthChallengeScheme; - } - - return ac; - } -}; - -pub const ResponseHead = struct { - pub const MAX_CONTENT_TYPE_LEN = 64; - - status: u16, - url: [*c]const u8, - redirect_count: u32, - _content_type_len: usize = 0, - _content_type: [MAX_CONTENT_TYPE_LEN]u8 = undefined, - // this is normally an empty list, but if the response is being injected - // than it'll be populated. It isn't meant to be used directly, but should - // be used through the transfer.responseHeaderIterator() which abstracts - // whether the headers are from a live curl easy handle, or injected. - _injected_headers: []const Header = &.{}, - - pub fn contentType(self: *ResponseHead) ?[]u8 { - if (self._content_type_len == 0) { - return null; - } - return self._content_type[0..self._content_type_len]; - } -}; - -pub fn globalInit() Error!void { - try libcurl.curl_global_init(.{ .ssl = true }); -} - -pub fn globalDeinit() void { - libcurl.curl_global_cleanup(); -} - -pub const Connection = struct { - easy: *libcurl.Curl, - node: Handles.HandleList.Node = .{}, - - pub fn init( - ca_blob_: ?libcurl.CurlBlob, - config: *const Config, - ) !Connection { - const easy = libcurl.curl_easy_init() orelse return error.FailedToInitializeEasy; - errdefer libcurl.curl_easy_cleanup(easy); - - // timeouts - try libcurl.curl_easy_setopt(easy, .timeout_ms, config.httpTimeout()); - try libcurl.curl_easy_setopt(easy, .connect_timeout_ms, config.httpConnectTimeout()); - - // redirect behavior - try libcurl.curl_easy_setopt(easy, .max_redirs, config.httpMaxRedirects()); - try libcurl.curl_easy_setopt(easy, .follow_location, 2); - try libcurl.curl_easy_setopt(easy, .redir_protocols_str, "HTTP,HTTPS"); // remove FTP and FTPS from the default - - // proxy - const http_proxy = config.httpProxy(); - if (http_proxy) |proxy| { - try libcurl.curl_easy_setopt(easy, .proxy, proxy.ptr); - } - - // tls - if (ca_blob_) |ca_blob| { - try libcurl.curl_easy_setopt(easy, .ca_info_blob, ca_blob); - if (http_proxy != null) { - try libcurl.curl_easy_setopt(easy, .proxy_ca_info_blob, ca_blob); - } - } else { - assert(config.tlsVerifyHost() == false, "Http.init tls_verify_host", .{}); - - try libcurl.curl_easy_setopt(easy, .ssl_verify_host, false); - try libcurl.curl_easy_setopt(easy, .ssl_verify_peer, false); - - if (http_proxy != null) { - try libcurl.curl_easy_setopt(easy, .proxy_ssl_verify_host, false); - try libcurl.curl_easy_setopt(easy, .proxy_ssl_verify_peer, false); - } - } - - // compression, don't remove this. CloudFront will send gzip content - // even if we don't support it, and then it won't be decompressed. - // empty string means: use whatever's available - try libcurl.curl_easy_setopt(easy, .accept_encoding, ""); - - // debug - if (comptime ENABLE_DEBUG) { - try libcurl.curl_easy_setopt(easy, .verbose, true); - - // Sometimes the default debug output hides some useful data. You can - // uncomment the following line (BUT KEEP THE LIVE ABOVE AS-IS), to - // get more control over the data (specifically, the `CURLINFO_TEXT` - // can include useful data). - - // try libcurl.curl_easy_setopt(easy, .debug_function, debugCallback); - } - - return .{ - .easy = easy, - }; - } - - pub fn deinit(self: *const Connection) void { - libcurl.curl_easy_cleanup(self.easy); - } - - pub fn setURL(self: *const Connection, url: [:0]const u8) !void { - try libcurl.curl_easy_setopt(self.easy, .url, url.ptr); - } - - // a libcurl request has 2 methods. The first is the method that - // controls how libcurl behaves. This specifically influences how redirects - // are handled. For example, if you do a POST and get a 301, libcurl will - // change that to a GET. But if you do a POST and get a 308, libcurl will - // keep the POST (and re-send the body). - // The second method is the actual string that's included in the request - // headers. - // These two methods can be different - you can tell curl to behave as though - // you made a GET, but include "POST" in the request header. - // - // Here, we're only concerned about the 2nd method. If we want, we'll set - // the first one based on whether or not we have a body. - // - // It's important that, for each use of this connection, we set the 2nd - // method. Else, if we make a HEAD request and re-use the connection, but - // DON'T reset this, it'll keep making HEAD requests. - // (I don't know if it's as important to reset the 1st method, or if libcurl - // can infer that based on the presence of the body, but we also reset it - // to be safe); - pub fn setMethod(self: *const Connection, method: Method) !void { - const easy = self.easy; - const m: [:0]const u8 = switch (method) { - .GET => "GET", - .POST => "POST", - .PUT => "PUT", - .DELETE => "DELETE", - .HEAD => "HEAD", - .OPTIONS => "OPTIONS", - .PATCH => "PATCH", - .PROPFIND => "PROPFIND", - }; - try libcurl.curl_easy_setopt(easy, .custom_request, m.ptr); - } - - pub fn setBody(self: *const Connection, body: []const u8) !void { - const easy = self.easy; - try libcurl.curl_easy_setopt(easy, .post, true); - try libcurl.curl_easy_setopt(easy, .post_field_size, body.len); - try libcurl.curl_easy_setopt(easy, .copy_post_fields, body.ptr); - } - - pub fn setGetMode(self: *const Connection) !void { - try libcurl.curl_easy_setopt(self.easy, .http_get, true); - } - - pub fn setHeaders(self: *const Connection, headers: *Headers) !void { - try libcurl.curl_easy_setopt(self.easy, .http_header, headers.headers); - } - - pub fn setCookies(self: *const Connection, cookies: [*c]const u8) !void { - try libcurl.curl_easy_setopt(self.easy, .cookie, cookies); - } - - pub fn setPrivate(self: *const Connection, ptr: *anyopaque) !void { - try libcurl.curl_easy_setopt(self.easy, .private, ptr); - } - - pub fn setProxyCredentials(self: *const Connection, creds: [:0]const u8) !void { - try libcurl.curl_easy_setopt(self.easy, .proxy_user_pwd, creds.ptr); - } - - pub fn setCredentials(self: *const Connection, creds: [:0]const u8) !void { - try libcurl.curl_easy_setopt(self.easy, .user_pwd, creds.ptr); - } - - pub fn setCallbacks( - self: *const Connection, - comptime header_cb: libcurl.CurlHeaderFunction, - comptime data_cb: libcurl.CurlWriteFunction, - ) !void { - try libcurl.curl_easy_setopt(self.easy, .header_data, self.easy); - try libcurl.curl_easy_setopt(self.easy, .header_function, header_cb); - try libcurl.curl_easy_setopt(self.easy, .write_data, self.easy); - try libcurl.curl_easy_setopt(self.easy, .write_function, data_cb); - } - - pub fn setProxy(self: *const Connection, proxy: ?[*:0]const u8) !void { - try libcurl.curl_easy_setopt(self.easy, .proxy, proxy); - } - - pub fn setTlsVerify(self: *const Connection, verify: bool, use_proxy: bool) !void { - try libcurl.curl_easy_setopt(self.easy, .ssl_verify_host, verify); - try libcurl.curl_easy_setopt(self.easy, .ssl_verify_peer, verify); - if (use_proxy) { - try libcurl.curl_easy_setopt(self.easy, .proxy_ssl_verify_host, verify); - try libcurl.curl_easy_setopt(self.easy, .proxy_ssl_verify_peer, verify); - } - } - - pub fn getEffectiveUrl(self: *const Connection) ![*c]const u8 { - var url: [*c]u8 = undefined; - try libcurl.curl_easy_getinfo(self.easy, .effective_url, &url); - return url; - } - - pub fn getResponseCode(self: *const Connection) !u16 { - var status: c_long = undefined; - try libcurl.curl_easy_getinfo(self.easy, .response_code, &status); - if (status < 0 or status > std.math.maxInt(u16)) { - return 0; - } - return @intCast(status); - } - - pub fn getRedirectCount(self: *const Connection) !u32 { - var count: c_long = undefined; - try libcurl.curl_easy_getinfo(self.easy, .redirect_count, &count); - return @intCast(count); - } - - pub fn getResponseHeader(self: *const Connection, name: [:0]const u8, index: usize) ?HeaderValue { - var hdr: ?*libcurl.CurlHeader = null; - libcurl.curl_easy_header(self.easy, name, index, .header, -1, &hdr) catch |err| { - // ErrorHeader includes OutOfMemory — rare but real errors from curl internals. - // Logged and returned as null since callers don't expect errors. - log.err(.http, "get response header", .{ - .name = name, - .err = err, - }); - return null; - }; - const h = hdr orelse return null; - return .{ - .amount = h.amount, - .value = std.mem.span(h.value), - }; - } - - pub fn getPrivate(self: *const Connection) !*anyopaque { - var private: *anyopaque = undefined; - try libcurl.curl_easy_getinfo(self.easy, .private, &private); - return private; - } - - // These are headers that may not be send to the users for inteception. - pub fn secretHeaders(_: *const Connection, headers: *Headers, http_headers: *const Config.HttpHeaders) !void { - if (http_headers.proxy_bearer_header) |hdr| { - try headers.add(hdr); - } - } - - pub fn request(self: *const Connection, http_headers: *const Config.HttpHeaders) !u16 { - var header_list = try Headers.init(http_headers.user_agent_header); - defer header_list.deinit(); - try self.secretHeaders(&header_list, http_headers); - try self.setHeaders(&header_list); - - // Add cookies. - if (header_list.cookies) |cookies| { - try self.setCookies(cookies); - } - - try libcurl.curl_easy_perform(self.easy); - return self.getResponseCode(); - } -}; - -pub const Handles = struct { - connections: []Connection, - dirty: HandleList, - in_use: HandleList, - available: HandleList, - multi: *libcurl.CurlM, - performing: bool = false, - - pub const HandleList = std.DoublyLinkedList; - - pub fn init( - allocator: Allocator, - ca_blob: ?libcurl.CurlBlob, - config: *const Config, - ) !Handles { - const count: usize = config.httpMaxConcurrent(); - if (count == 0) return error.InvalidMaxConcurrent; - - const multi = libcurl.curl_multi_init() orelse return error.FailedToInitializeMulti; - errdefer libcurl.curl_multi_cleanup(multi) catch {}; - - try libcurl.curl_multi_setopt(multi, .max_host_connections, config.httpMaxHostOpen()); - - const connections = try allocator.alloc(Connection, count); - errdefer allocator.free(connections); - - var available: HandleList = .{}; - for (0..count) |i| { - connections[i] = try Connection.init(ca_blob, config); - available.append(&connections[i].node); - } - - return .{ - .dirty = .{}, - .in_use = .{}, - .connections = connections, - .available = available, - .multi = multi, - }; - } - - pub fn deinit(self: *Handles, allocator: Allocator) void { - for (self.connections) |*conn| { - conn.deinit(); - } - allocator.free(self.connections); - libcurl.curl_multi_cleanup(self.multi) catch {}; - } - - pub fn hasAvailable(self: *const Handles) bool { - return self.available.first != null; - } - - pub fn get(self: *Handles) ?*Connection { - if (self.available.popFirst()) |node| { - self.in_use.append(node); - return @as(*Connection, @fieldParentPtr("node", node)); - } - return null; - } - - pub fn add(self: *Handles, conn: *const Connection) !void { - try libcurl.curl_multi_add_handle(self.multi, conn.easy); - } - - pub fn remove(self: *Handles, conn: *Connection) void { - if (libcurl.curl_multi_remove_handle(self.multi, conn.easy)) { - self.isAvailable(conn); - } else |err| { - // can happen if we're in a perform() call, so we'll queue this - // for cleanup later. - const node = &conn.node; - self.in_use.remove(node); - self.dirty.append(node); - log.warn(.http, "multi remove handle", .{ .err = err }); - } - } - - pub fn isAvailable(self: *Handles, conn: *Connection) void { - const node = &conn.node; - self.in_use.remove(node); - self.available.append(node); - } - - pub fn perform(self: *Handles) !c_int { - self.performing = true; - defer self.performing = false; - - const multi = self.multi; - var running: c_int = undefined; - try libcurl.curl_multi_perform(self.multi, &running); - - { - const list = &self.dirty; - while (list.first) |node| { - list.remove(node); - const conn: *Connection = @fieldParentPtr("node", node); - if (libcurl.curl_multi_remove_handle(multi, conn.easy)) { - self.available.append(node); - } else |err| { - log.fatal(.http, "multi remove handle", .{ .err = err, .src = "perform" }); - @panic("multi_remove_handle"); - } - } - } - - return running; - } - - pub fn poll(self: *Handles, extra_fds: []libcurl.CurlWaitFd, timeout_ms: c_int) !void { - try libcurl.curl_multi_poll(self.multi, extra_fds, timeout_ms, null); - } - - pub const MultiMessage = struct { - conn: Connection, - err: ?Error, - }; - - pub fn readMessage(self: *Handles) ?MultiMessage { - var messages_count: c_int = 0; - const msg = libcurl.curl_multi_info_read(self.multi, &messages_count) orelse return null; - return switch (msg.data) { - .done => |err| .{ - .conn = .{ .easy = msg.easy_handle }, - .err = err, - }, - else => unreachable, - }; - } -}; - -// TODO: on BSD / Linux, we could just read the PEM file directly. -// This whole rescan + decode is really just needed for MacOS. On Linux -// bundle.rescan does find the .pem file(s) which could be in a few different -// places, so it's still useful, just not efficient. -pub fn loadCerts(allocator: Allocator) !libcurl.CurlBlob { - var bundle: std.crypto.Certificate.Bundle = .{}; - try bundle.rescan(allocator); - defer bundle.deinit(allocator); - - const bytes = bundle.bytes.items; - if (bytes.len == 0) { - log.warn(.app, "No system certificates", .{}); - return .{ - .len = 0, - .flags = 0, - .data = bytes.ptr, - }; - } - - const encoder = std.base64.standard.Encoder; - var arr: std.ArrayList(u8) = .empty; - - const encoded_size = encoder.calcSize(bytes.len); - const buffer_size = encoded_size + - (bundle.map.count() * 75) + // start / end per certificate + extra, just in case - (encoded_size / 64) // newline per 64 characters - ; - try arr.ensureTotalCapacity(allocator, buffer_size); - errdefer arr.deinit(allocator); - var writer = arr.writer(allocator); - - var it = bundle.map.valueIterator(); - while (it.next()) |index| { - const cert = try std.crypto.Certificate.der.Element.parse(bytes, index.*); - - try writer.writeAll("-----BEGIN CERTIFICATE-----\n"); - var line_writer = LineWriter{ .inner = writer }; - try encoder.encodeWriter(&line_writer, bytes[index.*..cert.slice.end]); - try writer.writeAll("\n-----END CERTIFICATE-----\n"); - } - - // Final encoding should not be larger than our initial size estimate - assert(buffer_size > arr.items.len, "Http loadCerts", .{ .estimate = buffer_size, .len = arr.items.len }); - - // Allocate exactly the size needed and copy the data - const result = try allocator.dupe(u8, arr.items); - // Free the original oversized allocation - arr.deinit(allocator); - - return .{ - .len = result.len, - .data = result.ptr, - .flags = 0, - }; -} - -// Wraps lines @ 64 columns. A PEM is basically a base64 encoded DER (which is -// what Zig has), with lines wrapped at 64 characters and with a basic header -// and footer -const LineWriter = struct { - col: usize = 0, - inner: std.ArrayList(u8).Writer, - - pub fn writeAll(self: *LineWriter, data: []const u8) !void { - var writer = self.inner; - - var col = self.col; - const len = 64 - col; - - var remain = data; - if (remain.len > len) { - col = 0; - try writer.writeAll(data[0..len]); - try writer.writeByte('\n'); - remain = data[len..]; - } - - while (remain.len > 64) { - try writer.writeAll(remain[0..64]); - try writer.writeByte('\n'); - remain = data[len..]; - } - try writer.writeAll(remain); - self.col = col + remain.len; - } -}; - -fn debugCallback(_: *libcurl.Curl, msg_type: libcurl.CurlInfoType, raw: [*c]u8, len: usize, _: *anyopaque) c_int { - const data = raw[0..len]; - switch (msg_type) { - .text => std.debug.print("libcurl [text]: {s}\n", .{data}), - .header_out => std.debug.print("libcurl [req-h]: {s}\n", .{data}), - .header_in => std.debug.print("libcurl [res-h]: {s}\n", .{data}), - // .data_in => std.debug.print("libcurl [res-b]: {s}\n", .{data}), - else => std.debug.print("libcurl ?? {d}\n", .{msg_type}), - } - return 0; -} - -// 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); -} - -// 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]; - } -} +const CDP_MAX_MESSAGE_SIZE = @import("../Config.zig").CDP_MAX_MESSAGE_SIZE; const Fragments = struct { type: Message.Type, @@ -763,76 +52,6 @@ const OpCode = enum(u8) { pong = 128 | 10, }; -fn fillWebsocketHeader(buf: std.ArrayList(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 { - assert(buf.len == 10, "Websocket.Header", .{ .len = buf.len }); - - 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]; -} - -fn growBuffer(allocator: Allocator, buf: []u8, required_capacity: usize) ![]u8 { - // from std.ArrayList - var new_capacity = buf.len; - while (true) { - new_capacity +|= new_capacity / 2 + 8; - if (new_capacity >= required_capacity) break; - } - - log.debug(.app, "CDP buffer growth", .{ .from = buf.len, .to = new_capacity }); - - if (allocator.resize(buf, new_capacity)) { - return buf.ptr[0..new_capacity]; - } - const new_buffer = try allocator.alloc(u8, new_capacity); - @memcpy(new_buffer[0..buf.len], buf); - allocator.free(buf); - return new_buffer; -} - // 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 @@ -932,7 +151,7 @@ pub fn Reader(comptime EXPECT_MASK: bool) type { if (message_len > 125) { return error.ControlTooLarge; } - } else if (message_len > Config.CDP_MAX_MESSAGE_SIZE) { + } else if (message_len > CDP_MAX_MESSAGE_SIZE) { return error.TooLarge; } else if (message_len > self.buf.len) { const len = self.buf.len; @@ -960,7 +179,7 @@ pub fn Reader(comptime EXPECT_MASK: bool) type { if (is_continuation) { const fragments = &(self.fragments orelse return error.InvalidContinuation); - if (fragments.message.items.len + message_len > Config.CDP_MAX_MESSAGE_SIZE) { + if (fragments.message.items.len + message_len > CDP_MAX_MESSAGE_SIZE) { return error.TooLarge; } @@ -1086,14 +305,6 @@ pub fn Reader(comptime EXPECT_MASK: bool) type { }; } -// In-place string lowercase -fn toLower(str: []u8) []u8 { - for (str, 0..) |ch, i| { - str[i] = std.ascii.toLower(ch); - } - return str; -} - pub const WsConnection = struct { // CLOSE, 2 length, code const CLOSE_NORMAL = [_]u8{ 136, 2, 3, 232 }; // code: 1000 @@ -1385,6 +596,118 @@ pub const WsConnection = struct { } }; +fn fillWebsocketHeader(buf: std.ArrayList(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 { + assert(buf.len == 10, "Websocket.Header", .{ .len = buf.len }); + + 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]; +} + +fn growBuffer(allocator: Allocator, buf: []u8, required_capacity: usize) ![]u8 { + // from std.ArrayList + var new_capacity = buf.len; + while (true) { + new_capacity +|= new_capacity / 2 + 8; + if (new_capacity >= required_capacity) break; + } + + log.debug(.app, "CDP buffer growth", .{ .from = buf.len, .to = new_capacity }); + + if (allocator.resize(buf, new_capacity)) { + return buf.ptr[0..new_capacity]; + } + const new_buffer = try allocator.alloc(u8, new_capacity); + @memcpy(new_buffer[0..buf.len], buf); + allocator.free(buf); + return new_buffer; +} + +// In-place string lowercase +fn toLower(str: []u8) []u8 { + for (str, 0..) |ch, i| { + str[i] = std.ascii.toLower(ch); + } + return str; +} + +// 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]; + } +} + +// 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); +} + const testing = std.testing; test "mask" { diff --git a/src/sys/libcurl.zig b/src/sys/libcurl.zig index 759cba25..f13e999a 100644 --- a/src/sys/libcurl.zig +++ b/src/sys/libcurl.zig @@ -41,6 +41,20 @@ pub const CurlHeaderFunction = fn ([*]const u8, usize, usize, *anyopaque) usize; pub const CurlWriteFunction = fn ([*]const u8, usize, usize, *anyopaque) usize; pub const curl_writefunc_error: usize = c.CURL_WRITEFUNC_ERROR; +pub const FreeCallback = fn (ptr: ?*anyopaque) void; +pub const StrdupCallback = fn (str: [*:0]const u8) ?[*:0]u8; +pub const MallocCallback = fn (size: usize) ?*anyopaque; +pub const CallocCallback = fn (nmemb: usize, size: usize) ?*anyopaque; +pub const ReallocCallback = fn (ptr: ?*anyopaque, size: usize) ?*anyopaque; + +pub const CurlAllocator = struct { + free: FreeCallback, + strdup: StrdupCallback, + malloc: MallocCallback, + calloc: CallocCallback, + realloc: ReallocCallback, +}; + pub const CurlGlobalFlags = packed struct(u8) { ssl: bool = false, _reserved: u7 = 0, @@ -449,8 +463,41 @@ pub const CurlMsg = struct { data: CurlMsgData, }; -pub fn curl_global_init(flags: CurlGlobalFlags) Error!void { - try errorCheck(c.curl_global_init(flags.to_c())); +pub fn curl_global_init(flags: CurlGlobalFlags, comptime curl_allocator: ?CurlAllocator) Error!void { + const alloc = curl_allocator orelse { + return errorCheck(c.curl_global_init(flags.to_c())); + }; + + // The purpose of these wrappers is to hide callconv + // and provide an easy place to add logging when debugging. + const free = struct { + fn cb(ptr: ?*anyopaque) callconv(.c) void { + alloc.free(ptr); + } + }.cb; + const strdup = struct { + fn cb(str: [*c]const u8) callconv(.c) [*c]u8 { + const s: [*:0]const u8 = @ptrCast(str orelse return null); + return @ptrCast(alloc.strdup(s)); + } + }.cb; + const malloc = struct { + fn cb(size: usize) callconv(.c) ?*anyopaque { + return alloc.malloc(size); + } + }.cb; + const calloc = struct { + fn cb(nmemb: usize, size: usize) callconv(.c) ?*anyopaque { + return alloc.calloc(nmemb, size); + } + }.cb; + const realloc = struct { + fn cb(ptr: ?*anyopaque, size: usize) callconv(.c) ?*anyopaque { + return alloc.realloc(ptr, size); + } + }.cb; + + try errorCheck(c.curl_global_init_mem(flags.to_c(), malloc, free, realloc, strdup, calloc)); } pub fn curl_global_cleanup() void { diff --git a/src/telemetry/lightpanda.zig b/src/telemetry/lightpanda.zig index d141e060..75552eeb 100644 --- a/src/telemetry/lightpanda.zig +++ b/src/telemetry/lightpanda.zig @@ -7,9 +7,9 @@ const Allocator = std.mem.Allocator; const log = @import("../log.zig"); const App = @import("../App.zig"); -const Net = @import("../Net.zig"); const Config = @import("../Config.zig"); const telemetry = @import("telemetry.zig"); +const Connection = @import("../network/http.zig").Connection; const URL = "https://telemetry.lightpanda.io"; const MAX_BATCH_SIZE = 20; @@ -20,13 +20,13 @@ pub const LightPanda = struct { allocator: Allocator, mutex: std.Thread.Mutex, cond: Thread.Condition, - connection: Net.Connection, + connection: Connection, config: *const Config, pending: std.DoublyLinkedList, mem_pool: std.heap.MemoryPool(LightPandaEvent), pub fn init(app: *App) !LightPanda { - const connection = try app.http.newConnection(); + const connection = try app.network.newConnection(); errdefer connection.deinit(); try connection.setURL(URL); diff --git a/src/testing.zig b/src/testing.zig index 16b06a35..774f76e4 100644 --- a/src/testing.zig +++ b/src/testing.zig @@ -39,7 +39,7 @@ pub fn reset() void { const App = @import("App.zig"); const js = @import("browser/js/js.zig"); const Config = @import("Config.zig"); -const Client = @import("http/Client.zig"); +const HttpClient = @import("browser/HttpClient.zig"); const Page = @import("browser/Page.zig"); const Browser = @import("browser/Browser.zig"); const Session = @import("browser/Session.zig"); @@ -335,7 +335,7 @@ fn isJsonValue(a: std.json.Value, b: std.json.Value) bool { } pub var test_app: *App = undefined; -pub var test_http: *Client = undefined; +pub var test_http: *HttpClient = undefined; pub var test_browser: Browser = undefined; pub var test_notification: *Notification = undefined; pub var test_session: *Session = undefined; @@ -414,15 +414,6 @@ fn runWebApiTest(test_file: [:0]const u8) !void { try_catch.init(&ls.local); defer try_catch.deinit(); - // by default, on load, testing.js will call testing.assertOk(). This makes our - // tests work well in a browser. But, for our test runner, we disable that - // and call it explicitly. This gives us better error messages. - ls.local.eval("window._lightpanda_skip_auto_assert = true;", "auto_assert") catch |err| { - const caught = try_catch.caughtOrError(arena_allocator, err); - std.debug.print("disable auto assert failure\nError: {f}\n", .{caught}); - return err; - }; - try page.navigate(url, .{}); _ = test_session.wait(2000); @@ -460,7 +451,7 @@ const log = @import("log.zig"); const TestHTTPServer = @import("TestHTTPServer.zig"); const Server = @import("Server.zig"); -var test_cdp_server: ?Server = null; +var test_cdp_server: ?*Server = null; var test_cdp_server_thread: ?std.Thread = null; var test_http_server: ?TestHTTPServer = null; var test_http_server_thread: ?std.Thread = null; @@ -483,7 +474,7 @@ test "tests:beforeAll" { test_app = try App.init(test_allocator, &test_config); errdefer test_app.deinit(); - test_http = try test_app.http.createClient(test_allocator); + test_http = try HttpClient.init(test_allocator, &test_app.network); errdefer test_http.deinit(); test_browser = try Browser.init(test_app, .{ .http_client = test_http }); @@ -509,13 +500,11 @@ test "tests:beforeAll" { } test "tests:afterAll" { - if (test_cdp_server) |*server| { - server.stop(); - } + test_app.network.stop(); if (test_cdp_server_thread) |thread| { thread.join(); } - if (test_cdp_server) |*server| { + if (test_cdp_server) |server| { server.deinit(); } @@ -540,14 +529,14 @@ test "tests:afterAll" { fn serveCDP(wg: *std.Thread.WaitGroup) !void { const address = try std.net.Address.parseIp("127.0.0.1", 9583); - test_cdp_server = try Server.init(test_app, address); - wg.finish(); - - test_cdp_server.?.run(address, 5) catch |err| { + test_cdp_server = Server.init(test_app, address) catch |err| { std.debug.print("CDP server error: {}", .{err}); return err; }; + wg.finish(); + + test_app.network.run(); } fn testHTTPHandler(req: *std.http.Server.Request) !void {