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