Merge branch 'main' into agent

This commit is contained in:
Adrià Arrufat
2026-04-17 09:19:46 +02:00
16 changed files with 628 additions and 36 deletions

View File

@@ -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*

View File

@@ -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;
}

View File

@@ -332,3 +332,34 @@
testing.expectEqual(new13, document.getElementById('new13'));
testing.expectEqual(l4, new13.parentElement);
</script>
<!-- Test 14: Callback removes ref_node during replaceWith -->
<script id=test14-callback-removes-ref-node>
// Custom element whose connectedCallback removes the ref_node
// This tests that replaceWith handles mid-operation DOM mutations
class RefRemover extends HTMLElement {
connectedCallback() {
// Remove the next sibling (which is the ref_node being replaced)
const sibling = this.nextSibling;
if (sibling) {
sibling.remove();
}
}
}
customElements.define('ref-remover', RefRemover);
const parent14 = document.createElement('div');
const oldChild14 = document.createElement('div');
parent14.appendChild(oldChild14);
document.body.appendChild(parent14);
const newChild14 = document.createElement('ref-remover');
// replaceWith inserts newChild14 before oldChild14, then connectedCallback
// fires and removes oldChild14. replaceWith should not crash when it
// tries to remove the already-removed oldChild14.
oldChild14.replaceWith(newChild14);
testing.expectEqual(newChild14, parent14.firstChild);
testing.expectEqual(null, oldChild14.parentNode);
</script>

View File

@@ -125,3 +125,17 @@
let ws = new TextDecoder(' utf-8 ');
testing.expectEqual('utf-8', ws.encoding);
</script>
<script id=stream_fatal_error>
{
let d = new TextDecoder('utf-8', {fatal: true});
d.decode(new Uint8Array([65]), { stream: true }); // 'A'
testing.expectError('TypeError', () => {
d.decode(new Uint8Array([0xFF, 0xFE])); // invalid UTF-8
});
// Verify decoder still works after error recovery
testing.expectEqual('B', d.decode(new Uint8Array([66])));
}
</script>

View File

@@ -112,3 +112,90 @@
testing.expectEqual('cloned body', text2);
});
</script>
<script id=body_types>
// Test Response with ArrayBuffer body
testing.async(async () => {
const data = new Uint8Array([72, 101, 108, 108, 111]); // "Hello"
const arrayBuffer = data.buffer;
const response = new Response(arrayBuffer);
const result = await response.arrayBuffer();
testing.expectEqual(true, result instanceof ArrayBuffer);
testing.expectEqual(5, result.byteLength);
const view = new Uint8Array(result);
testing.expectEqual(72, view[0]); // 'H'
testing.expectEqual(111, view[4]); // 'o'
});
// Test Response with Uint8Array body
testing.async(async () => {
const data = new Uint8Array([87, 111, 114, 108, 100]); // "World"
const response = new Response(data);
const result = await response.arrayBuffer();
testing.expectEqual(true, result instanceof ArrayBuffer);
testing.expectEqual(5, result.byteLength);
const view = new Uint8Array(result);
testing.expectEqual(87, view[0]); // 'W'
testing.expectEqual(100, view[4]); // 'd'
});
// Test Response with Blob body
testing.async(async () => {
const blob = new Blob(['Test', 'Data'], { type: 'text/plain' });
const response = new Response(blob);
const text = await response.text();
testing.expectEqual('TestData', text);
});
// Test Response with ReadableStream body
testing.async(async () => {
const chunks = [
new Uint8Array([65, 66, 67]), // "ABC"
new Uint8Array([68, 69, 70]) // "DEF"
];
let chunkIndex = 0;
const stream = new ReadableStream({
pull(controller) {
if (chunkIndex < chunks.length) {
controller.enqueue(chunks[chunkIndex++]);
} else {
controller.close();
}
}
});
const response = new Response(stream);
const result = await response.arrayBuffer();
testing.expectEqual(true, result instanceof ArrayBuffer);
testing.expectEqual(6, result.byteLength);
const view = new Uint8Array(result);
testing.expectEqual(65, view[0]); // 'A'
testing.expectEqual(66, view[1]); // 'B'
testing.expectEqual(67, view[2]); // 'C'
testing.expectEqual(68, view[3]); // 'D'
testing.expectEqual(69, view[4]); // 'E'
testing.expectEqual(70, view[5]); // 'F'
});
// Test Response.body returns ReadableStream
testing.async(async () => {
const stream = new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array([1, 2, 3]));
controller.close();
}
});
const response = new Response(stream);
const body = response.body;
testing.expectEqual(true, body instanceof ReadableStream);
});
</script>

View File

@@ -40,3 +40,34 @@
testing.expectEqual(c3, d1.replaceChild(c3, c3));
assertChildren([c3, c4], d1)
</script>
<script id=replaceChild_callback_removes_old_child>
// Custom element whose connectedCallback removes the old_child
// This tests that replaceChild handles mid-operation DOM mutations
class RemoverElement extends HTMLElement {
connectedCallback() {
// Remove the sibling that replaceChild is about to remove
const sibling = this.nextSibling;
if (sibling) {
sibling.remove();
}
}
}
customElements.define('remover-element', RemoverElement);
const parent = document.createElement('div');
const oldChild = document.createElement('div');
parent.appendChild(oldChild);
document.body.appendChild(parent);
const newChild = document.createElement('remover-element');
// insertBefore inserts newChild before oldChild, then connectedCallback
// fires and removes oldChild. replaceChild should not crash when it
// tries to remove the already-removed oldChild.
parent.replaceChild(newChild, oldChild);
testing.expectEqual(newChild, parent.firstChild);
testing.expectEqual(null, parent.lastChild.nextSibling);
testing.expectEqual(null, oldChild.parentNode);
</script>

View File

@@ -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 });
}
}

View File

@@ -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 });
}

View File

@@ -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

View File

@@ -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",

View File

@@ -1,4 +1,4 @@
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
@@ -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);

View File

@@ -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,

View File

@@ -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);

144
src/cookies.zig Normal file
View File

@@ -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 <https://www.gnu.org/licenses/>.
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,
};

View File

@@ -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.

View File

@@ -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();