add --load-resources CLI arg, supporting image param

This commit is contained in:
Halil Durak committed 2026-08-25 14:18:45 +03:00
1 parent feb8d17863
commit e58f99da18
7 files changed
+353 -21

No files matched your search

+95 -7
View File
@@ -539,7 +539,7 @@ pub fn activity(self: *const Client) Activity {
.http = self.http_active + self.dispatch_count + self.intercepted + self.delayed_count,
.ws_events = self.ws_dispatch_count,
.ws_conns = self.ws_active,
.pending = self.pending_queue.first != null,
.pending = self.pending_queue.first != null or self.pending_low_queue.first != null,
};
}
@@ -831,13 +831,24 @@ fn isGated(self: *const Client, transfer: *const Transfer) bool {
return transfer.id != blocking_id;
}
// Resources the page's progress doesn't depend on. Images are fetched for
// their status (so load/error is honest) and, with `--fetch-images headers`,
// a page can queue dozens of them in one parse — none of which should come
// ahead of the script that's blocking the parser.
fn isLowPriority(resource_type: Request.ResourceType) bool {
return switch (resource_type) {
.image => false, // EXPERIMENT
.document, .xhr, .script, .fetch, .stylesheet, .eventsource => false,
};
}
fn startPending(self: *Client) !void {
try self.startDelayed();
while (self.pending_queue.popFirst()) |queue_node| {
const transfer: *Transfer = @fieldParentPtr("_node", queue_node);
const conn = self.network.getConnection() orelse {
self.pending_queue.prepend(queue_node);
return;
queue.prepend(queue_node);
return false;
};
// Bridge state to .created so a failure inside makeRequest before
// any commit cleans up via the failAsync below. makeRequest flips to
@@ -853,6 +864,7 @@ fn startPending(self: *Client) !void {
return err;
};
}
return true;
}
// Enter the pipeline for every delayed transfer whose time has come.
@@ -1107,6 +1119,13 @@ fn cacheStore(self: *Client, transfer: *Transfer) void {
// entry a second time on any early return below.
transfer._cache_intent = .none;
// A headers_only transfer never read the body. Storing it would put an
// empty entry under the real cache key and every later full fetch of
// that URL (an XHR, a `full` image fetch) would hit it and get nothing.
if (transfer.req.headers_only) {
return;
}
// could have been disabled while waiting of the response
const cache = self.cache.active() orelse return;
@@ -1285,11 +1304,19 @@ pub fn syncRequest(self: *Client, transfer: *Transfer) !SyncResponse {
}
fn processTransfer(self: *Client, transfer: *Transfer) !void {
if (self.network.getConnection()) |conn| {
return self.makeRequest(conn, transfer);
const low = isLowPriority(transfer.req.resource_type);
if (!low or self.pending_queue.first == null) {
if (self.network.getConnection()) |conn| {
return self.makeRequest(conn, transfer);
}
}
self.pending_queue.append(&transfer._node);
transfer._queued_low = low;
if (low) {
self.pending_low_queue.append(&transfer._node);
} else {
self.pending_queue.append(&transfer._node);
}
transfer.state = .queued;
}
@@ -1509,6 +1536,11 @@ fn processOneMessage(self: *Client, msg: http.Handles.MultiMessage, transfer: *T
log.debug(.http, "WriteError downgraded", .{ .url = transfer.req.url, .bytes = transfer.res.bytes_received });
break :blk null;
}
// Our own headers_only abort, not a failure: fall through so the
// response is materialized and delivered with an empty body.
if (err == error.WriteError and transfer.res.headers_only_abort) {
break :blk null;
}
break :blk err;
} else null;
@@ -1751,6 +1783,7 @@ pub const Request = struct {
fetch,
stylesheet,
eventsource,
image,
// Allowed Values: Document, Stylesheet, Image, Media, Font, Script,
// TextTrack, XHR, Fetch, Prefetch, EventSource, WebSocket, Manifest,
@@ -1764,6 +1797,7 @@ pub const Request = struct {
.fetch => "Fetch",
.stylesheet => "Stylesheet",
.eventsource => "EventSource",
.image => "Image",
};
}
};
@@ -1772,6 +1806,12 @@ pub const Request = struct {
// internal requests transparently following redirects.
pub const RedirectMode = enum { follow, manual, @"error" };
// Largest body a headers_only transfer will read to the end rather than
// abort. Draining costs bandwidth but keeps the connection poolable;
// aborting saves bandwidth but forces a reconnect. 16 KiB is the rough
// break-even: about ten segments, versus a TCP handshake plus a TLS one.
pub const HEADERS_ONLY_DRAIN_MAX: usize = 16 * 1024;
frame_id: u32,
loader_id: u32,
method: Method,
@@ -1787,6 +1827,16 @@ pub const Request = struct {
timeout_ms: u32 = 0,
skip_cache: bool = false,
// Tear the transfer off the wire as soon as the first body byte arrives:
// the caller wants the status and the response headers, not the body.
// Unlike a HEAD, the request itself is byte-for-byte a normal GET, so
// origins and CDNs see (and answer) exactly what a real browser sends.
// The consumer still gets the usual start/header/done sequence with an
// empty body; `data_callback` never fires. Note the cost: aborting
// mid-response means the connection can't be drained, so libcurl closes
// it instead of returning it to the pool.
headers_only: bool = false,
// The document frame this request belongs to, for CDP attribution.
// This will be different than frame_id for Workers.
document_frame_id: ?u32 = null,
@@ -2080,6 +2130,11 @@ pub const Transfer = struct {
// node is always free by then).
_node: std.DoublyLinkedList.Node = .{},
// Which of the two pending queues _node is linked into. Only meaningful
// while state == .queued; set at enqueue, read when unlinking, because
// removing from the wrong list would corrupt both.
_queued_low: bool = false,
// Buffered response ordered events awaiting dispatch.
_events: std.ArrayList(Event) = .empty,
@@ -2643,7 +2698,11 @@ pub const Transfer = struct {
}
}
if (opts.check_content_length) {
// headers_only is exempt: the cap exists to bound how much body we
// buffer, and this transfer buffers none of it. Failing a 4 MB image
// we were never going to read would turn the size limit into a
// spurious `error` event on a perfectly good response.
if (opts.check_content_length and !self.req.headers_only) {
if (self.getContentLength()) |cl| {
if (cl > self.client.max_response_size) {
return error.ResponseTooLarge;
@@ -3056,6 +3115,29 @@ pub const Transfer = struct {
return @intCast(chunk_len);
}
if (transfer.req.headers_only) {
const drainable = if (transfer.getContentLength()) |cl|
cl <= Request.HEADERS_ONLY_DRAIN_MAX
else
// No Content-Length (chunked): we can't tell how much is
// coming, so don't gamble on it being small.
false;
if (drainable) {
// Reuses the redirect machinery: consumed, never buffered,
// so the response still completes with an empty body.
res.skip_body = true;
return @intCast(chunk_len);
}
// Returning writefunc_error is the only way to end a transfer
// early from a write callback; processOneMessage recognises
// the flag and treats the resulting CURLE_WRITE_ERROR as a
// completed response with an empty body.
res.headers_only_abort = true;
return http.writefunc_error;
}
// Pre-size buffer from Content-Length.
if (transfer.getContentLength()) |cl| {
if (cl > transfer.client.max_response_size) {
@@ -3338,6 +3420,12 @@ const Response = struct {
skip_body: bool = false,
first_data_received: bool = false,
// Set when dataCallback deliberately killed the transfer to satisfy
// `Request.headers_only`. processOneMessage uses it to tell our own
// abort apart from a real CURLE_WRITE_ERROR and deliver the response
// (headers, status, empty body) as a success.
headers_only_abort: bool = false,
// Response body. Filled by dataCallback, consumed in processMessages.
// See Stream.spare to see how this works in streaming mode
buffer: std.ArrayList(u8) = .empty,