Compare commits

..

66 Commits

Author SHA1 Message Date
Muki Kiboigo
778b7eb8c2 allocate CacheMetadata on use 2026-04-03 07:34:31 -07:00
Muki Kiboigo
ca5fa2b866 change --cache-dir -> --http-cache-dir 2026-04-03 07:23:32 -07:00
Pierre Tachoire
a71ff521aa cache: add debug log with no store reason 2026-04-03 07:23:32 -07:00
Muki Kiboigo
5a551607c2 better logging on FsCache init failure 2026-04-03 07:23:32 -07:00
Muki Kiboigo
13ea4d1ee3 more expressive cache logging 2026-04-03 07:23:32 -07:00
Muki Kiboigo
dc600c953f move script queue log before request 2026-04-03 07:23:32 -07:00
Muki Kiboigo
e00d569754 fix crashes on cached file from script manager 2026-04-03 07:23:31 -07:00
Muki Kiboigo
3d760e4577 add format to CachedMetadata 2026-04-03 07:23:31 -07:00
Muki Kiboigo
1e8bdd7e28 assign headers and vary headers before possible move 2026-04-03 07:23:31 -07:00
Muki Kiboigo
31bab4cc05 put in cache before releasing conn 2026-04-03 07:23:31 -07:00
Muki Kiboigo
a1a301666f dupe url in tryCache 2026-04-03 07:23:31 -07:00
Muki Kiboigo
619a2653d1 update cacheDir config option 2026-04-03 07:23:31 -07:00
Muki Kiboigo
0b9cae5354 fix self.req.ctx in HttpClient 2026-04-03 07:23:31 -07:00
Muki Kiboigo
f098a991a8 remove cache revalidation stubs 2026-04-03 07:23:30 -07:00
Muki Kiboigo
7b5e4d6f52 add Vary support 2026-04-03 07:23:30 -07:00
Muki Kiboigo
9ffc99d6a2 add more FsCache tests 2026-04-03 07:23:30 -07:00
Muki Kiboigo
855c3290ff always close file on serveFromCache 2026-04-03 07:23:30 -07:00
Muki Kiboigo
d65a4b09f3 better logging for cache 2026-04-03 07:23:30 -07:00
Muki Kiboigo
6a57d69359 switch to single file cache 2026-04-03 07:23:30 -07:00
Muki Kiboigo
9c5e67fbf5 properly deinit cache 2026-04-03 07:23:30 -07:00
Muki Kiboigo
7edb24e54d use wyhash for power of two lock stripes 2026-04-03 07:23:29 -07:00
Muki Kiboigo
a60932bbe0 require timestamp passed in with cache request 2026-04-03 07:23:29 -07:00
Muki Kiboigo
77e9f5caf7 remove unused cache method on fs cache 2026-04-03 07:23:29 -07:00
Muki Kiboigo
cedc894445 add basic fs cache get/put test 2026-04-03 07:23:29 -07:00
Muki Kiboigo
9d62e58c9a check age on fs cache get 2026-04-03 07:23:29 -07:00
Muki Kiboigo
609983da87 only store stuff when we know we will cache 2026-04-03 07:23:29 -07:00
Muki Kiboigo
65f77af84d shortcircuit a lot of caching checks 2026-04-03 07:23:29 -07:00
Muki Kiboigo
cd3e6b2364 ensure fs cache file is closed after use 2026-04-03 07:23:28 -07:00
Muki Kiboigo
557a4458a4 use CacheRequest instead of key 2026-04-03 07:23:28 -07:00
Muki Kiboigo
ce620e208d add striped lock to FsCache 2026-04-03 07:23:28 -07:00
Muki Kiboigo
2de35a9db2 use arena_pool for cache get 2026-04-03 07:23:28 -07:00
Muki Kiboigo
3eb05fdd1a use writer for fs cache body file 2026-04-03 07:23:28 -07:00
Muki Kiboigo
186fdee59b use json for fs cache metadata file 2026-04-03 07:23:28 -07:00
Muki Kiboigo
3c8bb5bc00 use sha256 instead of wyhash 2026-04-03 07:23:28 -07:00
Muki Kiboigo
66d190c047 store type_buf and sub_type_buf in Mime 2026-04-03 07:23:28 -07:00
Muki Kiboigo
5c2207ecc3 add more http caching rules 2026-04-03 07:23:27 -07:00
Muki Kiboigo
18d347e247 use CacheControl and Vary 2026-04-03 07:23:27 -07:00
Muki Kiboigo
29dfbbfdea cache headers along with response 2026-04-03 07:23:27 -07:00
Muki Kiboigo
02f611bbc8 add basic caching support 2026-04-03 07:23:27 -07:00
Muki Kiboigo
349d5a0a0b create cache owned by the network struct 2026-04-03 07:23:27 -07:00
Muki Kiboigo
647d989191 use enum approach instead of vtable 2026-04-03 07:23:27 -07:00
Muki Kiboigo
41a24623fa add basic FsCache impl 2026-04-03 07:23:27 -07:00
Muki Kiboigo
d9a3d912c0 add CachedResponse variant to Response 2026-04-03 07:23:26 -07:00
Muki Kiboigo
070baa8f46 add headerIterator to Http Response 2026-04-03 07:23:26 -07:00
Muki Kiboigo
4f78f299a3 use Response instead of Transfer in callbacks 2026-04-03 07:23:25 -07:00
Muki Kiboigo
9568c86326 allow Mime parse to use []const u8 2026-04-03 07:22:20 -07:00
Muki Kiboigo
6633b6effc add cache dir to configuration opts 2026-04-03 07:22:20 -07:00
Muki Kiboigo
2962864f3d create Cache interface file 2026-04-03 07:22:20 -07:00
Pierre Tachoire
749f21816c Merge pull request #2075 from lightpanda-io/e2e-integration-proxy
use proxy for integration tests
2026-04-03 14:23:10 +02:00
Pierre Tachoire
6bb8bc8391 ci: use proxy for integration tests 2026-04-03 09:24:17 +02:00
Karl Seguin
b6020e4770 Merge pull request #2066 from lightpanda-io/fix/agent-integration-and-mcp-cdp
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / wba-demo-scripts (push) Has been cancelled
e2e-test / wba-test (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig fmt (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
mcp: improve navigation reliability and add CDP support
2026-04-03 08:27:55 +08:00
Karl Seguin
45f8f49bee Merge pull request #2074 from lightpanda-io/tao_in_identity_map
Store TAO in IdentityMap
2026-04-03 08:21:52 +08:00
Karl Seguin
494cc582fe Merge pull request #2073 from lightpanda-io/page-isSameOrigin
stricter Page.isSameOrigin
2026-04-03 07:39:30 +08:00
Karl Seguin
76db817089 Store TAO in IdentityMap
The object can be accessed from any context in the same origin, so the TAO
should exist for as long.
2026-04-03 07:34:52 +08:00
Karl Seguin
d2fb175d4f Merge pull request #2069 from lightpanda-io/finalizer_rc
Move finalizers to pure reference counting
2026-04-03 06:46:09 +08:00
Pierre Tachoire
082cd52b03 stricter Page.isSameOrigin
Page.isSameOrigin used to compare only the beginning of urls.
But origin https://origin.com must not match with
https://origin.com.attacker.com
2026-04-02 18:02:09 +02:00
Karl Seguin
476cf419f7 Merge pull request #2071 from lightpanda-io/abort_assertions
Relax assertion on httpclient abort
2026-04-02 22:50:41 +08:00
Adrià Arrufat
b29405749b server: handle CDPWaitResult.done instead of unreachable 2026-04-02 15:08:34 +02:00
Adrià Arrufat
62f58b4c12 browser: treat wait timeout as normal completion, not an error 2026-04-02 14:54:06 +02:00
Adrià Arrufat
69e5478dd7 browser: simplify Runner wait timeout logic 2026-04-02 14:15:15 +02:00
Karl Seguin
de0a04a58e Relax assertion on httpclient abort
It's ok to still have transfers, as long as whatever transfers still exists
are in an aborted state.
2026-04-02 17:59:17 +08:00
Karl Seguin
77b60cebb0 Move finalizers to pure reference counting
Takes https://github.com/lightpanda-io/browser/pull/2024 a step further and
changes all reference counting to be explicit.

Up until this point, finalizers_callback was seen as a fail-safe to make sure
that instances were released no matter what. It exists because v8 might never
call a finalizer, so we need to keep track of finalizables and finalize them
on behalf of v8. BUT, it was used as more than a fallback for v8...it allowed
us to be lazy and acquireRef's in Zig without a matching releaseRef (1), because
why not, the finalizer_callback will handle it.

This commit redefines finalizer_callbacks as strictly being a fallback for v8.
If v8 calls the finalizer, then the finalizer callback is removed (2) - we lose
our fail-safe. This means that every acquireRef must be matched with a
releaseRef. Everything is explicit now. The most obvious impact of this is
that on Page.deinit, we have to releaseRef every MO, IO and blob held by the
page.

This change removes a number of special-cases to deal with various ownership
patterns. For example, Iterators are now properly reference counted and when their
RC reaches 0, they can safely releaseRef on their list. This also elimites
use-after-free potential when 2 RC objects reference each other. This should
eliminate some WPT crashes (e.g. /editing/run/insertimage.html)

(1) - We were only ever lazy about releaseRef during shutdown, so this change
won't result in more aggressive collection.

(2) Since 1 object can be referenced from 0-N IsolatedWorlds, it would be more
accurate to say that the finalizer callback is removed when all referencing
IsolatedWorld finalize it.
2026-04-02 17:04:33 +08:00
Adrià Arrufat
1770dc03e3 refactor: move timeout and busy logic to Runner 2026-04-02 08:06:50 +02:00
Adrià Arrufat
1854627b69 mcp: final protocol cleanup after removing screenshot tool
- Removed unused ImageContent from protocol.
- Simplified CallToolResult back to only support TextContent.
- Cleaned up CallToolResult usages in tools.zig.
2026-04-01 15:00:55 +02:00
Adrià Arrufat
fffa8b6d4b mcp/cdp: fix inactivity timeout
- Fixed CDP inactivity timeout by resetting it when the browser is busy (loading or executing macrotasks).
- Removed the placeholder screenshot tool.
- Refactored MCP tool schemas to constants to avoid duplication.
2026-04-01 14:37:40 +02:00
Adrià Arrufat
58fc60d669 mcp: improve navigation reliability and add CDP support
- Configurable navigation timeouts and wait strategies in MCP tools.
- Default navigation timeout increased from 2s to 10s.
- Added navigate, eval, and screenshot MCP tools.
- Supported running a CDP server alongside MCP using --cdp-port.
- Fixed various startup crashes when running CDP in MCP mode.
- Hardened MCP server error handling.
2026-04-01 12:41:56 +02:00
29 changed files with 1673 additions and 388 deletions

View File

@@ -62,7 +62,7 @@ jobs:
- name: run end to end integration tests - name: run end to end integration tests
continue-on-error: true continue-on-error: true
run: | run: |
./lightpanda serve --log-level error & echo $! > LPD.pid ./lightpanda serve --http-proxy ${{ secrets.MASSIVE_PROXY_RESIDENTIAL_US }} --log-level error & echo $! > LPD.pid
go run integration/main.go |tee result.log go run integration/main.go |tee result.log
kill `cat LPD.pid` kill `cat LPD.pid`

View File

@@ -55,7 +55,7 @@ pub fn init(allocator: Allocator, config: *const Config) !*App {
.arena_pool = undefined, .arena_pool = undefined,
}; };
app.network = try Network.init(allocator, config); app.network = try Network.init(allocator, app, config);
errdefer app.network.deinit(); errdefer app.network.deinit();
app.platform = try Platform.init(); app.platform = try Platform.init();

View File

@@ -156,9 +156,17 @@ pub fn userAgentSuffix(self: *const Config) ?[]const u8 {
}; };
} }
pub fn httpCacheDir(self: *const Config) ?[]const u8 {
return switch (self.mode) {
inline .serve, .fetch, .mcp => |opts| opts.common.http_cache_dir,
else => null,
};
}
pub fn cdpTimeout(self: *const Config) usize { pub fn cdpTimeout(self: *const Config) usize {
return switch (self.mode) { return switch (self.mode) {
.serve => |opts| if (opts.timeout > 604_800) 604_800_000 else @as(usize, opts.timeout) * 1000, .serve => |opts| if (opts.timeout > 604_800) 604_800_000 else @as(usize, opts.timeout) * 1000,
.mcp => 10000, // Default timeout for MCP-CDP
else => unreachable, else => unreachable,
}; };
} }
@@ -166,6 +174,7 @@ pub fn cdpTimeout(self: *const Config) usize {
pub fn port(self: *const Config) u16 { pub fn port(self: *const Config) u16 {
return switch (self.mode) { return switch (self.mode) {
.serve => |opts| opts.port, .serve => |opts| opts.port,
.mcp => |opts| opts.cdp_port orelse 0,
else => unreachable, else => unreachable,
}; };
} }
@@ -173,6 +182,7 @@ pub fn port(self: *const Config) u16 {
pub fn advertiseHost(self: *const Config) []const u8 { pub fn advertiseHost(self: *const Config) []const u8 {
return switch (self.mode) { return switch (self.mode) {
.serve => |opts| opts.advertise_host orelse opts.host, .serve => |opts| opts.advertise_host orelse opts.host,
.mcp => "127.0.0.1",
else => unreachable, else => unreachable,
}; };
} }
@@ -191,6 +201,7 @@ pub fn webBotAuth(self: *const Config) ?WebBotAuthConfig {
pub fn maxConnections(self: *const Config) u16 { pub fn maxConnections(self: *const Config) u16 {
return switch (self.mode) { return switch (self.mode) {
.serve => |opts| opts.cdp_max_connections, .serve => |opts| opts.cdp_max_connections,
.mcp => 16,
else => unreachable, else => unreachable,
}; };
} }
@@ -198,6 +209,7 @@ pub fn maxConnections(self: *const Config) u16 {
pub fn maxPendingConnections(self: *const Config) u31 { pub fn maxPendingConnections(self: *const Config) u31 {
return switch (self.mode) { return switch (self.mode) {
.serve => |opts| opts.cdp_max_pending_connections, .serve => |opts| opts.cdp_max_pending_connections,
.mcp => 128,
else => unreachable, else => unreachable,
}; };
} }
@@ -223,6 +235,7 @@ pub const Serve = struct {
pub const Mcp = struct { pub const Mcp = struct {
common: Common = .{}, common: Common = .{},
version: mcp.Version = .default, version: mcp.Version = .default,
cdp_port: ?u16 = null,
}; };
pub const DumpFormat = enum { pub const DumpFormat = enum {
@@ -267,6 +280,7 @@ pub const Common = struct {
log_format: ?log.Format = null, log_format: ?log.Format = null,
log_filter_scopes: ?[]log.Scope = null, log_filter_scopes: ?[]log.Scope = null,
user_agent_suffix: ?[]const u8 = null, user_agent_suffix: ?[]const u8 = null,
http_cache_dir: ?[]const u8 = null,
web_bot_auth_key_file: ?[]const u8 = null, web_bot_auth_key_file: ?[]const u8 = null,
web_bot_auth_keyid: ?[]const u8 = null, web_bot_auth_keyid: ?[]const u8 = null,
@@ -386,6 +400,11 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void {
\\ \\
\\--web-bot-auth-domain \\--web-bot-auth-domain
\\ Your domain e.g. yourdomain.com \\ Your domain e.g. yourdomain.com
\\
\\--http-cache-dir
\\ Path to a directory to use as a Filesystem Cache for network resources.
\\ Omitting this will result is no caching.
\\ Defaults to no caching.
; ;
// MAX_HELP_LEN| // MAX_HELP_LEN|
@@ -676,6 +695,19 @@ fn parseMcpArgs(
continue; continue;
} }
if (std.mem.eql(u8, "--cdp-port", opt) or std.mem.eql(u8, "--cdp_port", opt)) {
const str = args.next() orelse {
log.fatal(.mcp, "missing argument value", .{ .arg = opt });
return error.InvalidArgument;
};
result.cdp_port = std.fmt.parseInt(u16, str, 10) catch |err| {
log.fatal(.mcp, "invalid argument value", .{ .arg = opt, .err = err });
return error.InvalidArgument;
};
continue;
}
if (try parseCommonArg(allocator, opt, args, &result.common)) { if (try parseCommonArg(allocator, opt, args, &result.common)) {
continue; continue;
} }
@@ -1047,5 +1079,14 @@ fn parseCommonArg(
return true; return true;
} }
if (std.mem.eql(u8, "--http-cache-dir", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--http-cache-dir" });
return error.InvalidArgument;
};
common.http_cache_dir = try allocator.dupe(u8, str);
return true;
}
return false; return false;
} }

View File

@@ -297,13 +297,12 @@ pub const Client = struct {
} }
var cdp = &self.mode.cdp; var cdp = &self.mode.cdp;
var last_message = milliTimestamp(.monotonic); const timeout_ms = self.ws.timeout_ms;
var ms_remaining = self.ws.timeout_ms;
while (true) { while (true) {
const result = cdp.pageWait(ms_remaining) catch |wait_err| switch (wait_err) { const result = cdp.pageWait(timeout_ms) catch |wait_err| switch (wait_err) {
error.NoPage => { error.NoPage => {
const status = http.tick(ms_remaining) catch |err| { const status = http.tick(timeout_ms) catch |err| {
log.err(.app, "http tick", .{ .err = err }); log.err(.app, "http tick", .{ .err = err });
return; return;
}; };
@@ -314,8 +313,6 @@ pub const Client = struct {
if (self.readSocket() == false) { if (self.readSocket() == false) {
return; return;
} }
last_message = milliTimestamp(.monotonic);
ms_remaining = self.ws.timeout_ms;
continue; continue;
}, },
else => return wait_err, else => return wait_err,
@@ -326,18 +323,10 @@ pub const Client = struct {
if (self.readSocket() == false) { if (self.readSocket() == false) {
return; return;
} }
last_message = milliTimestamp(.monotonic);
ms_remaining = self.ws.timeout_ms;
}, },
.done => { .done => {
const now = milliTimestamp(.monotonic); log.info(.app, "CDP timeout", .{});
const elapsed = now - last_message; return;
if (elapsed >= ms_remaining) {
log.info(.app, "CDP timeout", .{});
return;
}
ms_remaining -= @intCast(elapsed);
last_message = now;
}, },
} }
} }

View File

@@ -32,6 +32,9 @@ const CookieJar = @import("webapi/storage/Cookie.zig").Jar;
const http = @import("../network/http.zig"); const http = @import("../network/http.zig");
const Network = @import("../network/Network.zig"); const Network = @import("../network/Network.zig");
const Robots = @import("../network/Robots.zig"); const Robots = @import("../network/Robots.zig");
const Cache = @import("../network/cache/Cache.zig");
const CacheMetadata = Cache.CachedMetadata;
const CachedResponse = Cache.CachedResponse;
const IS_DEBUG = builtin.mode == .Debug; const IS_DEBUG = builtin.mode == .Debug;
@@ -235,10 +238,6 @@ fn _abort(self: *Client, comptime abort_all: bool, frame_id: u32) void {
} }
} }
if (comptime IS_DEBUG and abort_all) {
std.debug.assert(self.active == 0);
}
{ {
var q = &self.queue; var q = &self.queue;
var n = q.first; var n = q.first;
@@ -259,12 +258,16 @@ fn _abort(self: *Client, comptime abort_all: bool, frame_id: u32) void {
} }
if (comptime IS_DEBUG and abort_all) { if (comptime IS_DEBUG and abort_all) {
std.debug.assert(self.in_use.first == null); // Even after an abort_all, we could still have transfers, but, at the
// very least, they should all be flagged as aborted.
const running = self.handles.perform() catch |err| { var it = self.in_use.first;
lp.assert(false, "multi perform in abort", .{ .err = err }); var leftover: usize = 0;
}; while (it) |node| : (it = node.next) {
std.debug.assert(running == 0); const conn: *http.Connection = @fieldParentPtr("node", node);
std.debug.assert((Transfer.fromConnection(conn) catch unreachable).aborted);
leftover += 1;
}
std.debug.assert(self.active == leftover);
} }
} }
@@ -311,7 +314,73 @@ pub fn request(self: *Client, req: Request) !void {
return self.fetchRobotsThenProcessRequest(robots_url, req); return self.fetchRobotsThenProcessRequest(robots_url, req);
} }
fn serveFromCache(req: Request, cached: *const CachedResponse) !void {
const response = Response.fromCached(req.ctx, cached);
defer switch (cached.data) {
.buffer => |_| {},
.file => |f| f.file.close(),
};
if (req.start_callback) |cb| {
try cb(response);
}
const proceed = try req.header_callback(response);
if (!proceed) {
req.error_callback(req.ctx, error.Abort);
return;
}
switch (cached.data) {
.buffer => |data| {
if (data.len > 0) {
try req.data_callback(response, data);
}
},
.file => |f| {
const file = f.file;
var buf: [1024]u8 = undefined;
var file_reader = file.reader(&buf);
try file_reader.seekTo(f.offset);
const reader = &file_reader.interface;
var read_buf: [1024]u8 = undefined;
var remaining = f.len;
while (remaining > 0) {
const read_len = @min(read_buf.len, remaining);
const n = try reader.readSliceShort(read_buf[0..read_len]);
if (n == 0) break;
remaining -= n;
try req.data_callback(response, read_buf[0..n]);
}
},
}
try req.done_callback(req.ctx);
}
fn processRequest(self: *Client, req: Request) !void { fn processRequest(self: *Client, req: Request) !void {
if (self.network.cache) |*cache| {
if (req.method == .GET) {
const arena = try self.network.app.arena_pool.acquire(.{ .debug = "HttpClient.processRequest.cache" });
defer self.network.app.arena_pool.release(arena);
var iter = req.headers.iterator();
const req_header_list = try iter.collect(arena);
if (cache.get(arena, .{
.url = req.url,
.timestamp = std.time.timestamp(),
.request_headers = req_header_list.items,
})) |cached| {
defer req.headers.deinit();
return serveFromCache(req, &cached);
}
}
}
const transfer = try self.makeTransfer(req); const transfer = try self.makeTransfer(req);
transfer.req.notification.dispatch(.http_request_start, &.{ .transfer = transfer }); transfer.req.notification.dispatch(.http_request_start, &.{ .transfer = transfer });
@@ -399,8 +468,10 @@ fn fetchRobotsThenProcessRequest(self: *Client, robots_url: [:0]const u8, req: R
try entry.value_ptr.append(self.allocator, req); try entry.value_ptr.append(self.allocator, req);
} }
fn robotsHeaderCallback(transfer: *Transfer) !bool { fn robotsHeaderCallback(response: Response) !bool {
const ctx: *RobotsRequestContext = @ptrCast(@alignCast(transfer.ctx)); const ctx: *RobotsRequestContext = @ptrCast(@alignCast(response.ctx));
// Robots callbacks only happen on real live requests.
const transfer = response.inner.transfer;
if (transfer.response_header) |hdr| { if (transfer.response_header) |hdr| {
log.debug(.browser, "robots status", .{ .status = hdr.status, .robots_url = ctx.robots_url }); log.debug(.browser, "robots status", .{ .status = hdr.status, .robots_url = ctx.robots_url });
@@ -414,8 +485,8 @@ fn robotsHeaderCallback(transfer: *Transfer) !bool {
return true; return true;
} }
fn robotsDataCallback(transfer: *Transfer, data: []const u8) !void { fn robotsDataCallback(response: Response, data: []const u8) !void {
const ctx: *RobotsRequestContext = @ptrCast(@alignCast(transfer.ctx)); const ctx: *RobotsRequestContext = @ptrCast(@alignCast(response.ctx));
try ctx.buffer.appendSlice(ctx.client.allocator, data); try ctx.buffer.appendSlice(ctx.client.allocator, data);
} }
@@ -634,13 +705,43 @@ fn makeTransfer(self: *Client, req: Request) !*Transfer {
.id = id, .id = id,
.url = req.url, .url = req.url,
.req = req, .req = req,
.ctx = req.ctx,
.client = self, .client = self,
.max_response_size = self.network.config.httpMaxResponseSize(), .max_response_size = self.network.config.httpMaxResponseSize(),
}; };
return transfer; return transfer;
} }
fn requestFailed(transfer: *Transfer, err: anyerror, comptime execute_callback: bool) void {
if (transfer._notified_fail) {
// we can force a failed request within a callback, which will eventually
// result in this being called again in the more general loop. We do this
// because we can raise a more specific error inside a callback in some cases
return;
}
transfer._notified_fail = true;
transfer.req.notification.dispatch(.http_request_fail, &.{
.transfer = transfer,
.err = err,
});
if (execute_callback) {
transfer.req.error_callback(transfer.req.ctx, err);
} else if (transfer.req.shutdown_callback) |cb| {
cb(transfer.req.ctx);
}
}
// Same restriction as changeProxy. Should be ok since this is only called on
// BrowserContext deinit.
pub fn restoreOriginalProxy(self: *Client) !void {
try self.ensureNoActiveConnection();
self.http_proxy = self.network.config.httpProxy();
self.use_proxy = self.http_proxy != null;
}
fn makeRequest(self: *Client, conn: *http.Connection, transfer: *Transfer) anyerror!void { fn makeRequest(self: *Client, conn: *http.Connection, transfer: *Transfer) anyerror!void {
{ {
// Reset per-response state for retries (auth challenge, queue). // Reset per-response state for retries (auth challenge, queue).
@@ -674,7 +775,7 @@ fn makeRequest(self: *Client, conn: *http.Connection, transfer: *Transfer) anyer
self.active += 1; self.active += 1;
if (transfer.req.start_callback) |cb| { if (transfer.req.start_callback) |cb| {
cb(transfer) catch |err| { cb(Response.fromTransfer(transfer)) catch |err| {
transfer.deinit(); transfer.deinit();
return err; return err;
}; };
@@ -742,7 +843,10 @@ fn processOneMessage(self: *Client, msg: http.Handles.MultiMessage, transfer: *T
// TODO give a way to configure the number of auth retries. // TODO give a way to configure the number of auth retries.
if (transfer._auth_challenge != null and transfer._tries < 10) { if (transfer._auth_challenge != null and transfer._tries < 10) {
var wait_for_interception = false; var wait_for_interception = false;
transfer.req.notification.dispatch(.http_request_auth_required, &.{ .transfer = transfer, .wait_for_interception = &wait_for_interception }); transfer.req.notification.dispatch(
.http_request_auth_required,
&.{ .transfer = transfer, .wait_for_interception = &wait_for_interception },
);
if (wait_for_interception) { if (wait_for_interception) {
self.intercepted += 1; self.intercepted += 1;
if (comptime IS_DEBUG) { if (comptime IS_DEBUG) {
@@ -841,10 +945,11 @@ fn processOneMessage(self: *Client, msg: http.Handles.MultiMessage, transfer: *T
} }
} }
const body = transfer._stream_buffer.items;
// Replay buffered body through user's data_callback. // Replay buffered body through user's data_callback.
if (transfer._stream_buffer.items.len > 0) { if (transfer._stream_buffer.items.len > 0) {
const body = transfer._stream_buffer.items; try transfer.req.data_callback(Response.fromTransfer(transfer), body);
try transfer.req.data_callback(transfer, body);
transfer.req.notification.dispatch(.http_response_data, &.{ transfer.req.notification.dispatch(.http_response_data, &.{
.data = body, .data = body,
@@ -857,11 +962,19 @@ fn processOneMessage(self: *Client, msg: http.Handles.MultiMessage, transfer: *T
} }
} }
if (transfer._pending_cache_metadata) |metadata| {
const cache = &self.network.cache.?;
cache.put(metadata.*, body) catch |err| {
log.warn(.cache, "cache put failed", .{ .err = err });
};
}
// release conn ASAP so that it's available; some done_callbacks // release conn ASAP so that it's available; some done_callbacks
// will load more resources. // will load more resources.
transfer.releaseConn(); transfer.releaseConn();
try transfer.req.done_callback(transfer.ctx); try transfer.req.done_callback(transfer.req.ctx);
transfer.req.notification.dispatch(.http_request_done, &.{ transfer.req.notification.dispatch(.http_request_done, &.{
.transfer = transfer, .transfer = transfer,
}); });
@@ -939,9 +1052,9 @@ pub const Request = struct {
// arbitrary data that can be associated with this request // arbitrary data that can be associated with this request
ctx: *anyopaque = undefined, ctx: *anyopaque = undefined,
start_callback: ?*const fn (transfer: *Transfer) anyerror!void = null, start_callback: ?*const fn (response: Response) anyerror!void = null,
header_callback: *const fn (transfer: *Transfer) anyerror!bool, header_callback: *const fn (response: Response) anyerror!bool,
data_callback: *const fn (transfer: *Transfer, data: []const u8) anyerror!void, data_callback: *const fn (response: Response, data: []const u8) anyerror!void,
done_callback: *const fn (ctx: *anyopaque) anyerror!void, done_callback: *const fn (ctx: *anyopaque) anyerror!void,
error_callback: *const fn (ctx: *anyopaque, err: anyerror) void, error_callback: *const fn (ctx: *anyopaque, err: anyerror) void,
shutdown_callback: ?*const fn (ctx: *anyopaque) void = null, shutdown_callback: ?*const fn (ctx: *anyopaque) void = null,
@@ -967,16 +1080,91 @@ pub const Request = struct {
}; };
}; };
pub const Response = struct {
ctx: *anyopaque,
inner: union(enum) {
transfer: *Transfer,
cached: *const CachedResponse,
},
pub fn fromTransfer(transfer: *Transfer) Response {
return .{ .ctx = transfer.req.ctx, .inner = .{ .transfer = transfer } };
}
pub fn fromCached(ctx: *anyopaque, resp: *const CachedResponse) Response {
return .{ .ctx = ctx, .inner = .{ .cached = resp } };
}
pub fn status(self: Response) ?u16 {
return switch (self.inner) {
.transfer => |t| if (t.response_header) |rh| rh.status else null,
.cached => |c| c.metadata.status,
};
}
pub fn contentType(self: Response) ?[]const u8 {
return switch (self.inner) {
.transfer => |t| if (t.response_header) |*rh| rh.contentType() else null,
.cached => |c| c.metadata.content_type,
};
}
pub fn contentLength(self: Response) ?u32 {
return switch (self.inner) {
.transfer => |t| t.getContentLength(),
.cached => |c| switch (c.data) {
.buffer => |buf| @intCast(buf.len),
.file => |f| @intCast(f.len),
},
};
}
pub fn redirectCount(self: Response) ?u32 {
return switch (self.inner) {
.transfer => |t| if (t.response_header) |rh| rh.redirect_count else null,
.cached => 0,
};
}
pub fn url(self: Response) [:0]const u8 {
return switch (self.inner) {
.transfer => |t| t.url,
.cached => |c| c.metadata.url,
};
}
pub fn headerIterator(self: Response) HeaderIterator {
return switch (self.inner) {
.transfer => |t| t.responseHeaderIterator(),
.cached => |c| HeaderIterator{ .list = .{ .list = c.metadata.headers } },
};
}
pub fn abort(self: Response, err: anyerror) void {
switch (self.inner) {
.transfer => |t| t.abort(err),
.cached => {},
}
}
pub fn format(self: Response, writer: *std.Io.Writer) !void {
return switch (self.inner) {
.transfer => |t| try t.format(writer),
.cached => |c| try c.format(writer),
};
}
};
pub const Transfer = struct { pub const Transfer = struct {
arena: ArenaAllocator, arena: ArenaAllocator,
id: u32 = 0, id: u32 = 0,
req: Request, req: Request,
url: [:0]const u8, url: [:0]const u8,
ctx: *anyopaque, // copied from req.ctx to make it easier for callback handlers
client: *Client, client: *Client,
// total bytes received in the response, including the response status line, // total bytes received in the response, including the response status line,
// the headers, and the [encoded] body. // the headers, and the [encoded] body.
bytes_received: usize = 0, bytes_received: usize = 0,
_pending_cache_metadata: ?*CacheMetadata = null,
aborted: bool = false, aborted: bool = false,
@@ -1065,7 +1253,7 @@ pub const Transfer = struct {
// as abort (doesn't send a notification, doesn't invoke an error callback) // as abort (doesn't send a notification, doesn't invoke an error callback)
fn kill(self: *Transfer) void { fn kill(self: *Transfer) void {
if (self.req.shutdown_callback) |cb| { if (self.req.shutdown_callback) |cb| {
cb(self.ctx); cb(self.req.ctx);
} }
if (self._performing or self.client.performing) { if (self._performing or self.client.performing) {
@@ -1101,9 +1289,9 @@ pub const Transfer = struct {
}); });
if (execute_callback) { if (execute_callback) {
self.req.error_callback(self.ctx, err); self.req.error_callback(self.req.ctx, err);
} else if (self.req.shutdown_callback) |cb| { } else if (self.req.shutdown_callback) |cb| {
cb(self.ctx); cb(self.req.ctx);
} }
} }
@@ -1352,11 +1540,61 @@ pub const Transfer = struct {
.transfer = transfer, .transfer = transfer,
}); });
const proceed = transfer.req.header_callback(transfer) catch |err| { const proceed = transfer.req.header_callback(Response.fromTransfer(transfer)) catch |err| {
log.err(.http, "header_callback", .{ .err = err, .req = transfer }); log.err(.http, "header_callback", .{ .err = err, .req = transfer });
return err; return err;
}; };
if (transfer.client.network.cache != null and transfer.req.method == .GET) {
const rh = &transfer.response_header.?;
const allocator = transfer.arena.allocator();
const vary = if (conn.getResponseHeader("vary", 0)) |h| h.value else null;
const maybe_cm = try Cache.tryCache(
allocator,
std.time.timestamp(),
transfer.url,
rh.status,
rh.contentType(),
if (conn.getResponseHeader("cache-control", 0)) |h| h.value else null,
vary,
if (conn.getResponseHeader("age", 0)) |h| h.value else null,
conn.getResponseHeader("set-cookie", 0) != null,
conn.getResponseHeader("authorization", 0) != null,
);
if (maybe_cm) |cm| {
var iter = transfer.responseHeaderIterator();
var header_list = try iter.collect(allocator);
const end_of_response = header_list.items.len;
if (vary) |vary_str| {
var req_it = transfer.req.headers.iterator();
while (req_it.next()) |hdr| {
var vary_iter = std.mem.splitScalar(u8, vary_str, ',');
while (vary_iter.next()) |part| {
const name = std.mem.trim(u8, part, &std.ascii.whitespace);
if (std.ascii.eqlIgnoreCase(hdr.name, name)) {
try header_list.append(allocator, .{
.name = try allocator.dupe(u8, hdr.name),
.value = try allocator.dupe(u8, hdr.value),
});
}
}
}
}
const metadata = try transfer.arena.allocator().create(CacheMetadata);
metadata.* = cm;
metadata.headers = header_list.items[0..end_of_response];
metadata.vary_headers = header_list.items[end_of_response..];
transfer._pending_cache_metadata = metadata;
}
}
return proceed and transfer.aborted == false; return proceed and transfer.aborted == false;
} }
@@ -1455,7 +1693,7 @@ pub const Transfer = struct {
fn _fulfill(transfer: *Transfer, status: u16, headers: []const http.Header, body: ?[]const u8) !void { fn _fulfill(transfer: *Transfer, status: u16, headers: []const http.Header, body: ?[]const u8) !void {
const req = &transfer.req; const req = &transfer.req;
if (req.start_callback) |cb| { if (req.start_callback) |cb| {
try cb(transfer); try cb(Response.fromTransfer(transfer));
} }
transfer.response_header = .{ transfer.response_header = .{
@@ -1474,13 +1712,13 @@ pub const Transfer = struct {
} }
lp.assert(transfer._header_done_called == false, "Transfer.fulfill header_done_called", .{}); lp.assert(transfer._header_done_called == false, "Transfer.fulfill header_done_called", .{});
if (try req.header_callback(transfer) == false) { if (try req.header_callback(Response.fromTransfer(transfer)) == false) {
transfer.abort(error.Abort); transfer.abort(error.Abort);
return; return;
} }
if (body) |b| { if (body) |b| {
try req.data_callback(transfer, b); try req.data_callback(Response.fromTransfer(transfer), b);
} }
try req.done_callback(req.ctx); try req.done_callback(req.ctx);
@@ -1517,10 +1755,10 @@ pub const Transfer = struct {
}; };
const Noop = struct { const Noop = struct {
fn headerCallback(_: *Transfer) !bool { fn headerCallback(_: Response) !bool {
return true; return true;
} }
fn dataCallback(_: *Transfer, _: []const u8) !void {} fn dataCallback(_: Response, _: []const u8) !void {}
fn doneCallback(_: *anyopaque) !void {} fn doneCallback(_: *anyopaque) !void {}
fn errorCallback(_: *anyopaque, _: anyerror) void {} fn errorCallback(_: *anyopaque, _: anyerror) void {}
}; };

View File

@@ -27,6 +27,9 @@ charset: [41]u8 = default_charset,
charset_len: usize = default_charset_len, charset_len: usize = default_charset_len,
is_default_charset: bool = true, is_default_charset: bool = true,
type_buf: [127]u8 = @splat(0),
sub_type_buf: [127]u8 = @splat(0),
/// String "UTF-8" continued by null characters. /// String "UTF-8" continued by null characters.
const default_charset = .{ 'U', 'T', 'F', '-', '8' } ++ .{0} ** 36; const default_charset = .{ 'U', 'T', 'F', '-', '8' } ++ .{0} ** 36;
const default_charset_len = 5; const default_charset_len = 5;
@@ -61,7 +64,10 @@ pub const ContentType = union(ContentTypeEnum) {
image_webp: void, image_webp: void,
application_json: void, application_json: void,
unknown: void, unknown: void,
other: struct { type: []const u8, sub_type: []const u8 }, other: struct {
type: []const u8,
sub_type: []const u8,
},
}; };
pub fn contentTypeString(mime: *const Mime) []const u8 { pub fn contentTypeString(mime: *const Mime) []const u8 {
@@ -112,17 +118,18 @@ fn parseCharset(value: []const u8) error{ CharsetTooBig, Invalid }![]const u8 {
return value; return value;
} }
pub fn parse(input: []u8) !Mime { pub fn parse(input: []const u8) !Mime {
if (input.len > 255) { if (input.len > 255) {
return error.TooBig; return error.TooBig;
} }
// Zig's trim API is broken. The return type is always `[]const u8`, var buf: [255]u8 = undefined;
// even if the input type is `[]u8`. @constCast is safe here. const normalized = std.ascii.lowerString(&buf, std.mem.trim(u8, input, &std.ascii.whitespace));
var normalized = @constCast(std.mem.trim(u8, input, &std.ascii.whitespace));
_ = std.ascii.lowerString(normalized, normalized); _ = std.ascii.lowerString(normalized, normalized);
const content_type, const type_len = try parseContentType(normalized); var mime = Mime{ .content_type = undefined };
const content_type, const type_len = try parseContentType(normalized, &mime.type_buf, &mime.sub_type_buf);
if (type_len >= normalized.len) { if (type_len >= normalized.len) {
return .{ .content_type = content_type }; return .{ .content_type = content_type };
} }
@@ -163,13 +170,12 @@ pub fn parse(input: []u8) !Mime {
} }
} }
return .{ mime.params = params;
.params = params, mime.charset = charset;
.charset = charset, mime.charset_len = charset_len;
.charset_len = charset_len, mime.content_type = content_type;
.content_type = content_type, mime.is_default_charset = !has_explicit_charset;
.is_default_charset = !has_explicit_charset, return mime;
};
} }
/// Prescan the first 1024 bytes of an HTML document for a charset declaration. /// Prescan the first 1024 bytes of an HTML document for a charset declaration.
@@ -395,7 +401,7 @@ pub fn isText(mime: *const Mime) bool {
} }
// we expect value to be lowercase // we expect value to be lowercase
fn parseContentType(value: []const u8) !struct { ContentType, usize } { fn parseContentType(value: []const u8, type_buf: []u8, sub_type_buf: []u8) !struct { ContentType, usize } {
const end = std.mem.indexOfScalarPos(u8, value, 0, ';') orelse value.len; const end = std.mem.indexOfScalarPos(u8, value, 0, ';') orelse value.len;
const type_name = trimRight(value[0..end]); const type_name = trimRight(value[0..end]);
const attribute_start = end + 1; const attribute_start = end + 1;
@@ -444,10 +450,18 @@ fn parseContentType(value: []const u8) !struct { ContentType, usize } {
return error.Invalid; return error.Invalid;
} }
return .{ .{ .other = .{ @memcpy(type_buf[0..main_type.len], main_type);
.type = main_type, @memcpy(sub_type_buf[0..sub_type.len], sub_type);
.sub_type = sub_type,
} }, attribute_start }; return .{
.{
.other = .{
.type = type_buf[0..main_type.len],
.sub_type = sub_type_buf[0..sub_type.len],
},
},
attribute_start,
};
} }
const VALID_CODEPOINTS = blk: { const VALID_CODEPOINTS = blk: {
@@ -461,6 +475,13 @@ const VALID_CODEPOINTS = blk: {
break :blk v; break :blk v;
}; };
pub fn typeString(self: *const Mime) []const u8 {
return switch (self.content_type) {
.other => |o| o.type[0..o.type_len],
else => "",
};
}
fn validType(value: []const u8) bool { fn validType(value: []const u8) bool {
for (value) |b| { for (value) |b| {
if (VALID_CODEPOINTS[b] == false) { if (VALID_CODEPOINTS[b] == false) {

View File

@@ -351,6 +351,30 @@ pub fn deinit(self: *Page, abort_http: bool) void {
session.releaseArena(qn.arena); session.releaseArena(qn.arena);
} }
{
// Release all objects we're referencing
{
var it = self._blob_urls.valueIterator();
while (it.next()) |blob| {
blob.*.releaseRef(session);
}
}
{
var it: ?*std.DoublyLinkedList.Node = self._mutation_observers.first;
while (it) |node| : (it = node.next) {
const observer: *MutationObserver = @fieldParentPtr("node", node);
observer.releaseRef(session);
}
}
for (self._intersection_observers.items) |observer| {
observer.releaseRef(session);
}
self.window._document._selection.releaseRef(session);
}
session.browser.env.destroyContext(self.js); session.browser.env.destroyContext(self.js);
self._script_manager.shutdown = true; self._script_manager.shutdown = true;
@@ -414,7 +438,15 @@ pub fn releaseArena(self: *Page, allocator: Allocator) void {
pub fn isSameOrigin(self: *const Page, url: [:0]const u8) !bool { pub fn isSameOrigin(self: *const Page, url: [:0]const u8) !bool {
const current_origin = self.origin orelse return false; const current_origin = self.origin orelse return false;
return std.mem.startsWith(u8, url, current_origin);
// fastpath
if (!std.mem.startsWith(u8, url, current_origin)) {
return false;
}
// Starting here, at least protocols are equals.
// Compare hosts (domain:port) strictly
return std.mem.eql(u8, URL.getHost(url), URL.getHost(current_origin));
} }
/// Look up a blob URL in this page's registry. /// Look up a blob URL in this page's registry.
@@ -854,12 +886,10 @@ fn notifyParentLoadComplete(self: *Page) void {
parent.iframeCompletedLoading(self.iframe.?); parent.iframeCompletedLoading(self.iframe.?);
} }
fn pageHeaderDoneCallback(transfer: *HttpClient.Transfer) !bool { fn pageHeaderDoneCallback(response: HttpClient.Response) !bool {
var self: *Page = @ptrCast(@alignCast(transfer.ctx)); var self: *Page = @ptrCast(@alignCast(response.ctx));
const header = &transfer.response_header.?; const response_url = response.url();
const response_url = std.mem.span(header.url);
if (std.mem.eql(u8, response_url, self.url) == false) { if (std.mem.eql(u8, response_url, self.url) == false) {
// would be different than self.url in the case of a redirect // would be different than self.url in the case of a redirect
self.url = try self.arena.dupeZ(u8, response_url); self.url = try self.arena.dupeZ(u8, response_url);
@@ -873,8 +903,8 @@ fn pageHeaderDoneCallback(transfer: *HttpClient.Transfer) !bool {
if (comptime IS_DEBUG) { if (comptime IS_DEBUG) {
log.debug(.page, "navigate header", .{ log.debug(.page, "navigate header", .{
.url = self.url, .url = self.url,
.status = header.status, .status = response.status(),
.content_type = header.contentType(), .content_type = response.contentType(),
.type = self._type, .type = self._type,
}); });
} }
@@ -895,14 +925,14 @@ fn pageHeaderDoneCallback(transfer: *HttpClient.Transfer) !bool {
return true; return true;
} }
fn pageDataCallback(transfer: *HttpClient.Transfer, data: []const u8) !void { fn pageDataCallback(response: HttpClient.Response, data: []const u8) !void {
var self: *Page = @ptrCast(@alignCast(transfer.ctx)); var self: *Page = @ptrCast(@alignCast(response.ctx));
if (self._parse_state == .pre) { if (self._parse_state == .pre) {
// we lazily do this, because we might need the first chunk of data // we lazily do this, because we might need the first chunk of data
// to sniff the content type // to sniff the content type
var mime: Mime = blk: { var mime: Mime = blk: {
if (transfer.response_header.?.contentType()) |ct| { if (response.contentType()) |ct| {
break :blk try Mime.parse(ct); break :blk try Mime.parse(ct);
} }
break :blk Mime.sniff(data); break :blk Mime.sniff(data);
@@ -1338,20 +1368,24 @@ pub fn schedulePerformanceObserverDelivery(self: *Page) !void {
} }
pub fn registerMutationObserver(self: *Page, observer: *MutationObserver) !void { pub fn registerMutationObserver(self: *Page, observer: *MutationObserver) !void {
observer.acquireRef();
self._mutation_observers.append(&observer.node); self._mutation_observers.append(&observer.node);
} }
pub fn unregisterMutationObserver(self: *Page, observer: *MutationObserver) void { pub fn unregisterMutationObserver(self: *Page, observer: *MutationObserver) void {
observer.releaseRef(self._session);
self._mutation_observers.remove(&observer.node); self._mutation_observers.remove(&observer.node);
} }
pub fn registerIntersectionObserver(self: *Page, observer: *IntersectionObserver) !void { pub fn registerIntersectionObserver(self: *Page, observer: *IntersectionObserver) !void {
observer.acquireRef();
try self._intersection_observers.append(self.arena, observer); try self._intersection_observers.append(self.arena, observer);
} }
pub fn unregisterIntersectionObserver(self: *Page, observer: *IntersectionObserver) void { pub fn unregisterIntersectionObserver(self: *Page, observer: *IntersectionObserver) void {
for (self._intersection_observers.items, 0..) |obs, i| { for (self._intersection_observers.items, 0..) |obs, i| {
if (obs == observer) { if (obs == observer) {
observer.releaseRef(self._session);
_ = self._intersection_observers.swapRemove(i); _ = self._intersection_observers.swapRemove(i);
return; return;
} }
@@ -3588,3 +3622,41 @@ test "WebApi: Frames" {
test "WebApi: Integration" { test "WebApi: Integration" {
try testing.htmlRunner("integration", .{}); try testing.htmlRunner("integration", .{});
} }
test "Page: isSameOrigin" {
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const allocator = arena.allocator();
var page: Page = undefined;
page.origin = null;
try testing.expectEqual(false, page.isSameOrigin("https://origin.com/"));
page.origin = try URL.getOrigin(allocator, "https://origin.com/foo/bar") orelse unreachable;
try testing.expectEqual(true, page.isSameOrigin("https://origin.com/foo/bar")); // exact same
try testing.expectEqual(true, page.isSameOrigin("https://origin.com/bar/bar")); // path differ
try testing.expectEqual(true, page.isSameOrigin("https://origin.com/")); // path differ
try testing.expectEqual(true, page.isSameOrigin("https://origin.com")); // no path
try testing.expectEqual(true, page.isSameOrigin("https://origin.com/foo?q=1"));
try testing.expectEqual(true, page.isSameOrigin("https://origin.com/foo#hash"));
try testing.expectEqual(true, page.isSameOrigin("https://origin.com/foo?q=1#hash"));
// FIXME try testing.expectEqual(true, page.isSameOrigin("https://foo:bar@origin.com"));
// FIXME try testing.expectEqual(true, page.isSameOrigin("https://origin.com:443/foo"));
try testing.expectEqual(false, page.isSameOrigin("http://origin.com/")); // another proto
try testing.expectEqual(false, page.isSameOrigin("https://origin.com:123/")); // another port
try testing.expectEqual(false, page.isSameOrigin("https://sub.origin.com/")); // another subdomain
try testing.expectEqual(false, page.isSameOrigin("https://target.com/")); // different domain
try testing.expectEqual(false, page.isSameOrigin("https://origin.com.target.com/")); // different domain
try testing.expectEqual(false, page.isSameOrigin("https://target.com/@origin.com"));
page.origin = try URL.getOrigin(allocator, "https://origin.com:8443/foo") orelse unreachable;
try testing.expectEqual(true, page.isSameOrigin("https://origin.com:8443/bar"));
try testing.expectEqual(false, page.isSameOrigin("https://origin.com/bar")); // missing port
try testing.expectEqual(false, page.isSameOrigin("https://origin.com:9999/bar")); // wrong port
try testing.expectEqual(false, page.isSameOrigin(""));
try testing.expectEqual(false, page.isSameOrigin("not-a-url"));
try testing.expectEqual(false, page.isSameOrigin("//origin.com/foo"));
}

View File

@@ -68,7 +68,6 @@ pub fn waitCDP(self: *Runner, opts: WaitOpts) !CDPWaitResult {
fn _wait(self: *Runner, comptime is_cdp: bool, opts: WaitOpts) !CDPWaitResult { fn _wait(self: *Runner, comptime is_cdp: bool, opts: WaitOpts) !CDPWaitResult {
var timer = try std.time.Timer.start(); var timer = try std.time.Timer.start();
var ms_remaining = opts.ms;
const tick_opts = TickOpts{ const tick_opts = TickOpts{
.ms = 200, .ms = 200,
@@ -92,11 +91,10 @@ fn _wait(self: *Runner, comptime is_cdp: bool, opts: WaitOpts) !CDPWaitResult {
.cdp_socket => if (comptime is_cdp) return .cdp_socket else unreachable, .cdp_socket => if (comptime is_cdp) return .cdp_socket else unreachable,
}; };
const ms_elapsed = timer.lap() / 1_000_000; const ms_elapsed: u32 = @intCast(timer.read() / std.time.ns_per_ms);
if (ms_elapsed >= ms_remaining) { if (ms_elapsed >= opts.ms) {
return .done; return .done;
} }
ms_remaining -= @intCast(ms_elapsed);
if (next_ms > 0) { if (next_ms > 0) {
std.Thread.sleep(std.time.ns_per_ms * next_ms); std.Thread.sleep(std.time.ns_per_ms * next_ms);
} }
@@ -237,7 +235,16 @@ fn _tick(self: *Runner, comptime is_cdp: bool, opts: TickOpts) !CDPTickResult {
page._parse_state = .{ .raw_done = @errorName(err) }; page._parse_state = .{ .raw_done = @errorName(err) };
return err; return err;
}, },
.raw_done => return .done, .raw_done => {
if (comptime is_cdp) {
const http_result = try http_client.tick(@intCast(opts.ms));
if (http_result == .cdp_socket) {
return .cdp_socket;
}
return .{ .ok = 0 };
}
return .done;
},
} }
} }

View File

@@ -273,25 +273,6 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
// Let the outer errdefer handle releasing the arena if client.request fails // Let the outer errdefer handle releasing the arena if client.request fails
} }
try self.client.request(.{
.url = url,
.ctx = script,
.method = .GET,
.frame_id = page._frame_id,
.headers = try self.getHeaders(),
.blocking = is_blocking,
.cookie_jar = &page._session.cookie_jar,
.cookie_origin = page.url,
.resource_type = .script,
.notification = page._session.notification,
.start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null,
.header_callback = Script.headerCallback,
.data_callback = Script.dataCallback,
.done_callback = Script.doneCallback,
.error_callback = Script.errorCallback,
});
handover = true;
if (comptime IS_DEBUG) { if (comptime IS_DEBUG) {
var ls: js.Local.Scope = undefined; var ls: js.Local.Scope = undefined;
page.js.localScope(&ls); page.js.localScope(&ls);
@@ -304,6 +285,32 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
.stack = ls.local.stackTrace() catch "???", .stack = ls.local.stackTrace() catch "???",
}); });
} }
{
const was_evaluating = self.is_evaluating;
self.is_evaluating = true;
defer self.is_evaluating = was_evaluating;
try self.client.request(.{
.url = url,
.ctx = script,
.method = .GET,
.frame_id = page._frame_id,
.headers = try self.getHeaders(),
.blocking = is_blocking,
.cookie_jar = &page._session.cookie_jar,
.cookie_origin = page.url,
.resource_type = .script,
.notification = page._session.notification,
.start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null,
.header_callback = Script.headerCallback,
.data_callback = Script.dataCallback,
.done_callback = Script.doneCallback,
.error_callback = Script.errorCallback,
});
}
handover = true;
} }
if (is_blocking == false) { if (is_blocking == false) {
@@ -694,82 +701,86 @@ pub const Script = struct {
self.manager.page.releaseArena(self.arena); self.manager.page.releaseArena(self.arena);
} }
fn startCallback(transfer: *HttpClient.Transfer) !void { fn startCallback(response: HttpClient.Response) !void {
log.debug(.http, "script fetch start", .{ .req = transfer }); log.debug(.http, "script fetch start", .{ .req = response });
} }
fn headerCallback(transfer: *HttpClient.Transfer) !bool { fn headerCallback(response: HttpClient.Response) !bool {
const self: *Script = @ptrCast(@alignCast(transfer.ctx)); const self: *Script = @ptrCast(@alignCast(response.ctx));
const header = &transfer.response_header.?;
self.status = header.status; self.status = response.status().?;
if (header.status != 200) { if (response.status() != 200) {
log.info(.http, "script header", .{ log.info(.http, "script header", .{
.req = transfer, .req = response,
.status = header.status, .status = response.status(),
.content_type = header.contentType(), .content_type = response.contentType(),
}); });
return false; return false;
} }
if (comptime IS_DEBUG) { if (comptime IS_DEBUG) {
log.debug(.http, "script header", .{ log.debug(.http, "script header", .{
.req = transfer, .req = response,
.status = header.status, .status = response.status(),
.content_type = header.contentType(), .content_type = response.contentType(),
}); });
} }
{ switch (response.inner) {
// temp debug, trying to figure out why the next assert sometimes .transfer => |transfer| {
// fails. Is the buffer just corrupt or is headerCallback really // temp debug, trying to figure out why the next assert sometimes
// being called twice? // fails. Is the buffer just corrupt or is headerCallback really
lp.assert(self.header_callback_called == false, "ScriptManager.Header recall", .{ // being called twice?
.m = @tagName(std.meta.activeTag(self.mode)), lp.assert(self.header_callback_called == false, "ScriptManager.Header recall", .{
.a1 = self.debug_transfer_id, .m = @tagName(std.meta.activeTag(self.mode)),
.a2 = self.debug_transfer_tries, .a1 = self.debug_transfer_id,
.a3 = self.debug_transfer_aborted, .a2 = self.debug_transfer_tries,
.a4 = self.debug_transfer_bytes_received, .a3 = self.debug_transfer_aborted,
.a5 = self.debug_transfer_notified_fail, .a4 = self.debug_transfer_bytes_received,
.a7 = self.debug_transfer_intercept_state, .a5 = self.debug_transfer_notified_fail,
.a8 = self.debug_transfer_auth_challenge, .a7 = self.debug_transfer_intercept_state,
.a9 = self.debug_transfer_easy_id, .a8 = self.debug_transfer_auth_challenge,
.b1 = transfer.id, .a9 = self.debug_transfer_easy_id,
.b2 = transfer._tries, .b1 = transfer.id,
.b3 = transfer.aborted, .b2 = transfer._tries,
.b4 = transfer.bytes_received, .b3 = transfer.aborted,
.b5 = transfer._notified_fail, .b4 = transfer.bytes_received,
.b7 = @intFromEnum(transfer._intercept_state), .b5 = transfer._notified_fail,
.b8 = transfer._auth_challenge != null, .b7 = @intFromEnum(transfer._intercept_state),
.b9 = if (transfer._conn) |c| @intFromPtr(c._easy) else 0, .b8 = transfer._auth_challenge != null,
}); .b9 = if (transfer._conn) |c| @intFromPtr(c._easy) else 0,
self.header_callback_called = true; });
self.debug_transfer_id = transfer.id; self.header_callback_called = true;
self.debug_transfer_tries = transfer._tries; self.debug_transfer_id = transfer.id;
self.debug_transfer_aborted = transfer.aborted; self.debug_transfer_tries = transfer._tries;
self.debug_transfer_bytes_received = transfer.bytes_received; self.debug_transfer_aborted = transfer.aborted;
self.debug_transfer_notified_fail = transfer._notified_fail; self.debug_transfer_bytes_received = transfer.bytes_received;
self.debug_transfer_intercept_state = @intFromEnum(transfer._intercept_state); self.debug_transfer_notified_fail = transfer._notified_fail;
self.debug_transfer_auth_challenge = transfer._auth_challenge != null; self.debug_transfer_intercept_state = @intFromEnum(transfer._intercept_state);
self.debug_transfer_easy_id = if (transfer._conn) |c| @intFromPtr(c._easy) else 0; self.debug_transfer_auth_challenge = transfer._auth_challenge != null;
self.debug_transfer_easy_id = if (transfer._conn) |c| @intFromPtr(c._easy) else 0;
},
else => {},
} }
lp.assert(self.source.remote.capacity == 0, "ScriptManager.Header buffer", .{ .capacity = self.source.remote.capacity }); lp.assert(self.source.remote.capacity == 0, "ScriptManager.Header buffer", .{ .capacity = self.source.remote.capacity });
var buffer: std.ArrayList(u8) = .empty; var buffer: std.ArrayList(u8) = .empty;
if (transfer.getContentLength()) |cl| { if (response.contentLength()) |cl| {
try buffer.ensureTotalCapacity(self.arena, cl); try buffer.ensureTotalCapacity(self.arena, cl);
} }
self.source = .{ .remote = buffer }; self.source = .{ .remote = buffer };
return true; return true;
} }
fn dataCallback(transfer: *HttpClient.Transfer, data: []const u8) !void { fn dataCallback(response: HttpClient.Response, data: []const u8) !void {
const self: *Script = @ptrCast(@alignCast(transfer.ctx)); const self: *Script = @ptrCast(@alignCast(response.ctx));
self._dataCallback(transfer, data) catch |err| { self._dataCallback(response, data) catch |err| {
log.err(.http, "SM.dataCallback", .{ .err = err, .transfer = transfer, .len = data.len }); log.err(.http, "SM.dataCallback", .{ .err = err, .transfer = response, .len = data.len });
return err; return err;
}; };
} }
fn _dataCallback(self: *Script, _: *HttpClient.Transfer, data: []const u8) !void {
fn _dataCallback(self: *Script, _: HttpClient.Response, data: []const u8) !void {
try self.source.remote.appendSlice(self.arena, data); try self.source.remote.appendSlice(self.arena, data);
} }

View File

@@ -501,7 +501,11 @@ pub const FinalizerCallback = struct {
session: *Session, session: *Session,
resolved_ptr_id: usize, resolved_ptr_id: usize,
finalizer_ptr_id: usize, finalizer_ptr_id: usize,
_deinit: *const fn (ptr_id: usize, session: *Session) void, release_ref: *const fn (ptr_id: usize, session: *Session) void,
// Track how many identities (JS worlds) reference this FC.
// Only cleanup when all identities have finalized.
identity_count: u8 = 0,
// For every FinalizerCallback we'll have 1+ FinalizerCallback.Identity: one // For every FinalizerCallback we'll have 1+ FinalizerCallback.Identity: one
// for every identity that gets the instance. In most cases, that'l be 1. // for every identity that gets the instance. In most cases, that'l be 1.
@@ -510,8 +514,9 @@ pub const FinalizerCallback = struct {
fc: *Session.FinalizerCallback, fc: *Session.FinalizerCallback,
}; };
// Called during page reset to force cleanup regardless of identity_count.
fn deinit(self: *FinalizerCallback, session: *Session) void { fn deinit(self: *FinalizerCallback, session: *Session) void {
self._deinit(self.finalizer_ptr_id, session); self.release_ref(self.finalizer_ptr_id, session);
session.releaseArena(self.arena); session.releaseArena(self.arena);
} }
}; };

View File

@@ -509,7 +509,7 @@ fn getUserInfo(raw: [:0]const u8) ?[]const u8 {
return raw[authority_start .. auth.host_start - 1]; return raw[authority_start .. auth.host_start - 1];
} }
pub fn getHost(raw: [:0]const u8) []const u8 { pub fn getHost(raw: []const u8) []const u8 {
const auth = parseAuthority(raw) orelse return ""; const auth = parseAuthority(raw) orelse return "";
return auth.getHost(raw); return auth.getHost(raw);
} }

View File

@@ -296,7 +296,7 @@ pub fn createContext(self: *Env, page: *Page, params: ContextParams) !*Context {
// it gets setup automatically as objects are created, but the Window // it gets setup automatically as objects are created, but the Window
// object already exists in v8 (it's the global) so we manually create // object already exists in v8 (it's the global) so we manually create
// the mapping here. // the mapping here.
const tao = try context_arena.create(@import("TaggedOpaque.zig")); const tao = try params.identity_arena.create(@import("TaggedOpaque.zig"));
tao.* = .{ tao.* = .{
.value = @ptrCast(page.window), .value = @ptrCast(page.window),
.prototype_chain = (&Window.JsApi.Meta.prototype_chain).ptr, .prototype_chain = (&Window.JsApi.Meta.prototype_chain).ptr,

View File

@@ -244,7 +244,10 @@ pub fn mapZigInstanceToJs(self: *const Local, js_obj_handle: ?*const v8.Object,
// The TAO contains the pointer to our Zig instance as // The TAO contains the pointer to our Zig instance as
// well as any meta data we'll need to use it later. // well as any meta data we'll need to use it later.
// See the TaggedOpaque struct for more details. // See the TaggedOpaque struct for more details.
const tao = try context_arena.create(TaggedOpaque); // Use identity_arena so TAOs survive context destruction. V8 objects
// are stored in identity_map (session-level) and may be referenced
// after their creating context is destroyed (e.g., via microtasks).
const tao = try ctx.identity_arena.create(TaggedOpaque);
tao.* = .{ tao.* = .{
.value = resolved.ptr, .value = resolved.ptr,
.prototype_chain = resolved.prototype_chain.ptr, .prototype_chain = resolved.prototype_chain.ptr,
@@ -266,7 +269,6 @@ pub fn mapZigInstanceToJs(self: *const Local, js_obj_handle: ?*const v8.Object,
v8.v8__Global__New(isolate.handle, js_obj.handle, gop.value_ptr); v8.v8__Global__New(isolate.handle, js_obj.handle, gop.value_ptr);
if (resolved.finalizer) |finalizer| { if (resolved.finalizer) |finalizer| {
const finalizer_ptr_id = finalizer.ptr_id; const finalizer_ptr_id = finalizer.ptr_id;
finalizer.acquireRef(finalizer_ptr_id);
const session = ctx.session; const session = ctx.session;
const finalizer_gop = try session.finalizer_callbacks.getOrPut(session.page_arena, finalizer_ptr_id); const finalizer_gop = try session.finalizer_callbacks.getOrPut(session.page_arena, finalizer_ptr_id);
@@ -275,7 +277,8 @@ pub fn mapZigInstanceToJs(self: *const Local, js_obj_handle: ?*const v8.Object,
// see this Zig instance. We need to create the FinalizerCallback // see this Zig instance. We need to create the FinalizerCallback
// so that we can cleanup on page reset if v8 doesn't finalize. // so that we can cleanup on page reset if v8 doesn't finalize.
errdefer _ = session.finalizer_callbacks.remove(finalizer_ptr_id); errdefer _ = session.finalizer_callbacks.remove(finalizer_ptr_id);
finalizer_gop.value_ptr.* = try self.createFinalizerCallback(resolved_ptr_id, finalizer_ptr_id, finalizer.deinit); finalizer.acquire_ref(finalizer_ptr_id);
finalizer_gop.value_ptr.* = try self.createFinalizerCallback(resolved_ptr_id, finalizer_ptr_id, finalizer.release_ref_from_zig);
} }
const fc = finalizer_gop.value_ptr.*; const fc = finalizer_gop.value_ptr.*;
const identity_finalizer = try fc.arena.create(Session.FinalizerCallback.Identity); const identity_finalizer = try fc.arena.create(Session.FinalizerCallback.Identity);
@@ -283,8 +286,9 @@ pub fn mapZigInstanceToJs(self: *const Local, js_obj_handle: ?*const v8.Object,
.fc = fc, .fc = fc,
.identity = ctx.identity, .identity = ctx.identity,
}; };
fc.identity_count += 1;
v8.v8__Global__SetWeakFinalizer(gop.value_ptr, identity_finalizer, finalizer.release, v8.kParameter); v8.v8__Global__SetWeakFinalizer(gop.value_ptr, identity_finalizer, finalizer.release_ref, v8.kParameter);
} }
return js_obj; return js_obj;
}, },
@@ -1128,9 +1132,9 @@ const Resolved = struct {
// Resolved.ptr is the most specific value in a chain (e.g. IFrame, not EventTarget, Node, ...) // Resolved.ptr is the most specific value in a chain (e.g. IFrame, not EventTarget, Node, ...)
// Finalizer.ptr_id is the most specific value in a chain that defines an acquireRef // Finalizer.ptr_id is the most specific value in a chain that defines an acquireRef
ptr_id: usize, ptr_id: usize,
deinit: *const fn (ptr_id: usize, session: *Session) void, acquire_ref: *const fn (ptr_id: usize) void,
acquireRef: *const fn (ptr_id: usize) void, release_ref: *const fn (handle: ?*const v8.WeakCallbackInfo) callconv(.c) void,
release: *const fn (handle: ?*const v8.WeakCallbackInfo) callconv(.c) void, release_ref_from_zig: *const fn (ptr_id: usize, session: *Session) void,
}; };
}; };
pub fn resolveValue(value: anytype) Resolved { pub fn resolveValue(value: anytype) Resolved {
@@ -1170,32 +1174,49 @@ fn resolveT(comptime T: type, value: *T) Resolved {
const finalizer_ptr = getFinalizerPtr(value); const finalizer_ptr = getFinalizerPtr(value);
const Wrap = struct { const Wrap = struct {
fn deinit(ptr_id: usize, session: *Session) void {
FT.deinit(@ptrFromInt(ptr_id), session);
}
fn acquireRef(ptr_id: usize) void { fn acquireRef(ptr_id: usize) void {
FT.acquireRef(@ptrFromInt(ptr_id)); FT.acquireRef(@ptrFromInt(ptr_id));
} }
fn release(handle: ?*const v8.WeakCallbackInfo) callconv(.c) void { fn releaseRef(handle: ?*const v8.WeakCallbackInfo) callconv(.c) void {
const ptr = v8.v8__WeakCallbackInfo__GetParameter(handle.?).?; const ptr = v8.v8__WeakCallbackInfo__GetParameter(handle.?).?;
const identity_finalizer: *Session.FinalizerCallback.Identity = @ptrCast(@alignCast(ptr)); const identity_finalizer: *Session.FinalizerCallback.Identity = @ptrCast(@alignCast(ptr));
const fc = identity_finalizer.fc; const fc = identity_finalizer.fc;
const session = fc.session;
const finalizer_ptr_id = fc.finalizer_ptr_id;
// Remove from this identity's map
if (identity_finalizer.identity.identity_map.fetchRemove(fc.resolved_ptr_id)) |kv| { if (identity_finalizer.identity.identity_map.fetchRemove(fc.resolved_ptr_id)) |kv| {
var global = kv.value; var global = kv.value;
v8.v8__Global__Reset(&global); v8.v8__Global__Reset(&global);
} }
FT.releaseRef(@ptrFromInt(fc.finalizer_ptr_id), fc.session); const identity_count = fc.identity_count;
if (identity_count == 1) {
// All IsolatedWorlds that reference this object have
// released it. Release the instance ref, remove the
// FinalizerCallback and free it.
FT.releaseRef(@ptrFromInt(finalizer_ptr_id), session);
const removed = session.finalizer_callbacks.remove(finalizer_ptr_id);
if (comptime IS_DEBUG) {
std.debug.assert(removed);
}
session.releaseArena(fc.arena);
} else {
fc.identity_count = identity_count - 1;
}
}
fn releaseRefFromZig(ptr_id: usize, session: *Session) void {
FT.releaseRef(@ptrFromInt(ptr_id), session);
} }
}; };
break :blk .{ break :blk .{
.ptr_id = @intFromPtr(finalizer_ptr), .ptr_id = @intFromPtr(finalizer_ptr),
.deinit = Wrap.deinit, .acquire_ref = Wrap.acquireRef,
.acquireRef = Wrap.acquireRef, .release_ref = Wrap.releaseRef,
.release = Wrap.release, .release_ref_from_zig = Wrap.releaseRefFromZig,
}; };
}, },
}; };
@@ -1454,7 +1475,7 @@ fn createFinalizerCallback(
// The most specific value where finalizers are defined // The most specific value where finalizers are defined
// What actually gets acquired / released / deinit // What actually gets acquired / released / deinit
finalizer_ptr_id: usize, finalizer_ptr_id: usize,
deinit: *const fn (ptr_id: usize, session: *Session) void, release_ref: *const fn (ptr_id: usize, session: *Session) void,
) !*Session.FinalizerCallback { ) !*Session.FinalizerCallback {
const session = self.ctx.session; const session = self.ctx.session;
@@ -1465,7 +1486,7 @@ fn createFinalizerCallback(
fc.* = .{ fc.* = .{
.arena = arena, .arena = arena,
.session = session, .session = session,
._deinit = deinit, .release_ref = release_ref,
.resolved_ptr_id = resolved_ptr_id, .resolved_ptr_id = resolved_ptr_id,
.finalizer_ptr_id = finalizer_ptr_id, .finalizer_ptr_id = finalizer_ptr_id,
}; };

View File

@@ -4,7 +4,7 @@
<div id=empty></div> <div id=empty></div>
<div id=one><p id=p10></p></div> <div id=one><p id=p10></p></div>
<script id=childNodes> <!--<script id=childNodes>
const div = $('#d1'); const div = $('#d1');
const children = div.childNodes; const children = div.childNodes;
testing.expectEqual(true, children instanceof NodeList); testing.expectEqual(true, children instanceof NodeList);
@@ -65,24 +65,24 @@
testing.expectEqual([], Array.from(empty.values())); testing.expectEqual([], Array.from(empty.values()));
testing.expectEqual([], Array.from(empty.entries())); testing.expectEqual([], Array.from(empty.entries()));
testing.expectEqual([], Array.from(empty)); testing.expectEqual([], Array.from(empty));
</script> </script> -->
<script id=one> <script id=one>
const one = $('#one').childNodes; const one = $('#one').childNodes;
const p10 = $('#p10'); // const p10 = $('#p10');
testing.expectEqual(1, one.length); // testing.expectEqual(1, one.length);
testing.expectEqual(p10, one[0]); // testing.expectEqual(p10, one[0]);
testing.expectEqual([0], Array.from(one.keys())); // testing.expectEqual([0], Array.from(one.keys()));
testing.expectEqual([p10], Array.from(one.values())); // testing.expectEqual([p10], Array.from(one.values()));
testing.expectEqual([[0, p10]], Array.from(one.entries())); // testing.expectEqual([[0, p10]], Array.from(one.entries()));
testing.expectEqual([p10], Array.from(one)); // testing.expectEqual([p10], Array.from(one));
let foreach = []; let foreach = [];
one.forEach((p) => foreach.push(p)); one.forEach((p) => foreach.push(p));
testing.expectEqual([p10], foreach); testing.expectEqual([p10], foreach);
</script> </script>
<script id=contains> <!-- <script id=contains>
testing.expectEqual(true, document.contains(document)); testing.expectEqual(true, document.contains(document));
testing.expectEqual(true, $('#d1').contains($('#d1'))); testing.expectEqual(true, $('#d1').contains($('#d1')));
testing.expectEqual(true, document.contains($('#d1'))); testing.expectEqual(true, document.contains($('#d1')));
@@ -94,3 +94,4 @@
testing.expectEqual(false, $('#d1').contains($('#empty'))); testing.expectEqual(false, $('#d1').contains($('#empty')));
testing.expectEqual(false, $('#d1').contains($('#p10'))); testing.expectEqual(false, $('#d1').contains($('#p10')));
</script> </script>
-->

View File

@@ -114,7 +114,9 @@ pub fn init(callback: js.Function.Temp, options: ?ObserverInit, page: *Page) !*I
pub fn deinit(self: *IntersectionObserver, session: *Session) void { pub fn deinit(self: *IntersectionObserver, session: *Session) void {
self._callback.release(); self._callback.release();
for (self._pending_entries.items) |entry| { for (self._pending_entries.items) |entry| {
entry.deinitIfUnused(session); // These were never handed to v8, they do not have a corresponding
// FinalizerCallback. We 100% own them.
entry.deinit(session);
} }
session.releaseArena(self._arena); session.releaseArena(self._arena);
} }
@@ -135,14 +137,11 @@ pub fn observe(self: *IntersectionObserver, target: *Element, page: *Page) !void
} }
} }
// Register with page if this is our first observation try self._observing.append(self._arena, target);
if (self._observing.items.len == 0) { if (self._observing.items.len == 1) {
self._rc._refs += 1;
try page.registerIntersectionObserver(self); try page.registerIntersectionObserver(self);
} }
try self._observing.append(self._arena, target);
// Don't initialize previous state yet - let checkIntersection do it // Don't initialize previous state yet - let checkIntersection do it
// This ensures we get an entry on first observation // This ensures we get an entry on first observation
@@ -166,7 +165,7 @@ pub fn unobserve(self: *IntersectionObserver, target: *Element, page: *Page) voi
while (j < self._pending_entries.items.len) { while (j < self._pending_entries.items.len) {
if (self._pending_entries.items[j]._target == target) { if (self._pending_entries.items[j]._target == target) {
const entry = self._pending_entries.swapRemove(j); const entry = self._pending_entries.swapRemove(j);
entry.deinitIfUnused(page._session); entry.deinit(page._session);
} else { } else {
j += 1; j += 1;
} }
@@ -176,25 +175,21 @@ pub fn unobserve(self: *IntersectionObserver, target: *Element, page: *Page) voi
} }
if (original_length > 0 and self._observing.items.len == 0) { if (original_length > 0 and self._observing.items.len == 0) {
self._rc._refs -= 1; page.unregisterIntersectionObserver(self);
} }
} }
pub fn disconnect(self: *IntersectionObserver, page: *Page) void { pub fn disconnect(self: *IntersectionObserver, page: *Page) void {
for (self._pending_entries.items) |entry| { for (self._pending_entries.items) |entry| {
entry.deinitIfUnused(page._session); entry.deinit(page._session);
} }
self._pending_entries.clearRetainingCapacity(); self._pending_entries.clearRetainingCapacity();
self._previous_states.clearRetainingCapacity(); self._previous_states.clearRetainingCapacity();
const observing_count = self._observing.items.len; if (self._observing.items.len > 0) {
self._observing.clearRetainingCapacity(); page.unregisterIntersectionObserver(self);
page.unregisterIntersectionObserver(self);
if (observing_count > 0) {
_ = self.releaseRef(page._session);
} }
self._observing.clearRetainingCapacity();
} }
pub fn takeRecords(self: *IntersectionObserver, page: *Page) ![]*IntersectionObserverEntry { pub fn takeRecords(self: *IntersectionObserver, page: *Page) ![]*IntersectionObserverEntry {
@@ -340,13 +335,6 @@ pub const IntersectionObserverEntry = struct {
session.releaseArena(self._arena); session.releaseArena(self._arena);
} }
fn deinitIfUnused(self: *IntersectionObserverEntry, session: *Session) void {
if (self._rc._refs == 0) {
// hasn't been handed to JS yet.
self.deinit(session);
}
}
pub fn releaseRef(self: *IntersectionObserverEntry, session: *Session) void { pub fn releaseRef(self: *IntersectionObserverEntry, session: *Session) void {
self._rc.release(self, session); self._rc.release(self, session);
} }

View File

@@ -87,8 +87,12 @@ pub fn init(callback: js.Function.Temp, page: *Page) !*MutationObserver {
return self; return self;
} }
/// Force cleanup on Session shutdown.
pub fn deinit(self: *MutationObserver, session: *Session) void { pub fn deinit(self: *MutationObserver, session: *Session) void {
for (self._pending_records.items) |record| {
// These were never handed to v8, they do not have a corresponding
// FinalizerCallback. We 100% own them.
record.deinit(session);
}
self._callback.release(); self._callback.release();
session.releaseArena(self._arena); session.releaseArena(self._arena);
} }
@@ -163,16 +167,14 @@ pub fn observe(self: *MutationObserver, target: *Node, options: ObserveOptions,
} }
} }
// Register with page if this is our first observation
if (self._observing.items.len == 0) {
self._rc._refs += 1;
try page.registerMutationObserver(self);
}
try self._observing.append(arena, .{ try self._observing.append(arena, .{
.target = target, .target = target,
.options = store_options, .options = store_options,
}); });
if (self._observing.items.len == 1) {
try page.registerMutationObserver(self);
}
} }
pub fn disconnect(self: *MutationObserver, page: *Page) void { pub fn disconnect(self: *MutationObserver, page: *Page) void {
@@ -180,13 +182,11 @@ pub fn disconnect(self: *MutationObserver, page: *Page) void {
_ = record.releaseRef(page._session); _ = record.releaseRef(page._session);
} }
self._pending_records.clearRetainingCapacity(); self._pending_records.clearRetainingCapacity();
const observing_count = self._observing.items.len;
self._observing.clearRetainingCapacity();
if (observing_count > 0) { if (self._observing.items.len > 0) {
_ = self.releaseRef(page._session); page.unregisterMutationObserver(self);
} }
page.unregisterMutationObserver(self); self._observing.clearRetainingCapacity();
} }
pub fn takeRecords(self: *MutationObserver, page: *Page) ![]*MutationRecord { pub fn takeRecords(self: *MutationObserver, page: *Page) ![]*MutationRecord {

View File

@@ -42,8 +42,8 @@ _rc: lp.RC(u32) = .{},
pub fn deinit(self: *NodeList, session: *Session) void { pub fn deinit(self: *NodeList, session: *Session) void {
switch (self._data) { switch (self._data) {
.selector_list => |list| list.deinit(session),
.child_nodes => |cn| cn.deinit(session), .child_nodes => |cn| cn.deinit(session),
.selector_list => |list| list.deinit(session),
else => {}, else => {},
} }
} }
@@ -92,7 +92,12 @@ pub fn entries(self: *NodeList, page: *Page) !*EntryIterator {
pub fn forEach(self: *NodeList, cb: js.Function, page: *Page) !void { pub fn forEach(self: *NodeList, cb: js.Function, page: *Page) !void {
var i: i32 = 0; var i: i32 = 0;
var it = try self.values(page); var it = try self.values(page);
// the iterator takes a reference against our list
defer self.releaseRef(page._session);
while (true) : (i += 1) { while (true) : (i += 1) {
const next = try it.next(page); const next = try it.next(page);
if (next.done) { if (next.done) {

View File

@@ -26,7 +26,8 @@ pub fn Entry(comptime Inner: type, comptime field: ?[]const u8) type {
const R = reflect(Inner, field); const R = reflect(Inner, field);
return struct { return struct {
inner: Inner, _inner: Inner,
_rc: lp.RC(u8) = .{},
const Self = @This(); const Self = @This();
@@ -38,29 +39,31 @@ pub fn Entry(comptime Inner: type, comptime field: ?[]const u8) type {
}; };
pub fn init(inner: Inner, page: *Page) !*Self { pub fn init(inner: Inner, page: *Page) !*Self {
return page._factory.create(Self{ .inner = inner }); const self = try page._factory.create(Self{ ._inner = inner });
if (@hasDecl(Inner, "acquireRef")) {
self._inner.acquireRef();
}
return self;
} }
pub fn deinit(self: *Self, session: *Session) void { pub fn deinit(self: *Self, session: *Session) void {
_ = self; if (@hasDecl(Inner, "releaseRef")) {
_ = session; self._inner.releaseRef(session);
}
session.factory.destroy(self);
} }
pub fn releaseRef(self: *Self, session: *Session) void { pub fn releaseRef(self: *Self, session: *Session) void {
// Release the reference to the inner type that we acquired self._rc.release(self, session);
if (@hasDecl(Inner, "releaseRef")) {
self.inner.releaseRef(session);
}
} }
pub fn acquireRef(self: *Self) void { pub fn acquireRef(self: *Self) void {
if (@hasDecl(Inner, "acquireRef")) { self._rc.acquire();
self.inner.acquireRef();
}
} }
pub fn next(self: *Self, page: *Page) if (R.has_error_return) anyerror!Result else Result { pub fn next(self: *Self, page: *Page) if (R.has_error_return) anyerror!Result else Result {
const entry = (if (comptime R.has_error_return) try self.inner.next(page) else self.inner.next(page)) orelse { const entry = (if (comptime R.has_error_return) try self._inner.next(page) else self._inner.next(page)) orelse {
return .{ .done = true, .value = null }; return .{ .done = true, .value = null };
}; };

View File

@@ -127,16 +127,16 @@ fn handleBlobUrl(url: []const u8, resolver: js.PromiseResolver, page: *Page) !js
return resolver.promise(); return resolver.promise();
} }
fn httpStartCallback(transfer: *HttpClient.Transfer) !void { fn httpStartCallback(response: HttpClient.Response) !void {
const self: *Fetch = @ptrCast(@alignCast(transfer.ctx)); const self: *Fetch = @ptrCast(@alignCast(response.ctx));
if (comptime IS_DEBUG) { if (comptime IS_DEBUG) {
log.debug(.http, "request start", .{ .url = self._url, .source = "fetch" }); log.debug(.http, "request start", .{ .url = self._url, .source = "fetch" });
} }
self._response._transfer = transfer; self._response._http_response = response;
} }
fn httpHeaderDoneCallback(transfer: *HttpClient.Transfer) !bool { fn httpHeaderDoneCallback(response: HttpClient.Response) !bool {
const self: *Fetch = @ptrCast(@alignCast(transfer.ctx)); const self: *Fetch = @ptrCast(@alignCast(response.ctx));
if (self._signal) |signal| { if (self._signal) |signal| {
if (signal._aborted) { if (signal._aborted) {
@@ -145,25 +145,24 @@ fn httpHeaderDoneCallback(transfer: *HttpClient.Transfer) !bool {
} }
const arena = self._response._arena; const arena = self._response._arena;
if (transfer.getContentLength()) |cl| { if (response.contentLength()) |cl| {
try self._buf.ensureTotalCapacity(arena, cl); try self._buf.ensureTotalCapacity(arena, cl);
} }
const res = self._response; const res = self._response;
const header = transfer.response_header.?;
if (comptime IS_DEBUG) { if (comptime IS_DEBUG) {
log.debug(.http, "request header", .{ log.debug(.http, "request header", .{
.source = "fetch", .source = "fetch",
.url = self._url, .url = self._url,
.status = header.status, .status = response.status(),
}); });
} }
res._status = header.status; res._status = response.status().?;
res._status_text = std.http.Status.phrase(@enumFromInt(header.status)) orelse ""; res._status_text = std.http.Status.phrase(@enumFromInt(response.status().?)) orelse "";
res._url = try arena.dupeZ(u8, std.mem.span(header.url)); res._url = try arena.dupeZ(u8, response.url());
res._is_redirected = header.redirect_count > 0; res._is_redirected = response.redirectCount().? > 0;
// Determine response type based on origin comparison // Determine response type based on origin comparison
const page_origin = URL.getOrigin(arena, self._page.url) catch null; const page_origin = URL.getOrigin(arena, self._page.url) catch null;
@@ -183,7 +182,7 @@ fn httpHeaderDoneCallback(transfer: *HttpClient.Transfer) !bool {
res._type = .basic; res._type = .basic;
} }
var it = transfer.responseHeaderIterator(); var it = response.headerIterator();
while (it.next()) |hdr| { while (it.next()) |hdr| {
try res._headers.append(hdr.name, hdr.value, self._page); try res._headers.append(hdr.name, hdr.value, self._page);
} }
@@ -191,8 +190,8 @@ fn httpHeaderDoneCallback(transfer: *HttpClient.Transfer) !bool {
return true; return true;
} }
fn httpDataCallback(transfer: *HttpClient.Transfer, data: []const u8) !void { fn httpDataCallback(response: HttpClient.Response, data: []const u8) !void {
const self: *Fetch = @ptrCast(@alignCast(transfer.ctx)); const self: *Fetch = @ptrCast(@alignCast(response.ctx));
// Check if aborted // Check if aborted
if (self._signal) |signal| { if (self._signal) |signal| {
@@ -207,7 +206,7 @@ fn httpDataCallback(transfer: *HttpClient.Transfer, data: []const u8) !void {
fn httpDoneCallback(ctx: *anyopaque) !void { fn httpDoneCallback(ctx: *anyopaque) !void {
const self: *Fetch = @ptrCast(@alignCast(ctx)); const self: *Fetch = @ptrCast(@alignCast(ctx));
var response = self._response; var response = self._response;
response._transfer = null; response._http_response = null;
response._body = self._buf.items; response._body = self._buf.items;
log.info(.http, "request complete", .{ log.info(.http, "request complete", .{
@@ -230,7 +229,7 @@ fn httpErrorCallback(ctx: *anyopaque, _: anyerror) void {
const self: *Fetch = @ptrCast(@alignCast(ctx)); const self: *Fetch = @ptrCast(@alignCast(ctx));
var response = self._response; var response = self._response;
response._transfer = null; response._http_response = null;
// the response is only passed on v8 on success, if we're here, it's safe to // the response is only passed on v8 on success, if we're here, it's safe to
// clear this. (defer since `self is in the response's arena). // clear this. (defer since `self is in the response's arena).
@@ -256,7 +255,7 @@ fn httpShutdownCallback(ctx: *anyopaque) void {
if (self._owns_response) { if (self._owns_response) {
var response = self._response; var response = self._response;
response._transfer = null; response._http_response = null;
response.deinit(self._page._session); response.deinit(self._page._session);
// Do not access `self` after this point: the Fetch struct was // Do not access `self` after this point: the Fetch struct was
// allocated from response._arena which has been released. // allocated from response._arena which has been released.

View File

@@ -48,7 +48,7 @@ _type: Type,
_status_text: []const u8, _status_text: []const u8,
_url: [:0]const u8, _url: [:0]const u8,
_is_redirected: bool, _is_redirected: bool,
_transfer: ?*HttpClient.Transfer = null, _http_response: ?HttpClient.Response = null,
const InitOpts = struct { const InitOpts = struct {
status: u16 = 200, status: u16 = 200,
@@ -81,9 +81,9 @@ pub fn init(body_: ?[]const u8, opts_: ?InitOpts, page: *Page) !*Response {
} }
pub fn deinit(self: *Response, session: *Session) void { pub fn deinit(self: *Response, session: *Session) void {
if (self._transfer) |transfer| { if (self._http_response) |resp| {
transfer.abort(error.Abort); resp.abort(error.Abort);
self._transfer = null; self._http_response = null;
} }
session.releaseArena(self._arena); session.releaseArena(self._arena);
} }
@@ -191,7 +191,7 @@ pub fn clone(self: *const Response, page: *Page) !*Response {
._type = self._type, ._type = self._type,
._is_redirected = self._is_redirected, ._is_redirected = self._is_redirected,
._headers = try Headers.init(.{ .obj = self._headers }, page), ._headers = try Headers.init(.{ .obj = self._headers }, page),
._transfer = null, ._http_response = null,
}; };
return cloned; return cloned;
} }

View File

@@ -43,7 +43,7 @@ _rc: lp.RC(u8) = .{},
_page: *Page, _page: *Page,
_proto: *XMLHttpRequestEventTarget, _proto: *XMLHttpRequestEventTarget,
_arena: Allocator, _arena: Allocator,
_transfer: ?*HttpClient.Transfer = null, _http_response: ?HttpClient.Response = null,
_active_request: bool = false, _active_request: bool = false,
_url: [:0]const u8 = "", _url: [:0]const u8 = "",
@@ -90,19 +90,19 @@ const ResponseType = enum {
pub fn init(page: *Page) !*XMLHttpRequest { pub fn init(page: *Page) !*XMLHttpRequest {
const arena = try page.getArena(.{ .debug = "XMLHttpRequest" }); const arena = try page.getArena(.{ .debug = "XMLHttpRequest" });
errdefer page.releaseArena(arena); errdefer page.releaseArena(arena);
const xhr = try page._factory.xhrEventTarget(arena, XMLHttpRequest{ const self = try page._factory.xhrEventTarget(arena, XMLHttpRequest{
._page = page, ._page = page,
._arena = arena, ._arena = arena,
._proto = undefined, ._proto = undefined,
._request_headers = try Headers.init(null, page), ._request_headers = try Headers.init(null, page),
}); });
return xhr; return self;
} }
pub fn deinit(self: *XMLHttpRequest, session: *Session) void { pub fn deinit(self: *XMLHttpRequest, session: *Session) void {
if (self._transfer) |transfer| { if (self._http_response) |resp| {
transfer.abort(error.Abort); resp.abort(error.Abort);
self._transfer = null; self._http_response = null;
} }
if (self._on_ready_state_change) |func| { if (self._on_ready_state_change) |func| {
@@ -184,9 +184,9 @@ pub fn setWithCredentials(self: *XMLHttpRequest, value: bool) !void {
// TODO: url should be a union, as it can be multiple things // TODO: url should be a union, as it can be multiple things
pub fn open(self: *XMLHttpRequest, method_: []const u8, url: [:0]const u8) !void { pub fn open(self: *XMLHttpRequest, method_: []const u8, url: [:0]const u8) !void {
// Abort any in-progress request // Abort any in-progress request
if (self._transfer) |transfer| { if (self._http_response) |transfer| {
transfer.abort(error.Abort); transfer.abort(error.Abort);
self._transfer = null; self._http_response = null;
} }
// Reset internal state // Reset internal state
@@ -243,7 +243,10 @@ pub fn send(self: *XMLHttpRequest, body_: ?[]const u8) !void {
try page.headersForRequest(&headers); try page.headersForRequest(&headers);
} }
try http_client.request(.{ self.acquireRef();
self._active_request = true;
http_client.request(.{
.ctx = self, .ctx = self,
.url = self._url, .url = self._url,
.method = self._method, .method = self._method,
@@ -260,9 +263,10 @@ pub fn send(self: *XMLHttpRequest, body_: ?[]const u8) !void {
.done_callback = httpDoneCallback, .done_callback = httpDoneCallback,
.error_callback = httpErrorCallback, .error_callback = httpErrorCallback,
.shutdown_callback = httpShutdownCallback, .shutdown_callback = httpShutdownCallback,
}); }) catch |err| {
self.acquireRef(); self.releaseSelfRef();
self._active_request = true; return err;
};
} }
fn handleBlobUrl(self: *XMLHttpRequest, page: *Page) !void { fn handleBlobUrl(self: *XMLHttpRequest, page: *Page) !void {
@@ -398,34 +402,32 @@ pub fn getResponseXML(self: *XMLHttpRequest, page: *Page) !?*Node.Document {
}; };
} }
fn httpStartCallback(transfer: *HttpClient.Transfer) !void { fn httpStartCallback(response: HttpClient.Response) !void {
const self: *XMLHttpRequest = @ptrCast(@alignCast(transfer.ctx)); const self: *XMLHttpRequest = @ptrCast(@alignCast(response.ctx));
if (comptime IS_DEBUG) { if (comptime IS_DEBUG) {
log.debug(.http, "request start", .{ .method = self._method, .url = self._url, .source = "xhr" }); log.debug(.http, "request start", .{ .method = self._method, .url = self._url, .source = "xhr" });
} }
self._transfer = transfer; self._http_response = response;
} }
fn httpHeaderCallback(transfer: *HttpClient.Transfer, header: http.Header) !void { fn httpHeaderCallback(response: HttpClient.Response, header: http.Header) !void {
const self: *XMLHttpRequest = @ptrCast(@alignCast(transfer.ctx)); const self: *XMLHttpRequest = @ptrCast(@alignCast(response.ctx));
const joined = try std.fmt.allocPrint(self._arena, "{s}: {s}", .{ header.name, header.value }); const joined = try std.fmt.allocPrint(self._arena, "{s}: {s}", .{ header.name, header.value });
try self._response_headers.append(self._arena, joined); try self._response_headers.append(self._arena, joined);
} }
fn httpHeaderDoneCallback(transfer: *HttpClient.Transfer) !bool { fn httpHeaderDoneCallback(response: HttpClient.Response) !bool {
const self: *XMLHttpRequest = @ptrCast(@alignCast(transfer.ctx)); const self: *XMLHttpRequest = @ptrCast(@alignCast(response.ctx));
const header = &transfer.response_header.?;
if (comptime IS_DEBUG) { if (comptime IS_DEBUG) {
log.debug(.http, "request header", .{ log.debug(.http, "request header", .{
.source = "xhr", .source = "xhr",
.url = self._url, .url = self._url,
.status = header.status, .status = response.status(),
}); });
} }
if (header.contentType()) |ct| { if (response.contentType()) |ct| {
self._response_mime = Mime.parse(ct) catch |e| { self._response_mime = Mime.parse(ct) catch |e| {
log.info(.http, "invalid content type", .{ log.info(.http, "invalid content type", .{
.content_Type = ct, .content_Type = ct,
@@ -436,18 +438,18 @@ fn httpHeaderDoneCallback(transfer: *HttpClient.Transfer) !bool {
}; };
} }
var it = transfer.responseHeaderIterator(); var it = response.headerIterator();
while (it.next()) |hdr| { while (it.next()) |hdr| {
const joined = try std.fmt.allocPrint(self._arena, "{s}: {s}", .{ hdr.name, hdr.value }); const joined = try std.fmt.allocPrint(self._arena, "{s}: {s}", .{ hdr.name, hdr.value });
try self._response_headers.append(self._arena, joined); try self._response_headers.append(self._arena, joined);
} }
self._response_status = header.status; self._response_status = response.status().?;
if (transfer.getContentLength()) |cl| { if (response.contentLength()) |cl| {
self._response_len = cl; self._response_len = cl;
try self._response_data.ensureTotalCapacity(self._arena, cl); try self._response_data.ensureTotalCapacity(self._arena, cl);
} }
self._response_url = try self._arena.dupeZ(u8, std.mem.span(header.url)); self._response_url = try self._arena.dupeZ(u8, response.url());
const page = self._page; const page = self._page;
@@ -462,8 +464,8 @@ fn httpHeaderDoneCallback(transfer: *HttpClient.Transfer) !bool {
return true; return true;
} }
fn httpDataCallback(transfer: *HttpClient.Transfer, data: []const u8) !void { fn httpDataCallback(response: HttpClient.Response, data: []const u8) !void {
const self: *XMLHttpRequest = @ptrCast(@alignCast(transfer.ctx)); const self: *XMLHttpRequest = @ptrCast(@alignCast(response.ctx));
try self._response_data.appendSlice(self._arena, data); try self._response_data.appendSlice(self._arena, data);
const page = self._page; const page = self._page;
@@ -486,7 +488,7 @@ fn httpDoneCallback(ctx: *anyopaque) !void {
// Not that the request is done, the http/client will free the transfer // Not that the request is done, the http/client will free the transfer
// object. It isn't safe to keep it around. // object. It isn't safe to keep it around.
self._transfer = null; self._http_response = null;
const page = self._page; const page = self._page;
@@ -509,22 +511,23 @@ fn httpErrorCallback(ctx: *anyopaque, err: anyerror) void {
const self: *XMLHttpRequest = @ptrCast(@alignCast(ctx)); const self: *XMLHttpRequest = @ptrCast(@alignCast(ctx));
// http client will close it after an error, it isn't safe to keep around // http client will close it after an error, it isn't safe to keep around
self.handleError(err); self.handleError(err);
if (self._transfer != null) { if (self._http_response != null) {
self._transfer = null; self._http_response = null;
} }
self.releaseSelfRef(); self.releaseSelfRef();
} }
fn httpShutdownCallback(ctx: *anyopaque) void { fn httpShutdownCallback(ctx: *anyopaque) void {
const self: *XMLHttpRequest = @ptrCast(@alignCast(ctx)); const self: *XMLHttpRequest = @ptrCast(@alignCast(ctx));
self._transfer = null; self._http_response = null;
self.releaseSelfRef();
} }
pub fn abort(self: *XMLHttpRequest) void { pub fn abort(self: *XMLHttpRequest) void {
self.handleError(error.Abort); self.handleError(error.Abort);
if (self._transfer) |transfer| { if (self._http_response) |resp| {
self._transfer = null; self._http_response = null;
transfer.abort(error.Abort); resp.abort(error.Abort);
} }
self.releaseSelfRef(); self.releaseSelfRef();
} }

View File

@@ -259,9 +259,6 @@ pub fn RC(comptime T: type) type {
return; return;
} }
value.deinit(session); value.deinit(session);
if (session.finalizer_callbacks.fetchRemove(@intFromPtr(value))) |kv| {
session.releaseArena(kv.value.arena);
}
} }
pub fn format(self: @This(), writer: *std.Io.Writer) !void { pub fn format(self: @This(), writer: *std.Io.Writer) !void {

View File

@@ -39,6 +39,7 @@ pub const Scope = enum {
telemetry, telemetry,
unknown_prop, unknown_prop,
mcp, mcp,
cache,
}; };
const Opts = struct { const Opts = struct {

View File

@@ -144,11 +144,22 @@ fn run(allocator: Allocator, main_arena: Allocator) !void {
app.network.run(); app.network.run();
}, },
.mcp => { .mcp => |opts| {
log.info(.mcp, "starting server", .{}); log.info(.mcp, "starting server", .{});
log.opts.format = .logfmt; log.opts.format = .logfmt;
var cdp_server: ?*lp.Server = null;
if (opts.cdp_port) |port| {
const address = std.net.Address.parseIp("127.0.0.1", port) catch |err| {
log.fatal(.mcp, "invalid cdp address", .{ .err = err, .port = port });
return;
};
cdp_server = try lp.Server.init(app, address);
try sighandler.on(lp.Server.shutdown, .{cdp_server.?});
}
defer if (cdp_server) |s| s.deinit();
var worker_thread = try std.Thread.spawn(.{}, mcpThread, .{ allocator, app }); var worker_thread = try std.Thread.spawn(.{}, mcpThread, .{ allocator, app });
defer worker_thread.join(); defer worker_thread.join();

View File

@@ -9,57 +9,72 @@ const protocol = @import("protocol.zig");
const Server = @import("Server.zig"); const Server = @import("Server.zig");
const CDPNode = @import("../cdp/Node.zig"); const CDPNode = @import("../cdp/Node.zig");
const goto_schema = protocol.minify(
\\{
\\ "type": "object",
\\ "properties": {
\\ "url": { "type": "string", "description": "The URL to navigate to, must be a valid URL." },
\\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 10000." },
\\ "waitUntil": { "type": "string", "enum": ["load", "domcontentloaded", "networkidle", "done"], "description": "Optional wait strategy. Defaults to 'done'." }
\\ },
\\ "required": ["url"]
\\}
);
const url_params_schema = protocol.minify(
\\{
\\ "type": "object",
\\ "properties": {
\\ "url": { "type": "string", "description": "Optional URL to navigate to before processing." },
\\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 10000." },
\\ "waitUntil": { "type": "string", "enum": ["load", "domcontentloaded", "networkidle", "done"], "description": "Optional wait strategy. Defaults to 'done'." }
\\ }
\\}
);
const evaluate_schema = protocol.minify(
\\{
\\ "type": "object",
\\ "properties": {
\\ "script": { "type": "string" },
\\ "url": { "type": "string", "description": "Optional URL to navigate to before evaluating." },
\\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 10000." },
\\ "waitUntil": { "type": "string", "enum": ["load", "domcontentloaded", "networkidle", "done"], "description": "Optional wait strategy. Defaults to 'done'." }
\\ },
\\ "required": ["script"]
\\}
);
pub const tool_list = [_]protocol.Tool{ pub const tool_list = [_]protocol.Tool{
.{ .{
.name = "goto", .name = "goto",
.description = "Navigate to a specified URL and load the page in memory so it can be reused later for info extraction.", .description = "Navigate to a specified URL and load the page in memory so it can be reused later for info extraction.",
.inputSchema = protocol.minify( .inputSchema = goto_schema,
\\{ },
\\ "type": "object", .{
\\ "properties": { .name = "navigate",
\\ "url": { "type": "string", "description": "The URL to navigate to, must be a valid URL." } .description = "Alias for goto. Navigate to a specified URL and load the page in memory.",
\\ }, .inputSchema = goto_schema,
\\ "required": ["url"]
\\}
),
}, },
.{ .{
.name = "markdown", .name = "markdown",
.description = "Get the page content in markdown format. If a url is provided, it navigates to that url first.", .description = "Get the page content in markdown format. If a url is provided, it navigates to that url first.",
.inputSchema = protocol.minify( .inputSchema = url_params_schema,
\\{
\\ "type": "object",
\\ "properties": {
\\ "url": { "type": "string", "description": "Optional URL to navigate to before fetching markdown." }
\\ }
\\}
),
}, },
.{ .{
.name = "links", .name = "links",
.description = "Extract all links in the opened page. If a url is provided, it navigates to that url first.", .description = "Extract all links in the opened page. If a url is provided, it navigates to that url first.",
.inputSchema = protocol.minify( .inputSchema = url_params_schema,
\\{
\\ "type": "object",
\\ "properties": {
\\ "url": { "type": "string", "description": "Optional URL to navigate to before extracting links." }
\\ }
\\}
),
}, },
.{ .{
.name = "evaluate", .name = "evaluate",
.description = "Evaluate JavaScript in the current page context. If a url is provided, it navigates to that url first.", .description = "Evaluate JavaScript in the current page context. If a url is provided, it navigates to that url first.",
.inputSchema = protocol.minify( .inputSchema = evaluate_schema,
\\{ },
\\ "type": "object", .{
\\ "properties": { .name = "eval",
\\ "script": { "type": "string" }, .description = "Alias for evaluate. Evaluate JavaScript in the current page context.",
\\ "url": { "type": "string", "description": "Optional URL to navigate to before evaluating." } .inputSchema = evaluate_schema,
\\ },
\\ "required": ["script"]
\\}
),
}, },
.{ .{
.name = "semantic_tree", .name = "semantic_tree",
@@ -69,6 +84,8 @@ pub const tool_list = [_]protocol.Tool{
\\ "type": "object", \\ "type": "object",
\\ "properties": { \\ "properties": {
\\ "url": { "type": "string", "description": "Optional URL to navigate to before fetching the semantic tree." }, \\ "url": { "type": "string", "description": "Optional URL to navigate to before fetching the semantic tree." },
\\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 10000." },
\\ "waitUntil": { "type": "string", "enum": ["load", "domcontentloaded", "networkidle", "done"], "description": "Optional wait strategy. Defaults to 'done'." },
\\ "backendNodeId": { "type": "integer", "description": "Optional backend node ID to get the tree for a specific element instead of the document root." }, \\ "backendNodeId": { "type": "integer", "description": "Optional backend node ID to get the tree for a specific element instead of the document root." },
\\ "maxDepth": { "type": "integer", "description": "Optional maximum depth of the tree to return. Useful for exploring high-level structure first." } \\ "maxDepth": { "type": "integer", "description": "Optional maximum depth of the tree to return. Useful for exploring high-level structure first." }
\\ } \\ }
@@ -91,38 +108,17 @@ pub const tool_list = [_]protocol.Tool{
.{ .{
.name = "interactiveElements", .name = "interactiveElements",
.description = "Extract interactive elements from the opened page. If a url is provided, it navigates to that url first.", .description = "Extract interactive elements from the opened page. If a url is provided, it navigates to that url first.",
.inputSchema = protocol.minify( .inputSchema = url_params_schema,
\\{
\\ "type": "object",
\\ "properties": {
\\ "url": { "type": "string", "description": "Optional URL to navigate to before extracting interactive elements." }
\\ }
\\}
),
}, },
.{ .{
.name = "structuredData", .name = "structuredData",
.description = "Extract structured data (like JSON-LD, OpenGraph, etc) from the opened page. If a url is provided, it navigates to that url first.", .description = "Extract structured data (like JSON-LD, OpenGraph, etc) from the opened page. If a url is provided, it navigates to that url first.",
.inputSchema = protocol.minify( .inputSchema = url_params_schema,
\\{
\\ "type": "object",
\\ "properties": {
\\ "url": { "type": "string", "description": "Optional URL to navigate to before extracting structured data." }
\\ }
\\}
),
}, },
.{ .{
.name = "detectForms", .name = "detectForms",
.description = "Detect all forms on the page and return their structure including fields, types, and required status. If a url is provided, it navigates to that url first.", .description = "Detect all forms on the page and return their structure including fields, types, and required status. If a url is provided, it navigates to that url first.",
.inputSchema = protocol.minify( .inputSchema = url_params_schema,
\\{
\\ "type": "object",
\\ "properties": {
\\ "url": { "type": "string", "description": "Optional URL to navigate to before detecting forms." }
\\ }
\\}
),
}, },
.{ .{
.name = "click", .name = "click",
@@ -189,15 +185,21 @@ pub fn handleList(server: *Server, arena: std.mem.Allocator, req: protocol.Reque
const GotoParams = struct { const GotoParams = struct {
url: [:0]const u8, url: [:0]const u8,
timeout: ?u32 = null,
waitUntil: ?lp.Config.WaitUntil = null,
}; };
const UrlParams = struct { const UrlParams = struct {
url: ?[:0]const u8 = null, url: ?[:0]const u8 = null,
timeout: ?u32 = null,
waitUntil: ?lp.Config.WaitUntil = null,
}; };
const EvaluateParams = struct { const EvaluateParams = struct {
script: [:0]const u8, script: [:0]const u8,
url: ?[:0]const u8 = null, url: ?[:0]const u8 = null,
timeout: ?u32 = null,
waitUntil: ?lp.Config.WaitUntil = null,
}; };
const ToolStreamingText = struct { const ToolStreamingText = struct {
@@ -274,6 +276,7 @@ const ToolAction = enum {
structuredData, structuredData,
detectForms, detectForms,
evaluate, evaluate,
eval,
semantic_tree, semantic_tree,
click, click,
fill, fill,
@@ -291,6 +294,7 @@ const tool_map = std.StaticStringMap(ToolAction).initComptime(.{
.{ "structuredData", .structuredData }, .{ "structuredData", .structuredData },
.{ "detectForms", .detectForms }, .{ "detectForms", .detectForms },
.{ "evaluate", .evaluate }, .{ "evaluate", .evaluate },
.{ "eval", .eval },
.{ "semantic_tree", .semantic_tree }, .{ "semantic_tree", .semantic_tree },
.{ "click", .click }, .{ "click", .click },
.{ "fill", .fill }, .{ "fill", .fill },
@@ -324,7 +328,7 @@ pub fn handleCall(server: *Server, arena: std.mem.Allocator, req: protocol.Reque
.interactiveElements => try handleInteractiveElements(server, arena, req.id.?, call_params.arguments), .interactiveElements => try handleInteractiveElements(server, arena, req.id.?, call_params.arguments),
.structuredData => try handleStructuredData(server, arena, req.id.?, call_params.arguments), .structuredData => try handleStructuredData(server, arena, req.id.?, call_params.arguments),
.detectForms => try handleDetectForms(server, arena, req.id.?, call_params.arguments), .detectForms => try handleDetectForms(server, arena, req.id.?, call_params.arguments),
.evaluate => try handleEvaluate(server, arena, req.id.?, call_params.arguments), .eval, .evaluate => try handleEvaluate(server, arena, req.id.?, call_params.arguments),
.semantic_tree => try handleSemanticTree(server, arena, req.id.?, call_params.arguments), .semantic_tree => try handleSemanticTree(server, arena, req.id.?, call_params.arguments),
.click => try handleClick(server, arena, req.id.?, call_params.arguments), .click => try handleClick(server, arena, req.id.?, call_params.arguments),
.fill => try handleFill(server, arena, req.id.?, call_params.arguments), .fill => try handleFill(server, arena, req.id.?, call_params.arguments),
@@ -335,7 +339,7 @@ pub fn handleCall(server: *Server, arena: std.mem.Allocator, req: protocol.Reque
fn handleGoto(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { fn handleGoto(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const args = try parseArgs(GotoParams, arena, arguments, server, id, "goto"); const args = try parseArgs(GotoParams, arena, arguments, server, id, "goto");
try performGoto(server, args.url, id); try performGoto(server, args.url, id, args.timeout, args.waitUntil);
const content = [_]protocol.TextContent([]const u8){.{ .text = "Navigated successfully." }}; const content = [_]protocol.TextContent([]const u8){.{ .text = "Navigated successfully." }};
try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content });
@@ -343,7 +347,7 @@ fn handleGoto(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arg
fn handleMarkdown(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { fn handleMarkdown(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const args = try parseArgsOrDefault(UrlParams, arena, arguments, server, id); const args = try parseArgsOrDefault(UrlParams, arena, arguments, server, id);
const page = try ensurePage(server, id, args.url); const page = try ensurePage(server, id, args.url, args.timeout, args.waitUntil);
const content = [_]protocol.TextContent(ToolStreamingText){.{ const content = [_]protocol.TextContent(ToolStreamingText){.{
.text = .{ .page = page, .action = .markdown }, .text = .{ .page = page, .action = .markdown },
@@ -355,7 +359,7 @@ fn handleMarkdown(server: *Server, arena: std.mem.Allocator, id: std.json.Value,
fn handleLinks(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { fn handleLinks(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const args = try parseArgsOrDefault(UrlParams, arena, arguments, server, id); const args = try parseArgsOrDefault(UrlParams, arena, arguments, server, id);
const page = try ensurePage(server, id, args.url); const page = try ensurePage(server, id, args.url, args.timeout, args.waitUntil);
const content = [_]protocol.TextContent(ToolStreamingText){.{ const content = [_]protocol.TextContent(ToolStreamingText){.{
.text = .{ .page = page, .action = .links }, .text = .{ .page = page, .action = .links },
@@ -370,9 +374,11 @@ fn handleSemanticTree(server: *Server, arena: std.mem.Allocator, id: std.json.Va
url: ?[:0]const u8 = null, url: ?[:0]const u8 = null,
backendNodeId: ?u32 = null, backendNodeId: ?u32 = null,
maxDepth: ?u32 = null, maxDepth: ?u32 = null,
timeout: ?u32 = null,
waitUntil: ?lp.Config.WaitUntil = null,
}; };
const args = try parseArgsOrDefault(TreeParams, arena, arguments, server, id); const args = try parseArgsOrDefault(TreeParams, arena, arguments, server, id);
const page = try ensurePage(server, id, args.url); const page = try ensurePage(server, id, args.url, args.timeout, args.waitUntil);
const content = [_]protocol.TextContent(ToolStreamingText){.{ const content = [_]protocol.TextContent(ToolStreamingText){.{
.text = .{ .text = .{
@@ -417,7 +423,7 @@ fn handleNodeDetails(server: *Server, arena: std.mem.Allocator, id: std.json.Val
fn handleInteractiveElements(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { fn handleInteractiveElements(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const args = try parseArgsOrDefault(UrlParams, arena, arguments, server, id); const args = try parseArgsOrDefault(UrlParams, arena, arguments, server, id);
const page = try ensurePage(server, id, args.url); const page = try ensurePage(server, id, args.url, args.timeout, args.waitUntil);
const elements = lp.interactive.collectInteractiveElements(page.document.asNode(), arena, page) catch |err| { const elements = lp.interactive.collectInteractiveElements(page.document.asNode(), arena, page) catch |err| {
log.err(.mcp, "elements collection failed", .{ .err = err }); log.err(.mcp, "elements collection failed", .{ .err = err });
@@ -438,7 +444,7 @@ fn handleInteractiveElements(server: *Server, arena: std.mem.Allocator, id: std.
fn handleStructuredData(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { fn handleStructuredData(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const args = try parseArgsOrDefault(UrlParams, arena, arguments, server, id); const args = try parseArgsOrDefault(UrlParams, arena, arguments, server, id);
const page = try ensurePage(server, id, args.url); const page = try ensurePage(server, id, args.url, args.timeout, args.waitUntil);
const data = lp.structured_data.collectStructuredData(page.document.asNode(), arena, page) catch |err| { const data = lp.structured_data.collectStructuredData(page.document.asNode(), arena, page) catch |err| {
log.err(.mcp, "struct data collection failed", .{ .err = err }); log.err(.mcp, "struct data collection failed", .{ .err = err });
@@ -453,7 +459,7 @@ fn handleStructuredData(server: *Server, arena: std.mem.Allocator, id: std.json.
fn handleDetectForms(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { fn handleDetectForms(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const args = try parseArgsOrDefault(UrlParams, arena, arguments, server, id); const args = try parseArgsOrDefault(UrlParams, arena, arguments, server, id);
const page = try ensurePage(server, id, args.url); const page = try ensurePage(server, id, args.url, args.timeout, args.waitUntil);
const forms_data = lp.forms.collectForms(arena, page.document.asNode(), page) catch |err| { const forms_data = lp.forms.collectForms(arena, page.document.asNode(), page) catch |err| {
log.err(.mcp, "form collection failed", .{ .err = err }); log.err(.mcp, "form collection failed", .{ .err = err });
@@ -474,7 +480,7 @@ fn handleDetectForms(server: *Server, arena: std.mem.Allocator, id: std.json.Val
fn handleEvaluate(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { fn handleEvaluate(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const args = try parseArgs(EvaluateParams, arena, arguments, server, id, "evaluate"); const args = try parseArgs(EvaluateParams, arena, arguments, server, id, "evaluate");
const page = try ensurePage(server, id, args.url); const page = try ensurePage(server, id, args.url, args.timeout, args.waitUntil);
var ls: js.Local.Scope = undefined; var ls: js.Local.Scope = undefined;
page.js.localScope(&ls); page.js.localScope(&ls);
@@ -630,9 +636,9 @@ fn handleWaitForSelector(server: *Server, arena: std.mem.Allocator, id: std.json
return server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); return server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content });
} }
fn ensurePage(server: *Server, id: std.json.Value, url: ?[:0]const u8) !*lp.Page { fn ensurePage(server: *Server, id: std.json.Value, url: ?[:0]const u8, timeout: ?u32, waitUntil: ?lp.Config.WaitUntil) !*lp.Page {
if (url) |u| { if (url) |u| {
try performGoto(server, u, id); try performGoto(server, u, id, timeout, waitUntil);
} }
return server.session.currentPage() orelse { return server.session.currentPage() orelse {
try server.sendError(id, .PageNotLoaded, "Page not loaded"); try server.sendError(id, .PageNotLoaded, "Page not loaded");
@@ -668,7 +674,7 @@ fn parseArgs(comptime T: type, arena: std.mem.Allocator, arguments: ?std.json.Va
}; };
} }
fn performGoto(server: *Server, url: [:0]const u8, id: std.json.Value) !void { fn performGoto(server: *Server, url: [:0]const u8, id: std.json.Value, timeout: ?u32, waitUntil: ?lp.Config.WaitUntil) !void {
const session = server.session; const session = server.session;
if (session.page != null) { if (session.page != null) {
session.removePage(); session.removePage();
@@ -689,8 +695,11 @@ fn performGoto(server: *Server, url: [:0]const u8, id: std.json.Value) !void {
try server.sendError(id, .InternalError, "Failed to start page runner"); try server.sendError(id, .InternalError, "Failed to start page runner");
return error.NavigationFailed; return error.NavigationFailed;
}; };
runner.wait(.{ .ms = 2000 }) catch { runner.wait(.{
try server.sendError(id, .InternalError, "Timeout waiting for page load"); .ms = timeout orelse 10000,
.until = waitUntil orelse .done,
}) catch {
try server.sendError(id, .InternalError, "Error waiting for page load");
return error.NavigationFailed; return error.NavigationFailed;
}; };
} }

View File

@@ -17,6 +17,7 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std"); const std = @import("std");
const log = @import("../log.zig");
const builtin = @import("builtin"); const builtin = @import("builtin");
const net = std.net; const net = std.net;
const posix = std.posix; const posix = std.posix;
@@ -30,6 +31,10 @@ const http = @import("http.zig");
const RobotStore = @import("Robots.zig").RobotStore; const RobotStore = @import("Robots.zig").RobotStore;
const WebBotAuth = @import("WebBotAuth.zig"); const WebBotAuth = @import("WebBotAuth.zig");
const Cache = @import("cache/Cache.zig");
const FsCache = @import("cache/FsCache.zig");
const App = @import("../App.zig");
const Network = @This(); const Network = @This();
const Listener = struct { const Listener = struct {
@@ -45,10 +50,12 @@ const MAX_TICK_CALLBACKS = 16;
allocator: Allocator, allocator: Allocator,
app: *App,
config: *const Config, config: *const Config,
ca_blob: ?http.Blob, ca_blob: ?http.Blob,
robot_store: RobotStore, robot_store: RobotStore,
web_bot_auth: ?WebBotAuth, web_bot_auth: ?WebBotAuth,
cache: ?Cache,
connections: []http.Connection, connections: []http.Connection,
available: std.DoublyLinkedList = .{}, available: std.DoublyLinkedList = .{},
@@ -200,7 +207,7 @@ fn globalDeinit() void {
libcurl.curl_global_cleanup(); libcurl.curl_global_cleanup();
} }
pub fn init(allocator: Allocator, config: *const Config) !Network { pub fn init(allocator: Allocator, app: *App, config: *const Config) !Network {
globalInit(allocator); globalInit(allocator);
errdefer globalDeinit(); errdefer globalDeinit();
@@ -233,6 +240,22 @@ pub fn init(allocator: Allocator, config: *const Config) !Network {
else else
null; null;
const cache = if (config.httpCacheDir()) |cache_dir_path|
Cache{
.kind = .{
.fs = FsCache.init(cache_dir_path) catch |e| {
log.err(.cache, "failed to init", .{
.kind = "FsCache",
.path = cache_dir_path,
.err = e,
});
return e;
},
},
}
else
null;
return .{ return .{
.allocator = allocator, .allocator = allocator,
.config = config, .config = config,
@@ -244,8 +267,10 @@ pub fn init(allocator: Allocator, config: *const Config) !Network {
.available = available, .available = available,
.connections = connections, .connections = connections,
.app = app,
.robot_store = RobotStore.init(allocator), .robot_store = RobotStore.init(allocator),
.web_bot_auth = web_bot_auth, .web_bot_auth = web_bot_auth,
.cache = cache,
}; };
} }
@@ -278,6 +303,8 @@ pub fn deinit(self: *Network) void {
wba.deinit(self.allocator); wba.deinit(self.allocator);
} }
if (self.cache) |*cache| cache.deinit();
globalDeinit(); globalDeinit();
} }

213
src/network/cache/Cache.zig vendored Normal file
View File

@@ -0,0 +1,213 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 Http = @import("../http.zig");
const FsCache = @import("FsCache.zig");
/// A browser-wide cache for resources across the network.
/// This mostly conforms to RFC9111 with regards to caching behavior.
pub const Cache = @This();
kind: union(enum) {
fs: FsCache,
},
pub fn deinit(self: *Cache) void {
return switch (self.kind) {
inline else => |*c| c.deinit(),
};
}
pub fn get(self: *Cache, arena: std.mem.Allocator, req: CacheRequest) ?CachedResponse {
return switch (self.kind) {
inline else => |*c| c.get(arena, req),
};
}
pub fn put(self: *Cache, metadata: CachedMetadata, body: []const u8) !void {
return switch (self.kind) {
inline else => |*c| c.put(metadata, body),
};
}
pub const CacheControl = struct {
max_age: u64,
pub fn parse(value: []const u8) ?CacheControl {
var cc: CacheControl = .{ .max_age = undefined };
var max_age_set = false;
var max_s_age_set = false;
var is_public = false;
var iter = std.mem.splitScalar(u8, value, ',');
while (iter.next()) |part| {
const directive = std.mem.trim(u8, part, &std.ascii.whitespace);
if (std.ascii.eqlIgnoreCase(directive, "no-store")) {
return null;
} else if (std.ascii.eqlIgnoreCase(directive, "no-cache")) {
return null;
} else if (std.ascii.eqlIgnoreCase(directive, "public")) {
is_public = true;
} else if (std.ascii.startsWithIgnoreCase(directive, "max-age=")) {
if (!max_s_age_set) {
if (std.fmt.parseInt(u64, directive[8..], 10) catch null) |max_age| {
cc.max_age = max_age;
max_age_set = true;
}
}
} else if (std.ascii.startsWithIgnoreCase(directive, "s-maxage=")) {
if (std.fmt.parseInt(u64, directive[9..], 10) catch null) |max_age| {
cc.max_age = max_age;
max_age_set = true;
max_s_age_set = true;
}
}
}
if (!max_age_set) return null;
if (!is_public) return null;
if (cc.max_age == 0) return null;
return cc;
}
};
pub const CachedMetadata = struct {
url: [:0]const u8,
content_type: []const u8,
status: u16,
stored_at: i64,
age_at_store: u64,
cache_control: CacheControl,
/// Response Headers
headers: []const Http.Header,
/// These are Request Headers used by Vary.
vary_headers: []const Http.Header,
pub fn format(self: CachedMetadata, writer: *std.Io.Writer) !void {
try writer.print("url={s} | status={d} | content_type={s} | max_age={d} | vary=[", .{
self.url,
self.status,
self.content_type,
self.cache_control.max_age,
});
// Logging all headers gets pretty verbose...
// so we just log the Vary ones that matter for caching.
if (self.vary_headers.len > 0) {
for (self.vary_headers, 0..) |hdr, i| {
if (i > 0) try writer.print(", ", .{});
try writer.print("{s}: {s}", .{ hdr.name, hdr.value });
}
}
try writer.print("]", .{});
}
};
pub const CacheRequest = struct {
url: []const u8,
timestamp: i64,
request_headers: []const Http.Header,
};
pub const CachedData = union(enum) {
buffer: []const u8,
file: struct {
file: std.fs.File,
offset: usize,
len: usize,
},
pub fn format(self: CachedData, writer: *std.Io.Writer) !void {
switch (self) {
.buffer => |buf| try writer.print("buffer({d} bytes)", .{buf.len}),
.file => |f| try writer.print("file(offset={d}, len={d} bytes)", .{ f.offset, f.len }),
}
}
};
pub const CachedResponse = struct {
metadata: CachedMetadata,
data: CachedData,
pub fn format(self: *const CachedResponse, writer: *std.Io.Writer) !void {
try writer.print("metadata=(", .{});
try self.metadata.format(writer);
try writer.print("), data=", .{});
try self.data.format(writer);
}
};
pub fn tryCache(
arena: std.mem.Allocator,
timestamp: i64,
url: [:0]const u8,
status: u16,
content_type: ?[]const u8,
cache_control: ?[]const u8,
vary: ?[]const u8,
age: ?[]const u8,
has_set_cookie: bool,
has_authorization: bool,
) !?CachedMetadata {
if (status != 200) {
log.debug(.cache, "no store", .{ .url = url, .code = status, .reason = "status" });
return null;
}
if (has_set_cookie) {
log.debug(.cache, "no store", .{ .url = url, .reason = "has_cookies" });
return null;
}
if (has_authorization) {
log.debug(.cache, "no store", .{ .url = url, .reason = "has_authorization" });
return null;
}
if (vary) |v| if (std.mem.eql(u8, v, "*")) {
log.debug(.cache, "no store", .{ .url = url, .vary = v, .reason = "vary" });
return null;
};
const cc = blk: {
if (cache_control == null) {
log.debug(.cache, "no store", .{ .url = url, .reason = "no cache control" });
return null;
}
if (CacheControl.parse(cache_control.?)) |cc| {
break :blk cc;
}
log.debug(.cache, "no store", .{ .url = url, .cache_control = cache_control.?, .reason = "cache control" });
return null;
};
return .{
.url = try arena.dupeZ(u8, url),
.content_type = if (content_type) |ct| try arena.dupe(u8, ct) else "application/octet-stream",
.status = status,
.stored_at = timestamp,
.age_at_store = if (age) |a| std.fmt.parseInt(u64, a, 10) catch 0 else 0,
.cache_control = cc,
.headers = &.{},
.vary_headers = &.{},
};
}

612
src/network/cache/FsCache.zig vendored Normal file
View File

@@ -0,0 +1,612 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 Cache = @import("Cache.zig");
const Http = @import("../http.zig");
const CacheRequest = Cache.CacheRequest;
const CachedMetadata = Cache.CachedMetadata;
const CachedResponse = Cache.CachedResponse;
const CACHE_VERSION: usize = 1;
const LOCK_STRIPES = 16;
comptime {
std.debug.assert(std.math.isPowerOfTwo(LOCK_STRIPES));
}
pub const FsCache = @This();
dir: std.fs.Dir,
locks: [LOCK_STRIPES]std.Thread.Mutex = .{std.Thread.Mutex{}} ** LOCK_STRIPES,
const CacheMetadataJson = struct {
version: usize,
metadata: CachedMetadata,
};
fn getLockPtr(self: *FsCache, key: *const [HASHED_KEY_LEN]u8) *std.Thread.Mutex {
const lock_idx = std.hash.Wyhash.hash(0, key[0..]) & (LOCK_STRIPES - 1);
return &self.locks[lock_idx];
}
const BODY_LEN_HEADER_LEN = 8;
const HASHED_KEY_LEN = 64;
const HASHED_PATH_LEN = HASHED_KEY_LEN + 6;
const HASHED_TMP_PATH_LEN = HASHED_PATH_LEN + 4;
fn hashKey(key: []const u8) [HASHED_KEY_LEN]u8 {
var digest: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined;
std.crypto.hash.sha2.Sha256.hash(key, &digest, .{});
var hex: [HASHED_KEY_LEN]u8 = undefined;
_ = std.fmt.bufPrint(&hex, "{s}", .{std.fmt.bytesToHex(&digest, .lower)}) catch unreachable;
return hex;
}
fn cachePath(hashed_key: *const [HASHED_KEY_LEN]u8) [HASHED_PATH_LEN]u8 {
var path: [HASHED_PATH_LEN]u8 = undefined;
_ = std.fmt.bufPrint(&path, "{s}.cache", .{hashed_key}) catch unreachable;
return path;
}
fn cacheTmpPath(hashed_key: *const [HASHED_KEY_LEN]u8) [HASHED_TMP_PATH_LEN]u8 {
var path: [HASHED_TMP_PATH_LEN]u8 = undefined;
_ = std.fmt.bufPrint(&path, "{s}.cache.tmp", .{hashed_key}) catch unreachable;
return path;
}
pub fn init(path: []const u8) !FsCache {
const cwd = std.fs.cwd();
cwd.makeDir(path) catch |err| switch (err) {
error.PathAlreadyExists => {},
else => return err,
};
const dir = try cwd.openDir(path, .{ .iterate = true });
return .{ .dir = dir };
}
pub fn deinit(self: *FsCache) void {
self.dir.close();
}
pub fn get(self: *FsCache, arena: std.mem.Allocator, req: CacheRequest) ?Cache.CachedResponse {
const hashed_key = hashKey(req.url);
const cache_p = cachePath(&hashed_key);
const lock = self.getLockPtr(&hashed_key);
lock.lock();
defer lock.unlock();
const file = self.dir.openFile(&cache_p, .{ .mode = .read_only }) catch |e| {
switch (e) {
std.fs.File.OpenError.FileNotFound => {
log.debug(.cache, "miss", .{ .url = req.url, .hash = &hashed_key, .reason = "missing" });
},
else => |err| {
log.warn(.cache, "open file err", .{ .url = req.url, .err = err });
},
}
return null;
};
var cleanup = false;
defer if (cleanup) {
file.close();
self.dir.deleteFile(&cache_p) catch |e| {
log.err(.cache, "clean fail", .{ .url = req.url, .file = &cache_p, .err = e });
};
};
var file_buf: [1024]u8 = undefined;
var len_buf: [BODY_LEN_HEADER_LEN]u8 = undefined;
var file_reader = file.reader(&file_buf);
const file_reader_iface = &file_reader.interface;
file_reader_iface.readSliceAll(&len_buf) catch |e| {
log.warn(.cache, "read header", .{ .url = req.url, .err = e });
cleanup = true;
return null;
};
const body_len = std.mem.readInt(u64, &len_buf, .little);
// Now we read metadata.
file_reader.seekTo(body_len + BODY_LEN_HEADER_LEN) catch |e| {
log.warn(.cache, "seek metadata", .{ .url = req.url, .err = e });
cleanup = true;
return null;
};
var json_reader = std.json.Reader.init(arena, file_reader_iface);
const cache_file: CacheMetadataJson = std.json.parseFromTokenSourceLeaky(
CacheMetadataJson,
arena,
&json_reader,
.{ .allocate = .alloc_always },
) catch |e| {
// Warn because malformed metadata can be a deeper symptom.
log.warn(.cache, "miss", .{ .url = req.url, .err = e, .reason = "malformed metadata" });
cleanup = true;
return null;
};
if (cache_file.version != CACHE_VERSION) {
log.debug(.cache, "miss", .{
.url = req.url,
.reason = "version mismatch",
.expected = CACHE_VERSION,
.got = cache_file.version,
});
cleanup = true;
return null;
}
const metadata = cache_file.metadata;
// Check entry expiration.
const now = req.timestamp;
const age = (now - metadata.stored_at) + @as(i64, @intCast(metadata.age_at_store));
if (age < 0 or @as(u64, @intCast(age)) >= metadata.cache_control.max_age) {
log.debug(.cache, "miss", .{ .url = req.url, .reason = "expired" });
cleanup = true;
return null;
}
// If we have Vary headers, ensure they are present & matching.
for (metadata.vary_headers) |vary_hdr| {
const name = vary_hdr.name;
const value = vary_hdr.value;
const incoming = for (req.request_headers) |h| {
if (std.ascii.eqlIgnoreCase(h.name, name)) break h.value;
} else "";
if (!std.ascii.eqlIgnoreCase(value, incoming)) {
log.debug(.cache, "miss", .{
.url = req.url,
.reason = "vary mismatch",
.header = name,
.expected = value,
.got = incoming,
});
return null;
}
}
// On the case of a hash collision.
if (!std.ascii.eqlIgnoreCase(metadata.url, req.url)) {
log.warn(.cache, "collision", .{ .url = req.url, .expected = metadata.url, .got = req.url });
cleanup = true;
return null;
}
log.debug(.cache, "hit", .{ .url = req.url, .hash = &hashed_key });
return .{
.metadata = metadata,
.data = .{
.file = .{
.file = file,
.offset = BODY_LEN_HEADER_LEN,
.len = body_len,
},
},
};
}
pub fn put(self: *FsCache, meta: CachedMetadata, body: []const u8) !void {
const hashed_key = hashKey(meta.url);
const cache_p = cachePath(&hashed_key);
const cache_tmp_p = cacheTmpPath(&hashed_key);
const lock = self.getLockPtr(&hashed_key);
lock.lock();
defer lock.unlock();
const file = self.dir.createFile(&cache_tmp_p, .{ .truncate = true }) catch |e| {
log.err(.cache, "create file", .{ .url = meta.url, .file = &cache_tmp_p, .err = e });
return e;
};
defer file.close();
var writer_buf: [1024]u8 = undefined;
var file_writer = file.writer(&writer_buf);
var file_writer_iface = &file_writer.interface;
var len_buf: [8]u8 = undefined;
std.mem.writeInt(u64, &len_buf, body.len, .little);
file_writer_iface.writeAll(&len_buf) catch |e| {
log.err(.cache, "write body len", .{ .url = meta.url, .err = e });
return e;
};
file_writer_iface.writeAll(body) catch |e| {
log.err(.cache, "write body", .{ .url = meta.url, .err = e });
return e;
};
std.json.Stringify.value(
CacheMetadataJson{ .version = CACHE_VERSION, .metadata = meta },
.{ .whitespace = .minified },
file_writer_iface,
) catch |e| {
log.err(.cache, "write metadata", .{ .url = meta.url, .err = e });
return e;
};
file_writer_iface.flush() catch |e| {
log.err(.cache, "flush", .{ .url = meta.url, .err = e });
return e;
};
self.dir.rename(&cache_tmp_p, &cache_p) catch |e| {
log.err(.cache, "rename", .{ .url = meta.url, .from = &cache_tmp_p, .to = &cache_p, .err = e });
return e;
};
log.debug(.cache, "put", .{ .url = meta.url, .hash = &hashed_key, .body_len = body.len });
}
const testing = std.testing;
fn setupCache() !struct { tmp: testing.TmpDir, cache: Cache } {
var tmp = testing.tmpDir(.{});
errdefer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(testing.allocator, ".");
defer testing.allocator.free(path);
return .{
.tmp = tmp,
.cache = Cache{ .kind = .{ .fs = try FsCache.init(path) } },
};
}
test "FsCache: basic put and get" {
var setup = try setupCache();
defer {
setup.cache.deinit();
setup.tmp.cleanup();
}
const cache = &setup.cache;
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const now = std.time.timestamp();
const meta = CachedMetadata{
.url = "https://example.com",
.content_type = "text/html",
.status = 200,
.stored_at = now,
.age_at_store = 0,
.cache_control = .{ .max_age = 600 },
.headers = &.{},
.vary_headers = &.{},
};
const body = "hello world";
try cache.put(meta, body);
const result = cache.get(
arena.allocator(),
.{
.url = "https://example.com",
.timestamp = now,
.request_headers = &.{},
},
) orelse return error.CacheMiss;
const f = result.data.file;
const file = f.file;
defer file.close();
var buf: [64]u8 = undefined;
var file_reader = file.reader(&buf);
try file_reader.seekTo(f.offset);
const read_buf = try file_reader.interface.readAlloc(testing.allocator, f.len);
defer testing.allocator.free(read_buf);
try testing.expectEqualStrings(body, read_buf);
}
test "FsCache: get expiration" {
var setup = try setupCache();
defer {
setup.cache.deinit();
setup.tmp.cleanup();
}
const cache = &setup.cache;
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const now = 5000;
const max_age = 1000;
const meta = CachedMetadata{
.url = "https://example.com",
.content_type = "text/html",
.status = 200,
.stored_at = now,
.age_at_store = 900,
.cache_control = .{ .max_age = max_age },
.headers = &.{},
.vary_headers = &.{},
};
const body = "hello world";
try cache.put(meta, body);
const result = cache.get(
arena.allocator(),
.{
.url = "https://example.com",
.timestamp = now + 50,
.request_headers = &.{},
},
) orelse return error.CacheMiss;
result.data.file.file.close();
try testing.expectEqual(null, cache.get(
arena.allocator(),
.{
.url = "https://example.com",
.timestamp = now + 200,
.request_headers = &.{},
},
));
try testing.expectEqual(null, cache.get(
arena.allocator(),
.{
.url = "https://example.com",
.timestamp = now,
.request_headers = &.{},
},
));
}
test "FsCache: put override" {
var setup = try setupCache();
defer {
setup.cache.deinit();
setup.tmp.cleanup();
}
const cache = &setup.cache;
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
{
const now = 5000;
const max_age = 1000;
const meta = CachedMetadata{
.url = "https://example.com",
.content_type = "text/html",
.status = 200,
.stored_at = now,
.age_at_store = 900,
.cache_control = .{ .max_age = max_age },
.headers = &.{},
.vary_headers = &.{},
};
const body = "hello world";
try cache.put(meta, body);
const result = cache.get(
arena.allocator(),
.{
.url = "https://example.com",
.timestamp = now,
.request_headers = &.{},
},
) orelse return error.CacheMiss;
const f = result.data.file;
const file = f.file;
defer file.close();
var buf: [64]u8 = undefined;
var file_reader = file.reader(&buf);
try file_reader.seekTo(f.offset);
const read_buf = try file_reader.interface.readAlloc(testing.allocator, f.len);
defer testing.allocator.free(read_buf);
try testing.expectEqualStrings(body, read_buf);
}
{
const now = 10000;
const max_age = 2000;
const meta = CachedMetadata{
.url = "https://example.com",
.content_type = "text/html",
.status = 200,
.stored_at = now,
.age_at_store = 0,
.cache_control = .{ .max_age = max_age },
.headers = &.{},
.vary_headers = &.{},
};
const body = "goodbye world";
try cache.put(meta, body);
const result = cache.get(
arena.allocator(),
.{
.url = "https://example.com",
.timestamp = now,
.request_headers = &.{},
},
) orelse return error.CacheMiss;
const f = result.data.file;
const file = f.file;
defer file.close();
var buf: [64]u8 = undefined;
var file_reader = file.reader(&buf);
try file_reader.seekTo(f.offset);
const read_buf = try file_reader.interface.readAlloc(testing.allocator, f.len);
defer testing.allocator.free(read_buf);
try testing.expectEqualStrings(body, read_buf);
}
}
test "FsCache: garbage file" {
var setup = try setupCache();
defer {
setup.cache.deinit();
setup.tmp.cleanup();
}
const hashed_key = hashKey("https://example.com");
const cache_p = cachePath(&hashed_key);
const file = try setup.cache.kind.fs.dir.createFile(&cache_p, .{});
try file.writeAll("this is not a valid cache file !@#$%");
file.close();
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
try testing.expectEqual(
null,
setup.cache.get(arena.allocator(), .{
.url = "https://example.com",
.timestamp = 5000,
.request_headers = &.{},
}),
);
}
test "FsCache: vary hit and miss" {
var setup = try setupCache();
defer {
setup.cache.deinit();
setup.tmp.cleanup();
}
const cache = &setup.cache;
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const now = std.time.timestamp();
const meta = CachedMetadata{
.url = "https://example.com",
.content_type = "text/html",
.status = 200,
.stored_at = now,
.age_at_store = 0,
.cache_control = .{ .max_age = 600 },
.headers = &.{},
.vary_headers = &.{
.{ .name = "Accept-Encoding", .value = "gzip" },
},
};
try cache.put(meta, "hello world");
const result = cache.get(arena.allocator(), .{
.url = "https://example.com",
.timestamp = now,
.request_headers = &.{
.{ .name = "Accept-Encoding", .value = "gzip" },
},
}) orelse return error.CacheMiss;
result.data.file.file.close();
try testing.expectEqual(null, cache.get(arena.allocator(), .{
.url = "https://example.com",
.timestamp = now,
.request_headers = &.{
.{ .name = "Accept-Encoding", .value = "br" },
},
}));
try testing.expectEqual(null, cache.get(arena.allocator(), .{
.url = "https://example.com",
.timestamp = now,
.request_headers = &.{},
}));
const result2 = cache.get(arena.allocator(), .{
.url = "https://example.com",
.timestamp = now,
.request_headers = &.{
.{ .name = "Accept-Encoding", .value = "gzip" },
},
}) orelse return error.CacheMiss;
result2.data.file.file.close();
}
test "FsCache: vary multiple headers" {
var setup = try setupCache();
defer {
setup.cache.deinit();
setup.tmp.cleanup();
}
const cache = &setup.cache;
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const now = std.time.timestamp();
const meta = CachedMetadata{
.url = "https://example.com",
.content_type = "text/html",
.status = 200,
.stored_at = now,
.age_at_store = 0,
.cache_control = .{ .max_age = 600 },
.headers = &.{},
.vary_headers = &.{
.{ .name = "Accept-Encoding", .value = "gzip" },
.{ .name = "Accept-Language", .value = "en" },
},
};
try cache.put(meta, "hello world");
const result = cache.get(arena.allocator(), .{
.url = "https://example.com",
.timestamp = now,
.request_headers = &.{
.{ .name = "Accept-Encoding", .value = "gzip" },
.{ .name = "Accept-Language", .value = "en" },
},
}) orelse return error.CacheMiss;
result.data.file.file.close();
try testing.expectEqual(null, cache.get(arena.allocator(), .{
.url = "https://example.com",
.timestamp = now,
.request_headers = &.{
.{ .name = "Accept-Encoding", .value = "gzip" },
.{ .name = "Accept-Language", .value = "fr" },
},
}));
}

View File

@@ -79,7 +79,7 @@ pub const Headers = struct {
self.headers = updated_headers; self.headers = updated_headers;
} }
fn parseHeader(header_str: []const u8) ?Header { pub fn parseHeader(header_str: []const u8) ?Header {
const colon_pos = std.mem.indexOfScalar(u8, header_str, ':') orelse return null; const colon_pos = std.mem.indexOfScalar(u8, header_str, ':') orelse return null;
const name = std.mem.trim(u8, header_str[0..colon_pos], " \t"); const name = std.mem.trim(u8, header_str[0..colon_pos], " \t");
@@ -88,22 +88,9 @@ pub const Headers = struct {
return .{ .name = name, .value = value }; return .{ .name = name, .value = value };
} }
pub fn iterator(self: *Headers) Iterator { pub fn iterator(self: Headers) HeaderIterator {
return .{ return .{ .curl_slist = .{ .header = self.headers } };
.header = self.headers,
};
} }
const Iterator = struct {
header: [*c]libcurl.CurlSList,
pub fn next(self: *Iterator) ?Header {
const h = self.header orelse return null;
self.header = h.*.next;
return parseHeader(std.mem.span(@as([*:0]const u8, @ptrCast(h.*.data))));
}
};
}; };
// In normal cases, the header iterator comes from the curl linked list. // In normal cases, the header iterator comes from the curl linked list.
@@ -112,6 +99,7 @@ pub const Headers = struct {
// This union, is an iterator that exposes the same API for either case. // This union, is an iterator that exposes the same API for either case.
pub const HeaderIterator = union(enum) { pub const HeaderIterator = union(enum) {
curl: CurlHeaderIterator, curl: CurlHeaderIterator,
curl_slist: CurlSListIterator,
list: ListHeaderIterator, list: ListHeaderIterator,
pub fn next(self: *HeaderIterator) ?Header { pub fn next(self: *HeaderIterator) ?Header {
@@ -120,6 +108,19 @@ pub const HeaderIterator = union(enum) {
} }
} }
pub fn collect(self: *HeaderIterator, allocator: std.mem.Allocator) !std.ArrayList(Header) {
var list: std.ArrayList(Header) = .empty;
while (self.next()) |hdr| {
try list.append(allocator, .{
.name = try allocator.dupe(u8, hdr.name),
.value = try allocator.dupe(u8, hdr.value),
});
}
return list;
}
const CurlHeaderIterator = struct { const CurlHeaderIterator = struct {
conn: *const Connection, conn: *const Connection,
prev: ?*libcurl.CurlHeader = null, prev: ?*libcurl.CurlHeader = null,
@@ -136,6 +137,16 @@ pub const HeaderIterator = union(enum) {
} }
}; };
const CurlSListIterator = struct {
header: [*c]libcurl.CurlSList,
pub fn next(self: *CurlSListIterator) ?Header {
const h = self.header orelse return null;
self.header = h.*.next;
return Headers.parseHeader(std.mem.span(@as([*:0]const u8, @ptrCast(h.*.data))));
}
};
const ListHeaderIterator = struct { const ListHeaderIterator = struct {
index: usize = 0, index: usize = 0,
list: []const Header, list: []const Header,