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*
diff --git a/src/Config.zig b/src/Config.zig
index e1d79949..47505775 100644
--- a/src/Config.zig
+++ b/src/Config.zig
@@ -147,6 +147,20 @@ pub fn httpCacheDir(self: *const Config) ?[]const u8 {
};
}
+pub fn cookieFile(self: *const Config) ?[]const u8 {
+ return switch (self.mode) {
+ 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,
+ };
+}
+
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,
@@ -292,6 +306,8 @@ pub const Common = struct {
user_agent_suffix: ?[]const u8 = null,
user_agent: ?[]const u8 = null,
http_cache_dir: ?[]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,
@@ -494,6 +510,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
@@ -523,12 +546,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 ++
\\
\\agent command
@@ -750,6 +783,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;
}
@@ -1297,6 +1338,24 @@ fn parseCommonArg(
return true;
}
+ if (std.mem.eql(u8, "--cookie", opt)) {
+ const str = args.next() orelse {
+ log.fatal(.app, "missing argument value", .{ .arg = "--cookie" });
+ return error.InvalidArgument;
+ };
+ 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;
+ }
+
return false;
}
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/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/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/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 });
}
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
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);
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 cc4919c6..433423b0 100644
--- a/src/cdp/CDP.zig
+++ b/src/cdp/CDP.zig
@@ -394,6 +394,9 @@ 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_path);
+ }
const browser = &cdp.browser;
const inspector_session = browser.env.inspector.?.startSession(self);
diff --git a/src/cookies.zig b/src/cookies.zig
new file mode 100644
index 00000000..aa069393
--- /dev/null
+++ b/src/cookies.zig
@@ -0,0 +1,144 @@
+// 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 log = @import("log.zig");
+
+const Session = @import("browser/Session.zig");
+const Cookie = @import("browser/webapi/storage/Cookie.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 {
+ _loadFromFile(session, path) catch |err| {
+ log.err(.app, "Cookie.loadFromFile", .{ .err = err, .path = path });
+ };
+}
+
+fn _loadFromFile(session: *Session, path: []const u8) !void {
+ const arena = try session.getArena(.medium, "Cookies.loadFromFile");
+ defer session.releaseArena(arena);
+
+ 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 json_cookies = std.json.parseFromSliceLeaky([]const JsonCookie, arena, content, .{
+ .ignore_unknown_fields = true,
+ }) catch |err| {
+ log.err(.app, "Cookie.parseFile", .{ .path = path, .err = err });
+ return;
+ };
+
+ const jar = &session.cookie_jar;
+ const now = std.time.timestamp();
+
+ var loaded: usize = 0;
+ 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 = 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 = jc.sameSite,
+ };
+
+ jar.add(cookie, now) catch |err| {
+ cookie.deinit();
+ log.warn(.app, "invalid cookie", .{ .name = jc.name, .err = err });
+ continue;
+ };
+ loaded += 1;
+ }
+
+ 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 {
+ _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, .{});
+ defer file.close();
+
+ var buf: [8192]u8 = undefined;
+ var writer = file.writer(&buf);
+ const w = &writer.interface;
+
+ try w.writeByte('[');
+ for (jar.cookies.items, 0..) |c, i| {
+ if (i > 0) {
+ try w.writeByte(',');
+ }
+
+ try w.writeAll("\n ");
+ try std.json.Stringify.value(JsonCookie{
+ .name = c.name,
+ .value = c.value,
+ .domain = c.domain,
+ .path = c.path,
+ .expires = c.expires,
+ .secure = c.secure,
+ .httpOnly = c.http_only,
+ .sameSite = c.same_site,
+ }, .{}, w);
+ }
+
+ if (jar.cookies.items.len > 0) {
+ try w.writeByte('\n');
+ }
+ try w.writeAll("]\n");
+ try writer.end();
+
+ log.info(.app, "Cookie.saveToFile", .{ .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,
+ sameSite: Cookie.SameSite = .none,
+};
diff --git a/src/lightpanda.zig b/src/lightpanda.zig
index 789621f5..5a466360 100644
--- a/src/lightpanda.zig
+++ b/src/lightpanda.zig
@@ -42,6 +42,7 @@ pub const structured_data = @import("browser/structured_data.zig");
pub const tools = @import("browser/tools.zig");
pub const mcp = @import("mcp.zig");
pub const agent = @import("agent.zig");
+pub const cookies = @import("cookies.zig");
pub const build_config = @import("build_config");
pub const crash_handler = @import("crash_handler.zig");
@@ -68,6 +69,17 @@ pub fn fetch(app: *App, url: [:0]const u8, opts: FetchOpts) !void {
defer browser.deinit();
var session = try browser.newSession(notification);
+
+ if (app.config.cookieFile()) |cookie_path| {
+ cookies.loadFromFile(session, cookie_path);
+ }
+
+ defer {
+ if (app.config.cookieJarFile()) |cookie_jar_path| {
+ cookies.saveToFile(&session.cookie_jar, cookie_jar_path);
+ }
+ }
+
const page = try session.createPage();
// // Comment this out to get a profile of the JS code in v8/profile.json.
diff --git a/src/mcp/Server.zig b/src/mcp/Server.zig
index a334bc38..59668398 100644
--- a/src/mcp/Server.zig
+++ b/src/mcp/Server.zig
@@ -50,10 +50,19 @@ 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);
+ }
+
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);
+ }
+
self.node_registry.deinit();
self.aw.deinit();
self.browser.deinit();