mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-06-11 17:46:32 -04:00
Merge branch 'main' into agent
This commit is contained in:
@@ -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*
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
144
src/cookies.zig
Normal 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,
|
||||
};
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user