From 4d384dfe018045d329115ba41b7a0771f4594256 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Thu, 9 Apr 2026 19:41:54 -0700 Subject: [PATCH 01/13] feat: add --cookies-file flag for session persistence Add a --cookies-file CLI option that loads cookies from a JSON file at startup and saves them back on exit. This enables AI agents to maintain login sessions across multiple Lightpanda invocations. The cookie format matches CDP Network.Cookie (compatible with Puppeteer's page.cookies() export): [{"name":"sid","value":"abc","domain":".example.com","path":"/", "expires":1234567890,"secure":true,"httpOnly":true}] Closes #335 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Config.zig | 23 +++++++++ src/cookies.zig | 120 +++++++++++++++++++++++++++++++++++++++++++++ src/lightpanda.zig | 14 ++++++ 3 files changed, 157 insertions(+) create mode 100644 src/cookies.zig diff --git a/src/Config.zig b/src/Config.zig index 30692516..928b0952 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -177,6 +177,13 @@ pub fn httpCacheDir(self: *const Config) ?[]const u8 { }; } +pub fn cookiesFile(self: *const Config) ?[]const u8 { + return switch (self.mode) { + inline .serve, .fetch, .mcp => |opts| opts.common.cookies_file, + else => null, + }; +} + 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, @@ -310,6 +317,7 @@ pub const Common = struct { user_agent_suffix: ?[]const u8 = null, user_agent: ?[]const u8 = null, http_cache_dir: ?[]const u8 = null, + cookies_file: ?[]const u8 = null, web_bot_auth_key_file: ?[]const u8 = null, web_bot_auth_keyid: ?[]const u8 = null, @@ -465,6 +473,12 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void { \\ Path to a directory to use as a Filesystem Cache for network resources. \\ Omitting this will result is no caching. \\ Defaults to no caching. + \\ + \\--cookies-file + \\ Path to a JSON file for cookie persistence. Cookies are loaded + \\ from this file at startup and saved back on exit. + \\ Format: [{name, value, domain, path, expires, secure, httpOnly}] + \\ Defaults to no persistence. ; // MAX_HELP_LEN| @@ -1185,6 +1199,15 @@ fn parseCommonArg( return true; } + if (std.mem.eql(u8, "--cookies-file", opt)) { + const str = args.next() orelse { + log.fatal(.app, "missing argument value", .{ .arg = "--cookies-file" }); + return error.InvalidArgument; + }; + common.cookies_file = try allocator.dupe(u8, str); + return true; + } + return false; } diff --git a/src/cookies.zig b/src/cookies.zig new file mode 100644 index 00000000..9d6eb475 --- /dev/null +++ b/src/cookies.zig @@ -0,0 +1,120 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// 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 Allocator = std.mem.Allocator; + +const Cookie = @import("browser/webapi/storage/Cookie.zig"); +const log = @import("log.zig"); + +/// Load cookies from a JSON file into the cookie jar. +/// The file format is an array of objects with: name, value, domain, path, +/// expires (optional, float), secure (optional, bool), httpOnly (optional, bool). +/// This matches the CDP Network.Cookie format used by Puppeteer and Playwright. +pub fn loadFromFile(jar: *Cookie.Jar, path: []const u8) !void { + const file = std.fs.cwd().openFile(path, .{}) catch |err| switch (err) { + error.FileNotFound => return, // No file yet, nothing to load + else => { + log.err(.app, "failed to open cookies file", .{ .path = path, .err = err }); + return err; + }, + }; + defer file.close(); + + const content = file.readToEndAlloc(jar.allocator, 1024 * 1024) catch |err| { + log.err(.app, "failed to read cookies file", .{ .path = path, .err = err }); + return err; + }; + defer jar.allocator.free(content); + + const parsed = std.json.parseFromSlice([]const JsonCookie, jar.allocator, content, .{ + .ignore_unknown_fields = true, + }) catch |err| { + log.err(.app, "failed to parse cookies JSON", .{ .path = path, .err = err }); + return err; + }; + defer parsed.deinit(); + + var loaded: usize = 0; + for (parsed.value) |jc| { + var arena = std.heap.ArenaAllocator.init(jar.allocator); + errdefer arena.deinit(); + const a = arena.allocator(); + + const cookie = Cookie{ + .arena = arena, + .name = try a.dupe(u8, jc.name), + .value = try a.dupe(u8, jc.value), + .domain = try a.dupe(u8, jc.domain), + .path = try a.dupe(u8, jc.path orelse "/"), + .expires = jc.expires, + .secure = jc.secure orelse false, + .http_only = jc.httpOnly orelse false, + .same_site = .none, + }; + + jar.add(cookie, std.time.timestamp()) catch |err| { + cookie.deinit(); + log.warn(.app, "skipping cookie", .{ .name = jc.name, .err = err }); + continue; + }; + loaded += 1; + } + + log.info(.app, "loaded cookies from file", .{ .path = path, .count = loaded }); +} + +/// Save all cookies from the jar to a JSON file. +pub fn saveToFile(jar: *Cookie.Jar, path: []const u8) !void { + jar.removeExpired(null); + + var file = try std.fs.cwd().createFile(path, .{}); + defer file.close(); + + var bw = std.io.bufferedWriter(file.writer()); + const writer = bw.writer(); + + try writer.writeByte('['); + for (jar.cookies.items, 0..) |c, i| { + if (i > 0) try writer.writeByte(','); + try writer.writeAll("\n "); + try std.json.stringify(JsonCookie{ + .name = c.name, + .value = c.value, + .domain = c.domain, + .path = c.path, + .expires = c.expires, + .secure = c.secure, + .httpOnly = c.http_only, + }, .{}, writer); + } + if (jar.cookies.items.len > 0) { + try writer.writeByte('\n'); + } + try writer.writeAll("]\n"); + try bw.flush(); + + log.info(.app, "saved cookies to file", .{ .path = path, .count = jar.cookies.items.len }); +} + +const JsonCookie = struct { + name: []const u8, + value: []const u8, + domain: []const u8, + path: ?[]const u8 = "/", + expires: ?f64 = null, + secure: ?bool = null, + httpOnly: ?bool = null, +}; diff --git a/src/lightpanda.zig b/src/lightpanda.zig index b0356e93..64308f91 100644 --- a/src/lightpanda.zig +++ b/src/lightpanda.zig @@ -40,6 +40,7 @@ pub const forms = @import("browser/forms.zig"); pub const actions = @import("browser/actions.zig"); pub const structured_data = @import("browser/structured_data.zig"); pub const mcp = @import("mcp.zig"); +pub const cookies = @import("cookies.zig"); pub const build_config = @import("build_config"); pub const crash_handler = @import("crash_handler.zig"); @@ -66,6 +67,19 @@ pub fn fetch(app: *App, url: [:0]const u8, opts: FetchOpts) !void { defer browser.deinit(); var session = try browser.newSession(notification); + + // Load cookies from file if --cookies-file was specified, save on exit. + if (app.config.cookiesFile()) |cookies_path| { + cookies.loadFromFile(&session.cookie_jar, cookies_path) catch |err| { + log.err(.app, "cookie load error", .{ .err = err }); + }; + defer { + cookies.saveToFile(&session.cookie_jar, cookies_path) catch |err| { + log.err(.app, "cookie save error", .{ .err = err }); + }; + } + } + const page = try session.createPage(); // // Comment this out to get a profile of the JS code in v8/profile.json. From 242851249d93ac9c80ee62bdcaf6f78d81e9c75c Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Thu, 9 Apr 2026 20:48:55 -0700 Subject: [PATCH 02/13] fix: use file.writer() instead of std.io.bufferedWriter std.io.bufferedWriter doesn't exist in Zig 0.15.2. Use the file.writer(&buf) pattern that matches the rest of the codebase. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cookies.zig | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/cookies.zig b/src/cookies.zig index 9d6eb475..8404f69b 100644 --- a/src/cookies.zig +++ b/src/cookies.zig @@ -83,12 +83,12 @@ pub fn saveToFile(jar: *Cookie.Jar, path: []const u8) !void { var file = try std.fs.cwd().createFile(path, .{}); defer file.close(); - var bw = std.io.bufferedWriter(file.writer()); - const writer = bw.writer(); + var buf: [8192]u8 = undefined; + var writer = file.writer(&buf); - try writer.writeByte('['); + try writer.writeAll("["); for (jar.cookies.items, 0..) |c, i| { - if (i > 0) try writer.writeByte(','); + if (i > 0) try writer.writeAll(","); try writer.writeAll("\n "); try std.json.stringify(JsonCookie{ .name = c.name, @@ -98,13 +98,13 @@ pub fn saveToFile(jar: *Cookie.Jar, path: []const u8) !void { .expires = c.expires, .secure = c.secure, .httpOnly = c.http_only, - }, .{}, writer); + }, .{}, &writer); } if (jar.cookies.items.len > 0) { - try writer.writeByte('\n'); + try writer.writeAll("\n"); } try writer.writeAll("]\n"); - try bw.flush(); + try writer.flush(); log.info(.app, "saved cookies to file", .{ .path = path, .count = jar.cookies.items.len }); } From 494ef345a150e417598f4b0a1157333890d1bcb7 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Fri, 10 Apr 2026 06:53:40 -0700 Subject: [PATCH 03/13] fix: resolve Zig 0.15 compilation errors in cookies and config - Escape braces in help text format string to avoid std.debug.print interpreting them as format specifiers - Use writer.interface for std.Io.Writer methods (writeAll, stringify) instead of calling them directly on fs.File.Writer - Replace writer.flush() with writer.end() per codebase convention Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Config.zig | 2 +- src/cookies.zig | 15 ++++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/Config.zig b/src/Config.zig index 928b0952..dd2c8e07 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -477,7 +477,7 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void { \\--cookies-file \\ Path to a JSON file for cookie persistence. Cookies are loaded \\ from this file at startup and saved back on exit. - \\ Format: [{name, value, domain, path, expires, secure, httpOnly}] + \\ Format: [{{name, value, domain, path, expires, secure, httpOnly}}] \\ Defaults to no persistence. ; diff --git a/src/cookies.zig b/src/cookies.zig index 8404f69b..5b5a4459 100644 --- a/src/cookies.zig +++ b/src/cookies.zig @@ -85,11 +85,12 @@ pub fn saveToFile(jar: *Cookie.Jar, path: []const u8) !void { var buf: [8192]u8 = undefined; var writer = file.writer(&buf); + const w = &writer.interface; - try writer.writeAll("["); + try w.writeAll("["); for (jar.cookies.items, 0..) |c, i| { - if (i > 0) try writer.writeAll(","); - try writer.writeAll("\n "); + if (i > 0) try w.writeAll(","); + try w.writeAll("\n "); try std.json.stringify(JsonCookie{ .name = c.name, .value = c.value, @@ -98,13 +99,13 @@ pub fn saveToFile(jar: *Cookie.Jar, path: []const u8) !void { .expires = c.expires, .secure = c.secure, .httpOnly = c.http_only, - }, .{}, &writer); + }, .{}, w); } if (jar.cookies.items.len > 0) { - try writer.writeAll("\n"); + try w.writeAll("\n"); } - try writer.writeAll("]\n"); - try writer.flush(); + try w.writeAll("]\n"); + try writer.end(); log.info(.app, "saved cookies to file", .{ .path = path, .count = jar.cookies.items.len }); } From 35991a1b32864222018f77e84f8ca65366d46aed Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sun, 12 Apr 2026 09:55:45 -0400 Subject: [PATCH 04/13] refactor: split --cookies-file into --cookie/--cookie-jar per curl convention Split the single --cookies-file flag into two flags following curl's convention as requested by @krichprollsch: - --cookie (read-only): loads cookies at startup for fetch, mcp, and serve/CDP commands - --cookie-jar (write-only): saves cookies on exit for fetch and mcp only (CDP cookie-jar deferred per maintainer guidance) Add cookie integration to MCP server (load in init, save in deinit) and CDP session creation (load only). The serve command now rejects --cookie-jar with a clear error message. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Config.zig | 51 +++++++++++++++++++++++++++++++++++++--------- src/cdp/CDP.zig | 5 +++++ src/lightpanda.zig | 10 +++++---- src/mcp/Server.zig | 13 ++++++++++++ 4 files changed, 65 insertions(+), 14 deletions(-) diff --git a/src/Config.zig b/src/Config.zig index dd2c8e07..e3c214fd 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -177,9 +177,16 @@ pub fn httpCacheDir(self: *const Config) ?[]const u8 { }; } -pub fn cookiesFile(self: *const Config) ?[]const u8 { +pub fn cookieFile(self: *const Config) ?[]const u8 { return switch (self.mode) { - inline .serve, .fetch, .mcp => |opts| opts.common.cookies_file, + inline .serve, .fetch, .mcp => |opts| opts.common.cookie, + else => null, + }; +} + +pub fn cookieJarFile(self: *const Config) ?[]const u8 { + return switch (self.mode) { + inline .fetch, .mcp => |opts| opts.common.cookie_jar, else => null, }; } @@ -317,7 +324,8 @@ pub const Common = struct { user_agent_suffix: ?[]const u8 = null, user_agent: ?[]const u8 = null, http_cache_dir: ?[]const u8 = null, - cookies_file: ?[]const u8 = null, + cookie: ?[]const u8 = null, + cookie_jar: ?[]const u8 = null, web_bot_auth_key_file: ?[]const u8 = null, web_bot_auth_keyid: ?[]const u8 = null, @@ -474,11 +482,17 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void { \\ Omitting this will result is no caching. \\ Defaults to no caching. \\ - \\--cookies-file - \\ Path to a JSON file for cookie persistence. Cookies are loaded - \\ from this file at startup and saved back on exit. + \\--cookie + \\ Path to a JSON file to load cookies from at startup + \\ (read-only). \\ Format: [{{name, value, domain, path, expires, secure, httpOnly}}] - \\ Defaults to no persistence. + \\ Defaults to no cookie loading. + \\ + \\--cookie-jar + \\ Path to a JSON file to save cookies to on exit (write-only). + \\ Available for fetch and mcp commands. + \\ Format: [{{name, value, domain, path, expires, secure, httpOnly}}] + \\ Defaults to no cookie saving. ; // MAX_HELP_LEN| @@ -733,6 +747,14 @@ fn parseServeArgs( continue; } + if (std.mem.eql(u8, "--cookie-jar", opt)) { + log.fatal(.app, "invalid argument value", .{ + .arg = opt, + .detail = "--cookie-jar is only available for fetch and mcp", + }); + return error.InvalidArgument; + } + if (try parseCommonArg(allocator, opt, args, &serve.common)) { continue; } @@ -1199,12 +1221,21 @@ fn parseCommonArg( return true; } - if (std.mem.eql(u8, "--cookies-file", opt)) { + if (std.mem.eql(u8, "--cookie", opt)) { const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = "--cookies-file" }); + log.fatal(.app, "missing argument value", .{ .arg = "--cookie" }); return error.InvalidArgument; }; - common.cookies_file = try allocator.dupe(u8, str); + common.cookie = try allocator.dupe(u8, str); + return true; + } + + if (std.mem.eql(u8, "--cookie-jar", opt)) { + const str = args.next() orelse { + log.fatal(.app, "missing argument value", .{ .arg = "--cookie-jar" }); + return error.InvalidArgument; + }; + common.cookie_jar = try allocator.dupe(u8, str); return true; } diff --git a/src/cdp/CDP.zig b/src/cdp/CDP.zig index 4ee83f66..ef83d479 100644 --- a/src/cdp/CDP.zig +++ b/src/cdp/CDP.zig @@ -394,6 +394,11 @@ pub const BrowserContext = struct { errdefer notification.deinit(); const session = try cdp.browser.newSession(notification); + if (cdp.client.app.config.cookieFile()) |cookie_path| { + lp.cookies.loadFromFile(&session.cookie_jar, cookie_path) catch |err| { + log.err(.app, "cookie load error", .{ .err = err }); + }; + } const browser = &cdp.browser; const inspector_session = browser.env.inspector.?.startSession(self); diff --git a/src/lightpanda.zig b/src/lightpanda.zig index 64308f91..8ec24a84 100644 --- a/src/lightpanda.zig +++ b/src/lightpanda.zig @@ -68,13 +68,15 @@ pub fn fetch(app: *App, url: [:0]const u8, opts: FetchOpts) !void { var session = try browser.newSession(notification); - // Load cookies from file if --cookies-file was specified, save on exit. - if (app.config.cookiesFile()) |cookies_path| { - cookies.loadFromFile(&session.cookie_jar, cookies_path) catch |err| { + if (app.config.cookieFile()) |cookie_path| { + cookies.loadFromFile(&session.cookie_jar, cookie_path) catch |err| { log.err(.app, "cookie load error", .{ .err = err }); }; + } + + if (app.config.cookieJarFile()) |cookie_jar_path| { defer { - cookies.saveToFile(&session.cookie_jar, cookies_path) catch |err| { + cookies.saveToFile(&session.cookie_jar, cookie_jar_path) catch |err| { log.err(.app, "cookie save error", .{ .err = err }); }; } diff --git a/src/mcp/Server.zig b/src/mcp/Server.zig index a334bc38..cf34158c 100644 --- a/src/mcp/Server.zig +++ b/src/mcp/Server.zig @@ -50,10 +50,23 @@ pub fn init(allocator: std.mem.Allocator, app: *App, writer: *std.io.Writer) !*S }; self.session = try self.browser.newSession(self.notification); + + if (app.config.cookieFile()) |cookie_path| { + lp.cookies.loadFromFile(&self.session.cookie_jar, cookie_path) catch |err| { + lp.log.err(.mcp, "cookie load error", .{ .err = err }); + }; + } + return self; } pub fn deinit(self: *Self) void { + if (self.app.config.cookieJarFile()) |cookie_jar_path| { + lp.cookies.saveToFile(&self.session.cookie_jar, cookie_jar_path) catch |err| { + lp.log.err(.mcp, "cookie save error", .{ .err = err }); + }; + } + self.node_registry.deinit(); self.aw.deinit(); self.browser.deinit(); From de3404dfc5907ab97fd5f07bd1487a828dac2db8 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Tue, 14 Apr 2026 15:56:16 -0400 Subject: [PATCH 05/13] fix zig syntax --- src/cookies.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cookies.zig b/src/cookies.zig index 5b5a4459..d0f0c12e 100644 --- a/src/cookies.zig +++ b/src/cookies.zig @@ -91,7 +91,7 @@ pub fn saveToFile(jar: *Cookie.Jar, path: []const u8) !void { for (jar.cookies.items, 0..) |c, i| { if (i > 0) try w.writeAll(","); try w.writeAll("\n "); - try std.json.stringify(JsonCookie{ + try std.json.Stringify.value(JsonCookie{ .name = c.name, .value = c.value, .domain = c.domain, From cc4bd417d23d46ce90f2738ce10ccf34cf0b59ab Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Tue, 14 Apr 2026 15:56:40 -0400 Subject: [PATCH 06/13] save cookies at the end of fetch --- src/lightpanda.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lightpanda.zig b/src/lightpanda.zig index 8ec24a84..444d0537 100644 --- a/src/lightpanda.zig +++ b/src/lightpanda.zig @@ -74,8 +74,8 @@ pub fn fetch(app: *App, url: [:0]const u8, opts: FetchOpts) !void { }; } - if (app.config.cookieJarFile()) |cookie_jar_path| { - defer { + defer { + if (app.config.cookieJarFile()) |cookie_jar_path| { cookies.saveToFile(&session.cookie_jar, cookie_jar_path) catch |err| { log.err(.app, "cookie save error", .{ .err = err }); }; From a24fcc6a5cc7df893f47453bd0f2746fdd289e0d Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Wed, 15 Apr 2026 10:08:39 -0400 Subject: [PATCH 07/13] use session arg to load cookies from file --- src/cdp/CDP.zig | 2 +- src/cookies.zig | 12 ++++++++---- src/lightpanda.zig | 2 +- src/mcp/Server.zig | 2 +- 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/cdp/CDP.zig b/src/cdp/CDP.zig index ef83d479..799d3ea9 100644 --- a/src/cdp/CDP.zig +++ b/src/cdp/CDP.zig @@ -395,7 +395,7 @@ pub const BrowserContext = struct { const session = try cdp.browser.newSession(notification); if (cdp.client.app.config.cookieFile()) |cookie_path| { - lp.cookies.loadFromFile(&session.cookie_jar, cookie_path) catch |err| { + lp.cookies.loadFromFile(session, cookie_path) catch |err| { log.err(.app, "cookie load error", .{ .err = err }); }; } diff --git a/src/cookies.zig b/src/cookies.zig index d0f0c12e..8d5df888 100644 --- a/src/cookies.zig +++ b/src/cookies.zig @@ -16,6 +16,7 @@ const std = @import("std"); const Allocator = std.mem.Allocator; +const Session = @import("browser/Session.zig"); const Cookie = @import("browser/webapi/storage/Cookie.zig"); const log = @import("log.zig"); @@ -23,7 +24,7 @@ const log = @import("log.zig"); /// The file format is an array of objects with: name, value, domain, path, /// expires (optional, float), secure (optional, bool), httpOnly (optional, bool). /// This matches the CDP Network.Cookie format used by Puppeteer and Playwright. -pub fn loadFromFile(jar: *Cookie.Jar, path: []const u8) !void { +pub fn loadFromFile(session: *Session, path: []const u8) !void { const file = std.fs.cwd().openFile(path, .{}) catch |err| switch (err) { error.FileNotFound => return, // No file yet, nothing to load else => { @@ -33,13 +34,15 @@ pub fn loadFromFile(jar: *Cookie.Jar, path: []const u8) !void { }; defer file.close(); - const content = file.readToEndAlloc(jar.allocator, 1024 * 1024) catch |err| { + const call_arena = try session.getArena(.medium, "cookies.jar.allocatorloadFromFile"); + defer session.releaseArena(call_arena); + + const content = file.readToEndAlloc(call_arena, 1024 * 1024) catch |err| { log.err(.app, "failed to read cookies file", .{ .path = path, .err = err }); return err; }; - defer jar.allocator.free(content); - const parsed = std.json.parseFromSlice([]const JsonCookie, jar.allocator, content, .{ + const parsed = std.json.parseFromSlice([]const JsonCookie, call_arena, content, .{ .ignore_unknown_fields = true, }) catch |err| { log.err(.app, "failed to parse cookies JSON", .{ .path = path, .err = err }); @@ -47,6 +50,7 @@ pub fn loadFromFile(jar: *Cookie.Jar, path: []const u8) !void { }; defer parsed.deinit(); + const jar = &session.cookie_jar; var loaded: usize = 0; for (parsed.value) |jc| { var arena = std.heap.ArenaAllocator.init(jar.allocator); diff --git a/src/lightpanda.zig b/src/lightpanda.zig index 444d0537..84775a7c 100644 --- a/src/lightpanda.zig +++ b/src/lightpanda.zig @@ -69,7 +69,7 @@ pub fn fetch(app: *App, url: [:0]const u8, opts: FetchOpts) !void { var session = try browser.newSession(notification); if (app.config.cookieFile()) |cookie_path| { - cookies.loadFromFile(&session.cookie_jar, cookie_path) catch |err| { + cookies.loadFromFile(session, cookie_path) catch |err| { log.err(.app, "cookie load error", .{ .err = err }); }; } diff --git a/src/mcp/Server.zig b/src/mcp/Server.zig index cf34158c..5dbb8a0f 100644 --- a/src/mcp/Server.zig +++ b/src/mcp/Server.zig @@ -52,7 +52,7 @@ pub fn init(allocator: std.mem.Allocator, app: *App, writer: *std.io.Writer) !*S self.session = try self.browser.newSession(self.notification); if (app.config.cookieFile()) |cookie_path| { - lp.cookies.loadFromFile(&self.session.cookie_jar, cookie_path) catch |err| { + lp.cookies.loadFromFile(self.session, cookie_path) catch |err| { lp.log.err(.mcp, "cookie load error", .{ .err = err }); }; } From e922bf7369407fece047825e03756e65db38f702 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Wed, 15 Apr 2026 10:48:23 -0400 Subject: [PATCH 08/13] update help for --cookies and --cookie-jar option --- src/Config.zig | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/src/Config.zig b/src/Config.zig index e3c214fd..6da118ed 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -481,18 +481,6 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void { \\ Path to a directory to use as a Filesystem Cache for network resources. \\ Omitting this will result is no caching. \\ Defaults to no caching. - \\ - \\--cookie - \\ Path to a JSON file to load cookies from at startup - \\ (read-only). - \\ Format: [{{name, value, domain, path, expires, secure, httpOnly}}] - \\ Defaults to no cookie loading. - \\ - \\--cookie-jar - \\ Path to a JSON file to save cookies to on exit (write-only). - \\ Available for fetch and mcp commands. - \\ Format: [{{name, value, domain, path, expires, secure, httpOnly}}] - \\ Defaults to no cookie saving. ; // MAX_HELP_LEN| @@ -540,6 +528,13 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void { \\--wait-script-file \\ Like --wait-script, but reads the script from a file. \\ + \\--cookie Path to a JSON file to load cookies from (read-only). + \\ Defaults to no cookie loading. + \\ + \\--cookie-jar Path to a JSON file to save cookies to on exit (write-only). + \\ Available for fetch and mcp commands. + \\ Defaults to no cookie saving. + \\ ++ common_options ++ \\ \\serve command @@ -569,12 +564,22 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void { \\ Maximum pending connections in the accept queue. \\ Defaults to 128. \\ + \\--cookie Path to a JSON file to load cookies from (read-only). + \\ Defaults to no cookie loading. + \\ ++ common_options ++ \\ \\mcp command \\Starts an MCP (Model Context Protocol) server over stdio \\Example: {0s} mcp \\ + \\--cookie Path to a JSON file to load cookies from (read-only). + \\ Defaults to no cookie loading. + \\ + \\--cookie-jar Path to a JSON file to save cookies to on exit (write-only). + \\ Available for fetch and mcp commands. + \\ Defaults to no cookie saving. + \\ ++ common_options ++ \\ \\version command From 3ca1f230b9626458d21072e141192adc3ff44180 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 16 Apr 2026 10:35:34 +0800 Subject: [PATCH 09/13] Serialize sameSite Tweak ergonomics (public functions log internally and are infallible). Use readFileAlloc directly. Fix possible memory leak with cookie arena - I don't think you can make a copy of the arena, and then dupe with the original. --- src/browser/webapi/storage/Cookie.zig | 2 +- src/cdp/CDP.zig | 4 +- src/cookies.zig | 93 ++++++++++++++++----------- src/lightpanda.zig | 8 +-- src/mcp/Server.zig | 8 +-- 5 files changed, 62 insertions(+), 53 deletions(-) diff --git a/src/browser/webapi/storage/Cookie.zig b/src/browser/webapi/storage/Cookie.zig index 58728653..f6fb3ec5 100644 --- a/src/browser/webapi/storage/Cookie.zig +++ b/src/browser/webapi/storage/Cookie.zig @@ -41,7 +41,7 @@ secure: bool = false, http_only: bool = false, same_site: SameSite = .none, -const SameSite = enum { +pub const SameSite = enum { strict, lax, none, diff --git a/src/cdp/CDP.zig b/src/cdp/CDP.zig index 799d3ea9..0a55b6a9 100644 --- a/src/cdp/CDP.zig +++ b/src/cdp/CDP.zig @@ -395,9 +395,7 @@ pub const BrowserContext = struct { const session = try cdp.browser.newSession(notification); if (cdp.client.app.config.cookieFile()) |cookie_path| { - lp.cookies.loadFromFile(session, cookie_path) catch |err| { - log.err(.app, "cookie load error", .{ .err = err }); - }; + lp.cookies.loadFromFile(session, cookie_path); } const browser = &cdp.browser; diff --git a/src/cookies.zig b/src/cookies.zig index 8d5df888..aa069393 100644 --- a/src/cookies.zig +++ b/src/cookies.zig @@ -14,74 +14,87 @@ // along with this program. If not, see . const std = @import("std"); -const Allocator = std.mem.Allocator; +const log = @import("log.zig"); const Session = @import("browser/Session.zig"); const Cookie = @import("browser/webapi/storage/Cookie.zig"); -const log = @import("log.zig"); + +const Allocator = std.mem.Allocator; /// Load cookies from a JSON file into the cookie jar. /// The file format is an array of objects with: name, value, domain, path, /// expires (optional, float), secure (optional, bool), httpOnly (optional, bool). /// This matches the CDP Network.Cookie format used by Puppeteer and Playwright. -pub fn loadFromFile(session: *Session, path: []const u8) !void { - const file = std.fs.cwd().openFile(path, .{}) catch |err| switch (err) { - error.FileNotFound => return, // No file yet, nothing to load - else => { - log.err(.app, "failed to open cookies file", .{ .path = path, .err = err }); - return err; - }, +pub fn loadFromFile(session: *Session, path: []const u8) void { + _loadFromFile(session, path) catch |err| { + log.err(.app, "Cookie.loadFromFile", .{ .err = err, .path = path }); }; - defer file.close(); +} - const call_arena = try session.getArena(.medium, "cookies.jar.allocatorloadFromFile"); - defer session.releaseArena(call_arena); +fn _loadFromFile(session: *Session, path: []const u8) !void { + const arena = try session.getArena(.medium, "Cookies.loadFromFile"); + defer session.releaseArena(arena); - const content = file.readToEndAlloc(call_arena, 1024 * 1024) catch |err| { - log.err(.app, "failed to read cookies file", .{ .path = path, .err = err }); - return err; + const content = std.fs.cwd().readFileAlloc(arena, path, 1024 * 1024) catch |err| { + switch (err) { + error.FileNotFound => log.debug(.app, "Cookie.readFile", .{ .path = path, .note = "file not found" }), + else => log.err(.app, "Cookie.readFile", .{ .path = path, .err = err }), + } + return; }; - const parsed = std.json.parseFromSlice([]const JsonCookie, call_arena, content, .{ + const json_cookies = std.json.parseFromSliceLeaky([]const JsonCookie, arena, content, .{ .ignore_unknown_fields = true, }) catch |err| { - log.err(.app, "failed to parse cookies JSON", .{ .path = path, .err = err }); - return err; + log.err(.app, "Cookie.parseFile", .{ .path = path, .err = err }); + return; }; - defer parsed.deinit(); const jar = &session.cookie_jar; + const now = std.time.timestamp(); + var loaded: usize = 0; - for (parsed.value) |jc| { - var arena = std.heap.ArenaAllocator.init(jar.allocator); - errdefer arena.deinit(); - const a = arena.allocator(); + for (json_cookies) |jc| { + var cookie_arena = std.heap.ArenaAllocator.init(jar.allocator); + errdefer cookie_arena.deinit(); + + const a = cookie_arena.allocator(); + const name = try a.dupe(u8, jc.name); + const value = try a.dupe(u8, jc.value); + const domain = try a.dupe(u8, jc.domain); + const cookie_path = if (jc.path) |p| try a.dupe(u8, p) else "/"; const cookie = Cookie{ - .arena = arena, - .name = try a.dupe(u8, jc.name), - .value = try a.dupe(u8, jc.value), - .domain = try a.dupe(u8, jc.domain), - .path = try a.dupe(u8, jc.path orelse "/"), + .arena = cookie_arena, + .name = name, + .value = value, + .domain = domain, + .path = cookie_path, .expires = jc.expires, .secure = jc.secure orelse false, .http_only = jc.httpOnly orelse false, - .same_site = .none, + .same_site = jc.sameSite, }; - jar.add(cookie, std.time.timestamp()) catch |err| { + jar.add(cookie, now) catch |err| { cookie.deinit(); - log.warn(.app, "skipping cookie", .{ .name = jc.name, .err = err }); + log.warn(.app, "invalid cookie", .{ .name = jc.name, .err = err }); continue; }; loaded += 1; } - log.info(.app, "loaded cookies from file", .{ .path = path, .count = loaded }); + log.info(.app, "Cookie.loadFromFile", .{ .path = path, .count = loaded }); } /// Save all cookies from the jar to a JSON file. -pub fn saveToFile(jar: *Cookie.Jar, path: []const u8) !void { +pub fn saveToFile(jar: *Cookie.Jar, path: []const u8) void { + _saveToFile(jar, path) catch |err| { + log.err(.app, "Cookie.saveToFile", .{ .path = path, .err = err }); + }; +} + +fn _saveToFile(jar: *Cookie.Jar, path: []const u8) !void { jar.removeExpired(null); var file = try std.fs.cwd().createFile(path, .{}); @@ -91,9 +104,12 @@ pub fn saveToFile(jar: *Cookie.Jar, path: []const u8) !void { var writer = file.writer(&buf); const w = &writer.interface; - try w.writeAll("["); + try w.writeByte('['); for (jar.cookies.items, 0..) |c, i| { - if (i > 0) try w.writeAll(","); + if (i > 0) { + try w.writeByte(','); + } + try w.writeAll("\n "); try std.json.Stringify.value(JsonCookie{ .name = c.name, @@ -103,15 +119,17 @@ pub fn saveToFile(jar: *Cookie.Jar, path: []const u8) !void { .expires = c.expires, .secure = c.secure, .httpOnly = c.http_only, + .sameSite = c.same_site, }, .{}, w); } + if (jar.cookies.items.len > 0) { - try w.writeAll("\n"); + try w.writeByte('\n'); } try w.writeAll("]\n"); try writer.end(); - log.info(.app, "saved cookies to file", .{ .path = path, .count = jar.cookies.items.len }); + log.info(.app, "Cookie.saveToFile", .{ .path = path, .count = jar.cookies.items.len }); } const JsonCookie = struct { @@ -122,4 +140,5 @@ const JsonCookie = struct { expires: ?f64 = null, secure: ?bool = null, httpOnly: ?bool = null, + sameSite: Cookie.SameSite = .none, }; diff --git a/src/lightpanda.zig b/src/lightpanda.zig index 84775a7c..42f7c937 100644 --- a/src/lightpanda.zig +++ b/src/lightpanda.zig @@ -69,16 +69,12 @@ pub fn fetch(app: *App, url: [:0]const u8, opts: FetchOpts) !void { var session = try browser.newSession(notification); if (app.config.cookieFile()) |cookie_path| { - cookies.loadFromFile(session, cookie_path) catch |err| { - log.err(.app, "cookie load error", .{ .err = err }); - }; + cookies.loadFromFile(session, cookie_path); } defer { if (app.config.cookieJarFile()) |cookie_jar_path| { - cookies.saveToFile(&session.cookie_jar, cookie_jar_path) catch |err| { - log.err(.app, "cookie save error", .{ .err = err }); - }; + cookies.saveToFile(&session.cookie_jar, cookie_jar_path); } } diff --git a/src/mcp/Server.zig b/src/mcp/Server.zig index 5dbb8a0f..59668398 100644 --- a/src/mcp/Server.zig +++ b/src/mcp/Server.zig @@ -52,9 +52,7 @@ pub fn init(allocator: std.mem.Allocator, app: *App, writer: *std.io.Writer) !*S self.session = try self.browser.newSession(self.notification); if (app.config.cookieFile()) |cookie_path| { - lp.cookies.loadFromFile(self.session, cookie_path) catch |err| { - lp.log.err(.mcp, "cookie load error", .{ .err = err }); - }; + lp.cookies.loadFromFile(self.session, cookie_path); } return self; @@ -62,9 +60,7 @@ pub fn init(allocator: std.mem.Allocator, app: *App, writer: *std.io.Writer) !*S pub fn deinit(self: *Self) void { if (self.app.config.cookieJarFile()) |cookie_jar_path| { - lp.cookies.saveToFile(&self.session.cookie_jar, cookie_jar_path) catch |err| { - lp.log.err(.mcp, "cookie save error", .{ .err = err }); - }; + lp.cookies.saveToFile(&self.session.cookie_jar, cookie_jar_path); } self.node_registry.deinit(); From 84bb395c021b1a25ad7f7837f8f381603385dc16 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 16 Apr 2026 11:10:41 +0800 Subject: [PATCH 10/13] Avoid double free on decoder error Fixes WPT crash: /encoding/textdecoder-fatal-streaming.any.html --- src/browser/tests/encoding/text_decoder.html | 14 ++++++++++++++ src/browser/webapi/encoding/TextDecoder.zig | 11 ++++++----- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/browser/tests/encoding/text_decoder.html b/src/browser/tests/encoding/text_decoder.html index 6314c924..665e651b 100644 --- a/src/browser/tests/encoding/text_decoder.html +++ b/src/browser/tests/encoding/text_decoder.html @@ -125,3 +125,17 @@ let ws = new TextDecoder(' utf-8 '); testing.expectEqual('utf-8', ws.encoding); + + diff --git a/src/browser/webapi/encoding/TextDecoder.zig b/src/browser/webapi/encoding/TextDecoder.zig index 16176d66..6c7cf6c5 100644 --- a/src/browser/webapi/encoding/TextDecoder.zig +++ b/src/browser/webapi/encoding/TextDecoder.zig @@ -127,11 +127,12 @@ pub fn decode(self: *TextDecoder, input_: ?[]const u8, opts_: ?DecodeOpts) ![]co if (self._decoder) |decoder| { // Non-streaming with existing decoder: flush with is_last=true, then free - defer { - html5ever.encoding_decoder_free(decoder); - self._decoder = null; - } - return self._decode(input, decoder, true); + const result = try self._decode(input, decoder, true); + + // on error, _decode will free the decoder. So we only free it on non-error + html5ever.encoding_decoder_free(decoder); + self._decoder = null; + return result; } // non-streaming, no existing decoder From 68ca3907ec3ca68dfe48baec77efd5ebae5554a9 Mon Sep 17 00:00:00 2001 From: gujishh Date: Sun, 12 Apr 2026 23:47:07 +0900 Subject: [PATCH 11/13] docs: suggest verifying nightly binary --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index a5acdd13..8da76cef 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,11 @@ curl -L -o lightpanda https://github.com/lightpanda-io/browser/releases/download chmod a+x ./lightpanda ``` +Verify the binary before running anything: +```console +./lightpanda version +``` + [Linux aarch64 is also available](https://github.com/lightpanda-io/browser/releases/tag/nightly) *For MacOS* From 7da63432ec5ca913d9ba7ea7de279aab149deb40 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 16 Apr 2026 14:48:59 +0800 Subject: [PATCH 12/13] Expand body types new Response(...) can be created with Was originally just a []const u8, but now allows for Blob, BufferArray (and related) and Stream. I saw this being used on http://www.github.com/ (homepage). Unfortunately its using the data for webgl stuff that we don't support, but it does move the processing a step further. --- src/browser/tests/net/response.html | 87 ++++++++++ src/browser/webapi/net/Fetch.zig | 4 +- src/browser/webapi/net/Response.zig | 244 +++++++++++++++++++++++++--- 3 files changed, 307 insertions(+), 28 deletions(-) diff --git a/src/browser/tests/net/response.html b/src/browser/tests/net/response.html index 3b74e72c..a61cbfec 100644 --- a/src/browser/tests/net/response.html +++ b/src/browser/tests/net/response.html @@ -112,3 +112,90 @@ testing.expectEqual('cloned body', text2); }); + + diff --git a/src/browser/webapi/net/Fetch.zig b/src/browser/webapi/net/Fetch.zig index 07901487..bae31302 100644 --- a/src/browser/webapi/net/Fetch.zig +++ b/src/browser/webapi/net/Fetch.zig @@ -121,7 +121,7 @@ fn handleBlobUrl(url: []const u8, resolver: js.PromiseResolver, page: *Page) !js }; const response = try Response.init(null, .{ .status = 200 }, page); - response._body = try response._arena.dupe(u8, blob._slice); + response._body = .{ .bytes = try response._arena.dupe(u8, blob._slice) }; response._url = try response._arena.dupeZ(u8, url); response._type = .basic; @@ -214,7 +214,7 @@ fn httpDoneCallback(ctx: *anyopaque) !void { const self: *Fetch = @ptrCast(@alignCast(ctx)); var response = self._response; response._http_response = null; - response._body = self._buf.items; + response._body = .{ .bytes = self._buf.items }; log.info(.http, "request complete", .{ .source = "fetch", diff --git a/src/browser/webapi/net/Response.zig b/src/browser/webapi/net/Response.zig index 7ed7ba4d..39f9b24e 100644 --- a/src/browser/webapi/net/Response.zig +++ b/src/browser/webapi/net/Response.zig @@ -1,4 +1,4 @@ -// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire @@ -43,29 +43,74 @@ _rc: lp.RC(u8) = .{}, _status: u16, _arena: Allocator, _headers: *Headers, -_body: ?[]const u8, +_body: Body = .empty, _type: Type, _status_text: []const u8, _url: [:0]const u8, _is_redirected: bool, _http_response: ?HttpClient.Response = null, +const Body = union(enum) { + empty, + bytes: []const u8, + stream: *ReadableStream, +}; + const InitOpts = struct { status: u16 = 200, headers: ?Headers.InitOpts = null, statusText: ?[]const u8 = null, }; -pub fn init(body_: ?[]const u8, opts_: ?InitOpts, page: *Page) !*Response { +/// Body can be: null, string ([]const u8), ReadableStream, Blob, ArrayBuffer +pub const BodyInit = union(enum) { + stream: *ReadableStream, + bytes: []const u8, + js_val: js.Value, +}; + +pub fn init(body_: ?BodyInit, opts_: ?InitOpts, page: *Page) !*Response { const arena = try page.getArena(.large, "Response"); errdefer page.releaseArena(arena); const opts = opts_ orelse InitOpts{}; - - // Store empty string as empty string, not null - const body = if (body_) |b| try arena.dupe(u8, b) else null; const status_text = if (opts.statusText) |st| try arena.dupe(u8, st) else ""; + // Parse body from the union + const body: Body = blk: { + const b = body_ orelse break :blk .empty; + switch (b) { + .bytes => |body_bytes| break :blk .{ .bytes = try arena.dupe(u8, body_bytes) }, + .stream => |stream| break :blk .{ .stream = stream }, + .js_val => |js_val| { + const local = page.js.local.?; + + if (local.jsValueToZig(*ReadableStream, js_val)) |stream| { + break :blk .{ .stream = stream }; + } else |_| {} + + if (js_val.isString()) |js_str| { + break :blk .{ .bytes = try js_str.toSliceWithAlloc(arena) }; + } + + if (js_val.isArrayBuffer() or js_val.isTypedArray() or js_val.isArrayBufferView()) { + if (local.jsValueToZig([]u8, js_val)) |data| { + break :blk .{ .bytes = try arena.dupe(u8, data) }; + } else |_| {} + } + + if (local.jsValueToZig(*Blob, js_val)) |blob_obj| { + break :blk .{ .bytes = try arena.dupe(u8, blob_obj._slice) }; + } else |_| {} + + if (js_val.isNullOrUndefined() == false) { + break :blk .{ .bytes = try js_val.toStringSliceWithAlloc(arena) }; + } + }, + } + break :blk .empty; + }; + const self = try arena.create(Response); self.* = .{ ._arena = arena, @@ -120,17 +165,19 @@ pub fn getType(self: *const Response) []const u8 { return @tagName(self._type); } -pub fn getBody(self: *const Response, page: *Page) !?*ReadableStream { - const body = self._body orelse return null; - - // Empty string should create a closed stream with no data - if (body.len == 0) { - const stream = try ReadableStream.init(null, null, page); - try stream._controller.close(); - return stream; - } - - return ReadableStream.initWithData(body, page); +pub fn getBody(self: *Response, page: *Page) !?*ReadableStream { + return switch (self._body) { + .empty => null, + .stream => |stream| stream, + .bytes => |body| { + if (body.len == 0) { + const stream = try ReadableStream.init(null, null, page); + try stream._controller.close(); + return stream; + } + return ReadableStream.initWithData(body, page); + }, + }; } pub fn isOK(self: *const Response) bool { @@ -138,25 +185,155 @@ pub fn isOK(self: *const Response) bool { } pub fn getText(self: *const Response, page: *Page) !js.Promise { - const body = self._body orelse ""; + const body = switch (self._body) { + .bytes => |b| b, + .empty => "", + .stream => return page.js.local.?.rejectPromise(.{ .type_error = "Cannot read text from stream body" }), + }; return page.js.local.?.resolvePromise(body); } pub fn getJson(self: *Response, page: *Page) !js.Promise { - const body = self._body orelse ""; const local = page.js.local.?; + const body = switch (self._body) { + .bytes => |b| b, + .empty => "", + .stream => return local.rejectPromise(.{ .type_error = "Cannot read JSON from stream body" }), + }; const value = local.parseJSON(body) catch { return local.rejectPromise(.{ .syntax_error = "failed to parse" }); }; return local.resolvePromise(try value.persist()); } -pub fn arrayBuffer(self: *const Response, page: *Page) !js.Promise { - return page.js.local.?.resolvePromise(js.ArrayBuffer{ .values = self._body orelse "" }); +pub fn arrayBuffer(self: *Response, page: *Page) !js.Promise { + return switch (self._body) { + .bytes => |body| page.js.local.?.resolvePromise(js.ArrayBuffer{ .values = body }), + .empty => page.js.local.?.resolvePromise(js.ArrayBuffer{ .values = "" }), + .stream => |stream| StreamConsumer.start(stream, page), + }; } +/// Async consumer for reading all data from a ReadableStream +const StreamConsumer = struct { + const ReadableStreamDefaultReader = @import("../streams/ReadableStreamDefaultReader.zig"); + + page: *Page, + total_len: usize, + arena: Allocator, + reader: *ReadableStreamDefaultReader, + chunks: std.ArrayList([]const u8), + resolver: js.PromiseResolver.Global, + + fn start(stream: *ReadableStream, page: *Page) !js.Promise { + const local = page.js.local.?; + var resolver = local.createPromiseResolver(); + const promise = resolver.promise(); + + const reader = try stream.getReader(page); + + const state = try page.arena.create(StreamConsumer); + state.* = .{ + .page = page, + .reader = reader, + .chunks = .empty, + .total_len = 0, + .arena = page.arena, + .resolver = try resolver.persist(), + }; + + try state.pumpRead(); + return promise; + } + + fn pumpRead(self: *StreamConsumer) !void { + const local = self.page.js.local.?; + const read_promise = try self.reader.read(self.page); + + const then_fn = local.newCallback(onReadFulfilled, self); + const catch_fn = local.newCallback(onReadRejected, self); + + _ = read_promise.thenAndCatch(then_fn, catch_fn) catch { + self.finish(local, null); + }; + } + + const ReadData = struct { + done: bool, + value: js.Value, + }; + + fn onReadFulfilled(self: *StreamConsumer, data_: ?ReadData) void { + const page = self.page; + + const data = data_ orelse { + return self.finish(page.js.local.?, null); + }; + + self._onReadFulfilled(data) catch { + self.finish(page.js.local.?, null); + }; + } + + fn _onReadFulfilled(self: *StreamConsumer, data: ReadData) !void { + const page = self.page; + const local = page.js.local.?; + + if (data.done) { + // Stream is finished, concatenate all chunks and resolve + self.reader.releaseLock(); + const result = try self.concatenateChunks(page.call_arena); + local.toLocal(self.resolver).resolve("arrayBuffer complete", js.ArrayBuffer{ .values = result }); + return; + } + + // Collect the chunk data + const value = data.value; + if (!value.isUndefined()) { + // Try to get bytes from the value (could be Uint8Array or string) + if (value.isTypedArray() or value.isArrayBufferView() or value.isArrayBuffer()) { + if (local.jsValueToZig([]u8, value)) |typed_data| { + const chunk_copy = try self.arena.dupe(u8, typed_data); + try self.chunks.append(self.arena, chunk_copy); + self.total_len += chunk_copy.len; + } else |_| {} + } else if (value.isString()) |str| { + const slice = try str.toSlice(); + const chunk_copy = try self.arena.dupe(u8, slice); + try self.chunks.append(self.arena, chunk_copy); + self.total_len += chunk_copy.len; + } + } + try self.pumpRead(); + } + + fn onReadRejected(self: *StreamConsumer) void { + self.finish(self.page.js.local.?, null); + } + + fn concatenateChunks(self: *StreamConsumer, allocator: Allocator) ![]const u8 { + if (self.chunks.items.len == 0) { + return ""; + } + if (self.chunks.items.len == 1) { + return self.chunks.items[0]; + } + return std.mem.join(allocator, "", self.chunks.items); + } + + fn finish(self: *StreamConsumer, local: *const js.Local, err: ?[]const u8) void { + self.reader.releaseLock(); + local.toLocal(self.resolver).rejectError("arrayBuffer error", .{ .type_error = err orelse "Failed to read stream" }); + } +}; + pub fn blob(self: *const Response, page: *Page) !js.Promise { - const body = self._body orelse ""; + const local = page.js.local.?; + const body = switch (self._body) { + .bytes => |b| b, + .empty => "", + .stream => return local.rejectPromise(.{ .type_error = "Cannot read blob from stream body" }), + }; const content_type = try self._headers.get("content-type", page) orelse ""; const b = try Blob.initWithMimeValidation( @@ -166,18 +343,33 @@ pub fn blob(self: *const Response, page: *Page) !js.Promise { page, ); - return page.js.local.?.resolvePromise(b); + return local.resolvePromise(b); } pub fn bytes(self: *const Response, page: *Page) !js.Promise { - return page.js.local.?.resolvePromise(js.TypedArray(u8){ .values = self._body orelse "" }); + const local = page.js.local.?; + const body = switch (self._body) { + .bytes => |b| b, + .empty => "", + .stream => return local.rejectPromise(.{ .type_error = "Cannot read bytes from stream body" }), + }; + return local.resolvePromise(js.TypedArray(u8){ .values = body }); } pub fn clone(self: *const Response, page: *Page) !*Response { - const arena = try page.getArena((self._body orelse "").len + self._url.len + 256, "Response.clone"); + const body_len = switch (self._body) { + .bytes => |b| b.len, + .empty => 0, + .stream => 0, + }; + const arena = try page.getArena(body_len + self._url.len + 256, "Response.clone"); errdefer page.releaseArena(arena); - const body = if (self._body) |b| try arena.dupe(u8, b) else null; + const body: Body = switch (self._body) { + .bytes => |b| .{ .bytes = try arena.dupe(u8, b) }, + .empty => .empty, + .stream => .empty, // TODO: implement stream tee for proper cloning + }; const status_text = try arena.dupe(u8, self._status_text); const url = try arena.dupeZ(u8, self._url); From 1bffde88081a10a9ea0727204f9431cb65eb73be Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 16 Apr 2026 16:01:27 +0800 Subject: [PATCH 13/13] Improve safety of Node.replaceChild and Element.replaceWith We've seen this sort of thing before - an assumption we make about the state of the DOM through a transition is broken by CustomElement callbacks. Here we see replaceChild which (a) inserts the new nodes and (b) removes the old one. As part of removing, our page.removeNode has an assertion that the child belongs to the parent. This is a guarantee that the Page is asking the caller (Node.replaceChild in this case) to make. But, if the node being inserted is a custom element, it can have a callback so step (a) can cause changes to the document, including removing/moving the node being replaced. TL;DR - CustomElement callbacks means that we have to check that the child to be replaced is still a child of the parent after our insert. --- src/browser/tests/element/replace_with.html | 31 +++++++++++++++++++++ src/browser/tests/node/replace_child.html | 31 +++++++++++++++++++++ src/browser/webapi/Element.zig | 4 ++- src/browser/webapi/Node.zig | 4 ++- 4 files changed, 68 insertions(+), 2 deletions(-) diff --git a/src/browser/tests/element/replace_with.html b/src/browser/tests/element/replace_with.html index 14940f4d..0597b9b1 100644 --- a/src/browser/tests/element/replace_with.html +++ b/src/browser/tests/element/replace_with.html @@ -332,3 +332,34 @@ testing.expectEqual(new13, document.getElementById('new13')); testing.expectEqual(l4, new13.parentElement); + + + diff --git a/src/browser/tests/node/replace_child.html b/src/browser/tests/node/replace_child.html index 51b0a173..b45a3d51 100644 --- a/src/browser/tests/node/replace_child.html +++ b/src/browser/tests/node/replace_child.html @@ -40,3 +40,34 @@ testing.expectEqual(c3, d1.replaceChild(c3, c3)); assertChildren([c3, c4], d1) + + diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index 655a4e9d..a26f1f80 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -872,7 +872,9 @@ pub fn replaceWith(self: *Element, nodes: []const Node.NodeOrText, page: *Page) ); } - if (rm_ref_node) { + // Re-check parent after insertNodeRelative since callbacks (e.g. connectedCallback) + // could have already removed ref_node from parent. + if (rm_ref_node and ref_node._parent == parent) { page.removeNode(parent, ref_node, .{ .will_be_reconnected = false }); } } diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig index 5871abee..d6d73fee 100644 --- a/src/browser/webapi/Node.zig +++ b/src/browser/webapi/Node.zig @@ -624,7 +624,9 @@ pub fn replaceChild(self: *Node, new_child: *Node, old_child: *Node, page: *Page // Special case: if we replace a node by itself, we don't remove it. // insertBefore is an noop in this case. - if (new_child != old_child) { + // Re-check parent after insertBefore since callbacks (e.g. connectedCallback) + // could have already removed old_child from self. + if (new_child != old_child and old_child._parent == self) { page.removeNode(self, old_child, .{ .will_be_reconnected = false }); }