mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-08-01 10:16:25 -04:00
328 lines
11 KiB
Zig
328 lines
11 KiB
Zig
// 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 builtin = @import("builtin");
|
|
const lp = @import("lightpanda");
|
|
const log = lp.log;
|
|
|
|
const IS_DEBUG = builtin.mode == .Debug;
|
|
|
|
const http = @import("../http.zig");
|
|
const HttpClient = @import("../../browser/HttpClient.zig");
|
|
const Request = @import("../../browser/HttpClient.zig").Request;
|
|
const Transfer = @import("../../browser/HttpClient.zig").Transfer;
|
|
const Response = @import("../../browser/HttpClient.zig").Response;
|
|
const FulfilledResponse = @import("../../browser/HttpClient.zig").FulfilledResponse;
|
|
const Layer = @import("../../browser/HttpClient.zig").Layer;
|
|
const Forward = @import("Forward.zig");
|
|
const HeaderResult = @import("../../browser/HttpClient.zig").HeaderResult;
|
|
|
|
const InterceptionLayer = @This();
|
|
|
|
// Count of intercepted requests. The client doesn't track intercepted transfers
|
|
// on its own active counters: once intercepted, a transfer leaves the layer
|
|
// chain and waits for the interceptor (CDP) to call continue/abort/fulfill.
|
|
// We track them here so the network-idle / network-almost-idle CDP lifecycle
|
|
// events don't fire prematurely.
|
|
intercepted: usize = 0,
|
|
|
|
next: Layer = undefined,
|
|
|
|
pub fn layer(self: *InterceptionLayer) Layer {
|
|
return .{
|
|
.ptr = self,
|
|
.vtable = &.{ .request = request },
|
|
};
|
|
}
|
|
|
|
fn request(ptr: *anyopaque, transfer: *Transfer) anyerror!void {
|
|
const self: *InterceptionLayer = @ptrCast(@alignCast(ptr));
|
|
const req = &transfer.req;
|
|
|
|
const ctx = try transfer.arena.create(InterceptContext);
|
|
ctx.* = .{
|
|
.layer = self,
|
|
.transfer = transfer,
|
|
.forward = Forward.capture(req),
|
|
};
|
|
|
|
// Install our wrappers on the transfer's request. The interceptor wants to
|
|
// observe every callback (start/header/data/done/err/shutdown) so it can
|
|
// mirror the Network.* CDP events.
|
|
req.ctx = ctx;
|
|
if (ctx.forward.start != null) req.start_callback = InterceptContext.startCallback;
|
|
req.header_callback = InterceptContext.headerCallback;
|
|
req.data_callback = InterceptContext.dataCallback;
|
|
req.done_callback = InterceptContext.doneCallback;
|
|
req.error_callback = InterceptContext.errorCallback;
|
|
if (ctx.forward.shutdown != null) req.shutdown_callback = InterceptContext.shutdownCallback;
|
|
|
|
req.notification.dispatch(.http_request_start, &.{ .transfer = transfer });
|
|
|
|
var wait_for_interception = false;
|
|
req.notification.dispatch(.http_request_intercept, &.{
|
|
.transfer = transfer,
|
|
.wait_for_interception = &wait_for_interception,
|
|
});
|
|
|
|
log.debug(.http, "interception check", .{
|
|
.wait_for_interception = wait_for_interception,
|
|
.intercepted = self.intercepted,
|
|
.url = req.url,
|
|
});
|
|
|
|
if (!wait_for_interception) {
|
|
return self.next.request(transfer);
|
|
}
|
|
|
|
// Paused: the CDP listener stashed `transfer` and will eventually call
|
|
// continueRequest / abortRequest / fulfillRequest. Until then, CDP owns
|
|
// the transfer's lifecycle. Park keeps the outer Client.request errdefer
|
|
// from tearing it down.
|
|
self.intercepted += 1;
|
|
transfer.park(.intercept_request);
|
|
if (comptime IS_DEBUG) {
|
|
log.debug(.http, "wait for interception", .{ .intercepted = self.intercepted });
|
|
}
|
|
}
|
|
|
|
pub const InterceptContext = struct {
|
|
layer: *InterceptionLayer,
|
|
transfer: *Transfer,
|
|
forward: Forward,
|
|
content_length: usize = 0,
|
|
|
|
fn startCallback(response: Response) anyerror!void {
|
|
const self: *InterceptContext = @ptrCast(@alignCast(response.ctx));
|
|
log.debug(.http, "intercept start", .{ .url = self.transfer.req.url });
|
|
return self.forward.forwardStart(response);
|
|
}
|
|
|
|
fn headerCallback(response: Response) anyerror!HeaderResult {
|
|
const self: *InterceptContext = @ptrCast(@alignCast(response.ctx));
|
|
log.debug(.http, "intercept header", .{
|
|
.url = self.transfer.req.url,
|
|
.status = response.status(),
|
|
.content_length = response.contentLength(),
|
|
});
|
|
|
|
self.content_length = response.contentLength() orelse 0;
|
|
|
|
self.transfer.req.notification.dispatch(.http_response_header_done, &.{
|
|
.transfer = self.transfer,
|
|
.response = &response,
|
|
});
|
|
|
|
return self.forward.forwardHeader(response);
|
|
}
|
|
|
|
fn dataCallback(response: Response, chunk: []const u8) anyerror!void {
|
|
const self: *InterceptContext = @ptrCast(@alignCast(response.ctx));
|
|
log.debug(.http, "intercept data", .{
|
|
.url = self.transfer.req.url,
|
|
.len = chunk.len,
|
|
});
|
|
|
|
self.transfer.req.notification.dispatch(.http_response_data, &.{
|
|
.data = chunk,
|
|
.transfer = self.transfer,
|
|
});
|
|
|
|
return self.forward.forwardData(response, chunk);
|
|
}
|
|
|
|
fn doneCallback(ctx: *anyopaque) anyerror!void {
|
|
const self: *InterceptContext = @ptrCast(@alignCast(ctx));
|
|
|
|
log.debug(.http, "intercept done", .{
|
|
.url = self.transfer.req.url,
|
|
.content_length = self.content_length,
|
|
});
|
|
|
|
self.transfer.req.notification.dispatch(.http_request_done, &.{
|
|
.transfer = self.transfer,
|
|
.content_length = self.content_length,
|
|
});
|
|
return self.forward.forwardDone();
|
|
}
|
|
|
|
fn errorCallback(ctx: *anyopaque, err: anyerror) void {
|
|
const self: *InterceptContext = @ptrCast(@alignCast(ctx));
|
|
|
|
log.debug(.http, "intercept error", .{
|
|
.url = self.transfer.req.url,
|
|
.err = err,
|
|
});
|
|
self.transfer.req.notification.dispatch(.http_request_fail, &.{
|
|
.transfer = self.transfer,
|
|
.err = err,
|
|
});
|
|
self.forward.forwardErr(err);
|
|
}
|
|
|
|
fn shutdownCallback(ctx: *anyopaque) void {
|
|
const self: *InterceptContext = @ptrCast(@alignCast(ctx));
|
|
|
|
log.debug(.http, "intercept shutdown", .{ .url = self.transfer.req.url });
|
|
self.transfer.req.notification.dispatch(.http_request_fail, &.{
|
|
.transfer = self.transfer,
|
|
.err = error.Shutdown,
|
|
});
|
|
self.forward.forwardShutdown();
|
|
}
|
|
};
|
|
|
|
// CDP-driven resolution entry points. The transfer was paused inside `request`
|
|
// (state = .parked = .intercept_request). One of these three is called by CDP
|
|
// to resume / drop the transfer.
|
|
|
|
pub fn continueRequest(self: *InterceptionLayer, transfer: *Transfer) anyerror!void {
|
|
if (comptime IS_DEBUG) {
|
|
lp.assert(self.intercepted > 0, "InterceptionLayer.continueRequest", .{ .value = self.intercepted });
|
|
log.debug(.http, "continue transfer", .{ .intercepted = self.intercepted });
|
|
}
|
|
|
|
// Resume the layer chain. Ownership is re-handed to whichever subsequent
|
|
// layer commits the transfer (queue, multi, or another park). If the
|
|
// chain fails before any commit, we clean up here — mirror the errdefer
|
|
// pattern in Client.request.
|
|
transfer.unpark();
|
|
self.next.request(transfer) catch |err| {
|
|
if (transfer.state == .created) {
|
|
transfer.abort(err);
|
|
}
|
|
return err;
|
|
};
|
|
}
|
|
|
|
pub fn abortRequest(self: *InterceptionLayer, transfer: *Transfer) void {
|
|
if (comptime IS_DEBUG) {
|
|
lp.assert(self.intercepted > 0, "InterceptionLayer.abortRequest", .{ .value = self.intercepted });
|
|
log.debug(.http, "abort transfer", .{ .intercepted = self.intercepted });
|
|
}
|
|
transfer.abortParked(error.Abort);
|
|
}
|
|
|
|
pub fn fulfillRequest(
|
|
self: *InterceptionLayer,
|
|
transfer: *Transfer,
|
|
status: u16,
|
|
headers: []const http.Header,
|
|
body: ?[]const u8,
|
|
) !void {
|
|
if (comptime IS_DEBUG) {
|
|
lp.assert(self.intercepted > 0, "InterceptionLayer.fulfillRequest", .{ .value = self.intercepted });
|
|
log.debug(.http, "fulfill transfer", .{ .intercepted = self.intercepted });
|
|
}
|
|
|
|
// Leave the parked state (accounting `intercepted` exactly once) and move to
|
|
// .completing BEFORE running the user callbacks.
|
|
transfer.unpark();
|
|
|
|
if (HttpClient.isRedirectStatus(status)) {
|
|
if (findLocation(headers)) |location| {
|
|
fulfilledRedirect(transfer, status, headers, location) catch |err| {
|
|
if (transfer.state == .created) {
|
|
transfer.abort(err);
|
|
}
|
|
return err;
|
|
};
|
|
self.next.request(transfer) catch |err| {
|
|
if (transfer.state == .created) {
|
|
transfer.abort(err);
|
|
}
|
|
return err;
|
|
};
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Not a redirect: move to .completing BEFORE running the user callbacks.
|
|
transfer.state = .completing;
|
|
defer transfer.deinit();
|
|
|
|
// `done` flips true once we've called the user's done_callback. If
|
|
// done_callback itself throws, the user already saw their end-of-flow
|
|
// notification; suppress error_callback to avoid double-notify.
|
|
var done: bool = false;
|
|
fulfillInner(&transfer.req, status, headers, body, &done) catch |err| {
|
|
if (!done) {
|
|
// safe here despite the defer transfer.deinit() above since the
|
|
// state == .completing
|
|
transfer.abort(err);
|
|
}
|
|
return err;
|
|
};
|
|
}
|
|
|
|
fn fulfillInner(
|
|
req: *Request,
|
|
status: u16,
|
|
headers: []const http.Header,
|
|
body: ?[]const u8,
|
|
done: *bool,
|
|
) !void {
|
|
const fulfilled = FulfilledResponse{
|
|
.status = status,
|
|
.url = req.url,
|
|
.headers = headers,
|
|
.body = body,
|
|
};
|
|
|
|
const response = Response.fromFulfilled(req.ctx, &fulfilled);
|
|
|
|
if (req.start_callback) |cb| {
|
|
try cb(response);
|
|
}
|
|
|
|
const result = try req.header_callback(response);
|
|
if (result == .abort) {
|
|
return error.Abort;
|
|
}
|
|
|
|
if (body) |b| {
|
|
try req.data_callback(response, b);
|
|
}
|
|
|
|
done.* = true;
|
|
try req.done_callback(req.ctx);
|
|
}
|
|
|
|
fn fulfilledRedirect(transfer: *Transfer, status: u16, headers: []const http.Header, location: []const u8) !void {
|
|
// retrieve cookies from the fulfilled response's headers.
|
|
if (transfer.req.cookie_jar) |jar| {
|
|
for (headers) |hdr| {
|
|
if (std.ascii.eqlIgnoreCase(hdr.name, "set-cookie")) {
|
|
try jar.populateFromResponse(transfer.req.url, hdr.value);
|
|
}
|
|
}
|
|
}
|
|
|
|
try transfer.applyRedirectTarget(transfer.req.url, location, status);
|
|
}
|
|
|
|
fn findLocation(headers: []const http.Header) ?[]const u8 {
|
|
for (headers) |hdr| {
|
|
if (std.ascii.eqlIgnoreCase(hdr.name, "location")) {
|
|
return hdr.value;
|
|
}
|
|
}
|
|
return null;
|
|
}
|