Skip to content

Commit 2dc09c7

Browse files
authored
Merge pull request #930 from lightpanda-io/request_interception
request interception
2 parents bed3202 + a49154a commit 2dc09c7

File tree

10 files changed

+399
-85
lines changed

10 files changed

+399
-85
lines changed

src/browser/ScriptManager.zig

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,6 @@ deferreds: OrderList,
5757

5858
shutdown: bool = false,
5959

60-
6160
client: *HttpClient,
6261
allocator: Allocator,
6362
buffer_pool: BufferPool,
@@ -230,11 +229,15 @@ pub fn addFromElement(self: *ScriptManager, element: *parser.Element) !void {
230229

231230
errdefer pending_script.deinit();
232231

232+
var headers = try HttpClient.Headers.init();
233+
try page.requestCookie(.{}).headersForRequest(self.allocator, remote_url.?, &headers);
234+
233235
try self.client.request(.{
234236
.url = remote_url.?,
235237
.ctx = pending_script,
236238
.method = .GET,
237-
.cookie = page.requestCookie(.{}),
239+
.headers = headers,
240+
.cookie_jar = page.cookie_jar,
238241
.start_callback = if (log.enabled(.http, .debug)) startCallback else null,
239242
.header_done_callback = headerCallback,
240243
.data_callback = dataCallback,
@@ -293,12 +296,16 @@ pub fn blockingGet(self: *ScriptManager, url: [:0]const u8) !BlockingResult {
293296
.buffer_pool = &self.buffer_pool,
294297
};
295298

299+
var headers = try HttpClient.Headers.init();
300+
try self.page.requestCookie(.{}).headersForRequest(self.allocator, url, &headers);
301+
296302
var client = self.client;
297303
try client.blockingRequest(.{
298304
.url = url,
299305
.method = .GET,
306+
.headers = headers,
307+
.cookie_jar = self.page.cookie_jar,
300308
.ctx = &blocking,
301-
.cookie = self.page.requestCookie(.{}),
302309
.start_callback = if (log.enabled(.http, .debug)) Blocking.startCallback else null,
303310
.header_done_callback = Blocking.headerCallback,
304311
.data_callback = Blocking.dataCallback,

src/browser/browser.zig

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ pub const Browser = struct {
5252
errdefer env.deinit();
5353

5454
const notification = try Notification.init(allocator, app.notification);
55+
app.http.client.notification = notification;
56+
app.http.client.next_request_id = 0; // Should we track ids in CDP only?
5557
errdefer notification.deinit();
5658

5759
return .{
@@ -74,6 +76,7 @@ pub const Browser = struct {
7476
self.page_arena.deinit();
7577
self.session_arena.deinit();
7678
self.transfer_arena.deinit();
79+
self.http_client.notification = null;
7780
self.notification.deinit();
7881
self.state_pool.deinit();
7982
}

src/browser/page.zig

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -467,13 +467,17 @@ pub const Page = struct {
467467
const owned_url = try self.arena.dupeZ(u8, request_url);
468468
self.url = try URL.parse(owned_url, null);
469469

470+
var headers = try HttpClient.Headers.init();
471+
if (opts.header) |hdr| try headers.add(hdr);
472+
try self.requestCookie(.{ .is_navigation = true }).headersForRequest(self.arena, owned_url, &headers);
473+
470474
self.http_client.request(.{
471475
.ctx = self,
472476
.url = owned_url,
473477
.method = opts.method,
478+
.headers = headers,
474479
.body = opts.body,
475-
.header = opts.header,
476-
.cookie = self.requestCookie(.{ .is_navigation = true }),
480+
.cookie_jar = self.cookie_jar,
477481
.header_done_callback = pageHeaderDoneCallback,
478482
.data_callback = pageDataCallback,
479483
.done_callback = pageDoneCallback,

src/browser/xhr/xhr.zig

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -370,12 +370,19 @@ pub const XMLHttpRequest = struct {
370370
}
371371
}
372372

373+
var headers = try HttpClient.Headers.init();
374+
for (self.headers.items) |hdr| {
375+
try headers.add(hdr);
376+
}
377+
try page.requestCookie(.{}).headersForRequest(self.arena, self.url.?, &headers);
378+
373379
try page.http_client.request(.{
374380
.ctx = self,
375381
.url = self.url.?,
376382
.method = self.method,
383+
.headers = headers,
377384
.body = self.request_body,
378-
.cookie = page.requestCookie(.{}),
385+
.cookie_jar = page.cookie_jar,
379386
.start_callback = httpStartCallback,
380387
.header_callback = httpHeaderCallback,
381388
.header_done_callback = httpHeaderDoneCallback,
@@ -387,11 +394,6 @@ pub const XMLHttpRequest = struct {
387394

388395
fn httpStartCallback(transfer: *HttpClient.Transfer) !void {
389396
const self: *XMLHttpRequest = @alignCast(@ptrCast(transfer.ctx));
390-
391-
for (self.headers.items) |hdr| {
392-
try transfer.addHeader(hdr);
393-
}
394-
395397
log.debug(.http, "request start", .{ .method = self.method, .url = self.url, .source = "xhr" });
396398
self.transfer = transfer;
397399
}

src/cdp/cdp.zig

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const Page = @import("../browser/page.zig").Page;
2929
const Inspector = @import("../browser/env.zig").Env.Inspector;
3030
const Incrementing = @import("../id.zig").Incrementing;
3131
const Notification = @import("../notification.zig").Notification;
32+
const InterceptState = @import("domains/fetch.zig").InterceptState;
3233

3334
const polyfill = @import("../browser/polyfill/polyfill.zig");
3435

@@ -75,6 +76,8 @@ pub fn CDPT(comptime TypeProvider: type) type {
7576
// Extra headers to add to all requests. TBD under which conditions this should be reset.
7677
extra_headers: std.ArrayListUnmanaged(std.http.Header) = .empty,
7778

79+
intercept_state: InterceptState,
80+
7881
const Self = @This();
7982

8083
pub fn init(app: *App, client: TypeProvider.Client) !Self {
@@ -89,13 +92,15 @@ pub fn CDPT(comptime TypeProvider: type) type {
8992
.browser_context = null,
9093
.message_arena = std.heap.ArenaAllocator.init(allocator),
9194
.notification_arena = std.heap.ArenaAllocator.init(allocator),
95+
.intercept_state = try InterceptState.init(allocator), // TBD or browser session arena?
9296
};
9397
}
9498

9599
pub fn deinit(self: *Self) void {
96100
if (self.browser_context) |*bc| {
97101
bc.deinit();
98102
}
103+
self.intercept_state.deinit(); // TBD Should this live in BC?
99104
self.browser.deinit();
100105
self.message_arena.deinit();
101106
self.notification_arena.deinit();
@@ -451,6 +456,14 @@ pub fn BrowserContext(comptime CDP_T: type) type {
451456
self.cdp.browser.notification.unregister(.http_request_complete, self);
452457
}
453458

459+
pub fn fetchEnable(self: *Self) !void {
460+
try self.cdp.browser.notification.register(.http_request_intercept, self, onHttpRequestIntercept);
461+
}
462+
463+
pub fn fetchDisable(self: *Self) void {
464+
self.cdp.browser.notification.unregister(.http_request_intercept, self);
465+
}
466+
454467
pub fn onPageRemove(ctx: *anyopaque, _: Notification.PageRemove) !void {
455468
const self: *Self = @alignCast(@ptrCast(ctx));
456469
return @import("domains/page.zig").pageRemove(self);
@@ -475,7 +488,13 @@ pub fn BrowserContext(comptime CDP_T: type) type {
475488
pub fn onHttpRequestStart(ctx: *anyopaque, data: *const Notification.RequestStart) !void {
476489
const self: *Self = @alignCast(@ptrCast(ctx));
477490
defer self.resetNotificationArena();
478-
return @import("domains/network.zig").httpRequestStart(self.notification_arena, self, data);
491+
try @import("domains/network.zig").httpRequestStart(self.notification_arena, self, data);
492+
}
493+
494+
pub fn onHttpRequestIntercept(ctx: *anyopaque, data: *const Notification.RequestIntercept) !void {
495+
const self: *Self = @alignCast(@ptrCast(ctx));
496+
defer self.resetNotificationArena();
497+
try @import("domains/fetch.zig").requestPaused(self.notification_arena, self, data);
479498
}
480499

481500
pub fn onHttpRequestFail(ctx: *anyopaque, data: *const Notification.RequestFail) !void {

src/cdp/domains/fetch.zig

Lines changed: 200 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
1+
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
22
//
33
// Francis Bouvier <francis@lightpanda.io>
44
// Pierre Tachoire <pierre@lightpanda.io>
@@ -17,13 +17,211 @@
1717
// along with this program. If not, see <https://www.gnu.org/licenses/>.
1818

1919
const std = @import("std");
20+
const Allocator = std.mem.Allocator;
21+
const Notification = @import("../../notification.zig").Notification;
22+
const log = @import("../../log.zig");
23+
const Request = @import("../../http/Client.zig").Request;
24+
const Method = @import("../../http/Client.zig").Method;
2025

2126
pub fn processMessage(cmd: anytype) !void {
2227
const action = std.meta.stringToEnum(enum {
2328
disable,
29+
enable,
30+
continueRequest,
31+
failRequest,
2432
}, cmd.input.action) orelse return error.UnknownMethod;
2533

2634
switch (action) {
27-
.disable => return cmd.sendResult(null, .{}),
35+
.disable => return disable(cmd),
36+
.enable => return enable(cmd),
37+
.continueRequest => return continueRequest(cmd),
38+
.failRequest => return failRequest(cmd),
2839
}
2940
}
41+
42+
// Stored in CDP
43+
pub const InterceptState = struct {
44+
const Self = @This();
45+
waiting: std.AutoArrayHashMap(u64, Request),
46+
47+
pub fn init(allocator: Allocator) !InterceptState {
48+
return .{
49+
.waiting = std.AutoArrayHashMap(u64, Request).init(allocator),
50+
};
51+
}
52+
53+
pub fn deinit(self: *Self) void {
54+
self.waiting.deinit();
55+
}
56+
};
57+
58+
const RequestPattern = struct {
59+
urlPattern: []const u8 = "*", // Wildcards ('*' -> zero or more, '?' -> exactly one) are allowed. Escape character is backslash. Omitting is equivalent to "*".
60+
resourceType: ?ResourceType = null,
61+
requestStage: RequestStage = .Request,
62+
};
63+
const ResourceType = enum {
64+
Document,
65+
Stylesheet,
66+
Image,
67+
Media,
68+
Font,
69+
Script,
70+
TextTrack,
71+
XHR,
72+
Fetch,
73+
Prefetch,
74+
EventSource,
75+
WebSocket,
76+
Manifest,
77+
SignedExchange,
78+
Ping,
79+
CSPViolationReport,
80+
Preflight,
81+
FedCM,
82+
Other,
83+
};
84+
const RequestStage = enum {
85+
Request,
86+
Response,
87+
};
88+
89+
const EnableParam = struct {
90+
patterns: []RequestPattern = &.{},
91+
handleAuthRequests: bool = false,
92+
};
93+
const ErrorReason = enum {
94+
Failed,
95+
Aborted,
96+
TimedOut,
97+
AccessDenied,
98+
ConnectionClosed,
99+
ConnectionReset,
100+
ConnectionRefused,
101+
ConnectionAborted,
102+
ConnectionFailed,
103+
NameNotResolved,
104+
InternetDisconnected,
105+
AddressUnreachable,
106+
BlockedByClient,
107+
BlockedByResponse,
108+
};
109+
110+
fn disable(cmd: anytype) !void {
111+
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
112+
bc.fetchDisable();
113+
return cmd.sendResult(null, .{});
114+
}
115+
116+
fn enable(cmd: anytype) !void {
117+
const params = (try cmd.params(EnableParam)) orelse EnableParam{};
118+
if (params.patterns.len != 0) log.warn(.cdp, "Fetch.enable No patterns yet", .{});
119+
if (params.handleAuthRequests) log.warn(.cdp, "Fetch.enable No auth yet", .{});
120+
121+
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
122+
try bc.fetchEnable();
123+
124+
return cmd.sendResult(null, .{});
125+
}
126+
127+
pub fn requestPaused(arena: Allocator, bc: anytype, intercept: *const Notification.RequestIntercept) !void {
128+
var cdp = bc.cdp;
129+
130+
// unreachable because we _have_ to have a page.
131+
const session_id = bc.session_id orelse unreachable;
132+
const target_id = bc.target_id orelse unreachable;
133+
134+
// We keep it around to wait for modifications to the request.
135+
// NOTE: we assume whomever created the request created it with a lifetime of the Page.
136+
// TODO: What to do when receiving replies for a previous page's requests?
137+
138+
try cdp.intercept_state.waiting.put(intercept.request.id.?, intercept.request.*);
139+
140+
// NOTE: .request data preparation is duped from network.zig
141+
const full_request_url = try std.Uri.parse(intercept.request.url);
142+
const request_url = try @import("network.zig").urlToString(arena, &full_request_url, .{
143+
.scheme = true,
144+
.authentication = true,
145+
.authority = true,
146+
.path = true,
147+
.query = true,
148+
});
149+
const request_fragment = try @import("network.zig").urlToString(arena, &full_request_url, .{
150+
.fragment = true,
151+
});
152+
const headers = try intercept.request.headers.asHashMap(arena);
153+
// End of duped code
154+
155+
try cdp.sendEvent("Fetch.requestPaused", .{
156+
.requestId = try std.fmt.allocPrint(arena, "INTERCEPT-{d}", .{intercept.request.id.?}),
157+
.request = .{
158+
.url = request_url,
159+
.urlFragment = request_fragment,
160+
.method = @tagName(intercept.request.method),
161+
.hasPostData = intercept.request.body != null,
162+
.headers = std.json.ArrayHashMap([]const u8){ .map = headers },
163+
},
164+
.frameId = target_id,
165+
.resourceType = ResourceType.Document, // TODO!
166+
.networkId = try std.fmt.allocPrint(arena, "REQ-{d}", .{intercept.request.id.?}),
167+
}, .{ .session_id = session_id });
168+
169+
// Await either continueRequest, failRequest or fulfillRequest
170+
intercept.wait_for_interception.* = true;
171+
}
172+
173+
const HeaderEntry = struct {
174+
name: []const u8,
175+
value: []const u8,
176+
};
177+
178+
fn continueRequest(cmd: anytype) !void {
179+
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
180+
const params = (try cmd.params(struct {
181+
requestId: []const u8, // "INTERCEPT-{d}"
182+
url: ?[]const u8 = null,
183+
method: ?[]const u8 = null,
184+
postData: ?[]const u8 = null,
185+
headers: ?[]const HeaderEntry = null,
186+
interceptResponse: bool = false,
187+
})) orelse return error.InvalidParams;
188+
if (params.postData != null or params.headers != null or params.interceptResponse) return error.NotYetImplementedParams;
189+
190+
const request_id = try idFromRequestId(params.requestId);
191+
var waiting_request = (bc.cdp.intercept_state.waiting.fetchSwapRemove(request_id) orelse return error.RequestNotFound).value;
192+
193+
// Update the request with the new parameters
194+
if (params.url) |url| {
195+
// The request url must be modified in a way that's not observable by page. So page.url is not updated.
196+
waiting_request.url = try bc.cdp.browser.page_arena.allocator().dupeZ(u8, url);
197+
}
198+
if (params.method) |method| {
199+
waiting_request.method = std.meta.stringToEnum(Method, method) orelse return error.InvalidParams;
200+
}
201+
202+
log.info(.cdp, "Request continued by intercept", .{ .id = params.requestId });
203+
try bc.cdp.browser.http_client.request(waiting_request);
204+
205+
return cmd.sendResult(null, .{});
206+
}
207+
208+
fn failRequest(cmd: anytype) !void {
209+
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
210+
var state = &bc.cdp.intercept_state;
211+
const params = (try cmd.params(struct {
212+
requestId: []const u8, // "INTERCEPT-{d}"
213+
errorReason: ErrorReason,
214+
})) orelse return error.InvalidParams;
215+
216+
const request_id = try idFromRequestId(params.requestId);
217+
if (state.waiting.fetchSwapRemove(request_id) == null) return error.RequestNotFound;
218+
219+
log.info(.cdp, "Request aborted by intercept", .{ .reason = params.errorReason });
220+
return cmd.sendResult(null, .{});
221+
}
222+
223+
// Get u64 from requestId which is formatted as: "INTERCEPT-{d}"
224+
fn idFromRequestId(request_id: []const u8) !u64 {
225+
if (!std.mem.startsWith(u8, request_id, "INTERCEPT-")) return error.InvalidParams;
226+
return std.fmt.parseInt(u64, request_id[10..], 10) catch return error.InvalidParams;
227+
}

0 commit comments

Comments
 (0)